@event4u/agent-config 2.12.0 → 2.14.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 (107) hide show
  1. package/.agent-src/commands/council/analysis.md +142 -0
  2. package/.agent-src/commands/council/debate.md +129 -0
  3. package/.agent-src/commands/council/default.md +8 -0
  4. package/.agent-src/commands/council/design.md +16 -12
  5. package/.agent-src/commands/council/optimize.md +16 -15
  6. package/.agent-src/commands/council/pr.md +12 -12
  7. package/.agent-src/commands/council.md +48 -2
  8. package/.agent-src/commands/memory/learn-low-impact.md +143 -0
  9. package/.agent-src/personas/advisors/contrarian.md +95 -0
  10. package/.agent-src/personas/advisors/executor.md +99 -0
  11. package/.agent-src/personas/advisors/expansionist.md +98 -0
  12. package/.agent-src/personas/advisors/first-principles.md +98 -0
  13. package/.agent-src/personas/advisors/outsider.md +102 -0
  14. package/.agent-src/rules/ask-when-uncertain.md +10 -6
  15. package/.agent-src/rules/copilot-routing.md +19 -0
  16. package/.agent-src/rules/devcontainer-routing.md +20 -0
  17. package/.agent-src/rules/external-reference-deep-dive.md +1 -1
  18. package/.agent-src/rules/fast-path-marker-visibility.md +38 -0
  19. package/.agent-src/rules/laravel-routing.md +20 -0
  20. package/.agent-src/rules/low-impact-corpus-privacy-floor.md +74 -0
  21. package/.agent-src/rules/symfony-routing.md +20 -0
  22. package/.agent-src/skills/ai-council/SKILL.md +388 -10
  23. package/.agent-src/skills/copilot-config/SKILL.md +1 -1
  24. package/.agent-src/skills/devcontainer/SKILL.md +1 -1
  25. package/.agent-src/skills/laravel/SKILL.md +1 -1
  26. package/.agent-src/skills/project-analysis-core/SKILL.md +1 -1
  27. package/.agent-src/skills/project-analyzer/SKILL.md +1 -1
  28. package/.agent-src/skills/symfony-workflow/SKILL.md +1 -1
  29. package/.agent-src/skills/universal-project-analysis/SKILL.md +1 -1
  30. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  31. package/.claude-plugin/marketplace.json +4 -1
  32. package/AGENTS.md +1 -1
  33. package/CHANGELOG.md +346 -124
  34. package/CONTRIBUTING.md +5 -0
  35. package/README.md +6 -6
  36. package/config/agent-settings.template.yml +5 -93
  37. package/config/gitignore-block.txt +6 -0
  38. package/docs/architecture/multi-tool-projection.md +53 -0
  39. package/docs/architecture/{compression.md → source-projection.md} +21 -3
  40. package/docs/architecture.md +15 -15
  41. package/docs/archive/CHANGELOG-pre-2.11.0.md +141 -0
  42. package/docs/catalog.md +25 -12
  43. package/docs/contracts/adr-architectural-consensus-mechanism.md +68 -0
  44. package/docs/contracts/adr-level-6-productization.md +7 -9
  45. package/docs/contracts/ai-council-config.md +658 -0
  46. package/docs/contracts/command-clusters.md +58 -2
  47. package/docs/contracts/command-surface-tiers.md +3 -2
  48. package/docs/contracts/cost-profile-defaults.md +5 -0
  49. package/docs/contracts/decision-engine-gates.md +5 -0
  50. package/docs/contracts/decision-trace-v1.md +2 -2
  51. package/docs/contracts/file-ownership-matrix.json +1735 -72
  52. package/docs/contracts/installed-tools-lockfile.md +2 -1
  53. package/docs/contracts/low-impact-corpus-format.md +95 -0
  54. package/docs/contracts/mcp-beta-criteria.md +6 -5
  55. package/docs/contracts/mcp-cloud-scope.md +5 -4
  56. package/docs/contracts/multi-tool-projection-fidelity.md +115 -0
  57. package/docs/contracts/release-trunk-sync.md +4 -3
  58. package/docs/contracts/tier-3-contrib-plugin.md +5 -6
  59. package/docs/getting-started.md +2 -2
  60. package/docs/guidelines/agent-infra/installed-tools-manifest.md +2 -1
  61. package/docs/installation.md +32 -0
  62. package/package.json +1 -1
  63. package/scripts/_archive/README.md +59 -0
  64. package/scripts/_cli/cmd_doctor.py +134 -0
  65. package/scripts/ai_council/_default_prices.py +10 -1
  66. package/scripts/ai_council/advisors.py +148 -0
  67. package/scripts/ai_council/airgap.py +165 -0
  68. package/scripts/ai_council/cli_hints.py +123 -0
  69. package/scripts/ai_council/clients.py +959 -5
  70. package/scripts/ai_council/compile_corpus.py +178 -0
  71. package/scripts/ai_council/confidence_gate.py +156 -0
  72. package/scripts/ai_council/config.py +1364 -0
  73. package/scripts/ai_council/consensus.py +329 -0
  74. package/scripts/ai_council/events_log.py +137 -0
  75. package/scripts/ai_council/learn_low_impact_preview.py +252 -0
  76. package/scripts/ai_council/low_impact.py +714 -0
  77. package/scripts/ai_council/low_impact_corpus.py +466 -0
  78. package/scripts/ai_council/low_impact_intake.py +163 -0
  79. package/scripts/ai_council/modes.py +6 -1
  80. package/scripts/ai_council/necessity.py +782 -0
  81. package/scripts/ai_council/orchestrator.py +872 -20
  82. package/scripts/ai_council/probation_gate.py +152 -0
  83. package/scripts/ai_council/prompts.py +335 -0
  84. package/scripts/ai_council/redact_low_impact_entry.py +155 -0
  85. package/scripts/ai_council/replay.py +155 -0
  86. package/scripts/ai_council/session.py +19 -1
  87. package/scripts/ai_council/shadow_dispatch.py +235 -0
  88. package/scripts/ai_council/solo_dispatch.py +226 -0
  89. package/scripts/audit_cloud_compatibility.py +74 -0
  90. package/scripts/audit_command_surface.py +363 -0
  91. package/scripts/check_compressed_paths.py +6 -1
  92. package/scripts/check_council_layout.py +11 -0
  93. package/scripts/ci_time_ratio.py +168 -0
  94. package/scripts/council_cli.py +2005 -30
  95. package/scripts/install.sh +12 -0
  96. package/scripts/measure_projection_bytes.py +159 -0
  97. package/scripts/measure_roadmap_trajectory.py +112 -0
  98. package/scripts/probe_projection_fidelity.py +202 -0
  99. package/scripts/score_skill_selection.py +198 -0
  100. package/scripts/skill_collision_clusters.py +162 -0
  101. /package/scripts/{_backfill_skill_domains.py → _archive/_backfill_skill_domains.py} +0 -0
  102. /package/scripts/{_bootstrap_tier_frontmatter.py → _archive/_bootstrap_tier_frontmatter.py} +0 -0
  103. /package/scripts/{_p43_bodies.py → _archive/_p43_bodies.py} +0 -0
  104. /package/scripts/{_p43_compress.py → _archive/_p43_compress.py} +0 -0
  105. /package/scripts/{_p4_migrate.py → _archive/_p4_migrate.py} +0 -0
  106. /package/scripts/{_phase2_shim_helper.py → _archive/_phase2_shim_helper.py} +0 -0
  107. /package/scripts/{_pilot_council_question.py → _archive/_pilot_council_question.py} +0 -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())
