@event4u/agent-config 1.39.0 → 1.40.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 (45) hide show
  1. package/.agent-src/commands/orchestrate.md +123 -0
  2. package/.agent-src/commands/sync-gitignore/fix.md +135 -0
  3. package/.agent-src/commands/sync-gitignore.md +31 -5
  4. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +30 -2
  5. package/.agent-src/skills/subagent-orchestration/SKILL.md +9 -0
  6. package/.agent-src/skills/using-git-worktrees/SKILL.md +25 -0
  7. package/.agent-src/templates/agent-settings.md +9 -0
  8. package/.agent-src/templates/scripts/work_engine/orchestration.py +168 -0
  9. package/.claude-plugin/marketplace.json +3 -1
  10. package/CHANGELOG.md +42 -0
  11. package/README.md +5 -5
  12. package/bin/install.php +13 -6
  13. package/config/agent-settings.template.yml +21 -0
  14. package/docs/DISTRIBUTION_CHECKLIST.md +169 -0
  15. package/docs/architecture.md +1 -1
  16. package/docs/catalog.md +3 -2
  17. package/docs/contracts/audit-log-v1.md +142 -0
  18. package/docs/contracts/command-clusters.md +2 -0
  19. package/docs/contracts/file-ownership-matrix.json +20 -0
  20. package/docs/contracts/orchestration-dsl-v1.md +152 -0
  21. package/docs/getting-started.md +1 -1
  22. package/docs/installation.md +132 -0
  23. package/docs/setup/per-ide/aider.md +48 -0
  24. package/docs/setup/per-ide/claude-code.md +108 -0
  25. package/docs/setup/per-ide/claude-desktop.md +148 -0
  26. package/docs/setup/per-ide/cline.md +43 -0
  27. package/docs/setup/per-ide/codex.md +46 -0
  28. package/docs/setup/per-ide/copilot.md +80 -0
  29. package/docs/setup/per-ide/cursor.md +125 -0
  30. package/docs/setup/per-ide/gemini-cli.md +45 -0
  31. package/docs/setup/per-ide/windsurf.md +120 -0
  32. package/package.json +1 -1
  33. package/scripts/compress.py +153 -1
  34. package/scripts/extract_audit_patterns.py +202 -0
  35. package/scripts/install +156 -1
  36. package/scripts/install.py +270 -10
  37. package/scripts/install.sh +52 -7
  38. package/scripts/lint_orchestration_dsl.py +214 -0
  39. package/scripts/skill_linter.py +9 -0
  40. package/scripts/sync_gitignore.py +56 -1
  41. package/templates/claude_desktop_config.json.template +21 -0
  42. package/templates/cursor-rule.mdc.j2 +7 -0
  43. package/templates/global-install-manifest.yml +91 -0
  44. package/templates/marketing-copy.yml +64 -0
  45. package/templates/windsurf-rule.md.j2 +7 -0
@@ -1217,6 +1217,189 @@ def _smoke_test_hooks(project_root: Path, package_root: Path) -> int:
1217
1217
  return 1 if failed else 0
1218
1218
 
1219
1219
 
