@event4u/agent-config 2.13.0 → 2.15.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 (74) hide show
  1. package/.agent-src/commands/agents/user/accept.md +117 -0
  2. package/.agent-src/commands/agents/user/init.md +163 -0
  3. package/.agent-src/commands/agents/user/review.md +107 -0
  4. package/.agent-src/commands/agents/user/show.md +109 -0
  5. package/.agent-src/commands/agents/user/update.md +98 -0
  6. package/.agent-src/commands/agents/user.md +66 -0
  7. package/.agent-src/commands/agents.md +2 -0
  8. package/.agent-src/commands/memory/learn-low-impact.md +143 -0
  9. package/.agent-src/rules/ask-when-uncertain.md +10 -6
  10. package/.agent-src/rules/copilot-routing.md +1 -1
  11. package/.agent-src/rules/devcontainer-routing.md +1 -1
  12. package/.agent-src/rules/external-reference-deep-dive.md +1 -1
  13. package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
  14. package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
  15. package/.agent-src/rules/symfony-routing.md +1 -1
  16. package/.agent-src/skills/ai-council/SKILL.md +208 -8
  17. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  18. package/.claude-plugin/marketplace.json +8 -1
  19. package/CHANGELOG.md +328 -124
  20. package/README.md +21 -6
  21. package/config/agent-settings.template.yml +4 -0
  22. package/config/gitignore-block.txt +17 -0
  23. package/docs/architecture.md +12 -12
  24. package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
  25. package/docs/catalog.md +16 -7
  26. package/docs/contracts/adr-architectural-consensus-mechanism.md +4 -3
  27. package/docs/contracts/adr-level-6-productization.md +7 -9
  28. package/docs/contracts/agent-user-schema.md +165 -0
  29. package/docs/contracts/ai-council-config.md +492 -20
  30. package/docs/contracts/command-clusters.md +2 -2
  31. package/docs/contracts/command-surface-tiers.md +3 -2
  32. package/docs/contracts/cost-profile-defaults.md +5 -0
  33. package/docs/contracts/decision-engine-gates.md +5 -0
  34. package/docs/contracts/decision-trace-v1.md +2 -2
  35. package/docs/contracts/file-ownership-matrix.json +1961 -108
  36. package/docs/contracts/installed-tools-lockfile.md +2 -1
  37. package/docs/contracts/low-impact-corpus-format.md +95 -0
  38. package/docs/contracts/mcp-beta-criteria.md +6 -5
  39. package/docs/contracts/mcp-cloud-scope.md +5 -4
  40. package/docs/contracts/multi-tool-projection-fidelity.md +8 -2
  41. package/docs/contracts/release-trunk-sync.md +4 -3
  42. package/docs/contracts/tier-3-contrib-plugin.md +5 -6
  43. package/docs/examples/agent-user.example.md +21 -0
  44. package/docs/getting-started.md +2 -2
  45. package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
  46. package/docs/installation.md +32 -0
  47. package/package.json +1 -1
  48. package/scripts/_cli/cmd_doctor.py +134 -0
  49. package/scripts/ai_council/airgap.py +165 -0
  50. package/scripts/ai_council/cli_hints.py +123 -0
  51. package/scripts/ai_council/clients.py +787 -5
  52. package/scripts/ai_council/compile_corpus.py +178 -0
  53. package/scripts/ai_council/confidence_gate.py +156 -0
  54. package/scripts/ai_council/config.py +1007 -11
  55. package/scripts/ai_council/consensus.py +41 -2
  56. package/scripts/ai_council/events_log.py +137 -0
  57. package/scripts/ai_council/learn_low_impact_preview.py +252 -0
  58. package/scripts/ai_council/low_impact.py +714 -0
  59. package/scripts/ai_council/low_impact_corpus.py +466 -0
  60. package/scripts/ai_council/low_impact_intake.py +163 -0
  61. package/scripts/ai_council/modes.py +6 -1
  62. package/scripts/ai_council/necessity.py +782 -0
  63. package/scripts/ai_council/orchestrator.py +252 -14
  64. package/scripts/ai_council/probation_gate.py +152 -0
  65. package/scripts/ai_council/redact_low_impact_entry.py +155 -0
  66. package/scripts/ai_council/replay.py +155 -0
  67. package/scripts/ai_council/session.py +19 -1
  68. package/scripts/ai_council/shadow_dispatch.py +235 -0
  69. package/scripts/ai_council/solo_dispatch.py +226 -0
  70. package/scripts/audit_cloud_compatibility.py +74 -0
  71. package/scripts/audit_command_surface.py +363 -0
  72. package/scripts/check_council_layout.py +11 -0
  73. package/scripts/council_cli.py +1046 -15
  74. package/scripts/install.sh +12 -0