@@ -58,10 +58,15 @@ _LINK_RE = re.compile(r'\[[^\]]*\]\(([^)#\s]+)(?:#[^)]*)?\)')
58
58
  # Body-link prefixes whose resolution is intentionally out of scope.
59
59
  # Council Decision 2 (2026-05-06): P3.1 was cancelled, so guideline links
60
60
  # under `.agent-src/rules/` cannot resolve in the projected tree. Copilot
61
- # suppression (P6) is the silencer for the noise.
61
+ # suppression (P6) is the silencer for the noise. `docs/contracts/` shares
62
+ # the same shape as `docs/guidelines/` — both live at repo root and the
63
+ # rewriter collapses `../../docs/{contracts,guidelines}/...` to a
64
+ # `../docs/...` form that cannot resolve under `.agent-src/`.
62
65
  UNCHECKED_LINK_PREFIXES = (
63
66
  "../docs/guidelines/",
64
67
  "../../docs/guidelines/",
68
+ "../docs/contracts/",
69
+ "../../docs/contracts/",
65
70
  )
66
71
 
67
72
 
@@ -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/, "
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """CI-time / local-edit-time ratio (council file 07, Phase 2.3).
3
+
4
+ Samples the last N commits on a branch, classifies each by touched
5
+ paths (doc / skill / test / meta / mixed), and computes:
6
+
7
+ ratio = ci_time / local_time
8
+
9
+ where:
10
+ - `local_time` = delta between author-date of the *previous* commit and
11
+ author-date of the current commit, capped at 60 min to filter breaks.
12
+ - `ci_time` = sum of GitHub Actions workflow durations for that commit
13
+ sha (via `gh run list --commit <sha>`).
14
+
15
+ Threshold rule (Round-3 Sonnet protocol):
16
+ - Median ratio > 5× for any frequent class → that class needs a cheaper tier
17
+ - Median ratio < 3× across all classes → structural overhead acceptable
18
+
19
+ Output: human-readable table on stdout + JSON to
20
+ `agents/reports/ci-time-ratio.json`.
21
+
22
+ Usage:
23
+ python3 scripts/ci_time_ratio.py --limit 30
24
+ python3 scripts/ci_time_ratio.py --branch main --limit 30 --out path.json
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import statistics
32
+ import subprocess
33
+ import sys
34
+ from collections import defaultdict
35
+ from pathlib import Path
36
+
37
+ REPO_ROOT = Path(__file__).resolve().parent.parent
38
+ DEFAULT_OUT = REPO_ROOT / "agents" / "reports" / "ci-time-ratio.json"
39
+
40
+ LOCAL_TIME_CAP_S = 60 * 60 # cap a single edit window at 60 min
41
+ THRESHOLD_FAIL = 5.0
42
+ THRESHOLD_PASS = 3.0
43
+
44
+
45
+ def run(cmd: list[str]) -> str:
46
+ return subprocess.check_output(cmd, cwd=REPO_ROOT, text=True)
47
+
48
+
49
+ def list_commits(branch: str, limit: int) -> list[dict]:
50
+ out = run(["git", "log", branch, f"-n{limit + 1}",
51
+ "--format=%H\t%at\t%s"]).strip().splitlines()
52
+ rows = []
53
+ for line in out:
54
+ sha, ts, subject = line.split("\t", 2)
55
+ rows.append({"sha": sha, "timestamp": int(ts), "subject": subject})
56
+ return rows
57
+
58
+
59
+ def classify(sha: str) -> str:
60
+ files = run(["git", "show", "--name-only", "--format=", sha]).strip().splitlines()
61
+ files = [f for f in files if f]
62
+ if not files:
63
+ return "empty"
64
+ doc = sum(1 for f in files if f.startswith("docs/") or f.endswith(".md"))
65
+ skill = sum(1 for f in files if "/skills/" in f or f.startswith(".agent-src.uncompressed/skills/"))
66
+ test = sum(1 for f in files if f.startswith("tests/") or "/tests/" in f)
67
+ meta = sum(1 for f in files if f.startswith(("Taskfile", "scripts/", ".github/", "pyproject", "package")))
68
+ total = len(files)
69
+ # Single-class dominance: 70% of touched files in one bucket
70
+ for label, n in [("skill", skill), ("test", test), ("doc", doc), ("meta", meta)]:
71
+ if n >= max(1, int(total * 0.7)):
72
+ return label
73
+ return "mixed"
74
+
75
+
76
+ def ci_duration_for(sha: str) -> int | None:
77
+ """Total wall-clock seconds for all completed runs of this commit."""
78
+ try:
79
+ out = run(["gh", "run", "list", "--commit", sha, "--limit", "20",
80
+ "--json", "createdAt,updatedAt,status,conclusion"])
81
+ except subprocess.CalledProcessError:
82
+ return None
83
+ runs = json.loads(out)
84
+ if not runs:
85
+ return None
86
+ durations = []
87
+ for r in runs:
88
+ if r.get("status") != "completed":
89
+ continue
90
+ from datetime import datetime
91
+ c = datetime.fromisoformat(r["createdAt"].replace("Z", "+00:00"))
92
+ u = datetime.fromisoformat(r["updatedAt"].replace("Z", "+00:00"))
93
+ durations.append((u - c).total_seconds())
94
+ if not durations:
95
+ return None
96
+ # Workflows run in parallel — wall-clock is the max, not the sum.
97
+ return int(max(durations))
98
+
99
+
100
+ def collect(branch: str, limit: int) -> list[dict]:
101
+ commits = list_commits(branch, limit)
102
+ if len(commits) < 2:
103
+ return []
104
+ rows = []
105
+ for i in range(len(commits) - 1):
106
+ cur, prev = commits[i], commits[i + 1]
107
+ local_s = min(cur["timestamp"] - prev["timestamp"], LOCAL_TIME_CAP_S)
108
+ if local_s < 30:
109
+ continue
110
+ ci_s = ci_duration_for(cur["sha"])
111
+ if ci_s is None:
112
+ continue
113
+ cls = classify(cur["sha"])
114
+ rows.append({
115
+ "sha": cur["sha"][:10], "class": cls,
116
+ "local_s": local_s, "ci_s": ci_s,
117
+ "ratio": round(ci_s / local_s, 2) if local_s else None,
118
+ "subject": cur["subject"][:80],
119
+ })
120
+ return rows
121
+
122
+
123
+ def summarise(rows: list[dict]) -> dict:
124
+ by_class: dict[str, list[float]] = defaultdict(list)
125
+ for r in rows:
126
+ if r["ratio"] is not None:
127
+ by_class[r["class"]].append(r["ratio"])
128
+ summary = {}
129
+ for cls, ratios in sorted(by_class.items()):
130
+ m = statistics.median(ratios)
131
+ if m > THRESHOLD_FAIL:
132
+ verdict = "optimise"
133
+ elif m < THRESHOLD_PASS:
134
+ verdict = "acceptable"
135
+ else:
136
+ verdict = "watch"
137
+ summary[cls] = {"n": len(ratios), "median": round(m, 2),
138
+ "min": round(min(ratios), 2), "max": round(max(ratios), 2),
139
+ "verdict": verdict}
140
+ all_ratios = [r["ratio"] for r in rows if r["ratio"] is not None]
141
+ overall = {"n": len(all_ratios),
142
+ "median": round(statistics.median(all_ratios), 2) if all_ratios else None,
143
+ "verdict": ("acceptable" if all_ratios and statistics.median(all_ratios) < THRESHOLD_PASS
144
+ else "needs-review" if all_ratios else "no-data")}
145
+ return {"overall": overall, "by_class": summary}
146
+
147
+
148
+ def main() -> int:
149
+ p = argparse.ArgumentParser()
150
+ p.add_argument("--branch", default="HEAD")
151
+ p.add_argument("--limit", type=int, default=30)
152
+ p.add_argument("--out", type=Path, default=DEFAULT_OUT)
153
+ args = p.parse_args()
154
+ rows = collect(args.branch, args.limit)
155
+ report = summarise(rows)
156
+ report["sample"] = rows
157
+ args.out.parent.mkdir(parents=True, exist_ok=True)
158
+ args.out.write_text(json.dumps(report, indent=2) + "\n")
159
+ print(f"✅ Wrote {args.out.relative_to(REPO_ROOT)} (n={report['overall']['n']})")
160
+ ov = report["overall"]
161
+ print(f" overall median ratio: {ov['median']}× → {ov['verdict']}")
162
+ for cls, s in report["by_class"].items():
163
+ print(f" {cls:7} n={s['n']:2} median={s['median']:.2f}× range=[{s['min']}–{s['max']}] {s['verdict']}")
164
+ return 0
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())