its-magic 0.1.3-0 → 0.1.3-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 (61) hide show
  1. package/installer.ps1 +8 -0
  2. package/installer.py +8 -0
  3. package/installer.sh +1 -0
  4. package/package.json +1 -1
  5. package/scripts/check_intake_template_parity.py +62 -0
  6. package/template/.cursor/agents/curator.mdc +1 -0
  7. package/template/.cursor/agents/po.mdc +1 -0
  8. package/template/.cursor/agents/release.mdc +1 -0
  9. package/template/.cursor/commands/auto.md +90 -0
  10. package/template/.cursor/commands/execute.md +26 -0
  11. package/template/.cursor/commands/refresh-context.md +32 -0
  12. package/template/.cursor/commands/sovereign-critic.md +104 -0
  13. package/template/.cursor/model-catalog.local.example.cursor-only.json +9 -0
  14. package/template/.cursor/model-catalog.local.example.json +9 -0
  15. package/template/.cursor/model-catalog.local.example.level-1-easy.json +9 -0
  16. package/template/.cursor/model-catalog.local.example.level-2-complex.json +9 -0
  17. package/template/.cursor/model-catalog.local.example.level-3-mega.json +9 -0
  18. package/template/.cursor/model-catalog.local.example.level-4-super.json +9 -0
  19. package/template/.cursor/model-catalog.local.example.role-based-balanced.json +18 -0
  20. package/template/.cursor/model-catalog.local.example.role-based-highend.json +18 -0
  21. package/template/.cursor/rules/sovereign-role-manifest.mdc.example +27 -0
  22. package/template/.cursor/scratchpad.local.example.md +55 -0
  23. package/template/.cursor/scratchpad.md +203 -0
  24. package/template/.cursor/sovereign-role-manifest.yaml.example +53 -0
  25. package/template/decisions/DEC-0104.md +279 -0
  26. package/template/decisions/DEC-0105.md +231 -0
  27. package/template/decisions/DEC-0107.md +246 -0
  28. package/template/docs/engineering/architecture.md +78 -0
  29. package/template/docs/engineering/auto-orchestration-reference.md +7 -0
  30. package/template/docs/engineering/context/installer-owned-paths.manifest +8 -0
  31. package/template/docs/engineering/reason_codes.md +411 -0
  32. package/template/docs/engineering/runbook.md +1119 -0
  33. package/template/docs/engineering/sovereign-memory/.gitkeep +0 -0
  34. package/template/docs/engineering/sovereign-memory/retrospectives/.gitkeep +0 -0
  35. package/template/handoffs/sovereign_decisions/.gitkeep +0 -0
  36. package/template/handoffs/sovereign_deferrals/.gitkeep +0 -0
  37. package/template/handoffs/sovereign_role_reviews.jsonl +0 -0
  38. package/template/scripts/__pycache__/decision_ledger_lib.cpython-312.pyc +0 -0
  39. package/template/scripts/check_intake_template_parity.py +181 -0
  40. package/template/scripts/decision_ledger_lib.py +732 -0
  41. package/template/scripts/ledger_validate.py +153 -0
  42. package/template/scripts/model_tier_lib.py +680 -0
  43. package/template/scripts/model_tier_validate.py +368 -0
  44. package/template/scripts/parallel_dev_arbiter.py +923 -0
  45. package/template/scripts/release_trigger_adapters.py +843 -0
  46. package/template/scripts/self_healing_deploy_lib.py +463 -0
  47. package/template/scripts/self_healing_deploy_validate.py +78 -0
  48. package/template/scripts/sovereign_convergence_lib.py +994 -0
  49. package/template/scripts/sovereign_convergence_validate.py +206 -0
  50. package/template/scripts/sovereign_critic_lib.py +629 -0
  51. package/template/scripts/sovereign_critic_validate.py +131 -0
  52. package/template/scripts/sovereign_loop_lib.py +828 -0
  53. package/template/scripts/sovereign_loop_validate.py +122 -0
  54. package/template/scripts/sovereign_memory_lib.py +869 -0
  55. package/template/scripts/sovereign_memory_validate.py +153 -0
  56. package/template/scripts/sovereign_role_manifest_lib.py +547 -0
  57. package/template/scripts/sovereign_role_manifest_validate.py +105 -0
  58. package/template/tests/us0108_contract_test.py +207 -0
  59. package/template/tests/us0109_contract_test.py +209 -0
  60. package/template/tests/us0109_us0110_compose_test.py +34 -0
  61. package/template/tests/us0111_contract_test.py +345 -0
