syncmill 0.2.0__py3-none-any.whl

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 (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,482 @@
1
+ """Difficulty-based provider model routing (PR 1: explicit tiers + observe).
2
+
3
+ Implements the contract in ``docs/difficulty-model-routing-plan.md``: a
4
+ deterministic prompt classifier (``prompt-complexity-v1``), the canonical
5
+ immutable :class:`ModelRoutingDecision`, and the pure helpers the Supervisor
6
+ and CLI consume. This module is a leaf — it imports nothing from ``agents``,
7
+ ``supervisor``, or ``cli`` — so the decision can flow into the agents' ambient
8
+ ContextVar scope without an import cycle.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import unicodedata
15
+ from collections.abc import Mapping, Sequence
16
+ from dataclasses import dataclass
17
+ from types import MappingProxyType
18
+ from typing import Any
19
+
20
+ from syncmill.config import ModelRoutingConfig, valid_model_selector
21
+
22
+ CLASSIFIER_V1 = "prompt-complexity-v1"
23
+
24
+ TIERS = ("low", "medium", "high")
25
+
26
+ # Reason codes (§4.4 / §4.5).
27
+ REASON_HIGH_RISK = "high_risk"
28
+ REASON_BROAD_SCOPE = "broad_scope"
29
+ REASON_MULTI_TARGET = "multi_target"
30
+ REASON_LONG_STRUCTURED = "long_structured_prompt"
31
+ REASON_NARROW_SIMPLE = "narrow_simple_intent"
32
+ REASON_DEFAULT_MEDIUM = "default_medium"
33
+ REASON_EXPLICIT = "explicit_override"
34
+ REASON_AFFINITY = "selector_affinity"
35
+
36
+ _KNOWN_REASONS = {
37
+ REASON_HIGH_RISK,
38
+ REASON_BROAD_SCOPE,
39
+ REASON_MULTI_TARGET,
40
+ REASON_LONG_STRUCTURED,
41
+ REASON_NARROW_SIMPLE,
42
+ REASON_DEFAULT_MEDIUM,
43
+ REASON_EXPLICIT,
44
+ REASON_AFFINITY,
45
+ }
46
+
47
+ # The tier each classifier reason implies (§4.4) — a stored auto decision whose
48
+ # resolved_tier contradicts its reason code was not produced by the classifier.
49
+ _REASON_TIER = {
50
+ REASON_HIGH_RISK: "high",
51
+ REASON_BROAD_SCOPE: "high",
52
+ REASON_MULTI_TARGET: "high",
53
+ REASON_LONG_STRUCTURED: "high",
54
+ REASON_NARROW_SIMPLE: "low",
55
+ REASON_DEFAULT_MEDIUM: "medium",
56
+ }
57
+
58
+ CORRUPT_ROUTING_ERROR = "corrupt model routing metadata"
59
+
60
+
61
+ class ClassifierError(Exception):
62
+ """The deterministic classifier failed; observe callers degrade + warn."""
63
+
64
+
65
+ class AllUnmappedError(Exception):
66
+ """An applied tier has no selector for any eligible agent (§3.3)."""
67
+
68
+ def __init__(self, tier: str) -> None:
69
+ super().__init__(
70
+ f"Model routing selected {tier}, but no eligible agent has a mapping"
71
+ )
72
+ self.tier = tier
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # prompt-complexity-v1 (§4)
77
+ # ---------------------------------------------------------------------------
78
+
79
+ # English markers use letter/digit-boundary phrase matching with hyphen and
80
+ # underscore normalized to spaces; Korean markers use plain substring matching
81
+ # because agglutinated particles/suffixes attach directly to the marker
82
+ # (마이그레이션을, 배포해줘) and a boundary rule would never fire (§4.3).
83
+ _HIGH_RISK_EN = (
84
+ "migration", "schema change", "authentication", "authorization",
85
+ "security", "permission", "concurrency", "race condition", "data loss",
86
+ "backward compatibility", "public api", "deploy", "deployment",
87
+ "release", "production",
88
+ )
89
+ _HIGH_RISK_KO = (
90
+ "마이그레이션", "스키마 변경", "인증", "인가", "보안", "권한", "동시성",
91
+ "경쟁 상태", "데이터 손실", "하위 호환", "공개 api", "배포", "릴리스", "운영",
92
+ )
93
+ _BROAD_SCOPE_EN = (
94
+ "entire repo", "whole repository", "across the repository", "all modules",
95
+ "end to end", "architecture", "redesign",
96
+ )
97
+ _BROAD_SCOPE_KO = (
98
+ "전체 저장소", "저장소 전체", "모든 모듈", "전반", "엔드투엔드",
99
+ "아키텍처", "재설계",
100
+ )
101
+ _SIMPLE_EN = (
102
+ "status", "show", "list", "summarize", "translate", "rename", "typo",
103
+ "format", "lint",
104
+ )
105
+ _SIMPLE_KO = ("상태", "보여", "목록", "요약", "번역", "이름 변경", "오타", "포맷", "린트")
106
+
107
+ _LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+")
108
+ _PATH_TOKEN_RE = re.compile(r"(?<!\w)(?:\.{0,2}/)?[\w.@+-]+(?:/[\w.@+-]+)+")
109
+ _CODE_SPAN_RE = re.compile(r"`([^`\n]+)`")
110
+ # NOTE: no dedicated fence handling — the §4.2 fence semantics ("a fenced block
111
+ # is not itself one target; path tokens inside count normally") fall out of the
112
+ # path/span regexes as written (a multi-line fence can never be a single-line
113
+ # code span, and _PATH_TOKEN_RE scans inside it like anywhere else).
114
+
115
+
116
+ def _normalize(prompt: str) -> str:
117
+ text = unicodedata.normalize("NFKC", prompt)
118
+ text = text.casefold()
119
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
120
+ return text.strip()
121
+
122
+
123
+ def _phrase_boundary_match(text: str, marker: str) -> bool:
124
+ """English phrase match at Unicode letter/digit boundaries.
125
+
126
+ Hyphen/underscore in the text are treated like spaces so ``schema-change``
127
+ matches the marker ``schema change``.
128
+ """
129
+ normalized = re.sub(r"[-_]", " ", text)
130
+ pattern = r"(?<![^\W_])" + re.escape(marker) + r"(?![^\W_])"
131
+ # \w includes letters/digits/underscore; underscores were already folded to
132
+ # spaces above, so the lookarounds reduce to letter/digit boundaries.
133
+ return re.search(pattern, normalized) is not None
134
+
135
+
136
+ def _any_marker(text: str, en: tuple[str, ...], ko: tuple[str, ...]) -> bool:
137
+ return any(_phrase_boundary_match(text, m) for m in en) or any(
138
+ m in text for m in ko
139
+ )
140
+
141
+
142
+ def _count_targets(text: str) -> tuple[int, int]:
143
+ """Return ``(path_targets, targets)`` per §4.2.
144
+
145
+ ``path_targets`` excludes URL tokens (anything participating in a
146
+ ``scheme://`` sequence); ``targets`` unions in single-line inline code
147
+ spans. Fenced code blocks are not themselves targets, but path-like tokens
148
+ inside them count normally.
149
+ """
150
+ # URL exclusion: drop any whitespace-delimited token containing "://"
151
+ # BEFORE scanning, so no sub-span of a URL (host/path tail) can count.
152
+ scannable = re.sub(r"\S*://\S*", " ", text)
153
+ paths = {m.group(0) for m in _PATH_TOKEN_RE.finditer(scannable)}
154
+ # Spans are extracted from the ORIGINAL text, so a URL inside an inline
155
+ # code span (`https://…`) still counts as a target. Deliberate: it only
156
+ # affects the low rule's `targets <= 1`, and the error direction is the
157
+ # conservative one (medium instead of low) — URL exclusion exists to keep
158
+ # doc links out of the EXPENSIVE multi_target rule, which reads only
159
+ # `paths`.
160
+ spans = {
161
+ m.group(1).strip()
162
+ for m in _CODE_SPAN_RE.finditer(text)
163
+ if m.group(1).strip()
164
+ }
165
+ return len(paths), len(paths | spans)
166
+
167
+
168
+ def classify_prompt(prompt: str) -> tuple[str, str]:
169
+ """Classify a raw task prompt into ``(tier, reason_code)`` (§4.4).
170
+
171
+ Deterministic and self-contained: no filesystem, network, or model calls.
172
+ Any internal failure is wrapped in :class:`ClassifierError` so callers can
173
+ apply the §9 degrade policy without catching arbitrary exceptions.
174
+ """
175
+ try:
176
+ text = _normalize(prompt)
177
+ char_count = len(text)
178
+ lines = text.split("\n")
179
+ nonempty_lines = sum(1 for line in lines if line.strip())
180
+ list_items = sum(1 for line in lines if _LIST_ITEM_RE.match(line))
181
+ path_targets, targets = _count_targets(text)
182
+
183
+ if _any_marker(text, _HIGH_RISK_EN, _HIGH_RISK_KO):
184
+ return "high", REASON_HIGH_RISK
185
+ if _any_marker(text, _BROAD_SCOPE_EN, _BROAD_SCOPE_KO):
186
+ return "high", REASON_BROAD_SCOPE
187
+ if path_targets >= 3:
188
+ return "high", REASON_MULTI_TARGET
189
+ if char_count >= 1200 and list_items >= 4:
190
+ return "high", REASON_LONG_STRUCTURED
191
+ if (
192
+ char_count <= 240
193
+ and nonempty_lines <= 3
194
+ and targets <= 1
195
+ and list_items <= 1
196
+ and _any_marker(text, _SIMPLE_EN, _SIMPLE_KO)
197
+ ):
198
+ return "low", REASON_NARROW_SIMPLE
199
+ return "medium", REASON_DEFAULT_MEDIUM
200
+ except Exception as exc: # pragma: no cover - defensive wrap
201
+ raise ClassifierError(str(exc)) from exc
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Canonical decision (§5.1)
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ def _frozen(mapping: Mapping[str, str | None]) -> Mapping[str, str | None]:
210
+ return MappingProxyType(dict(mapping))
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class ModelRoutingDecision:
215
+ """One immutable routing decision per top-level run.
216
+
217
+ ``recommended_selectors``/``effective_selectors`` are projected onto the
218
+ eligible lineup only (never the full config tier table) and are stored as
219
+ read-only mapping proxies — the frozen dataclass alone would not freeze a
220
+ contained dict.
221
+ """
222
+
223
+ schema_version: int
224
+ requested: str
225
+ resolved_tier: str
226
+ source: str
227
+ classifier_version: str | None
228
+ config_mode: str
229
+ applied: bool
230
+ reason_codes: tuple[str, ...]
231
+ recommended_selectors: Mapping[str, str | None]
232
+ effective_selectors: Mapping[str, str | None]
233
+
234
+ def __post_init__(self) -> None:
235
+ object.__setattr__(
236
+ self, "recommended_selectors", _frozen(self.recommended_selectors)
237
+ )
238
+ object.__setattr__(
239
+ self, "effective_selectors", _frozen(self.effective_selectors)
240
+ )
241
+ object.__setattr__(self, "reason_codes", tuple(self.reason_codes))
242
+
243
+ def as_dict(self) -> dict[str, Any]:
244
+ """The ONLY serializer — deterministic sorted mappings and reasons."""
245
+ return {
246
+ "schema_version": self.schema_version,
247
+ "requested": self.requested,
248
+ "resolved_tier": self.resolved_tier,
249
+ "source": self.source,
250
+ "classifier_version": self.classifier_version,
251
+ "config_mode": self.config_mode,
252
+ "applied": self.applied,
253
+ "reason_codes": sorted(self.reason_codes),
254
+ "recommended_selectors": dict(sorted(self.recommended_selectors.items())),
255
+ "effective_selectors": dict(sorted(self.effective_selectors.items())),
256
+ }
257
+
258
+
259
+ def _project(
260
+ cfg: ModelRoutingConfig, tier: str, lineup: Sequence[str]
261
+ ) -> dict[str, str | None]:
262
+ table = cfg.models.get(tier, {})
263
+ return {agent_id: table.get(agent_id) for agent_id in lineup}
264
+
265
+
266
+ def build_decision(
267
+ cfg: ModelRoutingConfig,
268
+ *,
269
+ prompt: str,
270
+ lineup: Sequence[str],
271
+ requested: str | None,
272
+ ) -> ModelRoutingDecision | None:
273
+ """Compute the run's single decision (§5.2), or ``None`` when routing is off.
274
+
275
+ Raises :class:`ClassifierError` (auto path) for the caller to degrade+warn,
276
+ and :class:`AllUnmappedError` (explicit path) when the applied tier has no
277
+ selector for any eligible agent.
278
+ """
279
+ if requested in (None, ""):
280
+ if cfg.mode == "off":
281
+ return None
282
+ requested = "auto"
283
+
284
+ if requested == "auto":
285
+ tier, reason = classify_prompt(prompt)
286
+ recommended = _project(cfg, tier, lineup)
287
+ return ModelRoutingDecision(
288
+ schema_version=1,
289
+ requested="auto",
290
+ resolved_tier=tier,
291
+ source="auto",
292
+ classifier_version=CLASSIFIER_V1,
293
+ config_mode=cfg.mode,
294
+ applied=False,
295
+ reason_codes=(reason,),
296
+ recommended_selectors=recommended,
297
+ effective_selectors={agent_id: None for agent_id in lineup},
298
+ )
299
+
300
+ # Explicit tier: user-requested application, honored even under mode=off.
301
+ # An empty lineup is by definition all-unmapped — never a silently
302
+ # applied=true decision over empty maps.
303
+ recommended = _project(cfg, requested, lineup)
304
+ if not lineup or all(sel is None for sel in recommended.values()):
305
+ raise AllUnmappedError(requested)
306
+ return ModelRoutingDecision(
307
+ schema_version=1,
308
+ requested=requested,
309
+ resolved_tier=requested,
310
+ source="explicit",
311
+ classifier_version=None,
312
+ config_mode=cfg.mode,
313
+ applied=True,
314
+ reason_codes=(REASON_EXPLICIT,),
315
+ recommended_selectors=recommended,
316
+ effective_selectors=dict(recommended),
317
+ )
318
+
319
+
320
+ def inherit_decision(
321
+ stored: Any, *, agent_id: str
322
+ ) -> ModelRoutingDecision | None:
323
+ """Rebuild an inherited decision from a parent manifest block (§6.2).
324
+
325
+ ``None``/absent → legacy parent → ``None`` (resume with no model flag).
326
+ Anything present must be a strictly valid v1 block — EVERY canonical field
327
+ well-typed (both selector maps included), the cross-field invariants the
328
+ builder guarantees intact (an unapplied decision has no non-null effective
329
+ selector; an applied one has at least one), and an ``effective_selectors``
330
+ entry present for the winning ``agent_id``. Any violation raises
331
+ ``ValueError(CORRUPT_ROUTING_ERROR)`` (fail closed, §9).
332
+ """
333
+ if stored is None:
334
+ return None
335
+ if not isinstance(stored, dict):
336
+ raise ValueError(CORRUPT_ROUTING_ERROR)
337
+ schema_version = stored.get("schema_version")
338
+ if isinstance(schema_version, bool) or schema_version != 1:
339
+ raise ValueError(CORRUPT_ROUTING_ERROR)
340
+ requested = stored.get("requested")
341
+ resolved = stored.get("resolved_tier")
342
+ source = stored.get("source")
343
+ mode = stored.get("config_mode")
344
+ classifier_version = stored.get("classifier_version")
345
+ applied = stored.get("applied")
346
+ reasons = stored.get("reason_codes")
347
+ recommended = stored.get("recommended_selectors")
348
+ effective = stored.get("effective_selectors")
349
+
350
+ def _valid_map(mapping: Any) -> bool:
351
+ return isinstance(mapping, dict) and all(
352
+ isinstance(key, str) and (value is None or valid_model_selector(value))
353
+ for key, value in mapping.items()
354
+ )
355
+
356
+ if (
357
+ requested not in ("auto", *TIERS)
358
+ or resolved not in TIERS
359
+ or source not in ("auto", "explicit", "inherited")
360
+ or mode not in ("off", "observe", "enforce")
361
+ or not (classifier_version is None or isinstance(classifier_version, str))
362
+ or not isinstance(applied, bool)
363
+ or not isinstance(reasons, list)
364
+ or not all(isinstance(r, str) and r in _KNOWN_REASONS for r in reasons)
365
+ or not _valid_map(recommended)
366
+ or not _valid_map(effective)
367
+ or agent_id not in effective
368
+ ):
369
+ raise ValueError(CORRUPT_ROUTING_ERROR)
370
+ # Cross-field invariants the canonical builder guarantees — a block that
371
+ # contradicts any of them was not produced by syncmill and fails closed
372
+ # rather than being reinterpreted from whichever field one happens to read:
373
+ # * both maps cover the SAME lineup;
374
+ # * an applied decision's effective map IS its recommended map (and has
375
+ # at least one selector), an unapplied one is all-null;
376
+ # * the source shape is internally consistent (auto = classifier ran and
377
+ # nothing applied in this release; explicit = the requested tier, no
378
+ # classifier, exactly the explicit_override reason; inherited carries
379
+ # the selector_affinity marker).
380
+ classifier_reasons = _KNOWN_REASONS - {REASON_EXPLICIT, REASON_AFFINITY}
381
+ consistent = set(recommended) == set(effective) and applied == any(
382
+ value is not None for value in effective.values()
383
+ )
384
+ if applied:
385
+ consistent = consistent and effective == recommended
386
+ if source == "auto":
387
+ consistent = (
388
+ consistent
389
+ and requested == "auto"
390
+ and classifier_version == CLASSIFIER_V1
391
+ and not applied
392
+ and len(reasons) == 1
393
+ and reasons[0] in classifier_reasons
394
+ and resolved == _REASON_TIER.get(reasons[0])
395
+ )
396
+ elif source == "explicit":
397
+ consistent = (
398
+ consistent
399
+ and requested == resolved
400
+ and classifier_version is None
401
+ and applied
402
+ and reasons == [REASON_EXPLICIT]
403
+ )
404
+ else:
405
+ # Inherited (a continuation of a continuation): inherit_decision itself
406
+ # produced this block, so its shape is fully determined — single-agent
407
+ # maps keyed to the continued winner, recommended == effective, and
408
+ # reasons == the ancestor's ONE origin reason + selector_affinity, with
409
+ # the origin's own field constraints (explicit ancestry: no classifier,
410
+ # requested == resolved; auto ancestry: classifier v1, requested auto,
411
+ # and the reason must resolve to the tier it implies).
412
+ origin_reasons = [r for r in reasons if r != REASON_AFFINITY]
413
+ consistent = (
414
+ consistent
415
+ and REASON_AFFINITY in reasons
416
+ and len(reasons) == len(set(reasons))
417
+ and len(origin_reasons) == 1
418
+ and set(effective) == {agent_id}
419
+ and recommended == effective
420
+ )
421
+ if consistent:
422
+ origin = origin_reasons[0]
423
+ if origin == REASON_EXPLICIT:
424
+ consistent = classifier_version is None and requested == resolved
425
+ elif origin in classifier_reasons:
426
+ consistent = (
427
+ classifier_version == CLASSIFIER_V1
428
+ and requested == "auto"
429
+ and resolved == _REASON_TIER.get(origin)
430
+ )
431
+ else:
432
+ consistent = False
433
+ if not consistent:
434
+ raise ValueError(CORRUPT_ROUTING_ERROR)
435
+ selector = effective[agent_id]
436
+ reason_codes = tuple(reasons)
437
+ if REASON_AFFINITY not in reason_codes:
438
+ reason_codes = reason_codes + (REASON_AFFINITY,)
439
+ return ModelRoutingDecision(
440
+ schema_version=1,
441
+ requested=requested,
442
+ resolved_tier=resolved,
443
+ source="inherited",
444
+ classifier_version=classifier_version,
445
+ config_mode=mode,
446
+ applied=selector is not None,
447
+ reason_codes=reason_codes,
448
+ recommended_selectors={agent_id: selector},
449
+ effective_selectors={agent_id: selector},
450
+ )
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Output (§3.4)
455
+ # ---------------------------------------------------------------------------
456
+
457
+
458
+ def cli_default_count(decision: ModelRoutingDecision) -> int:
459
+ """Agents whose resolved tier has no configured selector.
460
+
461
+ Counted over ``recommended_selectors`` — in observe mode every effective
462
+ value is intentionally ``None``, so effective would always read all-default.
463
+ """
464
+ return sum(1 for sel in decision.recommended_selectors.values() if sel is None)
465
+
466
+
467
+ def render_banner(decision: ModelRoutingDecision) -> str:
468
+ """The compact one-line banner (§3.4).
469
+
470
+ An auto decision is ALWAYS labeled ``observe`` — the auto builder never
471
+ applies (``applied`` is hard-coded ``False`` there; only explicit/inherited
472
+ decisions apply), and `--difficulty auto` under ``mode="off"`` IS a one-run
473
+ observe (§3.2): the honest ``config_mode="off"`` snapshot in the serialized
474
+ block must not leak "off" into the label of a run that classified.
475
+ """
476
+ label = "observe" if decision.source == "auto" else decision.source
477
+ applied = "applied" if decision.applied else "not applied"
478
+ parts = [f"model routing: {decision.resolved_tier} / {label} / {applied}"]
479
+ defaults = cli_default_count(decision)
480
+ if defaults:
481
+ parts.append(f"{defaults} cli-default")
482
+ return " / ".join(parts)