@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.
Files changed (56) hide show
  1. package/.agent-src/commands/onboard.md +131 -50
  2. package/.agent-src/commands/orchestrate.md +123 -0
  3. package/.agent-src/commands/sync-gitignore/fix.md +135 -0
  4. package/.agent-src/commands/sync-gitignore.md +31 -5
  5. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +30 -2
  6. package/.agent-src/skills/subagent-orchestration/SKILL.md +9 -0
  7. package/.agent-src/skills/using-git-worktrees/SKILL.md +25 -0
  8. package/.agent-src/templates/agent-settings.md +9 -0
  9. package/.agent-src/templates/agents/agent-project-settings.example.yml +9 -2
  10. package/.agent-src/templates/scripts/work_engine/_lib/__init__.py +7 -0
  11. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +168 -0
  12. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +18 -19
  13. package/.agent-src/templates/scripts/work_engine/orchestration.py +168 -0
  14. package/.claude-plugin/marketplace.json +3 -1
  15. package/AGENTS.md +4 -4
  16. package/CHANGELOG.md +76 -0
  17. package/README.md +17 -6
  18. package/bin/install.php +13 -6
  19. package/config/agent-settings.template.yml +21 -0
  20. package/docs/DISTRIBUTION_CHECKLIST.md +169 -0
  21. package/docs/architecture.md +1 -1
  22. package/docs/catalog.md +3 -2
  23. package/docs/contracts/audit-log-v1.md +142 -0
  24. package/docs/contracts/command-clusters.md +2 -0
  25. package/docs/contracts/file-ownership-matrix.json +20 -0
  26. package/docs/contracts/orchestration-dsl-v1.md +152 -0
  27. package/docs/customization.md +45 -0
  28. package/docs/getting-started.md +1 -1
  29. package/docs/guidelines/agent-infra/layered-settings.md +54 -17
  30. package/docs/installation.md +132 -0
  31. package/docs/setup/mcp-client-config.md +152 -0
  32. package/docs/setup/mcp-cloud-endpoints.md +16 -0
  33. package/docs/setup/per-ide/aider.md +48 -0
  34. package/docs/setup/per-ide/claude-code.md +108 -0
  35. package/docs/setup/per-ide/claude-desktop.md +148 -0
  36. package/docs/setup/per-ide/cline.md +43 -0
  37. package/docs/setup/per-ide/codex.md +46 -0
  38. package/docs/setup/per-ide/copilot.md +80 -0
  39. package/docs/setup/per-ide/cursor.md +125 -0
  40. package/docs/setup/per-ide/gemini-cli.md +45 -0
  41. package/docs/setup/per-ide/windsurf.md +120 -0
  42. package/package.json +1 -1
  43. package/scripts/_lib/agent_settings.py +168 -0
  44. package/scripts/compress.py +153 -1
  45. package/scripts/extract_audit_patterns.py +202 -0
  46. package/scripts/install +156 -1
  47. package/scripts/install.py +270 -10
  48. package/scripts/install.sh +52 -7
  49. package/scripts/lint_orchestration_dsl.py +214 -0
  50. package/scripts/skill_linter.py +9 -0
  51. package/scripts/sync_gitignore.py +56 -1
  52. package/templates/claude_desktop_config.json.template +21 -0
  53. package/templates/cursor-rule.mdc.j2 +7 -0
  54. package/templates/global-install-manifest.yml +91 -0
  55. package/templates/marketing-copy.yml +64 -0
  56. 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
@@ -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())