1220
+ # --- Global user-level install (Phase 3 — road-to-simplicity-and-everywhere) ---
1221
+ #
1222
+ # `scripts/install --global` ships kernel rules + a curated top-N of skills
1223
+ # into per-tool user-scope directories so the agent has them in every
1224
+ # project on the machine. Curation lives in templates/global-install-manifest.yml.
1225
+ #
1226
+ # Files are written under an `event4u/` namespace so `--global --uninstall`
1227
+ # can wipe the namespace dir without touching user-added files.
1228
+
1229
+ GLOBAL_NAMESPACE = "event4u"
1230
+ GLOBAL_MANIFEST_REL = Path("templates") / "global-install-manifest.yml"
1231
+
1232
+ # Per-tool global directories. Each surface gets a `rules/` and `skills/`
1233
+ # bucket under the event4u/ namespace. claude-desktop reuses claude-code's
1234
+ # ~/.claude/ (the two surfaces share the dir per Anthropic's docs).
1235
+ GLOBAL_TOOL_DIRS: dict[str, dict[str, Path]] = {
1236
+ "claude-code": {
1237
+ "rules": Path.home() / ".claude" / "rules" / GLOBAL_NAMESPACE,
1238
+ "skills": Path.home() / ".claude" / "skills" / GLOBAL_NAMESPACE,
1239
+ },
1240
+ "cursor": {
1241
+ "rules": Path.home() / ".cursor" / "rules" / "imported" / GLOBAL_NAMESPACE / "rules",
1242
+ "skills": Path.home() / ".cursor" / "rules" / "imported" / GLOBAL_NAMESPACE / "skills",
1243
+ },
1244
+ "windsurf": {
1245
+ "rules": Path.home() / ".codeium" / "windsurf" / "global_workflows" / GLOBAL_NAMESPACE / "rules",
1246
+ "skills": Path.home() / ".codeium" / "windsurf" / "global_workflows" / GLOBAL_NAMESPACE / "skills",
1247
+ },
1248
+ "fallback": {
1249
+ "rules": Path.home() / ".config" / "agent-config" / "rules" / GLOBAL_NAMESPACE,
1250
+ "skills": Path.home() / ".config" / "agent-config" / "skills" / GLOBAL_NAMESPACE,
1251
+ },
1252
+ }
1253
+
1254
+
1255
+ def _load_global_manifest(package_root: Path) -> dict:
1256
+ """Parse templates/global-install-manifest.yml without a YAML dep.
1257
+
1258
+ Tiny hand-rolled parser — only handles the manifest's flat shape:
1259
+ `key: value`, `- item`, and `- key: value` indented under a parent
1260
+ list. We avoid pulling in PyYAML so install.py stays zero-dep.
1261
+ """
1262
+ path = package_root / GLOBAL_MANIFEST_REL
1263
+ if not path.is_file():
1264
+ fail(f"global manifest missing: {path}")
1265
+ out: dict = {"kernel_rules": [], "top_skills": []}
1266
+ section: str | None = None
1267
+ pending: dict | None = None
1268
+ for raw in path.read_text(encoding="utf-8").splitlines():
1269
+ line = raw.split("#", 1)[0].rstrip()
1270
+ if not line.strip():
1271
+ continue
1272
+ if line.startswith("kernel_rules:"):
1273
+ section = "kernel_rules"; pending = None; continue
1274
+ if line.startswith("top_skills:"):
1275
+ section = "top_skills"; pending = None; continue
1276
+ stripped = line.lstrip()
1277
+ indent = len(line) - len(stripped)
1278
+ if section == "kernel_rules" and stripped.startswith("- "):
1279
+ out["kernel_rules"].append(stripped[2:].strip())
1280
+ elif section == "top_skills":
1281
+ if stripped.startswith("- id:"):
1282
+ if pending is not None:
1283
+ out["top_skills"].append(pending)
1284
+ pending = {"id": stripped.split(":", 1)[1].strip(), "surfaces": []}
1285
+ elif pending is not None and stripped.startswith("surfaces:"):
1286
+ raw_list = stripped.split(":", 1)[1].strip()
1287
+ if raw_list.startswith("[") and raw_list.endswith("]"):
1288
+ pending["surfaces"] = [s.strip() for s in raw_list[1:-1].split(",") if s.strip()]
1289
+ elif indent == 0 and not stripped.startswith("-"):
1290
+ section = None
1291
+ if pending is not None:
1292
+ out["top_skills"].append(pending)
1293
+ return out
1294
+
1295
+
1296
+ def _resolve_skill_source(package_root: Path, skill_id: str) -> Path | None:
1297
+ """Locate a skill's SKILL.md in the package. Prefers .claude/skills/."""
1298
+ for base in (package_root / ".claude" / "skills",
1299
+ package_root / ".agent-src" / "skills"):
1300
+ candidate = base / skill_id / "SKILL.md"
1301
+ if candidate.is_file():
1302
+ return candidate.parent
1303
+ return None
1304
+
1305
+
1306
+ def _resolve_rule_source(package_root: Path, rule_id: str) -> Path | None:
1307
+ """Locate a rule's .md in the package. Prefers .agent-src/rules/."""
1308
+ for base in (package_root / ".agent-src" / "rules",
1309
+ package_root / ".augment" / "rules"):
1310
+ candidate = base / f"{rule_id}.md"
1311
+ if candidate.is_file():
1312
+ return candidate
1313
+ return None
1314
+
1315
+
1316
+ def _global_targets(tools: set[str]) -> dict[str, dict[str, Path]]:
1317
+ """Return the subset of GLOBAL_TOOL_DIRS to write to. Fallback always on."""
1318
+ targets: dict[str, dict[str, Path]] = {"fallback": GLOBAL_TOOL_DIRS["fallback"]}
1319
+ for tool_id, dirs in GLOBAL_TOOL_DIRS.items():
1320
+ if tool_id == "fallback":
1321
+ continue
1322
+ # claude-desktop shares claude-code's dir — both flip the same target.
1323
+ if tool_id == "claude-code" and ("claude-code" in tools or "claude-desktop" in tools):
1324
+ targets[tool_id] = dirs
1325
+ elif tool_id in tools:
1326
+ targets[tool_id] = dirs
1327
+ return targets
1328
+
1329
+
1330
+ def install_global(package_root: Path, tools: set[str], force: bool) -> int:
1331
+ """S13/S14: ship kernel rules + curated skills to user-scope dirs."""
1332
+ import shutil
1333
+ manifest = _load_global_manifest(package_root)
1334
+ targets = _global_targets(tools)
1335
+
1336
+ if not QUIET:
1337
+ info(f"Global install — surfaces: {', '.join(sorted(targets))}")
1338
+ info(f" rules: {len(manifest['kernel_rules'])}")
1339
+ info(f" skills: {len(manifest['top_skills'])}")
1340
+
1341
+ for surface, dirs in targets.items():
1342
+ for kind in ("rules", "skills"):
1343
+ dirs[kind].mkdir(parents=True, exist_ok=True)
1344
+ # Rules: copy each kernel rule (file).
1345
+ for rule_id in manifest["kernel_rules"]:
1346
+ src = _resolve_rule_source(package_root, rule_id)
1347
+ if src is None:
1348
+ warn(f"global: rule '{rule_id}' missing in package — skipped")
1349
+ continue
1350
+ dst = dirs["rules"] / f"{rule_id}.md"
1351
+ if dst.exists() and not force and dst.read_bytes() == src.read_bytes():
1352
+ continue
1353
+ dst.write_bytes(src.read_bytes())
1354
+ # Skills: copy each curated skill (directory).
1355
+ for entry in manifest["top_skills"]:
1356
+ if surface != "fallback" and surface not in entry.get("surfaces", []):
1357
+ continue
1358
+ src_dir = _resolve_skill_source(package_root, entry["id"])
1359
+ if src_dir is None:
1360
+ warn(f"global: skill '{entry['id']}' missing in package — skipped")
1361
+ continue
1362
+ dst_dir = dirs["skills"] / entry["id"]
1363
+ if dst_dir.exists() and not force:
1364
+ shutil.rmtree(dst_dir)
1365
+ shutil.copytree(src_dir, dst_dir)
1366
+ if not QUIET:
1367
+ success(f" {surface}: {dirs['rules']}, {dirs['skills']}")
1368
+ return 0
1369
+
1370
+
1371
+ def uninstall_global(tools: set[str]) -> int:
1372
+ """S15: remove the event4u/ namespace dir from each enabled surface."""
1373
+ import shutil
1374
+ targets = _global_targets(tools)
1375
+ removed: list[str] = []
1376
+ # Collect ancestor dirs named GLOBAL_NAMESPACE so we can drop the
1377
+ # shared parent (e.g. cursor/windsurf put rules + skills as siblings
1378
+ # under one event4u/ dir; removing both leaves a stranded namespace
1379
+ # dir). We never delete anything not literally named event4u/.
1380
+ namespace_parents: set[Path] = set()
1381
+ for surface, dirs in targets.items():
1382
+ for kind in ("rules", "skills"):
1383
+ d = dirs[kind]
1384
+ if d.exists():
1385
+ shutil.rmtree(d)
1386
+ removed.append(str(d))
1387
+ for ancestor in d.parents:
1388
+ if ancestor.name == GLOBAL_NAMESPACE:
1389
+ namespace_parents.add(ancestor)
1390
+ for parent in namespace_parents:
1391
+ if parent.is_dir() and not any(parent.iterdir()):
1392
+ parent.rmdir()
1393
+ removed.append(str(parent))
1394
+ if not QUIET:
1395
+ if removed:
1396
+ for r in removed:
1397
+ success(f"removed {r}")
1398
+ else:
1399
+ skip("global uninstall: nothing to remove")
1400
+ return 0
1401
+
1402
+
1220
1403
  # --- Argument parsing ---
