@event4u/agent-config 1.39.0 → 1.41.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/.agent-src/commands/orchestrate.md +123 -0
- package/.agent-src/commands/sync-gitignore/fix.md +135 -0
- package/.agent-src/commands/sync-gitignore.md +31 -5
- package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +30 -2
- package/.agent-src/skills/subagent-orchestration/SKILL.md +9 -0
- package/.agent-src/skills/using-git-worktrees/SKILL.md +25 -0
- package/.agent-src/templates/agent-settings.md +9 -0
- package/.agent-src/templates/scripts/work_engine/orchestration.py +168 -0
- package/.claude-plugin/marketplace.json +3 -1
- package/CHANGELOG.md +75 -0
- package/README.md +52 -26
- package/bin/install.php +13 -6
- package/config/agent-settings.template.yml +21 -0
- package/docs/DISTRIBUTION_CHECKLIST.md +169 -0
- package/docs/architecture.md +1 -1
- package/docs/catalog.md +5 -3
- package/docs/contracts/audit-log-v1.md +142 -0
- package/docs/contracts/command-clusters.md +2 -0
- package/docs/contracts/file-ownership-matrix.json +47 -0
- package/docs/contracts/mcp-discovery-phase-notice.md +56 -0
- package/docs/contracts/mcp-tool-stub-envelope.md +78 -0
- package/docs/contracts/orchestration-dsl-v1.md +152 -0
- package/docs/getting-started.md +1 -1
- package/docs/installation.md +132 -0
- package/docs/setup/mcp-client-config.md +94 -13
- package/docs/setup/mcp-cloud-setup.md +32 -1
- package/docs/setup/per-ide/aider.md +48 -0
- package/docs/setup/per-ide/claude-code.md +108 -0
- package/docs/setup/per-ide/claude-desktop.md +173 -0
- package/docs/setup/per-ide/cline.md +43 -0
- package/docs/setup/per-ide/codex.md +46 -0
- package/docs/setup/per-ide/copilot.md +80 -0
- package/docs/setup/per-ide/cursor.md +125 -0
- package/docs/setup/per-ide/gemini-cli.md +45 -0
- package/docs/setup/per-ide/windsurf.md +120 -0
- package/package.json +1 -1
- package/scripts/_lib/script_output.py +15 -11
- package/scripts/ai_council/session.py +14 -8
- package/scripts/chat_history.py +29 -53
- package/scripts/command_suggester/settings.py +15 -13
- package/scripts/compile_router.py +13 -9
- package/scripts/compress.py +175 -20
- package/scripts/council_cli.py +9 -3
- package/scripts/extract_audit_patterns.py +202 -0
- package/scripts/install +156 -1
- package/scripts/install.py +270 -10
- package/scripts/install.sh +52 -7
- package/scripts/lint_orchestration_dsl.py +214 -0
- package/scripts/mcp_parity_smoke.py +20 -2
- package/scripts/mcp_server/catalog.py +125 -0
- package/scripts/mcp_server/consumer_tool_catalog.json +275 -0
- package/scripts/mcp_server/telemetry.py +128 -0
- package/scripts/mcp_server/tools.py +474 -15
- package/scripts/mcp_telemetry_health.py +214 -0
- package/scripts/mcp_telemetry_query.py +203 -0
- package/scripts/mcp_telemetry_store.py +211 -0
- package/scripts/memory_signal.py +12 -10
- package/scripts/pack_mcp_content.py +18 -4
- package/scripts/skill_linter.py +9 -0
- package/scripts/sync_gitignore.py +56 -1
- package/templates/claude_desktop_config.json.template +22 -0
- package/templates/cursor-rule.mdc.j2 +7 -0
- package/templates/global-install-manifest.yml +91 -0
- package/templates/marketing-copy.yml +64 -0
- package/templates/windsurf-rule.md.j2 +7 -0
package/scripts/install.py
CHANGED
|
@@ -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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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:
|
package/scripts/install.sh
CHANGED
|
@@ -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
|
|
420
|
-
local -a
|
|
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
|
-
|
|
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())
|