@@ -207,6 +207,70 @@ def scan() -> list[dict]:
207
207
  return rows
208
208
 
209
209
 
210
+ # step-9 P11 · U3 — Iron-Law bypass scan. Any Python module that loads
211
+ # `agents/.ai-council.yml` directly (yaml.safe_load / open + parse) instead
212
+ # of going through `scripts.ai_council.config.load_council_config` skips the
213
+ # `_reject_top_level_locked_dispatch` gate and is a potential Iron-Law
214
+ # bypass. The scan is intentionally over-broad — false positives are
215
+ # annotated with `# iron-law-ok: …` on the offending line.
216
+ _IRON_LAW_YAML_LOAD_RE = re.compile(
217
+ r"yaml\.(?:safe_load|load|full_load|unsafe_load)\s*\(",
218
+ )
219
+ _IRON_LAW_AI_COUNCIL_REF_RE = re.compile(
220
+ r"['\"]\.ai-council\.yml['\"]|ai-council\.yml",
221
+ )
222
+ _IRON_LAW_ALLOWLIST = (
223
+ # the canonical loader itself
224
+ "scripts/ai_council/config.py",
225
+ # tests are allowed to construct synthetic configs directly
226
+ "tests/",
227
+ # this audit script's own pattern definitions
228
+ "scripts/audit_cloud_compatibility.py",
229
+ )
230
+
231
+
232
+ def _iron_law_bypass_scan() -> list[dict]:
233
+ """Scan ``scripts/`` for code that bypasses the Iron-Law validator.
234
+
235
+ A bypass is any module that calls a raw YAML loader on a value
236
+ associated with ``.ai-council.yml`` outside the canonical loader
237
+ in ``scripts/ai_council/config.py``. Proximity heuristic: the YAML
238
+ load line, or the 3 lines preceding it, must reference
239
+ ``ai-council.yml``. Annotate intentional cases with
240
+ ``# iron-law-ok: <reason>`` on the load line to suppress.
241
+ """
242
+ findings: list[dict] = []
243
+ scripts_dir = ROOT / "scripts"
244
+ if not scripts_dir.is_dir():
245
+ return findings
246
+ for py in sorted(scripts_dir.rglob("*.py")):
247
+ rel = py.relative_to(ROOT).as_posix()
248
+ if any(rel.startswith(p) for p in _IRON_LAW_ALLOWLIST):
249
+ continue
250
+ try:
251
+ text = py.read_text(encoding="utf-8")
252
+ except OSError:
253
+ continue
254
+ lines = text.splitlines()
255
+ offending: list[int] = []
256
+ for i, line in enumerate(lines, start=1):
257
+ if not _IRON_LAW_YAML_LOAD_RE.search(line):
258
+ continue
259
+ if "iron-law-ok" in line:
260
+ continue
261
+ window = "\n".join(lines[max(0, i - 4):i])
262
+ if _IRON_LAW_AI_COUNCIL_REF_RE.search(window):
263
+ offending.append(i)
264
+ if offending:
265
+ findings.append({
266
+ "path": rel,
267
+ "lines": offending,
268
+ "reason": "raw YAML load on ai-council.yml — bypasses "
269
+ "_reject_top_level_locked_dispatch",
270
+ })
271
+ return findings
272
+
273
+
210
274
  def summarize(rows: list[dict]) -> dict:
