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,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: {}