Repository navigation
Expand file tree
/
Copy pathinstall
More file actions
executable file
·457 lines (398 loc) · 20.5 KB
/
Copy pathinstall
File metadata and controls
executable file
·457 lines (398 loc) · 20.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
#!/usr/bin/env python3
"""Install the myteams pipeline + CLI; assemble the myagenticteams plugin; install the bundled teams.
Idempotent. Re-run to refresh after edits.
Install is two clearly separable phases, so the empty front door (the plugin with
just the static roster + config + code-review-by-myagenticteams skills, no teams) is a real,
deliverable state:
install_core() — the myteams engine + CLI + plugin scaffold, EMPTY registry:
1. Ensure the external/agenticcookbook submodule is populated (skip with
--skip-submodule).
2. references/ ships committed (vendored at author time via
pipeline.engine.source.toolkit.reference_vendor).
3. Tune review prompts: compose generic templates + per-provider steering into
teams/devteam/llm-guides/<provider>/review/ (gitignored). The pipeline reads
these at runtime via load_guide() with file-level fallback to default/.
4. Install pipeline: copy pipeline/ to ~/.myteams/pipeline/.
5. Install the CLI symlink: ~/.local/bin/myteams -> the installed myteams-cli.py
(and remove any stale ~/.local/bin/myteam left by the old tool — clean break).
6. Retire the old monolithic `myteams` SKILL: remove ~/.claude/skills/myteams/
(the CLI symlink lives on — only the skill was replaced by the plugin).
7. Assemble the `myagenticteams` skills-directory plugin under
~/.claude/skills/myagenticteams/ (manifest + the static roster + config +
code-review-by-myagenticteams skills). It loads with no `/plugin install` step; per-team skills
are generated once the registry is populated.
install_teams() — populate the registry from the bundled teams:
8. Call `myteams team install ./teams/<team>` for each team in teams/ (copies it
into the registry, enabled), then render one plugin skill per team. Each later
`team` mutation re-renders so the plugin tracks the live registry.
The default is core + all bundled teams. `--core-only` (alias `--no-teams`)
installs just the core and leaves the registry empty — the user then adds teams
themselves via `myteams team install <path>`.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent
SUBMODULE = REPO_ROOT / "external" / "agenticcookbook"
TEAMS_SRC = REPO_ROOT / "teams"
SKILL_SRC = REPO_ROOT / "skill"
PIPELINE_SRC = REPO_ROOT / "pipeline"
MYTEAMS_HOME = Path.home() / ".myteams"
PIPELINE_INSTALL = MYTEAMS_HOME / "pipeline"
TEAMS_INSTALL = MYTEAMS_HOME / "teams"
CLI_SCRIPT = PIPELINE_INSTALL / "engine" / "source" / "engine" / "myteams-cli.py"
BIN_DIR = Path.home() / ".local" / "bin"
SKILLS_DEST = Path.home() / ".claude" / "skills"
CLAUDE_SETTINGS = Path.home() / ".claude" / "settings.json"
# The CLI binary name (the ~/.local/bin/<CLI_NAME> symlink). The CLI lives on as
# the engine the plugin skills shell out to; only the old monolithic `myteams`
# SKILL was retired (see remove_retired_umbrella_skill).
CLI_NAME = "myteams"
# The skills-directory plugin assembled under ~/.claude/skills/<PLUGIN_NAME>/.
# Because it carries a .claude-plugin/plugin.json it loads as
# `<PLUGIN_NAME>@skills-dir` with NO `/plugin install` step, runs in place (its
# skills are not copied to a cache), and namespaces its skills as
# `/<PLUGIN_NAME>:<skill>`. Its static scaffold (manifest + roster + config +
# code-review-by-myagenticteams skills) ships in PLUGIN_SRC; per-team skills are generated by the
# renderer.
PLUGIN_SRC = SKILL_SRC / "plugin"
PLUGIN_NAME = "myagenticteams"
# The old tool's command name. install removes any stale ~/.local/bin/myteam so
# the rename to `myteams` is a clean break on upgrade (the old name must not
# survive). Only the symlink is touched — never the on-disk ~/.myteam data dir.
STALE_SKILL_NAME = "myteam"
# The build-loop spend guard, registered as a PreToolUse hook ONLY when the user
# opts in (--with-build-guard-hook) — it fires on every Bash call, so it is never
# imposed by default. The marker line makes registration idempotent. The command
# runs the installed hook entrypoint, which denies (exit 2) the loop's gate call
# when the active build run is over its token budget.
BUILD_GUARD_MARKER = "# myteams-build-guard"
# The OLD tool stamped this marker. On upgrade we strip any surviving old-marker
# hook — it points at the now-removed ~/.myteam pipeline and would error on every
# Bash call — regardless of whether the user re-opts into the new one.
LEGACY_BUILD_GUARD_MARKER = "# myteam-build-guard"
BUILD_GUARD_COMMAND = (
f'{BUILD_GUARD_MARKER}\n'
f'PYTHONPATH="$HOME/.myteams" python3 -m pipeline.engine.source.review.loop_guard'
)
_IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo")
PROMPTS_SRC = PIPELINE_SRC / "engine" / "source" / "review" / "prompts"
DEVTEAM_LLM_GUIDES = TEAMS_SRC / "devteam" / "llm-guides"
PROMPT_PROVIDERS = ("codex", "claude", "qwen")
PROMPT_KINDS = ("worker", "interest", "verifier")
# Prompt packs an objective's `guides_pack` can name, mapped to their template
# subdir under PROMPTS_SRC (None = PROMPTS_SRC itself). Each composes generic
# templates (+ optional per-provider steering) into llm-guides/<provider>/<pack>/.
# `review` is the default-everywhere base; `finish-review` adds the adjudication
# pack consumed by the finish-review objective; `conformance-review` adds the
# contract-adjudication pack consumed by the conformance-review objective.
# Resolved against PROMPTS_SRC at call time so tests can repoint the source tree.
PROMPT_PACKS = {
"review": None,
"finish-review": "finishreview",
"conformance-review": "conformancereview",
}
def _info(msg: str) -> None:
print(f"\033[1;36m› {msg}\033[0m")
def _ok(msg: str) -> None:
print(f"\033[1;32m✓ {msg}\033[0m")
def ensure_submodule() -> None:
# Survives the packaging flip (references now committed under teams/<team>/
# references/, not materialized at install): cookbookteam's authoring
# workflow and author-time vendoring (reference_vendor) still read the
# submodule directly, so runtime/install not needing it doesn't retire it.
_info("Ensuring cookbook submodule")
sentinel = SUBMODULE / "skills" / "cookbook" / "cli" / "reference-manifest.json"
if sentinel.is_file():
_ok("submodule already populated")
return
subprocess.run(
["git", "submodule", "update", "--init", "--recursive",
"external/agenticcookbook"],
cwd=str(REPO_ROOT), check=True,
)
_ok("submodule initialized")
def tune_prompts() -> None:
"""Compose generic + per-provider steering into the team's llm-guides/ tree,
one subtree per prompt pack (see PROMPT_PACKS).
For each pack, `default/<pack>/<kind>.txt` is always written (the un-steered
generic, used as the fallback for any provider without its own pack). A
provider pack is written only when at least one of its steering files is
non-empty — for providers without steering, `load_guide` falls back to
`default/`.
"""
_info("Tuning prompts per provider")
if DEVTEAM_LLM_GUIDES.exists():
shutil.rmtree(DEVTEAM_LLM_GUIDES)
written = 0
for pack, subdir in PROMPT_PACKS.items():
src = PROMPTS_SRC if subdir is None else PROMPTS_SRC / subdir
written += _tune_prompt_pack(pack, src)
_ok(f"tuned {written} prompt file(s) under "
f"{DEVTEAM_LLM_GUIDES.relative_to(REPO_ROOT)}")
def _tune_prompt_pack(pack: str, src: Path) -> int:
"""Compose one prompt pack from `src` into llm-guides/<provider>/<pack>/."""
steering_dir = src / "steering"
generics: dict[str, str] = {}
for kind in PROMPT_KINDS:
generic_path = src / f"{kind}.generic.txt"
if not generic_path.is_file():
sys.exit(f" MISSING generic prompt: {generic_path}")
generics[kind] = generic_path.read_text(encoding="utf-8")
written = 0
default_dir = DEVTEAM_LLM_GUIDES / "default" / pack
default_dir.mkdir(parents=True, exist_ok=True)
for kind, generic in generics.items():
composed = generic.replace("%%STEERING%%", "")
(default_dir / f"{kind}.txt").write_text(composed, encoding="utf-8")
written += 1
for provider in PROMPT_PROVIDERS:
steering_by_kind: dict[str, str] = {}
any_steering = False
for kind in PROMPT_KINDS:
steering_path = steering_dir / f"{provider}.{kind}.txt"
if steering_path.is_file():
text = steering_path.read_text(encoding="utf-8").strip()
steering_by_kind[kind] = text
if text:
any_steering = True
else:
steering_by_kind[kind] = ""
if not any_steering:
continue
provider_dir = DEVTEAM_LLM_GUIDES / provider / pack
provider_dir.mkdir(parents=True, exist_ok=True)
for kind, generic in generics.items():
steering = steering_by_kind[kind]
block = f"{steering}\n\n" if steering else ""
composed = generic.replace("%%STEERING%%", block)
(provider_dir / f"{kind}.txt").write_text(composed, encoding="utf-8")
written += 1
return written
def install_pipeline() -> None:
_info("Installing pipeline")
if not PIPELINE_SRC.is_dir():
sys.exit(f" MISSING pipeline source: {PIPELINE_SRC}")
if PIPELINE_INSTALL.exists():
shutil.rmtree(PIPELINE_INSTALL)
shutil.copytree(PIPELINE_SRC, PIPELINE_INSTALL, ignore=_IGNORE)
_ok(f"pipeline installed to {PIPELINE_INSTALL}")
def _cli(args: list[str]) -> subprocess.CompletedProcess:
"""Invoke the installed CLI script directly (symlink-independent — used here
for the team-neutral `team install` builtin)."""
return subprocess.run([sys.executable, str(CLI_SCRIPT), *args], check=False)
def install_cli_symlink() -> None:
_info("Installing CLI symlink")
if not CLI_SCRIPT.is_file():
sys.exit(f" MISSING script: {CLI_SCRIPT}")
CLI_SCRIPT.chmod(CLI_SCRIPT.stat().st_mode | 0o755)
BIN_DIR.mkdir(parents=True, exist_ok=True)
# Clean break on upgrade: remove any stale ~/.local/bin/myteam left by the
# old tool so `command -v myteam` fails afterwards. Only the symlink — the
# on-disk ~/.myteam data dir is left untouched.
stale = BIN_DIR / STALE_SKILL_NAME
if stale.exists() or stale.is_symlink():
stale.unlink()
_ok(f"removed stale symlink: {stale}")
link = BIN_DIR / CLI_NAME
if link.exists() or link.is_symlink():
link.unlink()
link.symlink_to(CLI_SCRIPT)
_ok(f"symlink: {link} -> {CLI_SCRIPT}")
def install_teams() -> None:
_info("Installing teams (team install)")
if not TEAMS_SRC.is_dir():
sys.exit(f" MISSING teams source: {TEAMS_SRC}")
# Do NOT wipe TEAMS_INSTALL: that would also destroy any team a user added
# via `myteams team install <external-dir>`. `team install` overwrites
# each repo team's dir + entry in place (enabled), and a registration
# orphaned by an old layout (a team whose declared planner module is gone)
# is tolerated at runtime — the conductor skips it with a warning rather
# than crashing — so there is nothing left to purge here.
installed = 0
for team_dir in sorted(TEAMS_SRC.iterdir()):
if not team_dir.is_dir():
continue
if not (team_dir / "team.json").is_file():
continue
# Defer the per-team plugin render (--no-plugin-refresh): the plugin
# skills are rendered once below, after the whole registry is populated,
# instead of re-rendering on every team.
if _cli(["team", "install", str(team_dir),
"--no-plugin-refresh"]).returncode != 0:
sys.exit(f" ! failed to install team {team_dir.name}")
installed += 1
_ok(f"installed {installed} team(s)")
# One render now that the registry holds every bundled team.
render_plugin_team_skills()
def _shared_skill_renderer():
"""Import the installed pipeline's shared per-team skill renderer.
install renders the plugin's per-team skills through the SAME renderer the
CLI's `team` mutations use (DRY: one home for the per-team SKILL.md), reached
via the just-copied installed pipeline so install and runtime stay in
lockstep. Must be called after install_pipeline()."""
if str(PIPELINE_INSTALL.parent) not in sys.path:
sys.path.insert(0, str(PIPELINE_INSTALL.parent))
from pipeline.engine.source.engine.conductor.skill_renderer import (
render_plugin_skills,
)
return render_plugin_skills
def render_plugin_team_skills() -> None:
"""Generate the plugin's per-team skills from the live registry, via the
shared renderer. A no-op until install_plugin() has laid the plugin scaffold
and the registry holds teams — so install_teams() calls this once at the end,
after the bundled teams populate the registry."""
_info("Rendering plugin team skills")
render_plugin_skills = _shared_skill_renderer()
team_skills = render_plugin_skills(
teams_root=TEAMS_INSTALL, plugin_dest=SKILLS_DEST / PLUGIN_NAME,
)
_ok(f"plugin team skills rendered ({len(team_skills)})")
def remove_retired_umbrella_skill() -> None:
"""Clean break: the monolithic `myteams` SKILL has been retired in favour of
the myagenticteams plugin (per-team skills + the static skills). Remove any
previously-installed ~/.claude/skills/myteams/ skill dir so Claude Code stops
surfacing the stale umbrella. Only the skill dir is touched — never the
~/.local/bin/myteams CLI symlink (the CLI lives on) or the ~/.myteams data."""
for name in (STALE_SKILL_NAME, CLI_NAME):
stale_skill = SKILLS_DEST / name
if stale_skill.exists():
shutil.rmtree(stale_skill)
_ok(f"removed retired skill: {stale_skill}")
def install_plugin() -> None:
"""Assemble the `myagenticteams` skills-directory plugin under
~/.claude/skills/myagenticteams/.
Copies the static plugin scaffold from PLUGIN_SRC — the
.claude-plugin/plugin.json manifest plus the `roster` (team management),
`config` (default LLMs), and `code-review-by-myagenticteams` (devteam review at risk scope)
skills. The per-team skills under skills/<team>/ are
generated separately by the shared renderer (on install and on every `team`
mutation), so this copy is additive (dirs_exist_ok=True) and never wipes a
generated per-team skill. Idempotent: re-running overwrites the static files
in place. The plugin loads with no `/plugin install` step — the user only
needs to start a new Claude Code session after the first install."""
_info("Installing plugin")
if not PLUGIN_SRC.is_dir():
sys.exit(f" MISSING plugin source: {PLUGIN_SRC}")
dest = SKILLS_DEST / PLUGIN_NAME
dest.mkdir(parents=True, exist_ok=True)
shutil.copytree(PLUGIN_SRC, dest, ignore=_IGNORE, dirs_exist_ok=True)
_ok(f"plugin installed to {dest}")
def register_build_guard_hook(settings_path: Path = CLAUDE_SETTINGS) -> str:
"""Opt-in: merge the build-loop spend guard into the user's PreToolUse hooks,
idempotently and additively (existing hooks untouched). Returns a status word
for the caller to report: 'added' | 'present' | 'skipped-bad-json'."""
settings: dict = {}
if settings_path.is_file():
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return "skipped-bad-json"
hooks = settings.setdefault("hooks", {})
pre = hooks.setdefault("PreToolUse", [])
for entry in pre:
for h in entry.get("hooks", []):
if BUILD_GUARD_MARKER in (h.get("command") or ""):
return "present"
pre.append({"matcher": "Bash", "hooks": [
{"type": "command", "command": BUILD_GUARD_COMMAND, "timeout": 10}]})
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
return "added"
def remove_legacy_build_guard_hook(settings_path: Path = CLAUDE_SETTINGS) -> None:
"""Clean-break hygiene: strip any PreToolUse hook left by the OLD tool (marker
LEGACY_BUILD_GUARD_MARKER). It points at the removed ~/.myteam pipeline and
would error on every Bash call, so an upgrade removes it even though the new
build-guard hook is opt-in and separate. Idempotent; a non-JSON settings file
is left untouched. Only entries carrying the legacy marker are touched."""
if not settings_path.is_file():
return
try:
settings = json.loads(settings_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return
pre = (settings.get("hooks") or {}).get("PreToolUse")
if not isinstance(pre, list):
return
kept, removed = [], False
for entry in pre:
orig = entry.get("hooks", [])
hooks = [h for h in orig
if LEGACY_BUILD_GUARD_MARKER not in (h.get("command") or "")]
if len(hooks) != len(orig):
removed = True
if hooks: # other hooks remain → keep, trimmed
kept.append({**entry, "hooks": hooks})
# else: the entry was solely the legacy hook → drop it
else:
kept.append(entry) # untouched entry
if not removed:
return
settings["hooks"]["PreToolUse"] = kept
settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
_ok(f"removed stale build-guard hook from {settings_path}")
def install_core(*, skip_submodule: bool = False) -> None:
"""Install the myteams engine + CLI as an EMPTY front door (no teams).
This is the standalone myteams core: pipeline, CLI symlink, and the
myagenticteams plugin scaffold (manifest + roster + config + code-review-by-myagenticteams
skills) against the
still-empty registry. After this runs, `myteams` works with zero teams
installed — the user can populate the registry themselves with `myteams team
install <path>` (this is exactly what install_teams() does for the bundled
teams). Kept separate from install_teams() so the empty-core state is a real,
deliverable target (the `--core-only` path).
"""
if not skip_submodule:
ensure_submodule()
tune_prompts()
install_pipeline()
install_cli_symlink()
# Clean break on upgrade: drop the old tool's dead build-guard hook (it points
# at the removed ~/.myteam pipeline and would error on every Bash call).
remove_legacy_build_guard_hook()
# Retire the monolithic `myteams` SKILL (replaced by the plugin's per-team
# skills + the static skills). The `myteams` CLI symlink is untouched.
remove_retired_umbrella_skill()
# Assemble the myagenticteams plugin scaffold (manifest + roster + config +
# code-review-by-myagenticteams skills). Per-team skills are generated by render_plugin_team_skills() once
# install_teams() populates the registry (and on every later `team` mutation).
install_plugin()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Install myteams.")
parser.add_argument("--skip-submodule", action="store_true",
help="Do not touch the git submodule (hermetic mode).")
parser.add_argument(
"--core-only", "--no-teams", dest="core_only", action="store_true",
help="Install only the myteams core (engine + CLI + empty skill) and "
"leave the registry empty — no bundled teams. Add teams yourself "
"with `myteams team install <path>`.")
parser.add_argument(
"--with-build-guard-hook", action="store_true",
help="Register the build-loop spend guard as a PreToolUse hook in "
"~/.claude/settings.json (opt-in; fires on every Bash call).")
args = parser.parse_args(argv)
if sys.version_info < (3, 9):
sys.exit("Python 3.9+ required.")
install_core(skip_submodule=args.skip_submodule)
if args.core_only:
_ok("core-only install: registry left empty "
"(add teams with `myteams team install <path>`)")
else:
install_teams()
if args.with_build_guard_hook:
_info("Registering build-loop spend guard (PreToolUse hook)")
status = register_build_guard_hook()
_ok({"added": "build-guard hook registered in ~/.claude/settings.json",
"present": "build-guard hook already registered",
"skipped-bad-json": "~/.claude/settings.json is not valid JSON — "
"skipped hook registration"}[status])
print("\n\033[1;32m✓ Done. Restart Claude Code to pick up the myagenticteams plugin.\033[0m")
return 0
if __name__ == "__main__":
sys.exit(main())