pi-rolecast 0.2.0

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 (39) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +260 -0
  3. package/SKILL.md +89 -0
  4. package/dist/extension.d.ts +25 -0
  5. package/dist/extension.js +287 -0
  6. package/examples/rust/README.md +22 -0
  7. package/examples/rust/profile.yaml +51 -0
  8. package/package.json +71 -0
  9. package/references/dispatch-model-semantics.md +110 -0
  10. package/references/gate-runner-usage.md +19 -0
  11. package/references/migration-from-rust-agent-workflow.md +107 -0
  12. package/references/profile-schema.md +66 -0
  13. package/references/registry-resolution.md +14 -0
  14. package/references/scaffolder-usage.md +27 -0
  15. package/references/sync-settings-usage.md +67 -0
  16. package/registry/aliases.yaml +38 -0
  17. package/registry/built_in.yaml +42 -0
  18. package/requirements.txt +2 -0
  19. package/role-packs/coding/coding-architect.md +33 -0
  20. package/role-packs/coding/coding-auditor.md +31 -0
  21. package/role-packs/coding/coding-canary.md +31 -0
  22. package/role-packs/coding/coding-docs.md +32 -0
  23. package/role-packs/coding/coding-implementer.md +33 -0
  24. package/role-packs/coding/coding-mapper.md +31 -0
  25. package/role-packs/coding/coding-orchestrator.md +28 -0
  26. package/role-packs/coding/coding-planner.md +31 -0
  27. package/role-packs/coding/coding-profiler.md +31 -0
  28. package/role-packs/coding/coding-reviewer.md +32 -0
  29. package/role-packs/coding/coding-tester.md +32 -0
  30. package/scripts/gate_runner.py +155 -0
  31. package/scripts/install.sh +120 -0
  32. package/scripts/profile_loader.py +633 -0
  33. package/scripts/scaffolder.py +289 -0
  34. package/scripts/sync_settings.py +362 -0
  35. package/templates/blank.yaml +13 -0
  36. package/templates/go.yaml +44 -0
  37. package/templates/python.yaml +46 -0
  38. package/templates/rust.yaml +49 -0
  39. package/templates/typescript.yaml +46 -0
