@event4u/agent-config 1.38.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.
- package/.agent-src/commands/onboard.md +131 -50
- 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/agents/agent-project-settings.example.yml +9 -2
- package/.agent-src/templates/scripts/work_engine/_lib/__init__.py +7 -0
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +168 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +18 -19
- package/.agent-src/templates/scripts/work_engine/orchestration.py +168 -0
- package/.claude-plugin/marketplace.json +3 -1
- package/AGENTS.md +4 -4
- package/CHANGELOG.md +76 -0
- package/README.md +17 -6
- 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 +3 -2
- package/docs/contracts/audit-log-v1.md +142 -0
- package/docs/contracts/command-clusters.md +2 -0
- package/docs/contracts/file-ownership-matrix.json +20 -0
- package/docs/contracts/orchestration-dsl-v1.md +152 -0
- package/docs/customization.md +45 -0
- package/docs/getting-started.md +1 -1
- package/docs/guidelines/agent-infra/layered-settings.md +54 -17
- package/docs/installation.md +132 -0
- package/docs/setup/mcp-client-config.md +152 -0
- package/docs/setup/mcp-cloud-endpoints.md +16 -0
- 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 +148 -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/agent_settings.py +168 -0
- package/scripts/compress.py +153 -1
- 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/skill_linter.py +9 -0
- package/scripts/sync_gitignore.py +56 -1
- package/templates/claude_desktop_config.json.template +21 -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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Centralized loader for ``.agent-settings.yml`` with user-global fallback.
|
|
2
|
+
|
|
3
|
+
Phase 1 of road-to-portable-dev-preferences. Single source of truth for
|
|
4
|
+
how scripts read agent settings — replaces ~15 ad-hoc loaders in P3.
|
|
5
|
+
|
|
6
|
+
Resolution order (project wins, user-global fills gaps for whitelisted
|
|
7
|
+
keys only):
|
|
8
|
+
|
|
9
|
+
1. Project ``./.agent-settings.yml`` (full file, all keys)
|
|
10
|
+
2. ``~/.config/agent-config/agent-settings.yml`` (whitelist only)
|
|
11
|
+
3. Built-in defaults (currently empty)
|
|
12
|
+
|
|
13
|
+
Whitelisted keys (``MERGEABLE_KEYS``) are exact dotted paths. A
|
|
14
|
+
non-whitelisted key in the user-global file is silently ignored — the
|
|
15
|
+
``verbose=True`` flag surfaces ignored paths via ``logging.info`` for
|
|
16
|
+
debugging.
|
|
17
|
+
|
|
18
|
+
Contract — pure, read-only, tolerant:
|
|
19
|
+
|
|
20
|
+
* Lazy PyYAML import; no yaml installed → defaults returned.
|
|
21
|
+
* Missing project file → user-global + defaults.
|
|
22
|
+
* Missing user-global file → project + defaults.
|
|
23
|
+
* Both missing → defaults.
|
|
24
|
+
* Malformed YAML / unreadable file → defaults, logged at WARNING.
|
|
25
|
+
* No file is ever created or written by this module.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
DEFAULT_PROJECT_FILE = ".agent-settings.yml"
|
|
36
|
+
DEFAULT_USER_GLOBAL_FILE = (
|
|
37
|
+
Path.home() / ".config" / "agent-config" / "agent-settings.yml"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
#: Exact dotted paths allowed to cascade from user-global into the merged
|
|
41
|
+
#: settings. Anything not listed here is silently ignored when present in
|
|
42
|
+
#: the user-global file. Adding a key requires an ADR — see
|
|
43
|
+
#: ``agents/roadmaps/road-to-portable-dev-preferences.md``.
|
|
44
|
+
MERGEABLE_KEYS: tuple[str, ...] = (
|
|
45
|
+
"name",
|
|
46
|
+
"ide",
|
|
47
|
+
"cost_profile",
|
|
48
|
+
"personal.bot_icon",
|
|
49
|
+
"personal.autonomy",
|
|
50
|
+
"caveman.speak_scope",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_DEFAULTS: dict[str, Any] = {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_agent_settings(
|
|
57
|
+
project_path: Path | str | None = None,
|
|
58
|
+
user_global_path: Path | str | None = None,
|
|
59
|
+
verbose: bool = False,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Return the merged settings dict.
|
|
62
|
+
|
|
63
|
+
``project_path`` defaults to ``./.agent-settings.yml`` (CWD-relative).
|
|
64
|
+
``user_global_path`` defaults to
|
|
65
|
+
``~/.config/agent-config/agent-settings.yml``. Both arguments accept
|
|
66
|
+
``Path`` or ``str``. Pass ``verbose=True`` to log keys present in
|
|
67
|
+
user-global that are not on the whitelist.
|
|
68
|
+
"""
|
|
69
|
+
project = _read_yaml(
|
|
70
|
+
Path(project_path) if project_path else Path(DEFAULT_PROJECT_FILE),
|
|
71
|
+
) or {}
|
|
72
|
+
user_global_raw = _read_yaml(
|
|
73
|
+
Path(user_global_path) if user_global_path else DEFAULT_USER_GLOBAL_FILE,
|
|
74
|
+
) or {}
|
|
75
|
+
|
|
76
|
+
user_global_filtered, ignored = _filter_whitelist(
|
|
77
|
+
user_global_raw, MERGEABLE_KEYS,
|
|
78
|
+
)
|
|
79
|
+
if verbose and ignored:
|
|
80
|
+
logger.info(
|
|
81
|
+
"agent_settings: ignored non-whitelisted user-global keys: %s",
|
|
82
|
+
sorted(ignored),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
merged: dict[str, Any] = _deep_copy_defaults(_DEFAULTS)
|
|
86
|
+
_deep_merge(merged, user_global_filtered)
|
|
87
|
+
_deep_merge(merged, project)
|
|
88
|
+
return merged
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _read_yaml(path: Path) -> dict[str, Any] | None:
|
|
92
|
+
"""Best-effort YAML read; never raises. Returns ``None`` on any failure."""
|
|
93
|
+
if not path.is_file():
|
|
94
|
+
return None
|
|
95
|
+
try:
|
|
96
|
+
import yaml # type: ignore[import-untyped]
|
|
97
|
+
except ImportError:
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
with path.open(encoding="utf-8") as fh:
|
|
101
|
+
data = yaml.safe_load(fh) or {}
|
|
102
|
+
except (OSError, yaml.YAMLError):
|
|
103
|
+
logger.warning("agent_settings: unreadable or malformed YAML at %s", path)
|
|
104
|
+
return None
|
|
105
|
+
return data if isinstance(data, dict) else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _filter_whitelist(
|
|
109
|
+
raw: dict[str, Any], allowed: tuple[str, ...],
|
|
110
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
111
|
+
"""Return ``(filtered_dict, ignored_paths)`` from a user-global blob."""
|
|
112
|
+
filtered: dict[str, Any] = {}
|
|
113
|
+
for dotted in allowed:
|
|
114
|
+
value = _get_dotted(raw, dotted)
|
|
115
|
+
if value is not None:
|
|
116
|
+
_set_dotted(filtered, dotted, value)
|
|
117
|
+
ignored = [p for p in _leaf_paths(raw) if p not in allowed]
|
|
118
|
+
return filtered, ignored
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _get_dotted(data: dict[str, Any], dotted: str) -> Any:
|
|
122
|
+
cursor: Any = data
|
|
123
|
+
for part in dotted.split("."):
|
|
124
|
+
if not isinstance(cursor, dict) or part not in cursor:
|
|
125
|
+
return None
|
|
126
|
+
cursor = cursor[part]
|
|
127
|
+
return cursor
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _set_dotted(target: dict[str, Any], dotted: str, value: Any) -> None:
|
|
131
|
+
parts = dotted.split(".")
|
|
132
|
+
cursor = target
|
|
133
|
+
for part in parts[:-1]:
|
|
134
|
+
nxt = cursor.setdefault(part, {})
|
|
135
|
+
if not isinstance(nxt, dict):
|
|
136
|
+
nxt = {}
|
|
137
|
+
cursor[part] = nxt
|
|
138
|
+
cursor = nxt
|
|
139
|
+
cursor[parts[-1]] = value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _leaf_paths(data: dict[str, Any], prefix: str = "") -> list[str]:
|
|
143
|
+
paths: list[str] = []
|
|
144
|
+
for key, value in data.items():
|
|
145
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
146
|
+
if isinstance(value, dict) and value:
|
|
147
|
+
paths.extend(_leaf_paths(value, path))
|
|
148
|
+
else:
|
|
149
|
+
paths.append(path)
|
|
150
|
+
return paths
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _deep_merge(dst: dict[str, Any], src: dict[str, Any]) -> None:
|
|
154
|
+
"""Merge ``src`` into ``dst`` in-place; nested dicts are merged recursively."""
|
|
155
|
+
for key, value in src.items():
|
|
156
|
+
if (
|
|
157
|
+
isinstance(value, dict)
|
|
158
|
+
and isinstance(dst.get(key), dict)
|
|
159
|
+
):
|
|
160
|
+
_deep_merge(dst[key], value)
|
|
161
|
+
else:
|
|
162
|
+
dst[key] = value
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _deep_copy_defaults(src: dict[str, Any]) -> dict[str, Any]:
|
|
166
|
+
out: dict[str, Any] = {}
|
|
167
|
+
_deep_merge(out, src)
|
|
168
|
+
return out
|
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
|
|
|
@@ -503,6 +505,149 @@ def generate_windsurfrules() -> int:
|
|
|
503
505
|
return len(rules)
|
|
504
506
|
|
|
505
507
|
|
|
508
|
+
# ── Modern editor formats (road-to-simplicity-and-everywhere Phase 5) ─
|
|
509
|
+
# Cursor `.cursor/rules/*.mdc` (frontmatter: description, globs,
|
|
510
|
+
# alwaysApply) and Windsurf `.windsurf/rules/*.md` (frontmatter:
|
|
511
|
+
# trigger, description, globs) are the formats current editors prefer.
|
|
512
|
+
# Legacy `.windsurfrules` aggregate stays for users who prefer it.
|
|
513
|
+
|
|
514
|
+
CURSOR_RULES_MDC_DIR = PROJECT_ROOT / ".cursor" / "rules"
|
|
515
|
+
WINDSURF_RULES_DIR = PROJECT_ROOT / ".windsurf" / "rules"
|
|
516
|
+
WINDSURF_WORKFLOWS_DIR = PROJECT_ROOT / ".windsurf" / "workflows"
|
|
517
|
+
CURSOR_COMMANDS_DIR = PROJECT_ROOT / ".cursor" / "commands"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _parse_frontmatter(content: str) -> tuple[dict, str]:
|
|
521
|
+
"""Split a `---`-delimited YAML frontmatter from the body."""
|
|
522
|
+
if not content.startswith("---"):
|
|
523
|
+
return {}, content
|
|
524
|
+
end = content.find("\n---", 3)
|
|
525
|
+
if end == -1:
|
|
526
|
+
return {}, content
|
|
527
|
+
raw = content[3:end].strip()
|
|
528
|
+
body = content[end + 4:].lstrip("\n")
|
|
529
|
+
try:
|
|
530
|
+
meta = yaml.safe_load(raw) or {}
|
|
531
|
+
except yaml.YAMLError:
|
|
532
|
+
meta = {}
|
|
533
|
+
return meta if isinstance(meta, dict) else {}, body
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _emit_cursor_mdc(source: Path, target: Path) -> None:
|
|
537
|
+
"""Write a Cursor `.mdc` file with Cursor-shaped frontmatter."""
|
|
538
|
+
meta, body = _parse_frontmatter(source.read_text())
|
|
539
|
+
description = (meta.get("description") or "").replace("\n", " ").strip()
|
|
540
|
+
always_apply = bool(meta.get("alwaysApply") or meta.get("type") == "always")
|
|
541
|
+
lines = [
|
|
542
|
+
"---",
|
|
543
|
+
f"description: {description}",
|
|
544
|
+
"globs: ",
|
|
545
|
+
f"alwaysApply: {'true' if always_apply else 'false'}",
|
|
546
|
+
"---",
|
|
547
|
+
"",
|
|
548
|
+
body.rstrip() + "\n",
|
|
549
|
+
]
|
|
550
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
551
|
+
target.write_text("\n".join(lines))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _emit_windsurf_rule(source: Path, target: Path) -> None:
|
|
555
|
+
"""Write a Windsurf rule with Wave-8 frontmatter (trigger/description/globs)."""
|
|
556
|
+
meta, body = _parse_frontmatter(source.read_text())
|
|
557
|
+
description = (meta.get("description") or "").replace("\n", " ").strip()
|
|
558
|
+
always_apply = bool(meta.get("alwaysApply") or meta.get("type") == "always")
|
|
559
|
+
trigger = "always_on" if always_apply else "model_decision"
|
|
560
|
+
lines = [
|
|
561
|
+
"---",
|
|
562
|
+
f"trigger: {trigger}",
|
|
563
|
+
f"description: {description}",
|
|
564
|
+
"globs: ",
|
|
565
|
+
"---",
|
|
566
|
+
"",
|
|
567
|
+
body.rstrip() + "\n",
|
|
568
|
+
]
|
|
569
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
570
|
+
target.write_text("\n".join(lines))
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _clean_modern_dir(target_dir: Path, valid_names: set[str]) -> None:
|
|
574
|
+
"""Drop files in `target_dir` whose names are not in `valid_names`."""
|
|
575
|
+
if not target_dir.exists():
|
|
576
|
+
return
|
|
577
|
+
for item in target_dir.iterdir():
|
|
578
|
+
if item.name == "README.md":
|
|
579
|
+
continue
|
|
580
|
+
if item.name not in valid_names:
|
|
581
|
+
if item.is_dir() and not item.is_symlink():
|
|
582
|
+
shutil.rmtree(item)
|
|
583
|
+
else:
|
|
584
|
+
item.unlink()
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def generate_cursor_mdc_rules() -> int:
|
|
588
|
+
"""Emit `.cursor/rules/*.mdc` per source rule (alongside legacy `.md` symlinks)."""
|
|
589
|
+
rules = sorted(RULES_SOURCE.glob("*.md"))
|
|
590
|
+
valid = {f"{r.stem}.mdc" for r in rules}
|
|
591
|
+
_clean_modern_dir(CURSOR_RULES_MDC_DIR, valid | {r.name for r in rules})
|
|
592
|
+
for rule in rules:
|
|
593
|
+
_emit_cursor_mdc(rule, CURSOR_RULES_MDC_DIR / f"{rule.stem}.mdc")
|
|
594
|
+
info(f" ✅ Wrote {len(rules)} `.cursor/rules/*.mdc` files")
|
|
595
|
+
return len(rules)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def generate_windsurf_modern_rules() -> int:
|
|
599
|
+
"""Emit `.windsurf/rules/*.md` per source rule (Wave-8 frontmatter)."""
|
|
600
|
+
rules = sorted(RULES_SOURCE.glob("*.md"))
|
|
601
|
+
valid = {r.name for r in rules}
|
|
602
|
+
_clean_modern_dir(WINDSURF_RULES_DIR, valid)
|
|
603
|
+
for rule in rules:
|
|
604
|
+
_emit_windsurf_rule(rule, WINDSURF_RULES_DIR / rule.name)
|
|
605
|
+
info(f" ✅ Wrote {len(rules)} `.windsurf/rules/*.md` files")
|
|
606
|
+
return len(rules)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def generate_cursor_commands() -> int:
|
|
610
|
+
"""Symlink `.cursor/commands/<slug>.md` per source command."""
|
|
611
|
+
if not COMMANDS_SOURCE.exists():
|
|
612
|
+
return 0
|
|
613
|
+
cmds = list(_iter_commands())
|
|
614
|
+
valid = {f"{slug}.md" for _, slug in cmds}
|
|
615
|
+
_clean_modern_dir(CURSOR_COMMANDS_DIR, valid)
|
|
616
|
+
CURSOR_COMMANDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
617
|
+
count = 0
|
|
618
|
+
for source_file, slug in cmds:
|
|
619
|
+
link = CURSOR_COMMANDS_DIR / f"{slug}.md"
|
|
620
|
+
if link.exists() or link.is_symlink():
|
|
621
|
+
link.unlink()
|
|
622
|
+
rel = Path("../../.agent-src/commands") / source_file.relative_to(COMMANDS_SOURCE)
|
|
623
|
+
link.symlink_to(rel)
|
|
624
|
+
count += 1
|
|
625
|
+
info(f" ✅ Linked {count} `.cursor/commands/*.md` files")
|
|
626
|
+
return count
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def generate_windsurf_workflows() -> int:
|
|
630
|
+
"""Symlink `.windsurf/workflows/<slug>.md` per source command."""
|
|
631
|
+
if not COMMANDS_SOURCE.exists():
|
|
632
|
+
return 0
|
|
633
|
+
cmds = list(_iter_commands())
|
|
634
|
+
valid = {f"{slug}.md" for _, slug in cmds}
|
|
635
|
+
_clean_modern_dir(WINDSURF_WORKFLOWS_DIR, valid)
|
|
636
|
+
WINDSURF_WORKFLOWS_DIR.mkdir(parents=True, exist_ok=True)
|
|
637
|
+
count = 0
|
|
638
|
+
for source_file, slug in cmds:
|
|
639
|
+
link = WINDSURF_WORKFLOWS_DIR / f"{slug}.md"
|
|
640
|
+
if link.exists() or link.is_symlink():
|
|
641
|
+
link.unlink()
|
|
642
|
+
rel = Path("../../.agent-src/commands") / source_file.relative_to(COMMANDS_SOURCE)
|
|
643
|
+
link.symlink_to(rel)
|
|
644
|
+
count += 1
|
|
645
|
+
info(f" ✅ Linked {count} `.windsurf/workflows/*.md` files")
|
|
646
|
+
return count
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
|
|
506
651
|
def generate_gemini_md() -> None:
|
|
507
652
|
"""Create GEMINI.md symlink to AGENTS.md."""
|
|
508
653
|
link = PROJECT_ROOT / "GEMINI.md"
|
|
@@ -695,9 +840,15 @@ def generate_tools() -> None:
|
|
|
695
840
|
skills = generate_claude_skills()
|
|
696
841
|
commands = generate_claude_commands()
|
|
697
842
|
personas = generate_persona_symlinks()
|
|
843
|
+
cursor_mdc = generate_cursor_mdc_rules()
|
|
844
|
+
windsurf_modern = generate_windsurf_modern_rules()
|
|
845
|
+
cursor_cmds = generate_cursor_commands()
|
|
846
|
+
windsurf_wf = generate_windsurf_workflows()
|
|
698
847
|
summary = (
|
|
699
848
|
f"✅ generate-tools — rules={rules} skills={skills} "
|
|
700
|
-
f"commands={commands} personas={personas}"
|
|
849
|
+
f"commands={commands} personas={personas} "
|
|
850
|
+
f"cursor_mdc={cursor_mdc} windsurf_rules={windsurf_modern} "
|
|
851
|
+
f"cursor_commands={cursor_cmds} windsurf_workflows={windsurf_wf}"
|
|
701
852
|
)
|
|
702
853
|
if resolve_level() == "verbose":
|
|
703
854
|
print(f"\n{summary}")
|
|
@@ -806,6 +957,7 @@ def clean_tools() -> None:
|
|
|
806
957
|
PROJECT_ROOT / ".claude",
|
|
807
958
|
PROJECT_ROOT / ".cursor",
|
|
808
959
|
PROJECT_ROOT / ".clinerules",
|
|
960
|
+
PROJECT_ROOT / ".windsurf",
|
|
809
961
|
PROJECT_ROOT / ".windsurfrules",
|
|
810
962
|
PROJECT_ROOT / "GEMINI.md",
|
|
811
963
|
]
|
|
@@ -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())
|