1221
1404
 
1222
1405
  def parse_options(argv: list[str]) -> argparse.Namespace:
@@ -1260,14 +1443,70 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
1260
1443
  parser.add_argument("--project", default=None, help="project root (default: cwd or PROJECT_ROOT env)")
1261
1444
  parser.add_argument("--package", default=None, help="package root (default: auto-detect under project)")
1262
1445
  parser.add_argument("--quiet", action="store_true", help="suppress info/success output (warnings/errors still shown)")
1446
+ parser.add_argument(
1447
+ "--tools",
1448
+ default="all",
1449
+ help=(
1450
+ "comma-separated tool IDs to install bridges for "
1451
+ "(claude-code,cursor,windsurf,cline,gemini-cli,copilot,augment,aider,codex,all). "
1452
+ "Default: all (backward-compatible)."
1453
+ ),
1454
+ )
1263
1455
  parser.add_argument(
1264
1456
  "--no-smoke",
1265
1457
  action="store_true",
1266
1458
  help="skip the post-install hook smoke test (default: dry-fire dispatch:hook against every installed bridge)",
1267
1459
  )
1460
+ parser.add_argument(
1461
+ "--global",
1462
+ dest="global_install",
1463
+ action="store_true",
1464
+ help=(
1465
+ "Phase-3 mode: ship kernel rules + curated top-N skills to user-scope "
1466
+ "dirs (~/.claude/, ~/.cursor/, ~/.codeium/windsurf/, ~/.config/agent-config/) "
1467
+ "so the agent has them in every project. Curation: templates/global-install-manifest.yml."
1468
+ ),
1469
+ )
1470
+ parser.add_argument(
1471
+ "--uninstall",
1472
+ action="store_true",
1473
+ help="With --global: remove the event4u/ namespace dir from each enabled surface.",
1474
+ )
1268
1475
  return parser.parse_args(argv)
