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,289 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""scaffolder.py — init / diff / validate CLI for project profiles.
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
- init [--template LANG] [--blank] [--dry-run] [--force]
|
|
6
|
+
- diff (spec §9.4)
|
|
7
|
+
- validate (spec §9.5)
|
|
8
|
+
|
|
9
|
+
Init flow: auto-detect language → load template → walk user through
|
|
10
|
+
sections → write profile + custom-role README.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
from profile_loader import ProfileError, load_profile
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
23
|
+
# Language auto-detect (spec §9.2)
|
|
24
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
LANGUAGE_MARKERS: list[tuple[str, list[str]]] = [
|
|
27
|
+
# (language, list of files; all required)
|
|
28
|
+
("rust", ["Cargo.toml"]),
|
|
29
|
+
("python", ["pyproject.toml"]),
|
|
30
|
+
("python", ["setup.py"]),
|
|
31
|
+
("typescript", ["package.json", "tsconfig.json"]),
|
|
32
|
+
("go", ["go.mod"]),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def auto_detect_languages(project_root: str | Path) -> list[str]:
|
|
37
|
+
"""Return ALL detected languages for a project (multi-lang aware).
|
|
38
|
+
|
|
39
|
+
Spec §9.2: package.json alone is ambiguous — caller must prompt user.
|
|
40
|
+
Both "typescript" and "javascript" are surfaced so the prompt can offer
|
|
41
|
+
both choices. detect_language() returns None in that case.
|
|
42
|
+
"""
|
|
43
|
+
root = Path(project_root)
|
|
44
|
+
detected: list[str] = []
|
|
45
|
+
# package.json alone is ambiguous — handled separately.
|
|
46
|
+
if (root / "package.json").exists() and not (root / "tsconfig.json").exists():
|
|
47
|
+
detected.extend(["typescript", "javascript"])
|
|
48
|
+
for lang, files in LANGUAGE_MARKERS:
|
|
49
|
+
if all((root / f).exists() for f in files) and lang not in detected:
|
|
50
|
+
detected.append(lang)
|
|
51
|
+
return detected
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def detect_language(project_root: str | Path) -> str | None:
|
|
55
|
+
"""Return the single best-guess language (spec §9.2 priority order),
|
|
56
|
+
or None if ambiguous / unknown.
|
|
57
|
+
|
|
58
|
+
Ambiguity is narrow: detected == ["typescript", "javascript"]
|
|
59
|
+
(package.json-only). Other languages override the ambiguity — e.g.
|
|
60
|
+
Cargo.toml + package.json still yields "rust" via priority order.
|
|
61
|
+
"""
|
|
62
|
+
detected = auto_detect_languages(project_root)
|
|
63
|
+
if detected == ["typescript", "javascript"]:
|
|
64
|
+
# Spec §9.2 row 4 — caller must prompt.
|
|
65
|
+
return None
|
|
66
|
+
confident = [l for l in detected if l != "javascript"]
|
|
67
|
+
if len(confident) == 1:
|
|
68
|
+
return confident[0]
|
|
69
|
+
if len(confident) == 0:
|
|
70
|
+
return None
|
|
71
|
+
# Multiple confident matches — return first by priority order
|
|
72
|
+
priority = ["rust", "python", "typescript", "go"]
|
|
73
|
+
for p in priority:
|
|
74
|
+
if p in confident:
|
|
75
|
+
return p
|
|
76
|
+
return confident[0]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
80
|
+
# init subcommand (partial — template + profile generation in T9)
|
|
81
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
84
|
+
project_root = Path(args.project_root).resolve()
|
|
85
|
+
framework_root = Path(args.framework_root).resolve()
|
|
86
|
+
templates_dir = framework_root / "templates"
|
|
87
|
+
# v0.2.0: prefer new filename; fall back to legacy if --legacy-name is passed.
|
|
88
|
+
profile_dir = project_root / ".pi"
|
|
89
|
+
if getattr(args, "legacy_name", False):
|
|
90
|
+
profile_path = profile_dir / "agent-workflow.yaml"
|
|
91
|
+
else:
|
|
92
|
+
profile_path = profile_dir / "rolecast.yaml"
|
|
93
|
+
|
|
94
|
+
if args.template:
|
|
95
|
+
lang = args.template
|
|
96
|
+
elif args.blank:
|
|
97
|
+
lang = None
|
|
98
|
+
else:
|
|
99
|
+
lang = _prompt_language(project_root)
|
|
100
|
+
|
|
101
|
+
if args.dry_run:
|
|
102
|
+
print(f"DRY RUN — would create: {profile_path}")
|
|
103
|
+
print(f"template: {lang or '(blank)'}")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
template_data = _load_template(templates_dir, lang) if lang else _blank_template()
|
|
107
|
+
|
|
108
|
+
if profile_path.exists() and not args.force:
|
|
109
|
+
print(f"profile already exists at {profile_path}; pass --force to overwrite",
|
|
110
|
+
file=sys.stderr)
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
profile_path.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
_write_profile(profile_path, template_data)
|
|
115
|
+
print(f"wrote {profile_path}")
|
|
116
|
+
print("next steps:")
|
|
117
|
+
print(f" python3 {framework_root}/scripts/scaffolder.py validate --profile {profile_path}")
|
|
118
|
+
print(f" python3 {framework_root}/scripts/gate_runner.py --profile {profile_path} --phase all")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _prompt_language(project_root: Path) -> str | None:
|
|
123
|
+
detected = auto_detect_languages(project_root)
|
|
124
|
+
if detected == ["typescript", "javascript"]:
|
|
125
|
+
# Spec §9.2 row 4 — caller must specify typescript or javascript.
|
|
126
|
+
print(
|
|
127
|
+
"Detected package.json without tsconfig.json.",
|
|
128
|
+
file=sys.stderr,
|
|
129
|
+
)
|
|
130
|
+
print(
|
|
131
|
+
"Specify --template typescript or --template javascript.",
|
|
132
|
+
file=sys.stderr,
|
|
133
|
+
)
|
|
134
|
+
return None
|
|
135
|
+
confident = [l for l in detected if l != "javascript"]
|
|
136
|
+
if not confident:
|
|
137
|
+
print("could not auto-detect language.", file=sys.stderr)
|
|
138
|
+
return None
|
|
139
|
+
if len(confident) > 1:
|
|
140
|
+
print(f"multiple languages detected: {confident}", file=sys.stderr)
|
|
141
|
+
print("use --template to pick one", file=sys.stderr)
|
|
142
|
+
return None
|
|
143
|
+
return confident[0]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _load_template(templates_dir: Path, lang: str) -> dict:
|
|
147
|
+
p = templates_dir / f"{lang}.yaml"
|
|
148
|
+
if not p.exists():
|
|
149
|
+
raise FileNotFoundError(f"no template for language '{lang}': {p}")
|
|
150
|
+
import yaml
|
|
151
|
+
return yaml.safe_load(p.read_text())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _blank_template() -> dict:
|
|
155
|
+
return {
|
|
156
|
+
"framework_version": "0.2.0",
|
|
157
|
+
"name": "",
|
|
158
|
+
"description": "",
|
|
159
|
+
"workflow": {"role_groups": []},
|
|
160
|
+
"gates": {},
|
|
161
|
+
"bindings": {},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _write_profile(path: Path, data: dict) -> None:
|
|
166
|
+
import yaml
|
|
167
|
+
path.write_text(yaml.safe_dump(data, sort_keys=False))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
171
|
+
# CLI dispatcher
|
|
172
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
175
|
+
p = argparse.ArgumentParser(prog="scaffolder")
|
|
176
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
177
|
+
|
|
178
|
+
init = sub.add_parser("init", help="generate a profile for the current project")
|
|
179
|
+
init.add_argument("--template", help="language template to use (rust, typescript, python, go, blank)")
|
|
180
|
+
init.add_argument("--blank", action="store_true", help="emit a profile with no fields pre-filled")
|
|
181
|
+
init.add_argument("--dry-run", action="store_true", help="print what would be created, do not write")
|
|
182
|
+
init.add_argument("--force", action="store_true", help="overwrite existing profile")
|
|
183
|
+
init.add_argument("--project-root", default=".", help="project root (default: cwd)")
|
|
184
|
+
init.add_argument("--framework-root", default=str(Path(__file__).resolve().parent.parent))
|
|
185
|
+
init.add_argument("--interactive", action="store_true",
|
|
186
|
+
help="walk through each profile section interactively (v2; v1 is a no-op)")
|
|
187
|
+
init.add_argument("--legacy-name", action="store_true",
|
|
188
|
+
help="write to .pi/agent-workflow.yaml instead of .pi/rolecast.yaml")
|
|
189
|
+
init.set_defaults(func=cmd_init)
|
|
190
|
+
|
|
191
|
+
diff = sub.add_parser("diff", help="report schema differences vs current framework")
|
|
192
|
+
diff.add_argument("--profile", required=True, help="path to rolecast.yaml (or legacy agent-workflow.yaml)")
|
|
193
|
+
diff.add_argument("--framework-root", default=str(Path(__file__).resolve().parent.parent))
|
|
194
|
+
diff.set_defaults(func=cmd_diff)
|
|
195
|
+
|
|
196
|
+
validate = sub.add_parser("validate", help="validate a profile against current schema")
|
|
197
|
+
validate.add_argument("--profile", required=True, help="path to rolecast.yaml (or legacy agent-workflow.yaml)")
|
|
198
|
+
validate.add_argument("--framework-root", default=str(Path(__file__).resolve().parent.parent))
|
|
199
|
+
validate.set_defaults(func=cmd_validate)
|
|
200
|
+
|
|
201
|
+
return p
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Stub implementations for diff + validate — filled in T10/T11.
|
|
205
|
+
def cmd_diff(args) -> int:
|
|
206
|
+
"""Report schema drift between profile.framework_version and the
|
|
207
|
+
current framework. Reads `added-fields.yaml` and `removed-fields.yaml`
|
|
208
|
+
from framework root for schema evolution metadata."""
|
|
209
|
+
import yaml
|
|
210
|
+
profile_path = Path(args.profile).resolve()
|
|
211
|
+
fw_root = Path(args.framework_root).resolve()
|
|
212
|
+
if not profile_path.exists():
|
|
213
|
+
print(f"profile not found: {profile_path}", file=sys.stderr)
|
|
214
|
+
return 1
|
|
215
|
+
raw = yaml.safe_load(profile_path.read_text())
|
|
216
|
+
if not isinstance(raw, dict):
|
|
217
|
+
print("profile is not a YAML mapping", file=sys.stderr)
|
|
218
|
+
return 1
|
|
219
|
+
profile_version = raw.get("framework_version")
|
|
220
|
+
if not profile_version:
|
|
221
|
+
print("profile.framework_version is required for diff", file=sys.stderr)
|
|
222
|
+
return 1
|
|
223
|
+
|
|
224
|
+
added_file = fw_root / "added-fields.yaml"
|
|
225
|
+
removed_file = fw_root / "removed-fields.yaml"
|
|
226
|
+
profile_fields = set(_flatten_keys(raw))
|
|
227
|
+
|
|
228
|
+
missing = []
|
|
229
|
+
if added_file.exists():
|
|
230
|
+
added_data = yaml.safe_load(added_file.read_text()) or {}
|
|
231
|
+
for f in added_data.get("added", []):
|
|
232
|
+
if f not in profile_fields:
|
|
233
|
+
missing.append(f)
|
|
234
|
+
|
|
235
|
+
print(f"profile framework_version: {profile_version}")
|
|
236
|
+
if missing:
|
|
237
|
+
print("fields missing from profile (added in newer framework):")
|
|
238
|
+
for f in missing:
|
|
239
|
+
print(f" - {f}")
|
|
240
|
+
else:
|
|
241
|
+
print("no missing fields")
|
|
242
|
+
|
|
243
|
+
deprecated = []
|
|
244
|
+
if removed_file.exists():
|
|
245
|
+
removed_data = yaml.safe_load(removed_file.read_text()) or {}
|
|
246
|
+
for f in removed_data.get("removed", []):
|
|
247
|
+
if f in profile_fields:
|
|
248
|
+
deprecated.append(f)
|
|
249
|
+
if deprecated:
|
|
250
|
+
print("fields deprecated in newer framework:")
|
|
251
|
+
for f in deprecated:
|
|
252
|
+
print(f" - {f}")
|
|
253
|
+
|
|
254
|
+
print("no auto-merge. apply changes manually.")
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _flatten_keys(d: dict, prefix: str = "") -> list[str]:
|
|
259
|
+
out: list[str] = []
|
|
260
|
+
for k, v in d.items():
|
|
261
|
+
path = f"{prefix}.{k}" if prefix else k
|
|
262
|
+
if isinstance(v, dict):
|
|
263
|
+
out.extend(_flatten_keys(v, path))
|
|
264
|
+
else:
|
|
265
|
+
out.append(path)
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_validate(args) -> int:
|
|
270
|
+
try:
|
|
271
|
+
profile = load_profile(args.profile, framework_root=args.framework_root)
|
|
272
|
+
except ProfileError as e:
|
|
273
|
+
print(f"INVALID: {e}", file=sys.stderr)
|
|
274
|
+
return 1
|
|
275
|
+
print(f"profile '{profile.name}' is valid (framework {profile.framework_version})")
|
|
276
|
+
print(f" bindings resolved: {len(profile.resolved_bindings)}")
|
|
277
|
+
for role, rb in profile.resolved_bindings.items():
|
|
278
|
+
warn = f" [WARN: {rb.warning}]" if rb.warning else ""
|
|
279
|
+
print(f" {role}: {rb.alias} -> {rb.model_id} on {rb.channel_id}{warn}")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def main() -> int:
|
|
284
|
+
args = build_parser().parse_args()
|
|
285
|
+
return args.func(args)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
sys.exit(main())
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pi-rolecast/scripts/sync_settings.py
|
|
3
|
+
|
|
4
|
+
Sync profile model bindings to pi dispatch config.
|
|
5
|
+
|
|
6
|
+
Two outputs:
|
|
7
|
+
|
|
8
|
+
1. **settings.json** (`~/.pi/agent/settings.json` or --settings path):
|
|
9
|
+
writes `subagents.agentOverrides.<full-role-name>` entries for each
|
|
10
|
+
binding. This is a soft hint used by some third-party extensions.
|
|
11
|
+
|
|
12
|
+
2. **project-local agent files** (`.pi/agents/<full-role-name>.md` in cwd):
|
|
13
|
+
copies each role-packs/<group>/<role>.md template and overwrites its
|
|
14
|
+
`model:` and `thinking:` frontmatter fields with the bound values.
|
|
15
|
+
This is the authoritative dispatch path: the **pi-subagents**
|
|
16
|
+
extension reads `.pi/agents/<name>.md` (project) before any global
|
|
17
|
+
fallback and honours `model:` and `thinking:` frontmatter fields.
|
|
18
|
+
|
|
19
|
+
v0.2.0 changes:
|
|
20
|
+
* Walks role-packs/<group>/<role>.md instead of hardcoded CORE_ROLES.
|
|
21
|
+
* Only emits roles for groups listed in `profile.workflow.role_groups`.
|
|
22
|
+
* Role names written to .pi/agents/ are the full prefixed names
|
|
23
|
+
(e.g. `coding-architect.md`) so they match the `<group>-<role>`
|
|
24
|
+
binding keys.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
34
|
+
|
|
35
|
+
from profile_loader import ( # noqa: E402
|
|
36
|
+
ProfileError,
|
|
37
|
+
available_roles,
|
|
38
|
+
discover_role_packs,
|
|
39
|
+
load_profile,
|
|
40
|
+
load_registry,
|
|
41
|
+
resolve_bindings,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DEFAULT_SETTINGS = Path.home() / ".pi" / "agent" / "settings.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# pi uses different provider keys than our registry's `vendor` field.
|
|
49
|
+
VENDOR_TO_PROVIDER = {
|
|
50
|
+
"minimax": "minimax-cn",
|
|
51
|
+
"deepseek": "deepseek",
|
|
52
|
+
"openai": "openai-codex",
|
|
53
|
+
"anthropic": "anthropic",
|
|
54
|
+
"moonshotai": "kimi-coding",
|
|
55
|
+
"kimi-coding": "kimi-coding",
|
|
56
|
+
"typesafe": "typesafe",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _provider_for_model(model_id: str, registry) -> str:
|
|
61
|
+
"""Look up pi provider for a model id. Returns "" if unknown.
|
|
62
|
+
|
|
63
|
+
Resolution order:
|
|
64
|
+
1. explicit `provider` attribute on the model (registry-overrides.yaml)
|
|
65
|
+
2. vendor -> VENDOR_TO_PROVIDER mapping
|
|
66
|
+
3. vendor itself
|
|
67
|
+
4. "" (caller should fall back to plain modelId)
|
|
68
|
+
"""
|
|
69
|
+
if registry is None:
|
|
70
|
+
return ""
|
|
71
|
+
if hasattr(registry, "_models"):
|
|
72
|
+
model = registry._models.get(model_id)
|
|
73
|
+
if model is not None:
|
|
74
|
+
if hasattr(model, "provider") and model.provider:
|
|
75
|
+
return model.provider
|
|
76
|
+
vendor = getattr(model, "vendor", "") or ""
|
|
77
|
+
if vendor in VENDOR_TO_PROVIDER:
|
|
78
|
+
return VENDOR_TO_PROVIDER[vendor]
|
|
79
|
+
if vendor:
|
|
80
|
+
return vendor
|
|
81
|
+
return ""
|
|
82
|
+
for m in registry.get("models", []) or []:
|
|
83
|
+
if m.get("id") == model_id:
|
|
84
|
+
if m.get("provider"):
|
|
85
|
+
return m["provider"]
|
|
86
|
+
vendor = m.get("vendor", "")
|
|
87
|
+
if vendor in VENDOR_TO_PROVIDER:
|
|
88
|
+
return VENDOR_TO_PROVIDER[vendor]
|
|
89
|
+
if vendor:
|
|
90
|
+
return vendor
|
|
91
|
+
break
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> int:
|
|
96
|
+
parser = argparse.ArgumentParser(
|
|
97
|
+
description="Sync profile bindings to pi dispatch config + project-local agent files."
|
|
98
|
+
)
|
|
99
|
+
parser.add_argument("--profile", type=Path, default=None,
|
|
100
|
+
help="profile path (default: .pi/rolecast.yaml in cwd; "
|
|
101
|
+
"falls back to legacy .pi/agent-workflow.yaml)")
|
|
102
|
+
parser.add_argument("--settings", type=Path, default=DEFAULT_SETTINGS)
|
|
103
|
+
parser.add_argument("--framework-root", type=Path, default=None)
|
|
104
|
+
parser.add_argument("--agents-dir", type=Path,
|
|
105
|
+
default=Path.cwd() / ".pi" / "agents",
|
|
106
|
+
help="project-local agent directory (default: .pi/agents)")
|
|
107
|
+
parser.add_argument("--clear", action="store_true",
|
|
108
|
+
help="remove framework role entries from settings.json and delete "
|
|
109
|
+
"project-local agent files written by sync")
|
|
110
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
111
|
+
parser.add_argument("--no-agents", action="store_true",
|
|
112
|
+
help="skip writing project-local agent files (settings.json only)")
|
|
113
|
+
parser.add_argument("--no-settings", action="store_true",
|
|
114
|
+
help="skip writing settings.json (agent files only)")
|
|
115
|
+
parser.add_argument("--status", action="store_true",
|
|
116
|
+
help="show current sync state vs profile bindings (no changes)")
|
|
117
|
+
parser.add_argument("--list-groups", action="store_true",
|
|
118
|
+
help="list available role groups from role-packs/ and exit")
|
|
119
|
+
args = parser.parse_args()
|
|
120
|
+
|
|
121
|
+
if args.framework_root is None:
|
|
122
|
+
args.framework_root = Path(__file__).resolve().parent.parent
|
|
123
|
+
|
|
124
|
+
if args.list_groups:
|
|
125
|
+
packs = discover_role_packs(args.framework_root)
|
|
126
|
+
if not packs:
|
|
127
|
+
print("(no role-packs/* directories found under framework root)")
|
|
128
|
+
return 0
|
|
129
|
+
for g, roles in packs.items():
|
|
130
|
+
print(f"{g}/ ({len(roles)} roles): " + ", ".join(r.full_name for r in roles))
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
if args.clear:
|
|
134
|
+
# --clear is a standalone cleanup operation — does NOT need a profile.
|
|
135
|
+
rc1 = _clear_settings(args.settings, args.dry_run)
|
|
136
|
+
rc2 = _clear_agents(args.agents_dir, args.framework_root, args.dry_run)
|
|
137
|
+
return rc1 or rc2
|
|
138
|
+
|
|
139
|
+
if args.profile is None:
|
|
140
|
+
from profile_loader import find_profile
|
|
141
|
+
discovered = find_profile(Path.cwd())
|
|
142
|
+
if discovered is None:
|
|
143
|
+
print("error: no profile found (looked for .pi/rolecast.yaml "
|
|
144
|
+
"and legacy .pi/agent-workflow.yaml in cwd)", file=sys.stderr)
|
|
145
|
+
return 2
|
|
146
|
+
args.profile = discovered
|
|
147
|
+
|
|
148
|
+
if not args.profile.exists():
|
|
149
|
+
print("error: profile not found: " + str(args.profile), file=sys.stderr)
|
|
150
|
+
return 2
|
|
151
|
+
try:
|
|
152
|
+
profile = load_profile(args.profile, framework_root=args.framework_root)
|
|
153
|
+
except ProfileError as e:
|
|
154
|
+
print("error: profile invalid: " + str(e), file=sys.stderr)
|
|
155
|
+
return 2
|
|
156
|
+
|
|
157
|
+
registry = try_load_registry(args.framework_root)
|
|
158
|
+
resolved = resolve_bindings(profile, registry)
|
|
159
|
+
|
|
160
|
+
# Build new_overrides using FULL role names (e.g. coding-architect).
|
|
161
|
+
new_overrides = {}
|
|
162
|
+
for role, binding in resolved.items():
|
|
163
|
+
new_overrides[role] = {"model": binding.model_id, "channel": binding.channel_id}
|
|
164
|
+
|
|
165
|
+
if args.status:
|
|
166
|
+
return _show_status(args.profile, args.settings, args.agents_dir,
|
|
167
|
+
args.framework_root, new_overrides)
|
|
168
|
+
|
|
169
|
+
rc1 = rc2 = 0
|
|
170
|
+
if not args.no_settings:
|
|
171
|
+
rc1 = _merge_and_write(args.settings, new_overrides, args.dry_run)
|
|
172
|
+
if not args.no_agents:
|
|
173
|
+
rc2 = _write_agents(args.agents_dir, args.framework_root, profile.workflow.role_groups,
|
|
174
|
+
new_overrides, args.dry_run, registry)
|
|
175
|
+
return rc1 or rc2
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _merge_and_write(settings_path: Path, new_overrides: dict, dry_run: bool) -> int:
|
|
179
|
+
settings = _read_settings(settings_path)
|
|
180
|
+
subagents = settings.get("subagents", {})
|
|
181
|
+
existing_overrides = subagents.get("agentOverrides", {}) or {}
|
|
182
|
+
merged = dict(existing_overrides)
|
|
183
|
+
for role, override in new_overrides.items():
|
|
184
|
+
merged[role] = override
|
|
185
|
+
subagents["agentOverrides"] = merged
|
|
186
|
+
settings["subagents"] = subagents
|
|
187
|
+
return _write_settings(settings_path, settings, dry_run,
|
|
188
|
+
"synced " + str(len(new_overrides)) + " roles in settings.json")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _clear_settings(settings_path: Path, dry_run: bool) -> int:
|
|
192
|
+
"""Remove ALL agentOverrides entries — we can't tell which are framework
|
|
193
|
+
ones without the profile, so --clear drops everything. Users with custom
|
|
194
|
+
overrides should re-merge manually."""
|
|
195
|
+
settings = _read_settings(settings_path)
|
|
196
|
+
subagents = settings.get("subagents", {})
|
|
197
|
+
existing = subagents.get("agentOverrides", {}) or {}
|
|
198
|
+
removed = list(existing.keys())
|
|
199
|
+
subagents["agentOverrides"] = {}
|
|
200
|
+
settings["subagents"] = subagents
|
|
201
|
+
return _write_settings(settings_path, settings, dry_run,
|
|
202
|
+
"removed " + str(len(removed)) + " agent overrides from settings.json")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _show_status(profile_path: Path, settings_path: Path, agents_dir: Path,
|
|
206
|
+
framework_root: Path, expected_overrides: dict) -> int:
|
|
207
|
+
"""Show diff between profile bindings and currently-synced state."""
|
|
208
|
+
import re
|
|
209
|
+
fm_re = re.compile(r"^model:\s*(.+?)\s*$", re.MULTILINE)
|
|
210
|
+
print("=" * 70)
|
|
211
|
+
print(" pi-rolecast sync status")
|
|
212
|
+
print("=" * 70)
|
|
213
|
+
|
|
214
|
+
# Profile bindings
|
|
215
|
+
print("\nProfile bindings (" + str(profile_path) + "):")
|
|
216
|
+
if not expected_overrides:
|
|
217
|
+
print(" (no bindings)")
|
|
218
|
+
for role in sorted(expected_overrides):
|
|
219
|
+
ov = expected_overrides[role]
|
|
220
|
+
print(" " + role.ljust(22) + " model=" + ov["model"].ljust(26) +
|
|
221
|
+
" channel=" + ov["channel"])
|
|
222
|
+
|
|
223
|
+
# Project-local agent files
|
|
224
|
+
print("\nProject-local agent files (" + str(agents_dir) + "):")
|
|
225
|
+
if not expected_overrides:
|
|
226
|
+
print(" (nothing to check — no profile bindings)")
|
|
227
|
+
for role in sorted(expected_overrides):
|
|
228
|
+
path = agents_dir / (role + ".md")
|
|
229
|
+
if not path.exists():
|
|
230
|
+
print(" " + role.ljust(22) + " (missing)")
|
|
231
|
+
continue
|
|
232
|
+
text = path.read_text()
|
|
233
|
+
m = fm_re.search(text)
|
|
234
|
+
actual = m.group(1) if m else "(no model)"
|
|
235
|
+
expected = expected_overrides[role]["model"]
|
|
236
|
+
match = "OK" if actual == expected else "DRIFT"
|
|
237
|
+
print(" " + role.ljust(22) + " model=" + actual.ljust(26) + " " + match)
|
|
238
|
+
|
|
239
|
+
# Settings.json
|
|
240
|
+
settings = _read_settings(settings_path)
|
|
241
|
+
subagents = settings.get("subagents", {})
|
|
242
|
+
overrides = subagents.get("agentOverrides", {}) or {}
|
|
243
|
+
expected_set = set(expected_overrides.keys())
|
|
244
|
+
framework_overrides = {r: v for r, v in overrides.items() if r in expected_set}
|
|
245
|
+
print("\nsettings.json: " + str(settings_path))
|
|
246
|
+
print(" framework overrides: " + str(len(framework_overrides)) + "/" + str(len(expected_set)))
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _write_agents(agents_dir: Path, framework_root: Path,
|
|
251
|
+
enabled_groups: list[str], new_overrides: dict,
|
|
252
|
+
dry_run: bool, registry) -> int:
|
|
253
|
+
"""Write project-local copies of role-packs/<group>/<role>.md for every
|
|
254
|
+
bound role. Uses full role name (`<group>-<role>`) as the filename so
|
|
255
|
+
pi-subagents can find it via @<full-name> mention syntax."""
|
|
256
|
+
roles = available_roles(framework_root, groups=enabled_groups or None)
|
|
257
|
+
if dry_run:
|
|
258
|
+
for role_full_name, rd in sorted(roles.items()):
|
|
259
|
+
if role_full_name not in new_overrides:
|
|
260
|
+
continue
|
|
261
|
+
model_id = new_overrides[role_full_name]["model"]
|
|
262
|
+
provider = _provider_for_model(model_id, registry)
|
|
263
|
+
full = (provider + "/" + model_id) if provider else model_id
|
|
264
|
+
print("would write " + str(agents_dir / (role_full_name + ".md")) +
|
|
265
|
+
" model=" + full)
|
|
266
|
+
return 0
|
|
267
|
+
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
written = 0
|
|
269
|
+
for role_full_name, rd in sorted(roles.items()):
|
|
270
|
+
if role_full_name not in new_overrides:
|
|
271
|
+
continue
|
|
272
|
+
src = rd.file_path
|
|
273
|
+
if src is None or not src.exists():
|
|
274
|
+
print("warning: role-packs file not found: " + str(rd.file_path),
|
|
275
|
+
file=sys.stderr)
|
|
276
|
+
continue
|
|
277
|
+
dst = agents_dir / (role_full_name + ".md")
|
|
278
|
+
body = src.read_text()
|
|
279
|
+
model_id = new_overrides[role_full_name]["model"]
|
|
280
|
+
provider = _provider_for_model(model_id, registry)
|
|
281
|
+
full = (provider + "/" + model_id) if provider else model_id
|
|
282
|
+
updated = _set_frontmatter_field(body, "model", full)
|
|
283
|
+
dst.write_text(updated)
|
|
284
|
+
written += 1
|
|
285
|
+
print("wrote " + str(written) + " project-local agent files to " + str(agents_dir))
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _clear_agents(agents_dir: Path, framework_root: Path, dry_run: bool) -> int:
|
|
290
|
+
"""Remove all project-local agent files (any *.md). Custom files outside
|
|
291
|
+
the role pack should be backed up first — we don't try to detect
|
|
292
|
+
framework vs user here because v0.2.0 always uses prefixed names."""
|
|
293
|
+
if not agents_dir.exists():
|
|
294
|
+
print("no project-local agent dir at " + str(agents_dir))
|
|
295
|
+
return 0
|
|
296
|
+
removed = 0
|
|
297
|
+
for path in sorted(agents_dir.glob("*.md")):
|
|
298
|
+
if path.is_symlink():
|
|
299
|
+
continue
|
|
300
|
+
if dry_run:
|
|
301
|
+
print("would remove " + str(path))
|
|
302
|
+
removed += 1
|
|
303
|
+
continue
|
|
304
|
+
path.unlink()
|
|
305
|
+
removed += 1
|
|
306
|
+
print("removed " + str(removed) + " project-local agent files")
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _set_frontmatter_field(body: str, field: str, value: str) -> str:
|
|
311
|
+
"""Replace or insert `field: value` in the file's frontmatter block."""
|
|
312
|
+
lines = body.splitlines()
|
|
313
|
+
if not lines or lines[0].strip() != "---":
|
|
314
|
+
new_front = ["---", field + ": " + value, "---", ""]
|
|
315
|
+
return "\n".join(new_front) + body
|
|
316
|
+
try:
|
|
317
|
+
end = lines.index("---", 1)
|
|
318
|
+
except ValueError:
|
|
319
|
+
return body
|
|
320
|
+
prefix = field + ":"
|
|
321
|
+
new_lines = []
|
|
322
|
+
replaced = False
|
|
323
|
+
for ln in lines[1:end]:
|
|
324
|
+
if ln.startswith(prefix) and not replaced:
|
|
325
|
+
new_lines.append(field + ": " + value)
|
|
326
|
+
replaced = True
|
|
327
|
+
else:
|
|
328
|
+
new_lines.append(ln)
|
|
329
|
+
if not replaced:
|
|
330
|
+
new_lines.append(field + ": " + value)
|
|
331
|
+
return "\n".join(lines[:1] + new_lines + lines[end:])
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _write_settings(path: Path, settings: dict, dry_run: bool, summary: str) -> int:
|
|
335
|
+
if dry_run:
|
|
336
|
+
print(json.dumps(settings, indent=2, sort_keys=True))
|
|
337
|
+
return 0
|
|
338
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
339
|
+
path.write_text(json.dumps(settings, indent=2, sort_keys=True) + "\n")
|
|
340
|
+
print(summary + ": " + str(path))
|
|
341
|
+
return 0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _read_settings(path: Path) -> dict:
|
|
345
|
+
if not path.exists():
|
|
346
|
+
return {}
|
|
347
|
+
text = path.read_text()
|
|
348
|
+
if not text.strip():
|
|
349
|
+
return {}
|
|
350
|
+
try:
|
|
351
|
+
return json.loads(text)
|
|
352
|
+
except json.JSONDecodeError:
|
|
353
|
+
return {}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def try_load_registry(framework_root: Path):
|
|
357
|
+
"""Wrapper for tests to monkey-patch."""
|
|
358
|
+
return load_registry(framework_root)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__":
|
|
362
|
+
sys.exit(main())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
framework_version: 0.2.0
|
|
2
|
+
name: my-project
|
|
3
|
+
description: |
|
|
4
|
+
(describe your workflow here)
|
|
5
|
+
|
|
6
|
+
workflow:
|
|
7
|
+
# Enable groups as the framework ships them. Example for future video group:
|
|
8
|
+
# role_groups: [coding, video]
|
|
9
|
+
role_groups: []
|
|
10
|
+
|
|
11
|
+
gates: {}
|
|
12
|
+
|
|
13
|
+
bindings: {}
|