@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/compress.py
CHANGED
|
@@ -26,6 +26,8 @@ import shutil
|
|
|
26
26
|
import sys
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
29
31
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
30
32
|
from _lib.script_output import info, success, flush_summary, resolve_level # noqa: E402
|
|
31
33
|
|
|
@@ -44,28 +46,31 @@ def _read_augment_rules_use_symlinks() -> bool:
|
|
|
44
46
|
"""Read augment.rules_use_symlinks from .agent-settings.yml.
|
|
45
47
|
|
|
46
48
|
Returns True only when the setting is present under the top-level
|
|
47
|
-
``augment:`` block and resolves to a truthy
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
``augment:`` block and resolves to a truthy value. Missing file,
|
|
50
|
+
missing block, or any other value → False (preserve copy default).
|
|
51
|
+
|
|
52
|
+
Centralized loader (road-to-portable-dev-preferences P3): tolerance
|
|
53
|
+
contract handles missing file / malformed YAML / no PyYAML uniformly.
|
|
50
54
|
"""
|
|
51
|
-
if not SETTINGS_FILE.exists():
|
|
52
|
-
return False
|
|
53
55
|
try:
|
|
54
|
-
|
|
55
|
-
except
|
|
56
|
+
from scripts._lib.agent_settings import load_agent_settings
|
|
57
|
+
except ImportError: # pragma: no cover — script-style invocation
|
|
58
|
+
import sys as _sys
|
|
59
|
+
from pathlib import Path as _Path
|
|
60
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
61
|
+
from _lib.agent_settings import load_agent_settings # type: ignore[import-not-found]
|
|
62
|
+
|
|
63
|
+
data = load_agent_settings(project_path=SETTINGS_FILE)
|
|
64
|
+
augment = data.get("augment")
|
|
65
|
+
if not isinstance(augment, dict):
|
|
56
66
|
return False
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
continue
|
|
65
|
-
if in_augment:
|
|
66
|
-
m = re.match(r"^\s+rules_use_symlinks\s*:\s*([^\s#]+)", line)
|
|
67
|
-
if m:
|
|
68
|
-
return m.group(1).strip().lower() in ("true", "yes", "on", "1")
|
|
67
|
+
value = augment.get("rules_use_symlinks")
|
|
68
|
+
if isinstance(value, bool):
|
|
69
|
+
return value
|
|
70
|
+
if isinstance(value, str):
|
|
71
|
+
return value.strip().lower() in ("true", "yes", "on", "1")
|
|
72
|
+
if isinstance(value, int):
|
|
73
|
+
return value == 1
|
|
69
74
|
return False
|
|
70
75
|
|
|
71
76
|
|
|
@@ -503,6 +508,149 @@ def generate_windsurfrules() -> int:
|
|
|
503
508
|
return len(rules)
|
|
504
509
|
|
|
505
510
|
|
|
511
|
+
# ── Modern editor formats (road-to-simplicity-and-everywhere Phase 5) ─
|
|
512
|
+
# Cursor `.cursor/rules/*.mdc` (frontmatter: description, globs,
|
|
513
|
+
# alwaysApply) and Windsurf `.windsurf/rules/*.md` (frontmatter:
|
|
514
|
+
# trigger, description, globs) are the formats current editors prefer.
|
|
515
|
+
# Legacy `.windsurfrules` aggregate stays for users who prefer it.
|
|
516
|
+
|
|
517
|
+
CURSOR_RULES_MDC_DIR = PROJECT_ROOT / ".cursor" / "rules"
|
|
518
|
+
WINDSURF_RULES_DIR = PROJECT_ROOT / ".windsurf" / "rules"
|
|
519
|
+
WINDSURF_WORKFLOWS_DIR = PROJECT_ROOT / ".windsurf" / "workflows"
|
|
520
|
+
CURSOR_COMMANDS_DIR = PROJECT_ROOT / ".cursor" / "commands"
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _parse_frontmatter(content: str) -> tuple[dict, str]:
|
|
524
|
+
"""Split a `---`-delimited YAML frontmatter from the body."""
|
|
525
|
+
if not content.startswith("---"):
|
|
526
|
+
return {}, content
|
|
527
|
+
end = content.find("\n---", 3)
|
|
528
|
+
if end == -1:
|
|
529
|
+
return {}, content
|
|
530
|
+
raw = content[3:end].strip()
|
|
531
|
+
body = content[end + 4:].lstrip("\n")
|
|
532
|
+
try:
|
|
533
|
+
meta = yaml.safe_load(raw) or {}
|
|
534
|
+
except yaml.YAMLError:
|
|
535
|
+
meta = {}
|
|
536
|
+
return meta if isinstance(meta, dict) else {}, body
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _emit_cursor_mdc(source: Path, target: Path) -> None:
|
|
540
|
+
"""Write a Cursor `.mdc` file with Cursor-shaped frontmatter."""
|
|
541
|
+
meta, body = _parse_frontmatter(source.read_text())
|
|
542
|
+
description = (meta.get("description") or "").replace("\n", " ").strip()
|
|
543
|
+
always_apply = bool(meta.get("alwaysApply") or meta.get("type") == "always")
|
|
544
|
+
lines = [
|
|
545
|
+
"---",
|
|
546
|
+
f"description: {description}",
|
|
547
|
+
"globs: ",
|
|
548
|
+
f"alwaysApply: {'true' if always_apply else 'false'}",
|
|
549
|
+
"---",
|
|
550
|
+
"",
|
|
551
|
+
body.rstrip() + "\n",
|
|
552
|
+
]
|
|
553
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
554
|
+
target.write_text("\n".join(lines))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _emit_windsurf_rule(source: Path, target: Path) -> None:
|
|
558
|
+
"""Write a Windsurf rule with Wave-8 frontmatter (trigger/description/globs)."""
|
|
559
|
+
meta, body = _parse_frontmatter(source.read_text())
|
|
560
|
+
description = (meta.get("description") or "").replace("\n", " ").strip()
|
|
561
|
+
always_apply = bool(meta.get("alwaysApply") or meta.get("type") == "always")
|
|
562
|
+
trigger = "always_on" if always_apply else "model_decision"
|
|
563
|
+
lines = [
|
|
564
|
+
"---",
|
|
565
|
+
f"trigger: {trigger}",
|
|
566
|
+
f"description: {description}",
|
|
567
|
+
"globs: ",
|
|
568
|
+
"---",
|
|
569
|
+
"",
|
|
570
|
+
body.rstrip() + "\n",
|
|
571
|
+
]
|
|
572
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
573
|
+
target.write_text("\n".join(lines))
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _clean_modern_dir(target_dir: Path, valid_names: set[str]) -> None:
|
|
577
|
+
"""Drop files in `target_dir` whose names are not in `valid_names`."""
|
|
578
|
+
if not target_dir.exists():
|
|
579
|
+
return
|
|
580
|
+
for item in target_dir.iterdir():
|
|
581
|
+
if item.name == "README.md":
|
|
582
|
+
continue
|
|
583
|
+
if item.name not in valid_names:
|
|
584
|
+
if item.is_dir() and not item.is_symlink():
|
|
585
|
+
shutil.rmtree(item)
|
|
586
|
+
else:
|
|
587
|
+
item.unlink()
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def generate_cursor_mdc_rules() -> int:
|
|
591
|
+
"""Emit `.cursor/rules/*.mdc` per source rule (alongside legacy `.md` symlinks)."""
|
|
592
|
+
rules = sorted(RULES_SOURCE.glob("*.md"))
|
|
593
|
+
valid = {f"{r.stem}.mdc" for r in rules}
|
|
594
|
+
_clean_modern_dir(CURSOR_RULES_MDC_DIR, valid | {r.name for r in rules})
|
|
595
|
+
for rule in rules:
|
|
596
|
+
_emit_cursor_mdc(rule, CURSOR_RULES_MDC_DIR / f"{rule.stem}.mdc")
|
|
597
|
+
info(f" ✅ Wrote {len(rules)} `.cursor/rules/*.mdc` files")
|
|
598
|
+
return len(rules)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def generate_windsurf_modern_rules() -> int:
|
|
602
|
+
"""Emit `.windsurf/rules/*.md` per source rule (Wave-8 frontmatter)."""
|
|
603
|
+
rules = sorted(RULES_SOURCE.glob("*.md"))
|
|
604
|
+
valid = {r.name for r in rules}
|
|
605
|
+
_clean_modern_dir(WINDSURF_RULES_DIR, valid)
|
|
606
|
+
for rule in rules:
|
|
607
|
+
_emit_windsurf_rule(rule, WINDSURF_RULES_DIR / rule.name)
|
|
608
|
+
info(f" ✅ Wrote {len(rules)} `.windsurf/rules/*.md` files")
|
|
609
|
+
return len(rules)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def generate_cursor_commands() -> int:
|
|
613
|
+
"""Symlink `.cursor/commands/<slug>.md` per source command."""
|
|
614
|
+
if not COMMANDS_SOURCE.exists():
|
|
615
|
+
return 0
|
|
616
|
+
cmds = list(_iter_commands())
|
|
617
|
+
valid = {f"{slug}.md" for _, slug in cmds}
|
|
618
|
+
_clean_modern_dir(CURSOR_COMMANDS_DIR, valid)
|
|
619
|
+
CURSOR_COMMANDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
620
|
+
count = 0
|
|
621
|
+
for source_file, slug in cmds:
|
|
622
|
+
link = CURSOR_COMMANDS_DIR / f"{slug}.md"
|
|
623
|
+
if link.exists() or link.is_symlink():
|
|
624
|
+
link.unlink()
|
|
625
|
+
rel = Path("../../.agent-src/commands") / source_file.relative_to(COMMANDS_SOURCE)
|
|
626
|
+
link.symlink_to(rel)
|
|
627
|
+
count += 1
|
|
628
|
+
info(f" ✅ Linked {count} `.cursor/commands/*.md` files")
|
|
629
|
+
return count
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def generate_windsurf_workflows() -> int:
|
|
633
|
+
"""Symlink `.windsurf/workflows/<slug>.md` per source command."""
|
|
634
|
+
if not COMMANDS_SOURCE.exists():
|
|
635
|
+
return 0
|
|
636
|
+
cmds = list(_iter_commands())
|
|
637
|
+
valid = {f"{slug}.md" for _, slug in cmds}
|
|
638
|
+
_clean_modern_dir(WINDSURF_WORKFLOWS_DIR, valid)
|
|
639
|
+
WINDSURF_WORKFLOWS_DIR.mkdir(parents=True, exist_ok=True)
|
|
640
|
+
count = 0
|
|
641
|
+
for source_file, slug in cmds:
|
|
642
|
+
link = WINDSURF_WORKFLOWS_DIR / f"{slug}.md"
|
|
643
|
+
if link.exists() or link.is_symlink():
|
|
644
|
+
link.unlink()
|
|
645
|
+
rel = Path("../../.agent-src/commands") / source_file.relative_to(COMMANDS_SOURCE)
|
|
646
|
+
link.symlink_to(rel)
|
|
647
|
+
count += 1
|
|
648
|
+
info(f" ✅ Linked {count} `.windsurf/workflows/*.md` files")
|
|
649
|
+
return count
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
|
|
506
654
|
def generate_gemini_md() -> None:
|
|
507
655
|
"""Create GEMINI.md symlink to AGENTS.md."""
|
|
508
656
|
link = PROJECT_ROOT / "GEMINI.md"
|
|
@@ -695,9 +843,15 @@ def generate_tools() -> None:
|
|
|
695
843
|
skills = generate_claude_skills()
|
|
696
844
|
commands = generate_claude_commands()
|
|
697
845
|
personas = generate_persona_symlinks()
|
|
846
|
+
cursor_mdc = generate_cursor_mdc_rules()
|
|
847
|
+
windsurf_modern = generate_windsurf_modern_rules()
|
|
848
|
+
cursor_cmds = generate_cursor_commands()
|
|
849
|
+
windsurf_wf = generate_windsurf_workflows()
|
|
698
850
|
summary = (
|
|
699
851
|
f"✅ generate-tools — rules={rules} skills={skills} "
|
|
700
|
-
f"commands={commands} personas={personas}"
|
|
852
|
+
f"commands={commands} personas={personas} "
|
|
853
|
+
f"cursor_mdc={cursor_mdc} windsurf_rules={windsurf_modern} "
|
|
854
|
+
f"cursor_commands={cursor_cmds} windsurf_workflows={windsurf_wf}"
|
|
701
855
|
)
|
|
702
856
|
if resolve_level() == "verbose":
|
|
703
857
|
print(f"\n{summary}")
|
|
@@ -806,6 +960,7 @@ def clean_tools() -> None:
|
|
|
806
960
|
PROJECT_ROOT / ".claude",
|
|
807
961
|
PROJECT_ROOT / ".cursor",
|
|
808
962
|
PROJECT_ROOT / ".clinerules",
|
|
963
|
+
PROJECT_ROOT / ".windsurf",
|
|
809
964
|
PROJECT_ROOT / ".windsurfrules",
|
|
810
965
|
PROJECT_ROOT / "GEMINI.md",
|
|
811
966
|
]
|
package/scripts/council_cli.py
CHANGED
|
@@ -53,9 +53,15 @@ class CouncilDisabledError(RuntimeError):
|
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
def load_settings(path: Path = SETTINGS_FILE) -> dict[str, Any]:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
"""Load merged settings via the centralized loader.
|
|
57
|
+
|
|
58
|
+
road-to-portable-dev-preferences P3 migration: tolerance contract
|
|
59
|
+
(missing file / malformed YAML / no PyYAML) is handled uniformly by
|
|
60
|
+
``load_agent_settings``. ``ai_council.*`` keys are not whitelisted,
|
|
61
|
+
so the project file remains authoritative for council config.
|
|
62
|
+
"""
|
|
63
|
+
from scripts._lib.agent_settings import load_agent_settings
|
|
64
|
+
return load_agent_settings(project_path=path)
|
|
59
65
|
|
|
60
66
|
|
|
61
67
|
def build_members(
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mine repeated phase patterns from ``agents/state/audit/*.jsonl``.
|
|
3
|
+
|
|
4
|
+
Consumer side of `audit-log-v1` (see
|
|
5
|
+
`docs/contracts/audit-log-v1.md`). Reads append-only JSONL audit
|
|
6
|
+
lines emitted by the `work_engine` phase hook and surfaces patterns
|
|
7
|
+
that repeat across **independent** runs — i.e. distinct `work_id`
|
|
8
|
+
values — so the human reviewer can promote them via the
|
|
9
|
+
`learning-to-rule-or-skill` skill.
|
|
10
|
+
|
|
11
|
+
Read-only: never mutates the JSONL, never writes outside `--output`.
|
|
12
|
+
|
|
13
|
+
Pattern shape (one per row):
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
"summary": "<phase>:<outcome>:<rules_hash>",
|
|
17
|
+
"phase": "verify",
|
|
18
|
+
"outcome": "success",
|
|
19
|
+
"rules_applied": ["verify-before-complete", "commit-policy"],
|
|
20
|
+
"count": 7, # distinct work_ids
|
|
21
|
+
"line_ids": ["01HXY...", ...],
|
|
22
|
+
"first_seen": "2026-05-01T...",
|
|
23
|
+
"last_seen": "2026-05-11T..."
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Repetition gate: a pattern is emitted only when ``count >= 2`` and
|
|
27
|
+
the two contributing lines come from **different** ``work_id`` values
|
|
28
|
+
(independence floor — same gate as the skill's evidence rule).
|
|
29
|
+
|
|
30
|
+
Usage::
|
|
31
|
+
|
|
32
|
+
python3 scripts/extract_audit_patterns.py # human table
|
|
33
|
+
python3 scripts/extract_audit_patterns.py --json # machine
|
|
34
|
+
python3 scripts/extract_audit_patterns.py --min-count 3
|
|
35
|
+
python3 scripts/extract_audit_patterns.py --month 2026-05 # one file
|
|
36
|
+
python3 scripts/extract_audit_patterns.py --audit-dir <p> # override
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import sys
|
|
43
|
+
from collections import defaultdict
|
|
44
|
+
from dataclasses import dataclass, field, asdict
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Iterable
|
|
47
|
+
|
|
48
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
49
|
+
DEFAULT_AUDIT_DIR = ROOT / "agents" / "state" / "audit"
|
|
50
|
+
SCHEMA_VERSION = 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Pattern:
|
|
55
|
+
summary: str
|
|
56
|
+
phase: str
|
|
57
|
+
outcome: str
|
|
58
|
+
rules_applied: list[str]
|
|
59
|
+
count: int = 0
|
|
60
|
+
line_ids: list[str] = field(default_factory=list)
|
|
61
|
+
work_ids: set[str] = field(default_factory=set)
|
|
62
|
+
first_seen: str = ""
|
|
63
|
+
last_seen: str = ""
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict:
|
|
66
|
+
d = asdict(self)
|
|
67
|
+
d["work_ids"] = sorted(self.work_ids)
|
|
68
|
+
return d
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _iter_lines(audit_dir: Path, month: str | None) -> Iterable[dict]:
|
|
72
|
+
"""Yield parsed JSONL records from the audit directory.
|
|
73
|
+
|
|
74
|
+
Silently skips malformed lines (forward-compat per contract § 86).
|
|
75
|
+
"""
|
|
76
|
+
if not audit_dir.exists():
|
|
77
|
+
return
|
|
78
|
+
files = (
|
|
79
|
+
[audit_dir / f"{month}.jsonl"] if month
|
|
80
|
+
else sorted(audit_dir.glob("*.jsonl"))
|
|
81
|
+
)
|
|
82
|
+
for path in files:
|
|
83
|
+
if not path.exists():
|
|
84
|
+
continue
|
|
85
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
86
|
+
for raw in fh:
|
|
87
|
+
raw = raw.strip()
|
|
88
|
+
if not raw:
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
rec = json.loads(raw)
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
continue
|
|
94
|
+
if rec.get("schema_version") != SCHEMA_VERSION:
|
|
95
|
+
continue
|
|
96
|
+
yield rec
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _pattern_key(rec: dict) -> tuple[str, str, tuple[str, ...]]:
|
|
100
|
+
rules = tuple(sorted(rec.get("rules_applied") or []))
|
|
101
|
+
return (rec.get("phase", ""), rec.get("outcome", ""), rules)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_supersedes(records: list[dict]) -> list[dict]:
|
|
105
|
+
"""Apply supersede chains: drop records whose id is superseded."""
|
|
106
|
+
superseded: set[str] = set()
|
|
107
|
+
for rec in records:
|
|
108
|
+
if rec.get("type") == "supersede" and rec.get("supersedes"):
|
|
109
|
+
superseded.add(rec["supersedes"])
|
|
110
|
+
return [r for r in records if r.get("id") not in superseded]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def mine(audit_dir: Path, month: str | None, min_count: int) -> list[dict]:
|
|
114
|
+
"""Group records into patterns; enforce independence floor."""
|
|
115
|
+
records = _resolve_supersedes(list(_iter_lines(audit_dir, month)))
|
|
116
|
+
groups: dict[tuple, Pattern] = {}
|
|
117
|
+
for rec in records:
|
|
118
|
+
if rec.get("type") not in (None, "phase"):
|
|
119
|
+
continue
|
|
120
|
+
key = _pattern_key(rec)
|
|
121
|
+
if key not in groups:
|
|
122
|
+
phase, outcome, rules = key
|
|
123
|
+
rules_hash = "+".join(rules) or "<none>"
|
|
124
|
+
groups[key] = Pattern(
|
|
125
|
+
summary=f"{phase}:{outcome}:{rules_hash}",
|
|
126
|
+
phase=phase,
|
|
127
|
+
outcome=outcome,
|
|
128
|
+
rules_applied=list(rules),
|
|
129
|
+
)
|
|
130
|
+
pat = groups[key]
|
|
131
|
+
ts = rec.get("ts", "")
|
|
132
|
+
wid = rec.get("work_id", "")
|
|
133
|
+
if wid:
|
|
134
|
+
pat.work_ids.add(wid)
|
|
135
|
+
line_id = rec.get("id", "")
|
|
136
|
+
if line_id:
|
|
137
|
+
pat.line_ids.append(line_id)
|
|
138
|
+
pat.count = len(pat.work_ids)
|
|
139
|
+
if not pat.first_seen or ts < pat.first_seen:
|
|
140
|
+
pat.first_seen = ts
|
|
141
|
+
if not pat.last_seen or ts > pat.last_seen:
|
|
142
|
+
pat.last_seen = ts
|
|
143
|
+
out = [p.to_dict() for p in groups.values() if p.count >= min_count]
|
|
144
|
+
out.sort(key=lambda d: (-d["count"], d["summary"]))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _render_table(patterns: list[dict]) -> str:
|
|
149
|
+
if not patterns:
|
|
150
|
+
return "(no patterns at or above the min-count threshold)"
|
|
151
|
+
lines = [
|
|
152
|
+
f"{'count':>5} {'phase':<10} {'outcome':<8} {'rules':<40} summary",
|
|
153
|
+
f"{'-' * 5} {'-' * 10} {'-' * 8} {'-' * 40} -------",
|
|
154
|
+
]
|
|
155
|
+
for p in patterns:
|
|
156
|
+
rules = ",".join(p["rules_applied"]) or "<none>"
|
|
157
|
+
if len(rules) > 38:
|
|
158
|
+
rules = rules[:35] + "..."
|
|
159
|
+
lines.append(
|
|
160
|
+
f"{p['count']:>5} {p['phase']:<10} {p['outcome']:<8} "
|
|
161
|
+
f"{rules:<40} {p['summary']}"
|
|
162
|
+
)
|
|
163
|
+
return "\n".join(lines)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv: list[str] | None = None) -> int:
|
|
167
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
168
|
+
ap.add_argument(
|
|
169
|
+
"--audit-dir", type=Path, default=DEFAULT_AUDIT_DIR,
|
|
170
|
+
help="Override audit-log directory (default: %(default)s).",
|
|
171
|
+
)
|
|
172
|
+
ap.add_argument(
|
|
173
|
+
"--month", help="Single YYYY-MM file instead of all months.",
|
|
174
|
+
)
|
|
175
|
+
ap.add_argument(
|
|
176
|
+
"--min-count", type=int, default=2,
|
|
177
|
+
help="Minimum distinct work_ids required (default: 2).",
|
|
178
|
+
)
|
|
179
|
+
ap.add_argument(
|
|
180
|
+
"--json", action="store_true", help="Emit machine-readable JSON.",
|
|
181
|
+
)
|
|
182
|
+
args = ap.parse_args(argv)
|
|
183
|
+
|
|
184
|
+
if args.min_count < 2:
|
|
185
|
+
print(
|
|
186
|
+
"❌ --min-count must be >= 2 (independence floor per "
|
|
187
|
+
"audit-log-v1 § Privacy floor).",
|
|
188
|
+
file=sys.stderr,
|
|
189
|
+
)
|
|
190
|
+
return 2
|
|
191
|
+
|
|
192
|
+
patterns = mine(args.audit_dir, args.month, args.min_count)
|
|
193
|
+
if args.json:
|
|
194
|
+
json.dump(patterns, sys.stdout, indent=2, sort_keys=True)
|
|
195
|
+
sys.stdout.write("\n")
|
|
196
|
+
else:
|
|
197
|
+
print(_render_table(patterns))
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
sys.exit(main())
|
package/scripts/install
CHANGED
|
@@ -15,13 +15,30 @@
|
|
|
15
15
|
# --source <dir> Package source directory (default: auto-detect)
|
|
16
16
|
# --target <dir> Target project root (default: cwd)
|
|
17
17
|
# --profile <name> Cost profile for bridges (minimal|balanced|full)
|
|
18
|
+
# --tools <list> Comma-separated tool IDs to install (default: all).
|
|
19
|
+
# Valid: claude-code,claude-desktop,cursor,windsurf,
|
|
20
|
+
# cline,gemini-cli,copilot,augment,aider,codex,all
|
|
21
|
+
# --list-tools Print supported tool IDs with descriptions, then exit
|
|
22
|
+
# --yes, -y Non-interactive mode: do not prompt (default for non-TTY)
|
|
18
23
|
# --force Overwrite existing bridge files
|
|
19
24
|
# --dry-run Show what payload sync would do (does not run bridges)
|
|
20
25
|
# --verbose Detailed payload sync output
|
|
21
26
|
# --quiet Suppress non-error output
|
|
22
27
|
# --skip-sync Skip payload sync (install.sh)
|
|
23
28
|
# --skip-bridges Skip bridge files (install.py)
|
|
29
|
+
# --global Phase-3: ship kernel rules + curated skills to user-scope
|
|
30
|
+
# dirs (~/.claude/, ~/.cursor/, ~/.codeium/windsurf/,
|
|
31
|
+
# ~/.config/agent-config/) so the agent has them in every
|
|
32
|
+
# project. Pair with --tools to scope surfaces; default = all.
|
|
33
|
+
# --uninstall With --global: remove the event4u/ namespace dir from each
|
|
34
|
+
# enabled surface (no effect on user-added files).
|
|
24
35
|
# --help, -h Show this help
|
|
36
|
+
#
|
|
37
|
+
# Examples:
|
|
38
|
+
# bash scripts/install # everything (default)
|
|
39
|
+
# bash scripts/install --tools=claude-code,cursor # only those two
|
|
40
|
+
# bash scripts/install --tools=cursor --yes # CI-friendly
|
|
41
|
+
# bash scripts/install --list-tools # show catalog
|
|
25
42
|
|
|
26
43
|
set -uo pipefail
|
|
27
44
|
|
|
@@ -32,19 +49,67 @@ INSTALL_PY="$SCRIPT_DIR/install.py"
|
|
|
32
49
|
SOURCE_DIR=""
|
|
33
50
|
TARGET_DIR=""
|
|
34
51
|
PROFILE=""
|
|
52
|
+
TOOLS=""
|
|
53
|
+
TOOLS_EXPLICIT=false
|
|
54
|
+
YES=false
|
|
35
55
|
FORCE=false
|
|
36
56
|
DRY_RUN=false
|
|
37
57
|
VERBOSE=false
|
|
38
58
|
QUIET=false
|
|
39
59
|
SKIP_SYNC=false
|
|
40
60
|
SKIP_BRIDGES=false
|
|
61
|
+
LIST_TOOLS=false
|
|
62
|
+
GLOBAL_INSTALL=false
|
|
63
|
+
UNINSTALL=false
|
|
64
|
+
|
|
65
|
+
# Single source of truth for valid tool IDs (also referenced by install.sh / install.py).
|
|
66
|
+
VALID_TOOLS="claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex all"
|
|
41
67
|
|
|
42
68
|
show_help() {
|
|
43
|
-
sed -n '3,
|
|
69
|
+
sed -n '3,35p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
list_tools() {
|
|
73
|
+
cat <<'EOF'
|
|
74
|
+
Supported --tools IDs (default: all):
|
|
75
|
+
|
|
76
|
+
claude-code .claude/rules, .claude/skills, .claude/commands, .claude/settings.json
|
|
77
|
+
claude-desktop ~/Library/Application Support/Claude/ (global, see Phase 4 docs)
|
|
78
|
+
cursor .cursor/rules, .cursor/commands (legacy .cursorrules also written)
|
|
79
|
+
windsurf .windsurf/rules, .windsurf/workflows (legacy .windsurfrules also written)
|
|
80
|
+
cline .clinerules/ symlinks
|
|
81
|
+
gemini-cli GEMINI.md, .gemini/settings.json
|
|
82
|
+
copilot .github/copilot-instructions.md, .vscode/settings.json
|
|
83
|
+
augment .augment/ payload + settings (substrate — recommended for every install)
|
|
84
|
+
aider AGENTS.md (Linux Foundation cross-tool contract; always written)
|
|
85
|
+
codex AGENTS.md (same as aider — no extra action)
|
|
86
|
+
all every ID above (the default; backward-compatible)
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
--tools=claude-code,cursor project-local install for those two surfaces
|
|
90
|
+
--tools=all equivalent to omitting the flag
|
|
91
|
+
EOF
|
|
44
92
|
}
|
|
45
93
|
|
|
46
94
|
err() { echo " ❌ $*" >&2; }
|
|
47
95
|
|
|
96
|
+
# Validate a comma-separated tool list against $VALID_TOOLS. Empty input is
|
|
97
|
+
# rejected so a stray --tools= does not silently behave like --tools=all.
|
|
98
|
+
validate_tools() {
|
|
99
|
+
local raw="$1"
|
|
100
|
+
[[ -z "$raw" ]] && { err "--tools requires a non-empty value (use --list-tools to see options)"; return 1; }
|
|
101
|
+
local IFS=','
|
|
102
|
+
local item
|
|
103
|
+
for item in $raw; do
|
|
104
|
+
[[ -z "$item" ]] && { err "--tools contains an empty entry"; return 1; }
|
|
105
|
+
if [[ " $VALID_TOOLS " != *" $item "* ]]; then
|
|
106
|
+
err "Unknown tool ID: $item (run --list-tools for the catalog)"
|
|
107
|
+
return 1
|
|
108
|
+
fi
|
|
109
|
+
done
|
|
110
|
+
return 0
|
|
111
|
+
}
|
|
112
|
+
|
|
48
113
|
while [[ $# -gt 0 ]]; do
|
|
49
114
|
case "$1" in
|
|
50
115
|
--source) SOURCE_DIR="$2"; shift 2 ;;
|
|
@@ -53,17 +118,84 @@ while [[ $# -gt 0 ]]; do
|
|
|
53
118
|
--target=*) TARGET_DIR="${1#*=}"; shift ;;
|
|
54
119
|
--profile) PROFILE="$2"; shift 2 ;;
|
|
55
120
|
--profile=*) PROFILE="${1#*=}"; shift ;;
|
|
121
|
+
--tools) TOOLS="$2"; TOOLS_EXPLICIT=true; shift 2 ;;
|
|
122
|
+
--tools=*) TOOLS="${1#*=}"; TOOLS_EXPLICIT=true; shift ;;
|
|
123
|
+
--list-tools) LIST_TOOLS=true; shift ;;
|
|
124
|
+
--yes|-y) YES=true; shift ;;
|
|
56
125
|
--force) FORCE=true; shift ;;
|
|
57
126
|
--dry-run) DRY_RUN=true; shift ;;
|
|
58
127
|
--verbose) VERBOSE=true; shift ;;
|
|
59
128
|
--quiet) QUIET=true; shift ;;
|
|
60
129
|
--skip-sync) SKIP_SYNC=true; shift ;;
|
|
61
130
|
--skip-bridges) SKIP_BRIDGES=true; shift ;;
|
|
131
|
+
--global) GLOBAL_INSTALL=true; shift ;;
|
|
132
|
+
--uninstall) UNINSTALL=true; shift ;;
|
|
62
133
|
--help|-h) show_help; exit 0 ;;
|
|
63
134
|
*) err "Unknown argument: $1"; show_help >&2; exit 1 ;;
|
|
64
135
|
esac
|
|
65
136
|
done
|
|
66
137
|
|
|
138
|
+
if $LIST_TOOLS; then
|
|
139
|
+
list_tools
|
|
140
|
+
exit 0
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
# Interactive --tools picker (S9). Fires only when:
|
|
144
|
+
# - --tools was not explicitly passed
|
|
145
|
+
# - --yes / -y was not passed (CI / non-interactive opt-out)
|
|
146
|
+
# - stdin AND stdout are both TTYs (so we're not in a pipe / curl|bash flow)
|
|
147
|
+
# - --dry-run, --quiet, --skip-sync, --skip-bridges did not opt out of UX
|
|
148
|
+
# Otherwise we fall through to the backward-compatible "all" default.
|
|
149
|
+
prompt_tools() {
|
|
150
|
+
local choice picked tool i=0
|
|
151
|
+
local -a menu=(claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex)
|
|
152
|
+
echo ""
|
|
153
|
+
echo " 📦 Pick the tools to install (comma-separated numbers, blank = all):"
|
|
154
|
+
for tool in "${menu[@]}"; do
|
|
155
|
+
i=$((i+1))
|
|
156
|
+
printf " %2d) %s\n" "$i" "$tool"
|
|
157
|
+
done
|
|
158
|
+
echo " a) all (default)"
|
|
159
|
+
echo ""
|
|
160
|
+
printf " Selection: "
|
|
161
|
+
IFS= read -r choice || choice=""
|
|
162
|
+
choice="${choice// /}"
|
|
163
|
+
if [[ -z "$choice" || "$choice" == "a" || "$choice" == "all" ]]; then
|
|
164
|
+
TOOLS="all"; return
|
|
165
|
+
fi
|
|
166
|
+
picked=""
|
|
167
|
+
local IFS=','
|
|
168
|
+
for n in $choice; do
|
|
169
|
+
if ! [[ "$n" =~ ^[0-9]+$ ]] || (( n < 1 || n > ${#menu[@]} )); then
|
|
170
|
+
err "Invalid selection: $n (expected 1-${#menu[@]} or 'a')"; exit 1
|
|
171
|
+
fi
|
|
172
|
+
picked+="${menu[$((n-1))]},"
|
|
173
|
+
done
|
|
174
|
+
TOOLS="${picked%,}"
|
|
175
|
+
echo " ✅ Selected: $TOOLS"
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if ! $TOOLS_EXPLICIT && ! $YES && ! $QUIET && ! $LIST_TOOLS && ! $GLOBAL_INSTALL && [[ -t 0 && -t 1 ]]; then
|
|
179
|
+
prompt_tools
|
|
180
|
+
TOOLS_EXPLICIT=true
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
if $UNINSTALL && ! $GLOBAL_INSTALL; then
|
|
184
|
+
err "--uninstall is only valid combined with --global"
|
|
185
|
+
exit 1
|
|
186
|
+
fi
|
|
187
|
+
|
|
188
|
+
# Default = "all": backward compatible with pre-Phase-1 invocations. An
|
|
189
|
+
# explicit --tools= (empty value) is rejected by validate_tools — only an
|
|
190
|
+
# absent flag falls through to "all".
|
|
191
|
+
if ! $TOOLS_EXPLICIT && [[ -z "$TOOLS" ]]; then
|
|
192
|
+
TOOLS="all"
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
if ! validate_tools "$TOOLS"; then
|
|
196
|
+
exit 1
|
|
197
|
+
fi
|
|
198
|
+
|
|
67
199
|
# Auto-detect source: directory above this script
|
|
68
200
|
if [[ -z "$SOURCE_DIR" ]]; then
|
|
69
201
|
SOURCE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
@@ -103,6 +235,7 @@ run_sync() {
|
|
|
103
235
|
$DRY_RUN && args+=(--dry-run)
|
|
104
236
|
$VERBOSE && args+=(--verbose)
|
|
105
237
|
$QUIET && args+=(--quiet)
|
|
238
|
+
args+=(--tools="$TOOLS")
|
|
106
239
|
bash "$INSTALL_SH" "${args[@]}"
|
|
107
240
|
}
|
|
108
241
|
|
|
@@ -123,10 +256,32 @@ run_bridges() {
|
|
|
123
256
|
[[ -n "$PROFILE" ]] && args+=(--profile="$PROFILE")
|
|
124
257
|
$FORCE && args+=(--force)
|
|
125
258
|
$QUIET && args+=(--quiet)
|
|
259
|
+
args+=(--tools="$TOOLS")
|
|
126
260
|
"$python_bin" "$INSTALL_PY" "${args[@]}"
|
|
127
261
|
}
|
|
128
262
|
|
|
129
263
|
RC=0
|
|
264
|
+
|
|
265
|
+
# --global: dedicated user-scope path. Skips the project-bridge sync entirely
|
|
266
|
+
# and forwards to install.py --global (Phase 3 / S13). --uninstall pairs with
|
|
267
|
+
# --global to wipe the event4u/ namespace dir under each surface.
|
|
268
|
+
if $GLOBAL_INSTALL; then
|
|
269
|
+
if ! python_bin="$(find_python)"; then
|
|
270
|
+
err "Python 3 not found — required for --global install"
|
|
271
|
+
exit 1
|
|
272
|
+
fi
|
|
273
|
+
if [[ ! -f "$INSTALL_PY" ]]; then
|
|
274
|
+
err "Missing $INSTALL_PY"
|
|
275
|
+
exit 1
|
|
276
|
+
fi
|
|
277
|
+
args=(--package "$SOURCE_DIR" --global --tools="$TOOLS")
|
|
278
|
+
$FORCE && args+=(--force)
|
|
279
|
+
$QUIET && args+=(--quiet)
|
|
280
|
+
$UNINSTALL && args+=(--uninstall)
|
|
281
|
+
"$python_bin" "$INSTALL_PY" "${args[@]}"
|
|
282
|
+
exit $?
|
|
283
|
+
fi
|
|
284
|
+
|
|
130
285
|
if ! $SKIP_SYNC; then
|
|
131
286
|
if [[ ! -f "$INSTALL_SH" ]]; then
|
|
132
287
|
err "Missing $INSTALL_SH"
|