1269
1476
 
1270
1477
 
1478
+ # Mapping of --tools IDs accepted by install.py. Mirrors VALID_TOOLS in
1479
+ # scripts/install. Bridges keyed off these IDs are gated; substrate
1480
+ # bridges (vscode, augment) always run.
1481
+ _VALID_TOOLS = {
1482
+ "claude-code", "claude-desktop", "cursor", "windsurf", "cline",
1483
+ "gemini-cli", "copilot", "augment", "aider", "codex", "all",
1484
+ }
1485
+
1486
+
1487
+ def _parse_tools(raw: str) -> set[str]:
1488
+ """Parse and validate --tools value. Returns set of normalized IDs.
1489
+
1490
+ "all" expands to every concrete ID. Empty input is rejected.
1491
+ Unknown IDs raise SystemExit so the caller's message reaches stderr.
1492
+ """
1493
+ if not raw or not raw.strip():
1494
+ fail("--tools requires a non-empty value")
1495
+ items = [s.strip() for s in raw.split(",") if s.strip()]
1496
+ if not items:
1497
+ fail("--tools requires at least one ID")
1498
+ unknown = [s for s in items if s not in _VALID_TOOLS]
1499
+ if unknown:
1500
+ fail(f"--tools: unknown ID(s): {', '.join(unknown)} (valid: {', '.join(sorted(_VALID_TOOLS))})")
1501
+ if "all" in items:
1502
+ return {t for t in _VALID_TOOLS if t != "all"}
1503
+ return set(items)
1504
+
1505
+
1506
+ def _is_tool_enabled(tools: set[str], tool_id: str) -> bool:
1507
+ return tool_id in tools
1508
+
1509
+
1271
1510
  # --- Main ---
1272
1511
 
1273
1512
  def main(argv: list[str]) -> int:
@@ -1300,31 +1539,52 @@ def main(argv: list[str]) -> int:
1300
1539
  info(f"Profile: {opts.profile}")
1301
1540
  print()
1302
1541
 
1542
+ # --global: short-circuits the project-bridge path. Ships kernel rules
1543
+ # + curated skills to user-scope dirs and exits. --uninstall pairs
1544
+ # with --global to remove the namespace dir.
1545
+ if opts.global_install:
1546
+ tools = _parse_tools(opts.tools)
1547
+ if opts.uninstall:
1548
+ return uninstall_global(tools)
1549
+ return install_global(package_root, tools, opts.force)
1550
+ if opts.uninstall:
1551
+ fail("--uninstall is only valid combined with --global")
1552
+
1303
1553
  ensure_agent_settings(project_root, package_root, opts.profile, opts.force)
1304
1554
 
1555
+ tools = _parse_tools(opts.tools)
1556
+
1305
1557
  if not opts.skip_bridges:
1558
+ # Substrate bridges (always written; other tools symlink/depend on them).
1306
1559
  ensure_vscode_bridge(project_root, package_type, opts.force)
1307
1560
  ensure_augment_bridge(project_root, opts.force)
1308
- ensure_claude_bridge(project_root, opts.force)
1309
- ensure_cursor_bridge(project_root, opts.force)
1310
- ensure_cline_bridge(project_root, opts.force)
1311
- ensure_windsurf_bridge(project_root, opts.force)
1312
- ensure_gemini_bridge(project_root, opts.force)
1313
- ensure_copilot_bridge(project_root, opts.force)
1561
+ # Tool-specific bridges (gated by --tools selection).
1562
+ if _is_tool_enabled(tools, "claude-code"):
1563
+ ensure_claude_bridge(project_root, opts.force)
1564
+ if _is_tool_enabled(tools, "cursor"):
1565
+ ensure_cursor_bridge(project_root, opts.force)
1566
+ if _is_tool_enabled(tools, "cline"):
1567
+ ensure_cline_bridge(project_root, opts.force)
1568
+ if _is_tool_enabled(tools, "windsurf"):
1569
+ ensure_windsurf_bridge(project_root, opts.force)
1570
+ if _is_tool_enabled(tools, "gemini-cli"):
1571
+ ensure_gemini_bridge(project_root, opts.force)
1572
+ if _is_tool_enabled(tools, "copilot"):
1573
+ ensure_copilot_bridge(project_root, opts.force)
1314
1574
 
