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.
- package/LICENSE +674 -0
- package/README.md +260 -0
- package/SKILL.md +89 -0
- package/dist/extension.d.ts +25 -0
- package/dist/extension.js +287 -0
- package/examples/rust/README.md +22 -0
- package/examples/rust/profile.yaml +51 -0
- package/package.json +71 -0
- package/references/dispatch-model-semantics.md +110 -0
- package/references/gate-runner-usage.md +19 -0
- package/references/migration-from-rust-agent-workflow.md +107 -0
- package/references/profile-schema.md +66 -0
- package/references/registry-resolution.md +14 -0
- package/references/scaffolder-usage.md +27 -0
- package/references/sync-settings-usage.md +67 -0
- package/registry/aliases.yaml +38 -0
- package/registry/built_in.yaml +42 -0
- package/requirements.txt +2 -0
- package/role-packs/coding/coding-architect.md +33 -0
- package/role-packs/coding/coding-auditor.md +31 -0
- package/role-packs/coding/coding-canary.md +31 -0
- package/role-packs/coding/coding-docs.md +32 -0
- package/role-packs/coding/coding-implementer.md +33 -0
- package/role-packs/coding/coding-mapper.md +31 -0
- package/role-packs/coding/coding-orchestrator.md +28 -0
- package/role-packs/coding/coding-planner.md +31 -0
- package/role-packs/coding/coding-profiler.md +31 -0
- package/role-packs/coding/coding-reviewer.md +32 -0
- package/role-packs/coding/coding-tester.md +32 -0
- package/scripts/gate_runner.py +155 -0
- package/scripts/install.sh +120 -0
- package/scripts/profile_loader.py +633 -0
- package/scripts/scaffolder.py +289 -0
- package/scripts/sync_settings.py +362 -0
- package/templates/blank.yaml +13 -0
- package/templates/go.yaml +44 -0
- package/templates/python.yaml +46 -0
- package/templates/rust.yaml +49 -0
- 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
|
+
)
|