@@ -0,0 +1,680 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Model tier resolver library (US-0101 / DEC-0086; US-0102 / DEC-0087).
4
+
5
+ Provides:
6
+ - Tier→alias resolution (cheap→fast, balanced→inherit, strong→omit)
7
+ - Local catalog schema validation (v1 + v2)
8
+ - Unified 5-step precedence resolver (US-0102)
9
+ - Resolver algorithm with fail-closed reason codes
10
+
11
+ Reason codes (DEC-0086):
12
+ - MODEL_TIER_INVALID: unknown tier value
13
+ - MODEL_CATALOG_INVALID: malformed catalog JSON (v1)
14
+ - MODEL_SLUG_UNKNOWN: tier key missing from catalog
15
+ - MODEL_RESOLVE_FALLBACK: catalog lookup failed, using fallback
16
+
17
+ Reason codes (DEC-0087):
18
+ - MODEL_OVERRIDE_SLUG_UNKNOWN: direct slug validation failure
19
+ - MODEL_ROLE_SLUG_UNKNOWN: role catalog lookup miss (fall through)
20
+ - MODEL_CATALOG_SCHEMA_V2_INVALID: v2 schema validation failure
21
+ """
22
+
23
+ import json
24
+ import sys
25
+ from dataclasses import dataclass
26
+ from enum import Enum
27
+ from pathlib import Path
28
+ from typing import Dict, Optional
29
+
30
+
31
+ class Tier(str, Enum):
32
+ """Three operator-facing tiers for LLM model strength."""
33
+ CHEAP = "cheap"
34
+ BALANCED = "balanced"
35
+ STRONG = "strong"
36
+
37
+
38
+ class ReasonCode(str, Enum):
39
+ """Fail-closed reason codes for model tier resolution."""
40
+ MODEL_TIER_INVALID = "MODEL_TIER_INVALID"
41
+ MODEL_CATALOG_INVALID = "MODEL_CATALOG_INVALID"
42
+ MODEL_SLUG_UNKNOWN = "MODEL_SLUG_UNKNOWN"
43
+ MODEL_RESOLVE_FALLBACK = "MODEL_RESOLVE_FALLBACK"
44
+ MODEL_OVERRIDE_SLUG_UNKNOWN = "MODEL_OVERRIDE_SLUG_UNKNOWN"
45
+ MODEL_ROLE_SLUG_UNKNOWN = "MODEL_ROLE_SLUG_UNKNOWN"
46
+ MODEL_CATALOG_SCHEMA_V2_INVALID = "MODEL_CATALOG_SCHEMA_V2_INVALID"
47
+
48
+
49
+ # Tier→Cursor alias mapping (DEC-0086 §2)
50
+ TIER_ALIAS_MAP = {
51
+ Tier.CHEAP: "fast",
52
+ Tier.BALANCED: "inherit",
53
+ Tier.STRONG: None, # omit model: field
54
+ }
55
+
56
+ # Default phase→tier matrix (DEC-0086 §4)
57
+ DEFAULT_PHASE_TIER_MATRIX = {
58
+ # cheap tier
59
+ "ask": Tier.CHEAP,
60
+ "refresh-context": Tier.CHEAP,
61
+ "memory-audit": Tier.CHEAP,
62
+ "status-reconcile": Tier.CHEAP,
63
+ "pause": Tier.CHEAP,
64
+ # balanced tier
65
+ "intake": Tier.BALANCED,
66
+ "discovery": Tier.BALANCED,
67
+ "research": Tier.BALANCED,
68
+ "release": Tier.BALANCED,
69
+ "plan-verify": Tier.BALANCED,
70
+ # strong tier
71
+ "architecture": Tier.STRONG,
72
+ "execute": Tier.STRONG,
73
+ "quick": Tier.STRONG,
74
+ "qa": Tier.STRONG,
75
+ "verify-work": Tier.STRONG,
76
+ "security-review": Tier.STRONG,
77
+ # auto inherits parent (no tier override)
78
+ "auto": None,
79
+ }
80
+
81
+ # Canonical phase ids for MODEL_<PHASE> keys (DEC-0086 + DEC-0087)
82
+ CANONICAL_PHASE_IDS = frozenset(DEFAULT_PHASE_TIER_MATRIX.keys())
83
+
84
+ # Catalog v2 role keys (DEC-0087 §5)
85
+ CATALOG_ROLE_KEYS = frozenset({
86
+ "po", "sa", "dev", "dev_difficult", "qa", "security", "release",
87
+ })
88
+
89
+ # Phase→logical role (DEC-0051 + DEC-0087 §6)
90
+ PHASE_LOGICAL_ROLE: Dict[str, Optional[str]] = {
91
+ "intake": "po",
92
+ "discovery": "po",
93
+ "research": "tech-lead",
94
+ "architecture": "tech-lead",
95
+ "plan-verify": "qa",
96
+ "execute": "dev",
97
+ "quick": "dev",
98
+ "qa": "qa",
99
+ "verify-work": "qa",
100
+ "security-review": "security",
101
+ "release": "release",
102
+ "refresh-context": "curator",
103
+ "ask": "dev",
104
+ "memory-audit": "dev",
105
+ "status-reconcile": "dev",
106
+ "pause": "dev",
107
+ "auto": None,
108
+ }
109
+
110
+ # Logical role → catalog roles key (DEC-0087 §6)
111
+ LOGICAL_ROLE_TO_CATALOG_KEY = {
112
+ "po": "po",
113
+ "tech-lead": "sa",
114
+ "dev": "dev",
115
+ "qa": "qa",
116
+ "security": "security",
117
+ "release": "release",
118
+ "curator": "dev",
119
+ }
120
+
121
+ # 5-step precedence chain labels (DEC-0087 §2) — for contract tests / docs
122
+ PRECEDENCE_CHAIN_STEPS = (
123
+ "MODEL_<PHASE>",
124
+ "MODEL_TIER_<PHASE>",
125
+ "role_catalog_lookup",
126
+ "MODEL_TIER_DEFAULT",
127
+ "cursor_alias",
128
+ )
129
+
130
+
131
+ def phase_to_model_key(phase_id: str) -> str:
132
+ """Scratchpad key for direct slug override: MODEL_<PHASE>."""
133
+ return f"MODEL_{phase_id.upper()}"
134
+
135
+
136
+ def phase_to_tier_key(phase_id: str) -> str:
137
+ """Scratchpad key for tier override: MODEL_TIER_<PHASE>."""
138
+ return f"MODEL_TIER_{phase_id.upper()}"
139
+
140
+
141
+ @dataclass
142
+ class ResolveResult:
143
+ """Result of model tier resolution."""
144
+ success: bool
145
+ alias: Optional[str] # "fast", "inherit", or None (omit)
146
+ reason_code: Optional[ReasonCode]
147
+ reason_message: Optional[str]
148
+ tier: Optional[Tier] = None
149
+ slug: Optional[str] = None # vendor-specific slug from catalog or direct override
150
+
151
+ @classmethod
152
+ def success_alias(cls, tier: Tier, alias: Optional[str]) -> "ResolveResult":
153
+ """Successful resolution with alias."""
154
+ return cls(
155
+ success=True,
156
+ alias=alias,
157
+ reason_code=None,
158
+ reason_message=None,
159
+ tier=tier,
160
+ )
161
+
162
+ @classmethod
163
+ def success_slug(cls, tier: Optional[Tier], slug: str) -> "ResolveResult":
164
+ """Successful resolution with vendor slug."""
165
+ return cls(
166
+ success=True,
167
+ alias=None,
168
+ reason_code=None,
169
+ reason_message=None,
170
+ tier=tier,
171
+ slug=slug,
172
+ )
173
+
174
+ @classmethod
175
+ def failure(cls, reason_code: ReasonCode, message: str) -> "ResolveResult":
176
+ """Failed resolution with reason code."""
177
+ return cls(
178
+ success=False,
179
+ alias=None,
180
+ reason_code=reason_code,
181
+ reason_message=message,
182
+ )
183
+
184
+ @classmethod
185
+ def fallthrough(
186
+ cls,
187
+ reason_code: ReasonCode,
188
+ message: str,
189
+ ) -> "ResolveResult":
190
+ """Emit reason code but signal fall-through (role catalog miss)."""
191
+ return cls(
192
+ success=False,
193
+ alias=None,
194
+ reason_code=reason_code,
195
+ reason_message=message,
196
+ )
197
+
198
+
199
+ def _validate_tiers_object(tiers: object) -> Optional[str]:
200
+ """Validate tiers object; return error message or None."""
201
+ if not isinstance(tiers, dict):
202
+ return "Field 'tiers' must be an object"
203
+ required_tiers = {t.value for t in Tier}
204
+ actual_tiers = set(tiers.keys())
205
+ missing = required_tiers - actual_tiers
206
+ if missing:
207
+ return f"Missing required tier keys: {', '.join(sorted(missing))}"
208
+ for tier_name, slug in tiers.items():
209
+ if not isinstance(slug, str) or not slug.strip():
210
+ return f"Tier '{tier_name}' must have a non-empty string slug"
211
+ return None
212
+
213
+
214
+ def _validate_roles_object(roles: object) -> Optional[str]:
215
+ """Validate v2 roles object; return error message or None."""
216
+ if not isinstance(roles, dict):
217
+ return "Field 'roles' must be an object"
218
+ actual_keys = set(roles.keys())
219
+ missing = CATALOG_ROLE_KEYS - actual_keys
220
+ extra = actual_keys - CATALOG_ROLE_KEYS
221
+ if missing:
222
+ return f"Missing required role keys: {', '.join(sorted(missing))}"
223
+ if extra:
224
+ return f"Unknown role keys: {', '.join(sorted(extra))}"
225
+ for role_name, slug in roles.items():
226
+ if not isinstance(slug, str) or not slug.strip():
227
+ return f"Role '{role_name}' must have a non-empty string slug"
228
+ return None
229
+
230
+
231
+ def validate_catalog_schema(catalog_path: Path) -> tuple[bool, Optional[str]]:
232
+ """
233
+ Validate local catalog schema (DEC-0086 v1 + DEC-0087 v2).
234
+
235
+ Returns:
236
+ (is_valid, error_message)
237
+ """
238
+ if not catalog_path.exists():
239
+ return False, f"Catalog file not found: {catalog_path}"
240
+
241
+ try:
242
+ with open(catalog_path, "r", encoding="utf-8") as f:
243
+ catalog = json.load(f)
244
+ except json.JSONDecodeError as e:
245
+ return False, f"Malformed JSON: {e}"
246
+
247
+ if "schema_version" not in catalog:
248
+ return False, "Missing required field: schema_version"
249
+
250
+ schema_version = catalog["schema_version"]
251
+ if schema_version not in (1, 2):
252
+ return False, f"Unsupported schema_version: {schema_version} (expected 1 or 2)"
253
+
254
+ if "tiers" not in catalog:
255
+ return False, "Missing required field: tiers"
256
+
257
+ tiers_error = _validate_tiers_object(catalog["tiers"])
258
+ if tiers_error:
259
+ if schema_version == 2:
260
+ return False, f"v2 schema error: {tiers_error}"
261
+ return False, tiers_error
262
+
263
+ if schema_version == 2 and "roles" in catalog:
264
+ roles_error = _validate_roles_object(catalog["roles"])
265
+ if roles_error:
266
+ return False, f"v2 schema error: {roles_error}"
267
+
268
+ return True, None
269
+
270
+
271
+ def catalog_validation_reason_code(error_message: Optional[str], catalog: Optional[dict]) -> ReasonCode:
272
+ """Pick fail-closed reason code for catalog validation failure."""
273
+ if catalog and catalog.get("schema_version") == 2:
274
+ return ReasonCode.MODEL_CATALOG_SCHEMA_V2_INVALID
275
+ if error_message and "v2 schema error" in error_message:
276
+ return ReasonCode.MODEL_CATALOG_SCHEMA_V2_INVALID
277
+ return ReasonCode.MODEL_CATALOG_INVALID
278
+
279
+
280
+ def load_catalog(catalog_path: Path) -> tuple[Optional[dict], Optional[str]]:
281
+ """
282
+ Load and validate catalog.
283
+
284
+ Returns:
285
+ (catalog_dict, error_message)
286
+ """
287
+ is_valid, error = validate_catalog_schema(catalog_path)
288
+ if not is_valid:
289
+ return None, error
290
+
291
+ with open(catalog_path, "r", encoding="utf-8") as f:
292
+ return json.load(f), None
293
+
294
+
295
+ def _catalog_contains_slug(catalog: dict, slug: str) -> bool:
296
+ """Check whether slug appears in catalog tiers or roles values."""
297
+ tiers = catalog.get("tiers", {})
298
+ if slug in tiers.values():
299
+ return True
300
+ roles = catalog.get("roles", {})
301
+ if isinstance(roles, dict) and slug in roles.values():
302
+ return True
303
+ return False
304
+
305
+
306
+ def validate_direct_slug(
307
+ slug: str,
308
+ model_resolve: str,
309
+ catalog: Optional[dict],
310
+ ) -> tuple[bool, Optional[str]]:
311
+ """
312
+ Validate direct MODEL_<PHASE> slug per DEC-0087 §4.
313
+
314
+ Returns:
315
+ (is_valid, error_message)
316
+ """
317
+ if not slug or not slug.strip():
318
+ return False, "Direct slug override must be a non-empty string"
319
+
320
+ if model_resolve == "alias_only":
321
+ return True, None
322
+
323
+ if model_resolve in ("local_catalog", "role_catalog"):
324
+ if not catalog:
325
+ return False, "Direct slug validation requires catalog when MODEL_RESOLVE is local_catalog or role_catalog"
326
+ if not _catalog_contains_slug(catalog, slug):
327
+ return False, f"Slug '{slug}' not found in catalog tiers or roles"
328
+ return True, None
329
+
330
+ return False, f"Unknown MODEL_RESOLVE value: {model_resolve}"
331
+
332
+
333
+ def resolve_logical_role(phase_id: str, scratchpad: Dict[str, str]) -> Optional[str]:
334
+ """Resolve phase to logical role with AUTO_ROLE_* policy overrides."""
335
+ if phase_id == "auto":
336
+ return None
337
+
338
+ base_role = PHASE_LOGICAL_ROLE.get(phase_id)
339
+ if base_role is None:
340
+ return "dev"
341
+
342
+ if phase_id == "research":
343
+ override = scratchpad.get("AUTO_ROLE_RESEARCH", "").strip()
344
+ if override in ("po", "tech-lead"):
345
+ return override
346
+ return base_role
347
+
348
+ if phase_id == "plan-verify":
349
+ override = scratchpad.get("AUTO_ROLE_PLAN_VERIFY", "").strip()
350
+ if override in ("qa", "tech-lead"):
351
+ return override
352
+ return base_role
353
+
354
+ if phase_id == "refresh-context":
355
+ override = scratchpad.get("AUTO_ROLE_REFRESH_CONTEXT", "").strip()
356
+ if override in ("curator", "po"):
357
+ return override
358
+ return base_role
359
+
360
+ return base_role
361
+
362
+
363
+ def _resolve_tier_chain(
364
+ phase_id: str,
365
+ tier: Optional[Tier],
366
+ model_resolve: str,
367
+ model_fallback: str,
368
+ catalog: Optional[dict],
369
+ ) -> ResolveResult:
370
+ """DEC-0086 tier→alias / local_catalog chain for a resolved tier."""
371
+ if tier is None:
372
+ return ResolveResult.success_alias(None, None)
373
+
374
+ if model_resolve == "alias_only":
375
+ alias = TIER_ALIAS_MAP.get(tier)
376
+ return ResolveResult.success_alias(tier, alias)
377
+
378
+ if model_resolve == "local_catalog" or model_resolve == "role_catalog":
379
+ if not catalog:
380
+ return ResolveResult.failure(
381
+ ReasonCode.MODEL_CATALOG_INVALID,
382
+ "MODEL_RESOLVE=local_catalog requires catalog",
383
+ )
384
+
385
+ tier_key = tier.value
386
+ tiers = catalog.get("tiers", {})
387
+ if tier_key not in tiers:
388
+ if model_fallback == "inherit":
389
+ return ResolveResult(
390
+ success=True,
391
+ alias="inherit",
392
+ reason_code=ReasonCode.MODEL_RESOLVE_FALLBACK,
393
+ reason_message=f"Tier '{tier_key}' not in catalog, using fallback 'inherit'",
394
+ tier=tier,
395
+ )
396
+ return ResolveResult.failure(
397
+ ReasonCode.MODEL_SLUG_UNKNOWN,
398
+ f"Tier '{tier_key}' not found in catalog",
399
+ )
400
+
401
+ slug = tiers[tier_key]
402
+ return ResolveResult.success_slug(tier, slug)
403
+
404
+ return ResolveResult.failure(
405
+ ReasonCode.MODEL_TIER_INVALID,
406
+ f"Unknown MODEL_RESOLVE value: {model_resolve} (expected: alias_only|local_catalog|role_catalog)",
407
+ )
408
+
409
+
410
+ def _resolve_tier_for_phase(
411
+ phase_id: str,
412
+ scratchpad: Dict[str, str],
413
+ use_default_only: bool = False,
414
+ ) -> Optional[Tier]:
415
+ """Resolve tier from MODEL_TIER_<PHASE> or phase matrix / default."""
416
+ if use_default_only:
417
+ default = scratchpad.get("MODEL_TIER_DEFAULT", "balanced").strip()
418
+ try:
419
+ return Tier(default)
420
+ except ValueError:
421
+ return Tier.BALANCED
422
+
423
+ tier_key = phase_to_tier_key(phase_id)
424
+ if tier_key in scratchpad and scratchpad[tier_key].strip():
425
+ try:
426
+ return Tier(scratchpad[tier_key].strip())
427
+ except ValueError:
428
+ return None
429
+
430
+ tier = DEFAULT_PHASE_TIER_MATRIX.get(phase_id)
431
+ if tier is not None:
432
+ return tier
433
+
434
+ if phase_id == "auto":
435
+ return None
436
+
437
+ default = scratchpad.get("MODEL_TIER_DEFAULT", "balanced").strip()
438
+ try:
439
+ return Tier(default)
440
+ except ValueError:
441
+ return Tier.BALANCED
442
+
443
+
444
+ def resolve_model_for_phase(
445
+ phase_id: str,
446
+ scratchpad: Optional[Dict[str, str]] = None,
447
+ catalog: Optional[dict] = None,
448
+ catalog_path: Optional[Path] = None,
449
+ ) -> ResolveResult:
450
+ """
451
+ Unified 5-step precedence resolver (DEC-0087 §2).
452
+
453
+ Precedence:
454
+ 1. MODEL_<PHASE> direct slug
455
+ 2. MODEL_TIER_<PHASE> → DEC-0086 tier chain
456
+ 3. MODEL_RESOLVE=role_catalog → phase→role→catalog slug (fall through on miss)
457
+ 4. MODEL_TIER_DEFAULT → DEC-0086 tier chain
458
+ 5. Cursor stable alias
459
+ """
460
+ pad = scratchpad or {}
461
+ model_resolve = pad.get("MODEL_RESOLVE", "alias_only").strip() or "alias_only"
462
+ model_fallback = pad.get("MODEL_FALLBACK", "inherit").strip() or "inherit"
463
+
464
+ if catalog is None and catalog_path is not None:
465
+ catalog, catalog_error = load_catalog(catalog_path)
466
+ if catalog_error:
467
+ code = catalog_validation_reason_code(catalog_error, None)
468
+ return ResolveResult.failure(code, catalog_error)
469
+
470
+ # Step 1: MODEL_<PHASE> direct slug override
471
+ model_key = phase_to_model_key(phase_id)
472
+ if model_key in pad and pad[model_key].strip():
473
+ slug = pad[model_key].strip()
474
+ is_valid, error = validate_direct_slug(slug, model_resolve, catalog)
475
+ if not is_valid:
476
+ return ResolveResult.failure(
477
+ ReasonCode.MODEL_OVERRIDE_SLUG_UNKNOWN,
478
+ error or "Direct slug validation failed",
479
+ )
480
+ return ResolveResult.success_slug(None, slug)
481
+
482
+ # Step 2: MODEL_TIER_<PHASE> tier chain (skipped when role_catalog — step 3 handles slug)
483
+ if model_resolve in ("alias_only", "local_catalog"):
484
+ tier = _resolve_tier_for_phase(phase_id, pad, use_default_only=False)
485
+ if tier is not None:
486
+ result = _resolve_tier_chain(phase_id, tier, model_resolve, model_fallback, catalog)
487
+ if result.success:
488
+ return result
489
+ if result.reason_code != ReasonCode.MODEL_RESOLVE_FALLBACK:
490
+ return result
491
+
492
+ # Step 3: role catalog lookup (opt-in)
493
+ if model_resolve == "role_catalog" and phase_id != "auto":
494
+ logical_role = resolve_logical_role(phase_id, pad)
495
+ if logical_role:
496
+ catalog_role_key = LOGICAL_ROLE_TO_CATALOG_KEY.get(logical_role)
497
+ if catalog and catalog_role_key:
498
+ roles = catalog.get("roles")
499
+ if isinstance(roles, dict) and catalog_role_key in roles:
500
+ slug = roles[catalog_role_key]
501
+ if slug and slug.strip():
502
+ return ResolveResult.success_slug(None, slug.strip())
503
+ # Miss → fall through with reason (not hard stop)
504
+ # Continue to step 4; reason emitted via optional metadata on result path
505
+
506
+ # Step 4: MODEL_TIER_DEFAULT tier chain
507
+ default_tier = _resolve_tier_for_phase(phase_id, pad, use_default_only=True)
508
+ if default_tier is not None:
509
+ result = _resolve_tier_chain(phase_id, default_tier, model_resolve, model_fallback, catalog)
510
+ if result.success:
511
+ return result
512
+
513
+ # Step 5: Cursor alias from phase matrix tier or balanced default
514
+ fallback_tier = DEFAULT_PHASE_TIER_MATRIX.get(phase_id, Tier.BALANCED)
515
+ if fallback_tier is None:
516
+ fallback_tier = Tier.BALANCED
517
+ alias = TIER_ALIAS_MAP.get(fallback_tier)
518
+ return ResolveResult.success_alias(fallback_tier, alias)
519
+
520
+
521
+ def resolve_model_tier(
522
+ phase: str,
523
+ model_resolve: str = "alias_only",
524
+ model_fallback: str = "inherit",
525
+ catalog_path: Optional[Path] = None,
526
+ tier_override: Optional[str] = None,
527
+ ) -> ResolveResult:
528
+ """
529
+ Resolve model tier for a given phase (DEC-0086 §3 resolver algorithm).
530
+
531
+ Args:
532
+ phase: Canonical phase ID (e.g., "execute", "qa")
533
+ model_resolve: Resolution strategy ("alias_only" | "local_catalog" | "role_catalog")
534
+ model_fallback: Fallback when catalog lookup fails ("inherit")
535
+ catalog_path: Path to local catalog (required when model_resolve="local_catalog")
536
+ tier_override: Explicit tier override (bypasses phase matrix lookup)
537
+
538
+ Returns:
539
+ ResolveResult with success/failure, alias/slug, and reason code
540
+ """
541
+ # Step 1: Determine tier value
542
+ tier = None
543
+ if tier_override:
544
+ try:
545
+ tier = Tier(tier_override)
546
+ except ValueError:
547
+ return ResolveResult.failure(
548
+ ReasonCode.MODEL_TIER_INVALID,
549
+ f"Unknown tier value: {tier_override} (expected: cheap|balanced|strong)",
550
+ )
551
+ else:
552
+ tier = DEFAULT_PHASE_TIER_MATRIX.get(phase)
553
+ if tier is None and phase != "auto":
554
+ tier = Tier.BALANCED
555
+
556
+ catalog = None
557
+ if catalog_path and model_resolve in ("local_catalog", "role_catalog"):
558
+ catalog, error = load_catalog(catalog_path)
559
+ if error:
560
+ code = catalog_validation_reason_code(error, None)
561
+ return ResolveResult.failure(code, error)
562
+
563
+ return _resolve_tier_chain(phase, tier, model_resolve, model_fallback, catalog)
564
+
565
+
566
+ def get_tier_for_phase(phase: str) -> Optional[Tier]:
567
+ """Get default tier for a phase (None for 'auto')."""
568
+ return DEFAULT_PHASE_TIER_MATRIX.get(phase)
569
+
570
+
571
+ def get_alias_for_tier(tier: Tier) -> Optional[str]:
572
+ """Get Cursor alias for a tier (None means omit model: field)."""
573
+ return TIER_ALIAS_MAP.get(tier)
574
+
575
+
576
+ def self_test() -> bool:
577
+ """Run self-test to validate library contract (US-0101 / DEC-0086; US-0102 / DEC-0087)."""
578
+ print("[SELF-TEST] Validating model_tier_lib contract...")
579
+ errors = []
580
+
581
+ # Test 1: Tier enum values
582
+ expected_tiers = {"cheap", "balanced", "strong"}
583
+ actual_tiers = {t.value for t in Tier}
584
+ if actual_tiers != expected_tiers:
585
+ errors.append(f"Tier enum mismatch: expected {expected_tiers}, got {actual_tiers}")
586
+
587
+ # Test 2: Phase matrix has all expected phases
588
+ expected_phases = {
589
+ "ask", "refresh-context", "memory-audit", "status-reconcile", "pause",
590
+ "intake", "discovery", "research", "release", "plan-verify",
591
+ "architecture", "execute", "quick", "qa", "verify-work", "security-review",
592
+ "auto"
593
+ }
594
+ actual_phases = set(DEFAULT_PHASE_TIER_MATRIX.keys())
595
+ if actual_phases != expected_phases:
596
+ errors.append(f"Phase matrix mismatch: expected {expected_phases}, got {actual_phases}")
597
+
598
+ # Test 3: Tier→alias mapping
599
+ if TIER_ALIAS_MAP[Tier.CHEAP] != "fast":
600
+ errors.append(f"CHEAP alias mismatch: expected 'fast', got '{TIER_ALIAS_MAP[Tier.CHEAP]}'")
601
+ if TIER_ALIAS_MAP[Tier.BALANCED] != "inherit":
602
+ errors.append(f"BALANCED alias mismatch: expected 'inherit', got '{TIER_ALIAS_MAP[Tier.BALANCED]}'")
603
+ if TIER_ALIAS_MAP[Tier.STRONG] is not None:
604
+ errors.append(f"STRONG alias mismatch: expected None, got '{TIER_ALIAS_MAP[Tier.STRONG]}'")
605
+
606
+ # Test 4: Reason codes
607
+ expected_codes = {
608
+ "MODEL_TIER_INVALID", "MODEL_CATALOG_INVALID", "MODEL_SLUG_UNKNOWN",
609
+ "MODEL_RESOLVE_FALLBACK", "MODEL_OVERRIDE_SLUG_UNKNOWN",
610
+ "MODEL_ROLE_SLUG_UNKNOWN", "MODEL_CATALOG_SCHEMA_V2_INVALID",
611
+ }
612
+ actual_codes = {c.value for c in ReasonCode}
613
+ if actual_codes != expected_codes:
614
+ errors.append(f"ReasonCode mismatch: expected {expected_codes}, got {actual_codes}")
615
+
616
+ # Test 5: Precedence chain
617
+ if len(PRECEDENCE_CHAIN_STEPS) != 5:
618
+ errors.append(f"Precedence chain must have 5 steps, got {len(PRECEDENCE_CHAIN_STEPS)}")
619
+
620
+ # Test 6: resolve_model_for_phase exported
621
+ if not callable(resolve_model_for_phase):
622
+ errors.append("resolve_model_for_phase is not callable")
623
+
624
+ if errors:
625
+ print("[SELF_TEST_FAILED]")
626
+ for error in errors:
627
+ print(f" {error}", file=sys.stderr)
628
+ return False
629
+ else:
630
+ print("[MODEL_TIER_SELF_TEST_OK]")
631
+ return True
632
+
633
+
634
+ if __name__ == "__main__":
635
+ import argparse
636
+
637
+ parser = argparse.ArgumentParser(description="Model tier resolver (US-0101 / US-0102)")
638
+ parser.add_argument("--phase", help="Phase ID (e.g., execute, qa)")
639
+ parser.add_argument(
640
+ "--resolve",
641
+ default="alias_only",
642
+ choices=["alias_only", "local_catalog", "role_catalog"],
643
+ )
644
+ parser.add_argument("--fallback", default="inherit", help="Fallback strategy")
645
+ parser.add_argument("--catalog", type=Path, help="Path to local catalog")
646
+ parser.add_argument("--tier-override", help="Explicit tier (cheap|balanced|strong)")
647
+ parser.add_argument("--self-test", action="store_true", help="Run self-test")
648
+
649
+ args = parser.parse_args()
650
+
651
+ if args.self_test:
652
+ success = self_test()
653
+ sys.exit(0 if success else 1)
654
+
655
+ if not args.phase:
656
+ parser.error("--phase is required unless --self-test is specified")
657
+
658
+ result = resolve_model_tier(
659
+ phase=args.phase,
660
+ model_resolve=args.resolve,
661
+ model_fallback=args.fallback,
662
+ catalog_path=args.catalog,
663
+ tier_override=args.tier_override,
664
+ )
665
+
666
+ if result.success:
667
+ print(f"[OK] phase={args.phase} tier={result.tier.value if result.tier else 'n/a'}", end="")
668
+ if result.alias:
669
+ print(f" alias={result.alias}")
670
+ elif result.slug:
671
+ print(f" slug={result.slug}")
672
+ else:
673
+ print(" (omit model: field)")
674
+ if result.reason_code:
675
+ print(f" reason={result.reason_code.value}: {result.reason_message}")
676
+ sys.exit(0)
677
+ else:
678
+ print(f"[FAIL] phase={args.phase} reason={result.reason_code.value}")
679
+ print(f" {result.reason_message}")
680
+ sys.exit(1)