@@ -0,0 +1,633 @@
1
+ #!/usr/bin/env python3
2
+ """pi-rolecast/scripts/profile_loader.py
3
+
4
+ Load + validate a project-local rolecast profile.
5
+
6
+ Single source of truth for the profile schema. Consumed by:
7
+ - scripts/gate_runner.py (load_profile before phase execution)
8
+ - scripts/scaffolder.py validate (load_profile)
9
+ - scripts/scaffolder.py init (load_profile after writing new profile)
10
+ - scripts/sync_settings.py (load_profile + available_roles for dispatch)
11
+ - scripts/install.sh (indirectly via the Python helpers above)
12
+
13
+ v0.2.0 breaking changes:
14
+ * Roles are now grouped (role-packs/<group>/<role>.md). Profile bindings
15
+ use full names like `coding-architect` (hyphen-namespaced so pi-subagents'
16
+ `@\\w-` mention regex accepts them).
17
+ * Profile gains a top-level `workflow.role_groups: [list]` field that
18
+ declares which groups are enabled. Only roles inside enabled groups
19
+ can be bound.
20
+ * Project-local profile filename changed from `.pi/agent-workflow.yaml`
21
+ to `.pi/rolecast.yaml`. The legacy name is still recognised for one
22
+ release as a deprecation aid.
23
+
24
+ Resolution rules (alias → model + channel) live in this module too.
25
+ """
26
+ from __future__ import annotations
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any, Iterable
31
+
32
+ import yaml
33
+
34
+ # Legacy constant retained only for migration messages. v0.2.0+ roles are
35
+ # discovered dynamically from role-packs/<group>/<role>.md.
36
+ LEGACY_CORE_ROLES = frozenset({
37
+ "orchestrator", "architect", "planner", "implementer", "tester",
38
+ "reviewer", "mapper", "profiler", "auditor", "canary", "docs",
39
+ })
40
+
41
+ # Default role group shipped with the framework. Users can add more by
42
+ # dropping a directory under role-packs/.
43
+ DEFAULT_GROUP = "coding"
44
+
45
+ # Default triggers for the coding group. Keys are the full role name
46
+ # (`<group>-<role>`); values are the trigger phrases that route to that
47
+ # role via the orchestrator.
48
+ DEFAULT_TRIGGERS: dict[str, list[str]] = {
49
+ "coding-architect": ["design", "architect", "trait", "API design", "system design"],
50
+ "coding-planner": ["plan", "plan this change", "break this down"],
51
+ "coding-implementer": ["implement", "code", "do it", "make this change"],
52
+ "coding-tester": ["write tests", "test this", "add coverage"],
53
+ "coding-reviewer": ["review this diff", "review", "check this"],
54
+ "coding-mapper": ["map", "repo map", "what's in this repo"],
55
+ "coding-profiler": ["profile this", "this is slow", "why is X slow"],
56
+ "coding-auditor": ["audit", "audit security", "check for vulnerabilities",
57
+ "what could go wrong"],
58
+ "coding-canary": ["is the relay real", "which group answered", "canary check"],
59
+ "coding-docs": ["write README", "document this", "user-facing copy",
60
+ "frontend"],
61
+ # coding-orchestrator is always-on, no phrase triggers
62
+ }
63
+
64
+
65
+ DEFAULT_FRAMEWORK_VERSION = "0.2.0"
66
+
67
+
68
+ class ProfileError(ValueError):
69
+ """Raised when a profile fails validation."""
70
+
71
+
72
+ @dataclass
73
+ class ForbiddenPattern:
74
+ pattern: str
75
+ message: str
76
+ compiled: re.Pattern
77
+
78
+ @classmethod
79
+ def from_dict(cls, d: dict) -> "ForbiddenPattern":
80
+ if "pattern" not in d or "message" not in d:
81
+ raise ProfileError("forbidden_pattern entry needs {pattern, message}")
82
+ try:
83
+ compiled = re.compile(d["pattern"])
84
+ except re.error as e:
85
+ raise ProfileError(f"forbidden_pattern regex invalid: {d['pattern']!r} ({e})") from e
86
+ return cls(pattern=d["pattern"], message=d["message"], compiled=compiled)
87
+
88
+
89
+ @dataclass
90
+ class NonNegotiables:
91
+ forbidden_patterns: list[ForbiddenPattern] = field(default_factory=list)
92
+ scope_constraints: dict[str, Any] = field(default_factory=dict)
93
+ required_gates: list[str] = field(default_factory=list)
94
+
95
+
96
+ @dataclass
97
+ class CustomRole:
98
+ name: str
99
+ description: str
100
+ agent_file: str
101
+ default_alias: str
102
+ default_channels: list[str]
103
+ triggers: list[str] = field(default_factory=list)
104
+
105
+
106
+ @dataclass
107
+ class Binding:
108
+ alias: str
109
+ channels: list[str]
110
+
111
+
112
+ @dataclass
113
+ class WorkflowConfig:
114
+ """Top-level `workflow:` block of a profile."""
115
+ role_groups: list[str] = field(default_factory=list)
116
+
117
+
118
+ @dataclass
119
+ class Escalation:
120
+ max_attempts: int = 2
121
+ on_permanent_failure: str = "stop" # stop | continue
122
+ preserve_logs: bool = True
123
+
124
+
125
+ @dataclass
126
+ class RoleDef:
127
+ """A role discovered from role-packs/<group>/<role>.md."""
128
+ full_name: str # e.g. "coding-architect"
129
+ group: str # e.g. "coding"
130
+ role: str # e.g. "architect" (the basename)
131
+ description: str = ""
132
+ triggers: list[str] = field(default_factory=list)
133
+ file_path: Path | None = None
134
+
135
+
136
+ @dataclass
137
+ class Profile:
138
+ framework_version: str
139
+ name: str
140
+ description: str
141
+ gates: dict[str, dict]
142
+ bindings: dict[str, Binding]
143
+ non_negotiables: NonNegotiables
144
+ escalation: Escalation
145
+ trigger_overrides: dict[str, dict]
146
+ custom_roles: list[CustomRole]
147
+ workflow: WorkflowConfig
148
+ resolved_bindings: dict[str, ResolvedBinding] = field(default_factory=dict)
149
+
150
+ @property
151
+ def enabled_role_names(self) -> set[str]:
152
+ """All role names available for binding under the enabled groups."""
153
+ return {b for b in self.bindings}
154
+
155
+ @property
156
+ def custom_role_names(self) -> set[str]:
157
+ return {r.name for r in self.custom_roles}
158
+
159
+
160
+ # ─────────────────────────────────────────────────────────────────────
161
+ # Role discovery from role-packs/<group>/<role>.md
162
+ # ─────────────────────────────────────────────────────────────────────
163
+
164
+ _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
165
+ _FM_FIELD_RE = re.compile(r"^(\w+):\s*(.*)$", re.MULTILINE)
166
+
167
+
168
+ def _parse_frontmatter(text: str) -> dict[str, str]:
169
+ m = _FRONTMATTER_RE.match(text)
170
+ if not m:
171
+ return {}
172
+ out: dict[str, str] = {}
173
+ for ln in m.group(1).splitlines():
174
+ m2 = _FM_FIELD_RE.match(ln)
175
+ if m2:
176
+ out[m2.group(1)] = m2.group(2).strip()
177
+ return out
178
+
179
+
180
+ def discover_role_packs(framework_root: str | Path) -> dict[str, list[RoleDef]]:
181
+ """Walk role-packs/<group>/*.md and return {group_name: [RoleDef...]}."""
182
+ root = Path(framework_root) / "role-packs"
183
+ if not root.is_dir():
184
+ return {}
185
+ out: dict[str, list[RoleDef]] = {}
186
+ for group_dir in sorted(root.iterdir()):
187
+ if not group_dir.is_dir():
188
+ continue
189
+ group = group_dir.name
190
+ roles: list[RoleDef] = []
191
+ for md in sorted(group_dir.glob("*.md")):
192
+ role = md.stem
193
+ # Strip optional `<group>-` prefix from filename so users can
194
+ # name files either `architect.md` or `coding-architect.md`.
195
+ if role.startswith(f"{group}-"):
196
+ role = role[len(group) + 1:]
197
+ fm = _parse_frontmatter(md.read_text())
198
+ full_name = fm.get("name", "").strip() or f"{group}-{role}"
199
+ desc = fm.get("description", "").strip()
200
+ roles.append(RoleDef(
201
+ full_name=full_name,
202
+ group=group,
203
+ role=role,
204
+ description=desc,
205
+ file_path=md,
206
+ ))
207
+ if roles:
208
+ out[group] = roles
209
+ return out
210
+
211
+
212
+ def available_roles(framework_root: str | Path,
213
+ groups: Iterable[str] | None = None) -> dict[str, RoleDef]:
214
+ """Return {full_role_name: RoleDef} for the requested groups (or all
215
+ groups if groups is None). The full role name format is
216
+ `<group>-<role>` (hyphen-namespaced)."""
217
+ packs = discover_role_packs(framework_root)
218
+ out: dict[str, RoleDef] = {}
219
+ target_groups = list(groups) if groups is not None else list(packs.keys())
220
+ for g in target_groups:
221
+ for rd in packs.get(g, []):
222
+ out[rd.full_name] = rd
223
+ return out
224
+
225
+
226
+ # ─────────────────────────────────────────────────────────────────────
227
+ # Loader entrypoint
228
+ # ─────────────────────────────────────────────────────────────────────
229
+
230
+ # v0.2.0 accepts both .pi/rolecast.yaml (new) and .pi/agent-workflow.yaml
231
+ # (legacy). The legacy form prints a deprecation hint but still loads.
232
+ LEGACY_PROFILE_FILENAMES = ("agent-workflow.yaml",)
233
+
234
+
235
+ def find_profile(cwd: str | Path = ".") -> Path | None:
236
+ """Return the first existing profile in cwd, preferring the new name."""
237
+ p = Path(cwd)
238
+ new = p / ".pi" / "rolecast.yaml"
239
+ if new.is_file():
240
+ return new
241
+ for legacy in LEGACY_PROFILE_FILENAMES:
242
+ cand = p / ".pi" / legacy
243
+ if cand.is_file():
244
+ return cand
245
+ return None
246
+
247
+
248
+ def load_profile(path: str | Path, *, framework_root: str | Path | None = None) -> Profile:
249
+ p = Path(path)
250
+ if not p.is_file():
251
+ raise ProfileError(f"profile not found: {p}")
252
+ raw = yaml.safe_load(p.read_text())
253
+ if not isinstance(raw, dict):
254
+ raise ProfileError(f"profile {p} is not a YAML mapping")
255
+ root = Path(framework_root) if framework_root else Path(__file__).resolve().parent.parent
256
+ profile = parse_profile(raw, framework_root=root)
257
+ registry = load_registry(root)
258
+ profile.resolved_bindings = resolve_bindings(profile, registry)
259
+ return profile
260
+
261
+
262
+ def parse_profile(raw: dict, *, framework_root: str | Path | None = None) -> Profile:
263
+ # Required fields
264
+ fv = raw.get("framework_version")
265
+ if not fv:
266
+ raise ProfileError("profile.framework_version is required")
267
+ name = raw.get("name")
268
+ if not name:
269
+ raise ProfileError("profile.name is required")
270
+ description = raw.get("description")
271
+ if not description:
272
+ raise ProfileError("profile.description is required")
273
+
274
+ gates = raw.get("gates") or {}
275
+ if not isinstance(gates, dict):
276
+ raise ProfileError("profile.gates must be a mapping")
277
+
278
+ workflow = _parse_workflow(raw.get("workflow") or {})
279
+ custom_roles = _parse_custom_roles(raw.get("custom_roles") or [])
280
+
281
+ root = Path(framework_root) if framework_root else Path(__file__).resolve().parent.parent
282
+ packs = available_roles(root, groups=workflow.role_groups)
283
+ allowed_roles = set(packs.keys()) | {r.name for r in custom_roles}
284
+
285
+ bindings = _parse_bindings(raw.get("bindings") or {}, allowed_roles)
286
+ trigger_overrides = raw.get("trigger_overrides") or {}
287
+ if not isinstance(trigger_overrides, dict):
288
+ raise ProfileError("profile.trigger_overrides must be a mapping")
289
+ non_negotiables = _parse_non_negotiables(raw.get("non_negotiables") or {})
290
+ escalation = _parse_escalation(raw.get("escalation") or {})
291
+
292
+ profile = Profile(
293
+ framework_version=str(fv),
294
+ name=str(name),
295
+ description=str(description),
296
+ gates=gates,
297
+ bindings=bindings,
298
+ non_negotiables=non_negotiables,
299
+ escalation=escalation,
300
+ trigger_overrides=trigger_overrides,
301
+ custom_roles=custom_roles,
302
+ workflow=workflow,
303
+ )
304
+
305
+ _check_trigger_collisions(profile, packs)
306
+ return profile
307
+
308
+
309
+ # ─────────────────────────────────────────────────────────────────────
310
+ # Sub-parsers
311
+ # ─────────────────────────────────────────────────────────────────────
312
+
313
+ def _parse_workflow(raw: Any) -> WorkflowConfig:
314
+ if not isinstance(raw, dict):
315
+ raise ProfileError("profile.workflow must be a mapping")
316
+ rg = raw.get("role_groups", [])
317
+ if not isinstance(rg, list):
318
+ raise ProfileError("workflow.role_groups must be a list of strings")
319
+ bad = [g for g in rg if not isinstance(g, str) or not g]
320
+ if bad:
321
+ raise ProfileError(f"workflow.role_groups has non-string entries: {bad}")
322
+ return WorkflowConfig(role_groups=list(rg))
323
+
324
+
325
+ def _parse_custom_roles(items: Iterable[Any]) -> list[CustomRole]:
326
+ out: list[CustomRole] = []
327
+ seen: set[str] = set()
328
+ required = ("name", "description", "agent_file", "default_alias", "default_channels")
329
+ for i, item in enumerate(items):
330
+ if not isinstance(item, dict):
331
+ raise ProfileError(f"custom_roles[{i}] must be a mapping")
332
+ missing = [k for k in required if k not in item]
333
+ if missing:
334
+ raise ProfileError(f"custom_roles[{i}] missing fields: {missing}")
335
+ if item["name"] in seen:
336
+ raise ProfileError(f"custom_roles[{i}].name duplicates {item['name']}")
337
+ seen.add(item["name"])
338
+ if not isinstance(item["default_channels"], list) or not item["default_channels"]:
339
+ raise ProfileError(f"custom_roles[{i}].default_channels must be non-empty list")
340
+ if not isinstance(item["triggers"], list):
341
+ raise ProfileError(f"custom_roles[{i}].triggers must be a list")
342
+ out.append(CustomRole(
343
+ name=item["name"],
344
+ description=item["description"],
345
+ agent_file=item["agent_file"],
346
+ default_alias=item["default_alias"],
347
+ default_channels=list(item["default_channels"]),
348
+ triggers=list(item["triggers"]),
349
+ ))
350
+ return out
351
+
352
+
353
+ def _parse_bindings(raw: dict, allowed_roles: set[str]) -> dict[str, Binding]:
354
+ if not isinstance(raw, dict):
355
+ raise ProfileError("profile.bindings must be a mapping")
356
+ out: dict[str, Binding] = {}
357
+ for role, b in raw.items():
358
+ if role not in allowed_roles:
359
+ hint = ""
360
+ if role in LEGACY_CORE_ROLES:
361
+ hint = (f" (hint: '{role}' is a legacy coding role name; "
362
+ f"use 'coding-{role}' in v0.2.0+, and add "
363
+ f"`workflow.role_groups: [coding]` to your profile)")
364
+ raise ProfileError(
365
+ f"bindings key '{role}' is not in any enabled role group "
366
+ f"and is not declared in custom_roles{hint}"
367
+ )
368
+ if not isinstance(b, dict):
369
+ raise ProfileError(f"bindings.{role} must be a mapping")
370
+ if "alias" not in b:
371
+ raise ProfileError(f"bindings.{role}.alias is required")
372
+ if "channels" not in b or not isinstance(b["channels"], list) or not b["channels"]:
373
+ raise ProfileError(f"bindings.{role}.channels must be a non-empty list")
374
+ out[role] = Binding(alias=b["alias"], channels=list(b["channels"]))
375
+ return out
376
+
377
+
378
+ def _parse_non_negotiables(raw: dict) -> NonNegotiables:
379
+ if not isinstance(raw, dict):
380
+ raise ProfileError("profile.non_negotiables must be a mapping")
381
+ patterns = [ForbiddenPattern.from_dict(p) for p in raw.get("forbidden_patterns", [])]
382
+ return NonNegotiables(
383
+ forbidden_patterns=patterns,
384
+ scope_constraints=dict(raw.get("scope_constraints", {}) or {}),
385
+ required_gates=list(raw.get("required_gates", []) or []),
386
+ )
387
+
388
+
389
+ def _parse_escalation(raw: dict) -> Escalation:
390
+ if not isinstance(raw, dict):
391
+ raise ProfileError("profile.escalation must be a mapping")
392
+ opf = raw.get("on_permanent_failure", "stop")
393
+ if opf not in {"stop", "continue"}:
394
+ raise ProfileError("escalation.on_permanent_failure must be 'stop' or 'continue'")
395
+ return Escalation(
396
+ max_attempts=int(raw.get("max_attempts", 2)),
397
+ on_permanent_failure=opf,
398
+ preserve_logs=bool(raw.get("preserve_logs", True)),
399
+ )
400
+
401
+
402
+ # ─────────────────────────────────────────────────────────────────────
403
+ # Trigger collision check
404
+ # ─────────────────────────────────────────────────────────────────────
405
+
406
+ def _check_trigger_collisions(profile: Profile,
407
+ packs: dict[str, RoleDef]) -> None:
408
+ phrase_to_roles: dict[str, set[str]] = {}
409
+ # Default triggers: ones we hardcode for the coding group + any custom
410
+ # role triggers. We use the full prefixed name so collisions stay scoped.
411
+ for full_name, phrases in DEFAULT_TRIGGERS.items():
412
+ for p in phrases:
413
+ phrase_to_roles.setdefault(p.lower(), set()).add(full_name)
414
+ # Per-role triggers declared in role-packs/<group>/<role>.md frontmatter
415
+ # (rare today but reserved for future-proofing).
416
+ for rd in packs.values():
417
+ for p in rd.triggers:
418
+ phrase_to_roles.setdefault(p.lower(), set()).add(rd.full_name)
419
+ for phrase, override in profile.trigger_overrides.items():
420
+ target = override.get("role") if isinstance(override, dict) else None
421
+ if not target:
422
+ raise ProfileError(
423
+ f"trigger_overrides['{phrase}'] must map to {{role: <role>}}"
424
+ )
425
+ if (target not in profile.bindings
426
+ and target not in profile.custom_role_names):
427
+ raise ProfileError(
428
+ f"trigger_overrides['{phrase}'] targets unknown role '{target}'"
429
+ )
430
+ phrase_to_roles.setdefault(phrase.lower(), set()).add(target)
431
+ for role in profile.custom_roles:
432
+ for p in role.triggers:
433
+ phrase_to_roles.setdefault(p.lower(), set()).add(role.name)
434
+
435
+ collisions = {
436
+ phrase: sorted(roles)
437
+ for phrase, roles in phrase_to_roles.items()
438
+ if len(roles) > 1
439
+ }
440
+ if collisions:
441
+ msg = "\n".join(f" '{p}' -> {r}" for p, r in collisions.items())
442
+ raise ProfileError(f"trigger phrase collision:\n{msg}")
443
+
444
+
445
+ # ─────────────────────────────────────────────────────────────────────
446
+ # Registry + alias resolution
447
+ # ─────────────────────────────────────────────────────────────────────
448
+
449
+ @dataclass
450
+ class Model:
451
+ id: str
452
+ vendor: str
453
+ capabilities: dict
454
+ channels: list[dict]
455
+ cost_tier: str
456
+ status: str
457
+
458
+
459
+ @dataclass
460
+ class Alias:
461
+ name: str
462
+ preferred: str
463
+ fallback_chain: list[str]
464
+ notes: str = ""
465
+
466
+
467
+ @dataclass
468
+ class ResolvedModel:
469
+ model_id: str
470
+ channel_id: str
471
+ trust: str
472
+ via_fallback: bool = False
473
+ warning: str | None = None
474
+
475
+
476
+ @dataclass
477
+ class ResolvedBinding:
478
+ role: str
479
+ alias: str
480
+ model_id: str
481
+ channel_id: str
482
+ trust: str
483
+ warning: str | None = None
484
+ via_fallback: bool = False
485
+
486
+
487
+ class Registry:
488
+ """Merged view of built-in + user-global + project-local registry layers."""
489
+
490
+ def __init__(self, models: dict[str, Model], aliases: dict[str, Alias]):
491
+ self._models = models
492
+ self._aliases = aliases
493
+
494
+ def has_model(self, model_id: str) -> bool:
495
+ return model_id in self._models
496
+
497
+ def get_model(self, model_id: str) -> Model:
498
+ if model_id not in self._models:
499
+ raise ProfileError(f"unknown model: {model_id}")
500
+ return self._models[model_id]
501
+
502
+ def resolve_alias(self, name: str) -> ResolvedModel:
503
+ if name not in self._aliases:
504
+ raise ProfileError(f"unknown alias: {name}")
505
+ alias = self._aliases[name]
506
+ m = self._models.get(alias.preferred)
507
+ if m is None:
508
+ raise ProfileError(
509
+ f"alias '{name}' preferred model '{alias.preferred}' "
510
+ f"not in merged registry"
511
+ )
512
+ if m.status == "withdrawn":
513
+ raise ProfileError(
514
+ f"alias '{name}' resolves to withdrawn model '{m.id}'"
515
+ )
516
+ ch = m.channels[0]
517
+ warning = (
518
+ f"alias '{name}' resolves to deprecated model '{m.id}' — "
519
+ f"update profile to a stable alias"
520
+ if m.status == "deprecated" else None
521
+ )
522
+ return ResolvedModel(
523
+ model_id=m.id,
524
+ channel_id=ch["id"],
525
+ trust=ch["trust"],
526
+ warning=warning,
527
+ )
528
+
529
+
530
+ def load_registry(framework_root: str | Path) -> Registry:
531
+ """Build a Registry by deep-merging three layers in priority order:
532
+ built-in → user-global → project-local (later wins)."""
533
+ root = Path(framework_root)
534
+ builtin_models, builtin_aliases = _read_registry_pair(root / "registry")
535
+
536
+ user_models, _ = _read_registry_pair(_user_global_dir() / "registry-overrides.yaml")
537
+ _, user_aliases = _read_registry_pair(_user_global_dir() / "aliases-overrides.yaml")
538
+
539
+ cwd = Path.cwd()
540
+ proj_models, proj_aliases = _read_registry_pair(
541
+ cwd / ".pi" / "rolecast-registry.yaml",
542
+ default={"models": [], "aliases": {}},
543
+ )
544
+
545
+ merged_models = _merge_models(builtin_models, user_models, proj_models)
546
+ merged_aliases = _merge_aliases(builtin_aliases, user_aliases, proj_aliases)
547
+ return Registry(merged_models, merged_aliases)
548
+
549
+
550
+ def _user_global_dir() -> Path:
551
+ return Path.home() / ".pi" / "rolecast"
552
+
553
+
554
+ def _read_registry_pair(path: Path, default: dict | None = None) -> tuple[list, dict]:
555
+ if path.is_dir():
556
+ builtin_p = path / "built_in.yaml"
557
+ alias_p = path / "aliases.yaml"
558
+ models = yaml.safe_load(builtin_p.read_text())["models"] if builtin_p.exists() else []
559
+ aliases = yaml.safe_load(alias_p.read_text())["aliases"] if alias_p.exists() else {}
560
+ return models, aliases
561
+ if path.is_file():
562
+ raw = yaml.safe_load(path.read_text()) or (default or {"models": [], "aliases": {}})
563
+ return raw.get("models", []) or [], raw.get("aliases", {}) or {}
564
+ return (default or {}).get("models", []) or [], (default or {}).get("aliases", {}) or {}
565
+
566
+
567
+ def _merge_models(*layers: list[dict]) -> dict[str, Model]:
568
+ merged_raw: dict[str, dict] = {}
569
+ for layer in layers:
570
+ for m in layer:
571
+ mid = m["id"]
572
+ base = merged_raw.get(mid, {})
573
+ merged_raw[mid] = {**base, **m}
574
+ out: dict[str, Model] = {}
575
+ for mid, m in merged_raw.items():
576
+ out[mid] = Model(
577
+ id=mid,
578
+ vendor=m.get("vendor", ""),
579
+ capabilities=m.get("capabilities", {}) or {},
580
+ channels=list(m.get("channels", []) or []),
581
+ cost_tier=m.get("cost_tier", ""),
582
+ status=m.get("status", "stable"),
583
+ )
584
+ return out
585
+
586
+
587
+ def _merge_aliases(*layers: dict) -> dict[str, Alias]:
588
+ merged_raw: dict[str, dict] = {}
589
+ for layer in layers:
590
+ for name, a in layer.items():
591
+ base = merged_raw.get(name, {})
592
+ merged_raw[name] = {**base, **a}
593
+ out: dict[str, Alias] = {}
594
+ for name, a in merged_raw.items():
595
+ if "preferred" not in a:
596
+ raise ProfileError(
597
+ f"alias '{name}' missing required field 'preferred' after merge"
598
+ )
599
+ out[name] = Alias(
600
+ name=name,
601
+ preferred=a["preferred"],
602
+ fallback_chain=list(a.get("fallback_chain", []) or []),
603
+ notes=a.get("notes", "") or "",
604
+ )
605
+ return out
606
+
607
+
608
+ def resolve_bindings(profile: Profile, registry: Registry) -> dict[str, ResolvedBinding]:
609
+ out: dict[str, ResolvedBinding] = {}
610
+ for role, binding in profile.bindings.items():
611
+ resolved = _resolve_binding(role, binding, registry)
612
+ out[role] = resolved
613
+ return out
614
+
615
+
616
+ def _resolve_binding(role: str, binding: Binding, registry: Registry) -> ResolvedBinding:
617
+ try:
618
+ primary = registry.resolve_alias(binding.alias)
619
+ except ProfileError as e:
620
+ raise ProfileError(f"bindings.{role}: {e}") from e
621
+ primary_model = registry.get_model(primary.model_id)
622
+ for ch in primary_model.channels:
623
+ if ch["id"] in binding.channels:
624
+ return ResolvedBinding(
625
+ role=role, alias=binding.alias,
626
+ model_id=primary_model.id, channel_id=ch["id"],
627
+ trust=ch["trust"], warning=primary.warning,
628
+ )
629
+ available = [ch["id"] for ch in primary_model.channels]
630
+ raise ProfileError(
631
+ f"bindings.{role}: no channel in {binding.channels} is available "
632
+ f"for preferred model '{primary_model.id}' (model exposes: {available})"
633
+ )