1315
1575
  if opts.augment_user_hooks:
1316
1576
  ensure_augment_user_hooks(package_root, opts.force)
1317
1577
 
1318
- if opts.cursor_user_hooks:
1578
+ if opts.cursor_user_hooks and _is_tool_enabled(tools, "cursor"):
1319
1579
  ensure_cursor_user_hooks(package_root, opts.force)
1320
1580
 
1321
- if opts.cline_user_hooks:
1581
+ if opts.cline_user_hooks and _is_tool_enabled(tools, "cline"):
1322
1582
  ensure_cline_user_hooks(package_root, opts.force)
1323
1583
 
1324
- if opts.windsurf_user_hooks:
1584
+ if opts.windsurf_user_hooks and _is_tool_enabled(tools, "windsurf"):
1325
1585
  ensure_windsurf_user_hooks(package_root, opts.force)
1326
1586
 
1327
- if opts.gemini_user_hooks:
1587
+ if opts.gemini_user_hooks and _is_tool_enabled(tools, "gemini-cli"):
1328
1588
  ensure_gemini_user_hooks(package_root, opts.force)
1329
1589
 
1330
1590
  if not opts.skip_bridges and not opts.no_smoke:
@@ -37,10 +37,26 @@ DRY_RUN=false
37
37
  VERBOSE=false
38
38
  QUIET=false
39
39
  SKIP_GITIGNORE=false
40
+ # Comma-separated tool IDs (default: all). Set by --tools or the
41
+ # orchestrator (scripts/install). The .augment/ substrate is always
42
+ # synced because every other tool symlinks back into it.
43
+ TOOLS="all"
40
44
  # Resolved from <TARGET>/.agent-settings.yml in resolve_settings(); when
41
45
  # true, .augment/rules/ files are symlinked instead of copied.
42
46
  USE_RULES_SYMLINKS=false
43
47
 
48
+ # Return 0 if a tool ID is enabled by the current --tools selection.
49
+ # "all" matches everything; otherwise match the comma list exactly.
50
+ is_tool_enabled() {
51
+ local id="$1"
52
+ [[ "$TOOLS" == "all" ]] && return 0
53
+ case ",$TOOLS," in
54
+ *,"$id",*) return 0 ;;
55
+ *,all,*) return 0 ;;
56
+ esac
57
+ return 1
58
+ }
59
+
44
60
  # --- Logging ---
45
61
  log_info() { $QUIET || echo " ✅ $*"; }
46
62
  log_warn() { $QUIET || echo " ⚠️ $*" >&2; }
@@ -57,11 +73,15 @@ parse_args() {
57
73
  --verbose) VERBOSE=true; shift ;;
58
74
  --quiet) QUIET=true; shift ;;
59
75
  --skip-gitignore) SKIP_GITIGNORE=true; shift ;;
76
+ --tools) TOOLS="$2"; shift 2 ;;
77
+ --tools=*) TOOLS="${1#*=}"; shift ;;
60
78
  --help|-h) show_help; exit 0 ;;
61
79
  *) log_error "Unknown argument: $1"; show_help; exit 1 ;;
62
80
  esac
63
81
  done
64
82
 
83
+ [[ -z "$TOOLS" ]] && TOOLS="all"
84
+
65
85
  # Auto-detect source: directory where this script lives (../ = package root)
66
86
  if [[ -z "$SOURCE_DIR" ]]; then
67
87
  SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -106,6 +126,10 @@ Syncs agent-config to target project. Copies rules, symlinks everything else.
106
126
  Options:
107
127
  --source <dir> Package source directory (default: auto-detect from script location)
108
128
  --target <dir> Target project root (default: $PROJECT_ROOT or cwd)
129
+ --tools <list> Comma-separated tool IDs (default: all). Filters tool-specific
130
+ payload (.claude/, .cursor/, .clinerules/, .windsurfrules,
131
+ GEMINI.md, copilot-instructions.md). The .augment/ substrate
132
+ and AGENTS.md are always written.
109
133
  --dry-run Show what would happen without making changes
110
134
  --verbose Show detailed output
111
135
  --quiet Suppress all output except errors
@@ -409,15 +433,25 @@ clean_stale() {
409
433
  }
410
434
 
411
435
 