211
275
  by_tier = Counter(r["tier"] for r in rows)
212
276
  by_kind_tier: dict[str, Counter] = {}
@@ -251,8 +315,18 @@ def main(argv: list[str] | None = None) -> int:
251
315
  help="filter --details to one cloud-action category",
252
316
  )
253
317
  p.add_argument("--format", choices=["json", "md"], default="json")
318
+ p.add_argument(
319
+ "--iron-law",
320
+ action="store_true",
321
+ help="scan scripts/ for Iron-Law validator bypasses (step-9 P11 · U3)",
322
+ )
254
323
  args = p.parse_args(argv)
255
324
 
325
+ if args.iron_law:
326
+ findings = _iron_law_bypass_scan()
327
+ print(json.dumps({"iron_law_bypass_findings": findings}, indent=2))
328
+ return 1 if findings else 0
329
+
256
330
  rows = scan()
257
331
  summary = summarize(rows)
258
332
 
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env python3
2
+ """Command-surface inventory + overlap detection + usage signal.
3
+
4
+ Walks ``.agent-src.uncompressed/commands/**/*.md``, collects metadata for
5
+ each command (path, description, aliases, line count, last-modified),
6
+ flags overlap pairs by keyword-cosine similarity, and adds a usage
7
+ signal from git history (commands not touched in 90+ days are
8
+ candidates for retirement).
9
+
10
+ Output:
11
+ - ``agents/reports/command-surface.json`` (machine-readable)
12
+ - ``agents/reports/command-surface.md`` (human-readable)
13
+
14
+ Context: ``agents/roadmaps/step-2-feedback-followup.md`` Phase 1 —
15
+ GPT's PR-#148 "108 commands" cognitive-load warning needs empirical
16
+ verification before any retirement decisions are made.
17
+
18
+ Usage:
19
+ python3 scripts/audit_command_surface.py
20
+ python3 scripts/audit_command_surface.py --root DIR
21
+ python3 scripts/audit_command_surface.py --quiet
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import math
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ from collections import Counter
33
+ from dataclasses import asdict, dataclass, field
34
+ from datetime import datetime, timezone
35
+ from itertools import combinations
36
+ from pathlib import Path
37
+ from typing import List
38
+
39
+ REPO_ROOT = Path(__file__).resolve().parent.parent
40
+ DEFAULT_ROOT = REPO_ROOT / ".agent-src.uncompressed" / "commands"
41
+ REPORT_DIR = REPO_ROOT / "agents" / "reports"
42
+ OUT_JSON = REPORT_DIR / "command-surface.json"
43
+ OUT_MD = REPORT_DIR / "command-surface.md"
44
+
45
+ FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
46
+ DESCRIPTION_RE = re.compile(r'^description:\s*"?(.*?)"?\s*$', re.MULTILINE)
47
+ ALIASES_RE = re.compile(r"^aliases:\s*(.*)$", re.MULTILINE)
48
+ NAME_RE = re.compile(r"^name:\s*(.*)$", re.MULTILINE)
49
+ CLUSTER_RE = re.compile(r"^cluster:\s*(.*)$", re.MULTILINE)
50
+ TIER_RE = re.compile(r"^tier:\s*(\d+)", re.MULTILINE)
51
+
52
+ STOPWORDS = {
53
+ "the", "and", "for", "with", "when", "use", "or", "of", "to", "a", "an",
54
+ "is", "in", "on", "by", "be", "at", "as", "it", "if", "are", "this",
55
+ "that", "from", "but", "not", "can", "any", "all", "no", "after",
56
+ "before", "during", "user", "agent", "code", "project", "via", "into",
57
+ "onto", "even", "without", "naming", "run", "runs", "running", "each",
58
+ "every", "one", "two", "now", "then", "also", "based", "default",
59
+ }
60
+
61
+ OVERLAP_COSINE_THRESHOLD = 0.6
62
+ # Commands with few commits AND younger than this many days since first
63
+ # commit are flagged as low-signal — newer, less battle-tested entries.
64
+ LOW_SIGNAL_COMMIT_COUNT = 2
65
+ LOW_SIGNAL_AGE_DAYS = 30
66
+
67
+
68
+ @dataclass
69
+ class Command:
70
+ name: str
71
+ path: str
72
+ relpath: str
73
+ directory: str
74
+ description: str
75
+ aliases: List[str] = field(default_factory=list)
76
+ tier: int | None = None
77
+ cluster: str = ""
78
+ line_count: int = 0
79
+ last_modified_iso: str = ""
80
+ days_since_modified: int | None = None
81
+ commit_count: int = 0
82
+ first_commit_iso: str = ""
83
+ days_since_first_commit: int | None = None
84
+
85
+
86
+ def parse_frontmatter(text: str) -> dict:
87
+ m = FRONTMATTER_RE.search(text)
88
+ if not m:
89
+ return {}
90
+ block = m.group(1)
91
+ out: dict = {}
92
+ if d := DESCRIPTION_RE.search(block):
93
+ out["description"] = d.group(1).strip()
94
+ if n := NAME_RE.search(block):
95
+ out["name"] = n.group(1).strip().strip('"').strip("'")
96
+ if c := CLUSTER_RE.search(block):
97
+ out["cluster"] = c.group(1).strip().strip('"').strip("'")
98
+ if t := TIER_RE.search(block):
99
+ out["tier"] = int(t.group(1))
100
+ if a := ALIASES_RE.search(block):
101
+ raw = a.group(1).strip()
102
+ if raw.startswith("["):
103
+ inner = raw.strip("[]")
104
+ out["aliases"] = [x.strip().strip('"').strip("'") for x in inner.split(",") if x.strip()]
105
+ else:
106
+ out["aliases"] = [raw.strip('"').strip("'")] if raw else []
107
+ return out
108
+
109
+
110
+ def keyword_vector(text: str) -> Counter[str]:
111
+ tokens = re.findall(r"[a-z][a-z0-9_-]{2,}", text.lower())
112
+ return Counter(t for t in tokens if t not in STOPWORDS)
113
+
114
+
115
+ def cosine(a: Counter[str], b: Counter[str]) -> float:
116
+ if not a or not b:
117
+ return 0.0
118
+ shared = set(a) & set(b)
119
+ if not shared:
120
+ return 0.0
121
+ num = sum(a[t] * b[t] for t in shared)
122
+ da = math.sqrt(sum(v * v for v in a.values()))
123
+ db = math.sqrt(sum(v * v for v in b.values()))
124
+ return num / (da * db) if da and db else 0.0
125
+
126
+
127
+ def git_last_modified(path: Path) -> tuple[str, int | None]:
128
+ try:
129
+ out = subprocess.check_output(
130
+ ["git", "log", "--follow", "-1", "--format=%cI", "--", str(path)],
131
+ cwd=REPO_ROOT, stderr=subprocess.DEVNULL, text=True,
132
+ ).strip()
133
+ if not out:
134
+ return "", None
135
+ ts = datetime.fromisoformat(out)
136
+ days = (datetime.now(timezone.utc) - ts).days
137
+ return out, days
138
+ except (subprocess.CalledProcessError, ValueError):
139
+ return "", None
140
+
141
+
142
+ def git_history(path: Path) -> tuple[int, str, int | None]:
143
+ """Return (commit_count, first_commit_iso, days_since_first_commit).
144
+
145
+ Uses ``--follow`` so renames (e.g. the ``.augment.uncompressed`` →
146
+ ``.agent-src.uncompressed`` rename) don't reset the per-file history.
147
+ """
148
+ try:
149
+ out = subprocess.check_output(
150
+ ["git", "log", "--follow", "--format=%cI", "--", str(path)],
151
+ cwd=REPO_ROOT, stderr=subprocess.DEVNULL, text=True,
152
+ ).strip().splitlines()
153
+ if not out:
154
+ return 0, "", None
155
+ first = out[-1]
156
+ ts = datetime.fromisoformat(first)
157
+ days = (datetime.now(timezone.utc) - ts).days
158
+ return len(out), first, days
159
+ except (subprocess.CalledProcessError, ValueError):
160
+ return 0, "", None
161
+
162
+
163
+ def collect(root: Path) -> List[Command]:
164
+ commands: List[Command] = []
165
+ for md in sorted(root.rglob("*.md")):
166
+ if any(p == "_archive" for p in md.parts):
167
+ continue
168
+ text = md.read_text(encoding="utf-8")
169
+ fm = parse_frontmatter(text)
170
+ rel = md.relative_to(REPO_ROOT)
171
+ directory = str(md.parent.relative_to(root)) if md.parent != root else "."
172
+ last_iso, days = git_last_modified(md)
173
+ n_commits, first_iso, first_days = git_history(md)
174
+ commands.append(Command(
175
+ name=fm.get("name", md.stem),
176
+ path=str(md),
177
+ relpath=str(rel),
178
+ directory=directory,
179
+ description=fm.get("description", ""),
180
+ aliases=fm.get("aliases", []),
181
+ tier=fm.get("tier"),
182
+ cluster=fm.get("cluster", ""),
183
+ line_count=len(text.splitlines()),
184
+ last_modified_iso=last_iso,
185
+ days_since_modified=days,
186
+ commit_count=n_commits,
187
+ first_commit_iso=first_iso,
188
+ days_since_first_commit=first_days,
189
+ ))
190
+ return commands
191
+
192
+
193
+ def find_overlap_pairs(commands: List[Command]) -> list[dict]:
194
+ vectors = {c.relpath: keyword_vector(c.description) for c in commands}
195
+ pairs: list[dict] = []
196
+ for a, b in combinations(commands, 2):
197
+ if not a.description or not b.description:
198
+ continue
199
+ sim = cosine(vectors[a.relpath], vectors[b.relpath])
200
+ if sim < OVERLAP_COSINE_THRESHOLD:
201
+ continue
202
+ pairs.append({
203
+ "a": a.relpath,
204
+ "b": b.relpath,
205
+ "a_name": a.name,
206
+ "b_name": b.name,
207
+ "cosine": round(sim, 3),
208
+ "a_description": a.description,
209
+ "b_description": b.description,
210
+ })
211
+ return sorted(pairs, key=lambda p: -p["cosine"])
212
+
213
+
214
+ def render_md(commands: List[Command], pairs: list[dict]) -> str:
215
+ by_dir: dict[str, list[Command]] = {}
216
+ for c in commands:
217
+ by_dir.setdefault(c.directory, []).append(c)
218
+
219
+ low_signal = [
220
+ c for c in commands
221
+ if c.commit_count and c.commit_count <= LOW_SIGNAL_COMMIT_COUNT
222
+ and (c.days_since_first_commit or 0) <= LOW_SIGNAL_AGE_DAYS
223
+ ]
224
+
225
+ lines = [
226
+ "# Command-Surface Inventory",
227
+ "",
228
+ f"> Generated by `scripts/audit_command_surface.py`. "
229
+ f"Source: `.agent-src.uncompressed/commands/`.",
230
+ "",
231
+ "## Summary",
232
+ "",
233
+ f"- **Total commands:** {len(commands)}",
234
+ f"- **Top-level commands (directory `.`):** {len(by_dir.get('.', []))}",
235
+ f"- **Sub-cluster directories:** {len([d for d in by_dir if d != '.'])}",
236
+ f"- **Low-signal (≤{LOW_SIGNAL_COMMIT_COUNT} commits AND ≤{LOW_SIGNAL_AGE_DAYS}d old):** {len(low_signal)}",
237
+ f"- **Overlap pairs (cosine ≥ {OVERLAP_COSINE_THRESHOLD}):** {len(pairs)}",
238
+ "",
239
+ "## Per-directory counts",
240
+ "",
241
+ "| Directory | Count |",
242
+ "|---|---:|",
243
+ ]
244
+ for d in sorted(by_dir):
245
+ lines.append(f"| `{d}` | {len(by_dir[d])} |")
246
+ lines.append("")
247
+
248
+ lines += ["## Likely-overlapping pairs", ""]
249
+ if not pairs:
250
+ lines.append("_No pairs above threshold._")
251
+ else:
252
+ lines += [
253
+ "| # | A | B | cosine | A description | B description |",
254
+ "|---|---|---|---:|---|---|",
255
+ ]
256
+ for i, p in enumerate(pairs, 1):
257
+ lines.append(
258
+ f"| {i} | `{p['a_name']}` | `{p['b_name']}` | {p['cosine']:.2f} | "
259
+ f"{p['a_description']} | {p['b_description']} |"
260
+ )
261
+ lines.append("")
262
+
263
+ lines += [
264
+ "## Usage-signal note",
265
+ "",
266
+ "Per-command invocation telemetry is **not** available. Two surrogate signals "
267
+ "were considered:",
268
+ "",
269
+ "- **Filesystem mtime** — useless: `task sync` rewrites every file when the "
270
+ " compressed and uncompressed trees are regenerated.",
271
+ "- **Git history (`--follow`)** — uninformative here: the `.agent-src.uncompressed/` "
272
+ " directory is the result of a recent rename (`.augment.uncompressed/` → "
273
+ " `.agent-src.uncompressed/`), so almost every file shows a single recent commit "
274
+ f" on the current branch. {len(low_signal)} of {len(commands)} commands fall into the "
275
+ f" ≤{LOW_SIGNAL_COMMIT_COUNT}-commits / ≤{LOW_SIGNAL_AGE_DAYS}d-old bucket purely as a "
276
+ " rename artefact, not as a real cold-tail signal.",
277
+ "",
278
+ "**Implication for Phase 1 categorisation:** keep / merge / retire decisions must "
279
+ "be made on **intent** (description content, overlap with sibling commands, tier "
280
+ "placement, cluster fit) rather than usage data. The cosine-≥0.6 overlap "
281
+ "pairs above are the primary structural lever.",
282
+ "",
283
+ ]
284
+
285
+ lines += [
286
+ "## Three-bucket categorisation (Phase 1 Step 4)",
287
+ "",
288
+ "The keep / merge / retire verdict lives in "
289
+ "[`command-surface-synthesis.md`](command-surface-synthesis.md) — hand-curated "
290
+ "and **not** regenerated by this script. Headline: 109 keep · 0 merge · 0 retire. "
291
+ "Every overlap pair and retire candidate surfaced by the council turned out to "
292
+ "be an intentional structural pattern (scope ladder, union dispatcher, thin "
293
+ "alias, tier-gated specialist), not redundancy.",
294
+ "",
295
+ ]
296
+
297
+ lines += [
298
+ "## Full inventory",
299
+ "",
300
+ "Column `bucket` is left blank — the categorisation lives in "
301
+ "[`command-surface-synthesis.md`](command-surface-synthesis.md). Every command "
302
+ "in this table maps to `keep` unless named in that file's tables.",
303
+ "",
304
+ "| Name | Path | Tier | Cluster | Aliases | Lines | Commits | Age (d) | Bucket |",
305
+ "|---|---|---:|---|---|---:|---:|---:|---|",
306
+ ]
307
+ for c in sorted(commands, key=lambda c: c.relpath):
308
+ aliases = ", ".join(c.aliases) if c.aliases else "—"
309
+ tier = "—" if c.tier is None else str(c.tier)
310
+ cluster = c.cluster or "—"
311
+ age = "—" if c.days_since_first_commit is None else str(c.days_since_first_commit)
312
+ lines.append(
313
+ f"| `{c.name}` | `{c.relpath}` | {tier} | {cluster} | {aliases} | "
314
+ f"{c.line_count} | {c.commit_count} | {age} | |"
315
+ )
316
+ lines.append("")
317
+ return "\n".join(lines)
318
+
319
+
320
+ def main() -> int:
321
+ parser = argparse.ArgumentParser(description=__doc__)
322
+ parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
323
+ parser.add_argument("--quiet", action="store_true")
324
+ args = parser.parse_args()
325
+ if not args.root.exists():
326
+ print(f"error: {args.root} does not exist", file=sys.stderr)
327
+ return 2
328
+
329
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
330
+ commands = collect(args.root)
331
+ pairs = find_overlap_pairs(commands)
332
+
333
+ OUT_JSON.write_text(
334
+ json.dumps({
335
+ "total": len(commands),
336
+ "thresholds": {
337
+ "overlap_cosine": OVERLAP_COSINE_THRESHOLD,
338
+ "low_signal_commit_count": LOW_SIGNAL_COMMIT_COUNT,
339
+ "low_signal_age_days": LOW_SIGNAL_AGE_DAYS,
340
+ },
341
+ "commands": [asdict(c) for c in commands],
342
+ "overlap_pairs": pairs,
343
+ }, indent=2),
344
+ encoding="utf-8",
345
+ )
346
+ OUT_MD.write_text(render_md(commands, pairs), encoding="utf-8")
347
+
348
+ if not args.quiet:
349
+ print(f"✅ Audited {len(commands)} commands.")
350
+ print(f" JSON: {OUT_JSON.relative_to(REPO_ROOT)}")
351
+ print(f" MD: {OUT_MD.relative_to(REPO_ROOT)}")
352
+ print(f" Overlap pairs (cosine ≥ {OVERLAP_COSINE_THRESHOLD}): {len(pairs)}")
353
+ low_n = sum(
354
+ 1 for c in commands
355
+ if c.commit_count and c.commit_count <= LOW_SIGNAL_COMMIT_COUNT
356
+ and (c.days_since_first_commit or 0) <= LOW_SIGNAL_AGE_DAYS
357
+ )
358
+ print(f" Low-signal (≤{LOW_SIGNAL_COMMIT_COUNT} commits, ≤{LOW_SIGNAL_AGE_DAYS}d): {low_n}")
359
+ return 0
360
+
361
+
362
+ if __name__ == "__main__":
363
+ sys.exit(main())
@@ -15,6 +15,11 @@ catches **misplacement**, not naming-conventions inside the dirs:
15
15
  (e.g. agents/council-question-foo.md, agents/.council-foo.md).
16
16
  - council-* files under any other subdirectory of agents/.
17
17
 
18
+ `agents/audit-*/` directories are exempt — historical audit bundles
19
+ are cohesive, checked-in narratives (the canonical council dirs are
20
+ gitignored) and may legitimately include council-* artefacts as part
21
+ of the audit's evidence trail.
22
+
18
23
  Failure modes are enforced by `.agent-src.uncompressed/skills/ai-council/SKILL.md`
19
24
  § "Output path convention".
20
25
 
@@ -40,6 +45,10 @@ CANONICAL_DIRS = {
40
45
  "council-responses": ".json",
41
46
  "council-sessions": ".json",
42
47
  }
48
+ # Subdirectory prefixes whose contents are exempt from the layout check.
49
+ # `audit-*/` covers historical audit bundles where council artefacts
50
+ # form part of the documented evidence trail.
51
+ EXEMPT_DIR_PREFIXES = ("audit-",)
43
52
  # A council artefact is a file whose name starts with `council-` or
44
53
  # `.council-`. This intentionally excludes roadmaps like
45
54
  # `road-to-ai-council.md` whose stem only contains the word "council".
@@ -79,6 +88,8 @@ def find_violations(root: Path) -> list[str]:
79
88
  continue # already handled above
80
89
  if rel.parts[0] in CANONICAL_DIRS:
81
90
  continue
91
+ if rel.parts[0].startswith(EXEMPT_DIR_PREFIXES):
92
+ continue
82
93
  findings.append(
83
94
  f"{path}: council artefact in non-canonical directory "
84
95
  f"agents/{rel.parts[0]}/ — only council-questions/, "