412
- # Create tool-specific rule symlinks
436
+ # Create tool-specific rule symlinks (filtered by --tools selection).
437
+ # Map: tool ID → (target dir, relative prefix from target → .augment/rules).
413
438
  create_tool_symlinks() {
414
439
  local project_root="$1"
415
440
  local rules_dir="$project_root/.augment/rules"
416
441
 
417
442
  [[ -d "$rules_dir" ]] || return
418
443
 
419
- local -a tool_dirs=(".claude/rules" ".cursor/rules" ".clinerules")
420
- local -a rel_prefixes=("../../.augment/rules" "../../.augment/rules" "../.augment/rules")
444
+ local -a tool_ids=()
445
+ local -a tool_dirs=()
446
+ local -a rel_prefixes=()
447
+ is_tool_enabled "claude-code" && { tool_ids+=("claude-code"); tool_dirs+=(".claude/rules"); rel_prefixes+=("../../.augment/rules"); }
448
+ is_tool_enabled "cursor" && { tool_ids+=("cursor"); tool_dirs+=(".cursor/rules"); rel_prefixes+=("../../.augment/rules"); }
449
+ is_tool_enabled "cline" && { tool_ids+=("cline"); tool_dirs+=(".clinerules"); rel_prefixes+=("../.augment/rules"); }
450
+
451
+ if [[ ${#tool_dirs[@]} -eq 0 ]]; then
452
+ log_verbose "no tool rule directories selected (--tools=$TOOLS)"
453
+ return 0
454
+ fi
421
455
 
422
456
  local count=0
423
457
  for i in "${!tool_dirs[@]}"; do
@@ -467,15 +501,16 @@ create_tool_symlinks() {
467
501
  done
468
502
  done
469
503
 
470
- log_info "Created $count rule symlinks across ${#tool_dirs[@]} tool directories"
504
+ log_info "Created $count rule symlinks across ${#tool_dirs[@]} tool directories (${tool_ids[*]})"
471
505
  }
472
506
 
473
- # Create .claude/skills/ directory symlinks
507
+ # Create .claude/skills/ directory symlinks (claude-code only).
474
508
  create_skill_symlinks() {
475
509
  local project_root="$1"
476
510
  local skills_dir="$project_root/.augment/skills"
477
511
 
478
512
  [[ -d "$skills_dir" ]] || return
513
+ is_tool_enabled "claude-code" || { log_verbose "skip .claude/skills/ (claude-code not selected)"; return 0; }
479
514
 
480
515
  local target_dir="$project_root/.claude/skills"
481
516
  mkdir -p "$target_dir"
@@ -528,6 +563,7 @@ generate_windsurfrules() {
528
563
  local rules_dir="$project_root/.augment/rules"
529
564
 
530
565
  [[ -d "$rules_dir" ]] || return
566
+ is_tool_enabled "windsurf" || { log_verbose "skip .windsurfrules (windsurf not selected)"; return 0; }
531
567
 
532
568
  local output="$project_root/.windsurfrules"
533
569
  local count=0
@@ -561,11 +597,13 @@ generate_windsurfrules() {
561
597
  }
562
598
 
563
599
 
564
- # Create GEMINI.md symlink
600
+ # Create GEMINI.md symlink (gemini-cli only).
565
601
  create_gemini_md() {
566
602
  local project_root="$1"
567
603
  local link="$project_root/GEMINI.md"
568
604
 
605
+ is_tool_enabled "gemini-cli" || { log_verbose "skip GEMINI.md (gemini-cli not selected)"; return 0; }
606
+
569
607
  if [[ -L "$link" ]] || [[ -f "$link" ]]; then
570
608
  return # Don't overwrite
571
609
  fi
@@ -726,8 +764,15 @@ main() {
726
764
  # own root AGENTS.md / copilot-instructions.md — those are meta docs
727
765
  # about the package itself and would leak package-specific content
728
766
  # into consumer projects.
767
+ # AGENTS.md is the universal cross-tool contract (aider, codex, claude,
768
+ # etc.) and is always written. copilot-instructions.md is gated on the
769
+ # copilot tool selection.
729
770
  copy_if_missing "$SOURCE_PAYLOAD/templates/AGENTS.md" "$TARGET_DIR/AGENTS.md"
730
- copy_if_missing "$SOURCE_PAYLOAD/templates/copilot-instructions.md" "$TARGET_DIR/.github/copilot-instructions.md"
771
+ if is_tool_enabled "copilot"; then
772
+ copy_if_missing "$SOURCE_PAYLOAD/templates/copilot-instructions.md" "$TARGET_DIR/.github/copilot-instructions.md"
773
+ else
774
+ log_verbose "skip .github/copilot-instructions.md (copilot not selected)"
775
+ fi
731
776
 
732
777
  # 3. Create tool-specific symlinks
733
778
  create_tool_symlinks "$TARGET_DIR"
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Lint `.agent-config/orchestrations/*.yaml` pipeline files.
3
+
4
+ CI gate for the orchestration DSL contract
5
+ (`docs/contracts/orchestration-dsl-v1.md`). Hard-fails on:
6
+
7
+ - missing or malformed top-level keys
8
+ (`schema_version`, `name`, `description`, `steps`)
9
+ - `schema_version != 1`
10
+ - `name` not matching `[a-z][a-z0-9-]*` or not matching the filename
11
+ - duplicate `steps[].id`
12
+ - `steps[].kind` outside the enum (`skill` / `command` / `persona` /
13
+ `subagent`)
14
+ - `steps[].ref` pointing at a non-existent target on disk
15
+ - `${{ ... }}` reference to an unknown input or step id
16
+ - `steps[]` length outside [1, 32]
17
+ - `outputs` referencing an unknown step
18
+
19
+ Exit codes mirror `lint_hook_manifest.py`:
20
+ 0 — clean
21
+ 1 — at least one hard failure
22
+ 2 — file or schema-load error
23
+
24
+ Invocation:
25
+
26
+ python3 scripts/lint_orchestration_dsl.py [--dir PATH] [--file PATH]
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import re
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ REPO_ROOT = Path(__file__).resolve().parent.parent
36
+ DEFAULT_DIR = REPO_ROOT / ".agent-config" / "orchestrations"
37
+
38
+ NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
39
+ STEP_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
40
+ INTERP_RE = re.compile(r"\$\{\{\s*(inputs|steps)\.([a-z0-9_-]+)(?:\.output)?\s*\}\}")
41
+
42
+ VALID_KINDS = {"skill", "command", "persona", "subagent"}
43
+ MAX_STEPS = 32
44
+ MIN_STEPS = 1
45
+
46
+ # Subagent-orchestration modes — kept in lock-step with
47
+ # .agent-src.uncompressed/skills/subagent-orchestration/SKILL.md.
48
+ SUBAGENT_MODES = {
49
+ "do-and-judge", "do-and-judge-two-stage",
50
+ "do-in-steps", "do-in-parallel", "do-in-worktrees",
51
+ "do-competitively", "judge-with-debate",
52
+ }
53
+
54
+
55
+ def _load_yaml(path: Path) -> object:
56
+ """Reuse the dispatcher's loader so the linter sees what the
57
+ runtime sees — fallback parser when PyYAML is not installed."""
58
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
59
+ from hooks.dispatch_hook import _load_yaml as _load # noqa: E402
60
+ return _load(path)
61
+
62
+
63
+ def _ref_exists(kind: str, ref: str) -> bool:
64
+ if kind == "skill":
65
+ return (REPO_ROOT / ".agent-src.uncompressed" / "skills" / ref / "SKILL.md").is_file()
66
+ if kind == "command":
67
+ return (REPO_ROOT / ".agent-src.uncompressed" / "commands" / f"{ref}.md").is_file()
68
+ if kind == "persona":
69
+ return (REPO_ROOT / ".agent-src.uncompressed" / "personas" / f"{ref}.md").is_file()
70
+ if kind == "subagent":
71
+ return ref in SUBAGENT_MODES
72
+ return False
73
+
74
+
75
+ def _walk_interpolations(value: object):
76
+ """Yield (namespace, ident) tuples for every ${{ ns.ident }} in the
77
+ nested value (dict / list / str)."""
78
+ if isinstance(value, str):
79
+ for match in INTERP_RE.finditer(value):
80
+ yield match.group(1), match.group(2)
81
+ elif isinstance(value, dict):
82
+ for v in value.values():
83
+ yield from _walk_interpolations(v)
84
+ elif isinstance(value, list):
85
+ for v in value:
86
+ yield from _walk_interpolations(v)
87
+
88
+
89
+ def _check_unknown_namespaces(value: object, path: str, errors: list[str]) -> None:
90
+ if isinstance(value, str):
91
+ for match in re.finditer(r"\$\{\{\s*([a-z]+)\.", value):
92
+ if match.group(1) not in ("inputs", "steps"):
93
+ errors.append(f"{path}: unknown interpolation namespace '{match.group(1)}'")
94
+ elif isinstance(value, dict):
95
+ for k, v in value.items():
96
+ _check_unknown_namespaces(v, f"{path}.{k}", errors)
97
+ elif isinstance(value, list):
98
+ for i, v in enumerate(value):
99
+ _check_unknown_namespaces(v, f"{path}[{i}]", errors)
100
+
101
+
102
+ def _check_steps(doc: dict, input_ids: set[str], errors: list[str]) -> set[str]:
103
+ steps = doc.get("steps")
104
+ if not isinstance(steps, list) or not (MIN_STEPS <= len(steps) <= MAX_STEPS):
105
+ errors.append(f"steps: must be a list of {MIN_STEPS}–{MAX_STEPS} entries")
106
+ return set()
107
+ step_ids: set[str] = set()
108
+ for i, step in enumerate(steps):
109
+ if not isinstance(step, dict):
110
+ errors.append(f"steps[{i}]: must be a mapping")
111
+ continue
112
+ sid = step.get("id")
113
+ if not isinstance(sid, str) or not STEP_ID_RE.match(sid):
114
+ errors.append(f"steps[{i}].id: must be snake-case identifier")
115
+ continue
116
+ if sid in step_ids:
117
+ errors.append(f"steps[{i}].id: duplicate id '{sid}'")
118
+ continue
119
+ step_ids.add(sid)
120
+ kind = step.get("kind")
121
+ ref = step.get("ref")
122
+ if kind not in VALID_KINDS:
123
+ errors.append(f"steps.{sid}.kind: must be one of {sorted(VALID_KINDS)}")
124
+ continue
125
+ if not isinstance(ref, str) or not _ref_exists(kind, ref):
126
+ errors.append(f"steps.{sid}.ref: {kind} '{ref}' not found on disk")
127
+ _check_unknown_namespaces(step.get("with"), f"steps.{sid}.with", errors)
128
+ for ns, ident in _walk_interpolations(step.get("with") or {}):
129
+ if ns == "inputs" and ident not in input_ids:
130
+ errors.append(f"steps.{sid}.with: unknown input '{ident}'")
131
+ if ns == "steps" and ident not in step_ids - {sid}:
132
+ errors.append(f"steps.{sid}.with: unknown step '{ident}' (forward ref or self)")
133
+ return step_ids
134
+
135
+
136
+
137
+ def _check_outputs(doc: dict, step_ids: set[str], input_ids: set[str], errors: list[str]) -> None:
138
+ outputs = doc.get("outputs")
139
+ if outputs is None:
140
+ return
141
+ if not isinstance(outputs, dict):
142
+ errors.append("outputs: must be a mapping")
143
+ return
144
+ for name, value in outputs.items():
145
+ for ns, ident in _walk_interpolations(value):
146
+ if ns == "steps" and ident not in step_ids:
147
+ errors.append(f"outputs.{name}: unknown step '{ident}'")
148
+ if ns == "inputs" and ident not in input_ids:
149
+ errors.append(f"outputs.{name}: unknown input '{ident}'")
150
+
151
+
152
+ def _check_inputs(doc: dict, errors: list[str]) -> set[str]:
153
+ inputs = doc.get("inputs") or []
154
+ if not isinstance(inputs, list):
155
+ errors.append("inputs: must be a list")
156
+ return set()
157
+ ids: set[str] = set()
158
+ for i, inp in enumerate(inputs):
159
+ if not isinstance(inp, dict) or not isinstance(inp.get("id"), str):
160
+ errors.append(f"inputs[{i}]: must be a mapping with string 'id'")
161
+ continue
162
+ if inp["id"] in ids:
163
+ errors.append(f"inputs[{i}].id: duplicate id '{inp['id']}'")
164
+ ids.add(inp["id"])
165
+ return ids
166
+
167
+
168
+ def lint(path: Path) -> int:
169
+ try:
170
+ doc = _load_yaml(path)
171
+ except Exception as exc:
172
+ sys.stderr.write(f"lint_orchestration_dsl: load error: {exc}\n")
173
+ return 2
174
+ if not isinstance(doc, dict):
175
+ sys.stderr.write(f"{path}: top-level must be a mapping\n")
176
+ return 1
177
+
178
+ errors: list[str] = []
179
+ if doc.get("schema_version") != 1:
180
+ errors.append("schema_version: must be 1")
181
+ name = doc.get("name")
182
+ if not isinstance(name, str) or not NAME_RE.match(name):
183
+ errors.append("name: must be kebab-case starting with a letter")
184
+ elif name != path.stem:
185
+ errors.append(f"name: '{name}' must match filename stem '{path.stem}'")
186
+ if not isinstance(doc.get("description"), str) or not doc["description"].strip():
187
+ errors.append("description: must be a non-empty string")
188
+
189
+ input_ids = _check_inputs(doc, errors)
190
+ step_ids = _check_steps(doc, input_ids, errors)
191
+ _check_outputs(doc, step_ids, input_ids, errors)
192
+
193
+ for e in errors:
194
+ sys.stderr.write(f"error: {path}: {e}\n")
195
+ return 1 if errors else 0
196
+
197
+
198
+ def main(argv: list[str] | None = None) -> int:
199
+ parser = argparse.ArgumentParser(description=__doc__)
200
+ parser.add_argument("--dir", type=Path, default=DEFAULT_DIR)
201
+ parser.add_argument("--file", type=Path, default=None)
202
+ args = parser.parse_args(argv)
203
+ if args.file is not None:
204
+ return lint(args.file)
205
+ if not args.dir.is_dir():
206
+ return 0 # opt-in directory; absence is not a failure
207
+ rc = 0
208
+ for path in sorted(args.dir.glob("*.yaml")):
209
+ rc = max(rc, lint(path))
210
+ return rc
211
+
212
+
213
+ if __name__ == "__main__":
214
+ raise SystemExit(main())