@event4u/agent-config 1.24.0 → 1.26.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 (75) hide show
  1. package/.agent-src/commands/review-routing.md +7 -10
  2. package/.agent-src/contexts/authority/kernel-rule-edits.md +48 -0
  3. package/.agent-src/contexts/authority/scope-mechanics.md +15 -0
  4. package/.agent-src/contexts/contracts/consumer-agents-md-guide.md +127 -0
  5. package/.agent-src/contexts/contracts/emergency-triage-block.md +53 -0
  6. package/.agent-src/rules/analysis-skill-routing.md +1 -1
  7. package/.agent-src/rules/artifact-drafting-protocol.md +1 -1
  8. package/.agent-src/rules/artifact-engagement-recording.md +1 -1
  9. package/.agent-src/rules/augment-source-of-truth.md +1 -1
  10. package/.agent-src/rules/autonomous-execution.md +1 -1
  11. package/.agent-src/rules/caveman-speak.md +1 -1
  12. package/.agent-src/rules/cli-output-handling.md +1 -1
  13. package/.agent-src/rules/command-suggestion-policy.md +1 -1
  14. package/.agent-src/rules/docs-sync.md +1 -1
  15. package/.agent-src/rules/guidelines.md +1 -1
  16. package/.agent-src/rules/improve-before-implement.md +1 -1
  17. package/.agent-src/rules/invite-challenge.md +1 -1
  18. package/.agent-src/rules/minimal-safe-diff.md +1 -1
  19. package/.agent-src/rules/model-recommendation.md +1 -1
  20. package/.agent-src/rules/no-attribution-footers.md +1 -1
  21. package/.agent-src/rules/no-roadmap-references.md +56 -20
  22. package/.agent-src/rules/onboarding-gate.md +1 -1
  23. package/.agent-src/rules/package-ci-checks.md +1 -1
  24. package/.agent-src/rules/reviewer-awareness.md +9 -2
  25. package/.agent-src/rules/roadmap-progress-sync.md +1 -1
  26. package/.agent-src/rules/scope-control.md +6 -0
  27. package/.agent-src/rules/security-sensitive-stop.md +1 -1
  28. package/.agent-src/rules/size-enforcement.md +1 -1
  29. package/.agent-src/rules/token-optimizer-maintenance.md +1 -1
  30. package/.agent-src/rules/ui-audit-gate.md +1 -1
  31. package/.agent-src/skills/adr-create/SKILL.md +2 -1
  32. package/.agent-src/skills/agents-md-thin-root/SKILL.md +125 -0
  33. package/.agent-src/skills/ai-council/SKILL.md +9 -7
  34. package/.agent-src/skills/review-routing/SKILL.md +3 -4
  35. package/.agent-src/templates/AGENTS.md +18 -148
  36. package/.agent-src/templates/copilot-instructions.md +41 -17
  37. package/.agent-src/templates/github-workflows/pr-risk-review.yml +1 -1
  38. package/.agent-src/templates/scripts/pr_review_routing.py +1 -1
  39. package/.claude-plugin/marketplace.json +2 -1
  40. package/AGENTS.md +18 -216
  41. package/CHANGELOG.md +58 -0
  42. package/README.md +2 -2
  43. package/docs/architecture.md +13 -7
  44. package/docs/catalog.md +26 -27
  45. package/docs/contracts/agents-md-tech-stack.md +74 -0
  46. package/docs/contracts/linear-ai-rules-inclusion.md +1 -1
  47. package/docs/contracts/linter-structural-model.md +180 -0
  48. package/docs/contracts/package-self-orientation.md +135 -0
  49. package/docs/contracts/rule-classification.md +4 -4
  50. package/docs/decisions/ADR-004-rule-governance-pruning.md +240 -0
  51. package/docs/getting-started.md +1 -1
  52. package/docs/guidelines/agent-infra/review-routing-data-format.md +1 -2
  53. package/docs/guidelines/agent-infra/size-and-scope.md +18 -12
  54. package/package.json +1 -1
  55. package/scripts/_p4_migrate.py +5 -5
  56. package/scripts/audit_auto_rules.py +159 -0
  57. package/scripts/audit_likelihood.py +148 -0
  58. package/scripts/audit_overlap.py +145 -0
  59. package/scripts/build_rule_trigger_matrix.py +3 -5
  60. package/scripts/check_augment_description_cap.py +79 -0
  61. package/scripts/check_council_references.py +3 -3
  62. package/scripts/check_kernel_rule_bundle.py +151 -0
  63. package/scripts/check_references.py +21 -1
  64. package/scripts/compile_router.py +3 -0
  65. package/scripts/install.sh +0 -1
  66. package/scripts/lint_agents_md.py +168 -0
  67. package/scripts/measure_augment_budget.py +208 -0
  68. package/scripts/measure_density.py +232 -0
  69. package/scripts/schemas/rule.schema.json +2 -1
  70. package/scripts/skill_linter.py +166 -31
  71. package/scripts/spotcheck_thin_root.py +134 -0
  72. package/scripts/update_counts.py +6 -10
  73. package/.agent-src/rules/no-council-references.md +0 -76
  74. package/.agent-src/rules/review-routing-awareness.md +0 -19
  75. package/.agent-src/templates/copilot-review-instructions.md +0 -76
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """Measure structural density across the artifact corpus.
3
+
4
+ Phase 1.1 of `agents/roadmaps/road-to-structural-linter-reform.md`.
5
+
6
+ Density score = structured_lines / total_lines, where structured_lines
7
+ sum lines inside fenced blocks + markdown-table rows + bullet lines +
8
+ numbered/ordered-list lines + section-heading lines. Higher = more
9
+ structured (catalogue, orchestrator, Iron-Law block); lower = prose-
10
+ dominant.
11
+
12
+ Companion signals collected per artifact (consumed by Phases 1.2-1.4):
13
+
14
+ - ``multi_workflow`` ≥ 2 ``## Procedure`` (or ``## Procedure: …``)
15
+ blocks in a skill — candidate for cluster split.
16
+ - ``delegation`` command frontmatter has ``cluster:`` or
17
+ ``routes_to:``, or the body links to ≥ 3 other
18
+ commands/skills via ``](...md)``.
19
+ - ``iron_law_block`` ≥ 1 fenced block whose body is ≥ 60 % ALL-CAPS
20
+ across ≥ 3 non-empty lines.
21
+
22
+ Output:
23
+ - Default stdout: per-type distribution buckets + tail (lowest density).
24
+ - ``--json`` deterministic JSON of every artifact.
25
+ - ``--snapshot`` writes JSONL to ``agents/.density-snapshot.jsonl``.
26
+
27
+ Stdlib only; no network. Re-runnable.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import re
34
+ import sys
35
+ from pathlib import Path
36
+ from typing import Any, Dict, List
37
+
38
+ REPO_ROOT = Path(__file__).resolve().parent.parent
39
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
40
+
41
+ from skill_linter import ( # noqa: E402
42
+ detect_artifact_type,
43
+ extract_frontmatter,
44
+ gather_all_candidate_files,
45
+ )
46
+
47
+ SNAPSHOT_FILE = REPO_ROOT / "agents" / ".density-snapshot.jsonl"
48
+
49
+ _TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")
50
+ _BULLET = re.compile(r"^\s*[-*]\s+\S")
51
+ _NUMBERED = re.compile(r"^\s*\d+\.\s+\S")
52
+ _HEADING = re.compile(r"^\s{0,3}#{1,6}\s+\S")
53
+ _PROCEDURE = re.compile(r"^##\s+Procedure(\s*:.*)?\s*$", re.MULTILINE)
54
+ _LINK_MD = re.compile(r"\]\([^)]+\.md[^)]*\)")
55
+ _FRONTMATTER_KEY = re.compile(r"^(cluster|routes_to)\s*:", re.MULTILINE)
56
+ _ALLCAPS_LINE = re.compile(r"[A-Z]")
57
+
58
+
59
+ def _classify_lines(text: str) -> Dict[str, int]:
60
+ """Bucket every non-blank line into one structural category."""
61
+ inside_fence = False
62
+ counts = {
63
+ "total": 0,
64
+ "fenced": 0,
65
+ "table": 0,
66
+ "bullet": 0,
67
+ "numbered": 0,
68
+ "heading": 0,
69
+ "prose": 0,
70
+ }
71
+ for raw in text.splitlines():
72
+ stripped = raw.strip()
73
+ if stripped.startswith("```"):
74
+ inside_fence = not inside_fence
75
+ counts["total"] += 1
76
+ counts["fenced"] += 1
77
+ continue
78
+ if not stripped:
79
+ continue
80
+ counts["total"] += 1
81
+ if inside_fence:
82
+ counts["fenced"] += 1
83
+ elif _TABLE_ROW.match(raw):
84
+ counts["table"] += 1
85
+ elif _HEADING.match(raw):
86
+ counts["heading"] += 1
87
+ elif _BULLET.match(raw):
88
+ counts["bullet"] += 1
89
+ elif _NUMBERED.match(raw):
90
+ counts["numbered"] += 1
91
+ else:
92
+ counts["prose"] += 1
93
+ return counts
94
+
95
+
96
+ def _detect_iron_law_blocks(text: str) -> int:
97
+ """Count fenced blocks that look like verbatim Iron-Law imperatives.
98
+
99
+ Heuristic: fenced block with ≥ 1 non-empty line whose alphabetical
100
+ body is ≥ 60 % uppercase AND has ≥ 30 letters total (filters single
101
+ short ALL-CAPS markers like ``OK``). Also matches blockquote-style
102
+ Iron Laws (``> NEVER COMMIT``).
103
+ """
104
+ blocks = 0
105
+ inside = False
106
+ body: list[str] = []
107
+ for raw in text.splitlines():
108
+ if raw.strip().startswith("```"):
109
+ if inside and body:
110
+ non_empty = [b for b in body if b.strip()]
111
+ letters = "".join(non_empty)
112
+ upper = sum(1 for c in letters if c.isalpha() and c.isupper())
113
+ total = sum(1 for c in letters if c.isalpha())
114
+ if total >= 30 and upper / total >= 0.6 and non_empty:
115
+ blocks += 1
116
+ inside = not inside
117
+ body = []
118
+ continue
119
+ if inside:
120
+ body.append(raw)
121
+ return blocks
122
+
123
+
124
+ def _count_procedures(text: str) -> int:
125
+ return len(_PROCEDURE.findall(text))
126
+
127
+
128
+ def _delegation_signal(text: str, frontmatter: str | None) -> Dict[str, Any]:
129
+ fm_keys = bool(frontmatter and _FRONTMATTER_KEY.search(frontmatter))
130
+ md_links = len(_LINK_MD.findall(text))
131
+ return {"frontmatter_routes": fm_keys, "md_links": md_links,
132
+ "has_signal": fm_keys or md_links >= 3}
133
+
134
+
135
+ def measure(path: Path) -> Dict[str, Any]:
136
+ text = path.read_text(encoding="utf-8")
137
+ rel = path.relative_to(REPO_ROOT) if path.is_absolute() else path
138
+ artifact_type = detect_artifact_type(rel, text)
139
+ frontmatter = extract_frontmatter(text)
140
+ counts = _classify_lines(text)
141
+ structured = counts["fenced"] + counts["table"] + counts["bullet"] + \
142
+ counts["numbered"] + counts["heading"]
143
+ density = structured / counts["total"] if counts["total"] else 0.0
144
+ return {
145
+ "file": str(rel),
146
+ "type": artifact_type,
147
+ "lines": counts["total"],
148
+ "words": len(text.split()),
149
+ "density": round(density, 3),
150
+ "fenced": counts["fenced"],
151
+ "table": counts["table"],
152
+ "bullet": counts["bullet"],
153
+ "numbered": counts["numbered"],
154
+ "heading": counts["heading"],
155
+ "prose": counts["prose"],
156
+ "iron_law_blocks": _detect_iron_law_blocks(text),
157
+ "procedures": _count_procedures(text),
158
+ "delegation": _delegation_signal(text, frontmatter),
159
+ }
160
+
161
+
162
+ def collect() -> List[Dict[str, Any]]:
163
+ paths = gather_all_candidate_files(REPO_ROOT)
164
+ return [measure(p) for p in paths]
165
+
166
+
167
+ def _bucketize(values: List[float]) -> Dict[str, int]:
168
+ buckets = {"0.0-0.2": 0, "0.2-0.4": 0, "0.4-0.6": 0,
169
+ "0.6-0.8": 0, "0.8-1.0": 0}
170
+ for v in values:
171
+ if v < 0.2:
172
+ buckets["0.0-0.2"] += 1
173
+ elif v < 0.4:
174
+ buckets["0.2-0.4"] += 1
175
+ elif v < 0.6:
176
+ buckets["0.4-0.6"] += 1
177
+ elif v < 0.8:
178
+ buckets["0.6-0.8"] += 1
179
+ else:
180
+ buckets["0.8-1.0"] += 1
181
+ return buckets
182
+
183
+
184
+ def report(results: List[Dict[str, Any]]) -> str:
185
+ by_type: Dict[str, List[Dict[str, Any]]] = {}
186
+ for r in results:
187
+ by_type.setdefault(r["type"], []).append(r)
188
+ lines: List[str] = ["# Structural Density Snapshot", "",
189
+ f"Total artifacts: {len(results)}", ""]
190
+ for t in sorted(by_type):
191
+ rows = by_type[t]
192
+ densities = [r["density"] for r in rows]
193
+ avg = sum(densities) / len(densities) if densities else 0.0
194
+ med = sorted(densities)[len(densities) // 2] if densities else 0.0
195
+ buckets = _bucketize(densities)
196
+ lines.append(f"## {t} ({len(rows)} artifacts)")
197
+ lines.append(f"avg density={avg:.2f} median={med:.2f}")
198
+ lines.append("buckets " + " ".join(
199
+ f"[{k}]={v}" for k, v in buckets.items()))
200
+ tail = sorted(rows, key=lambda r: r["density"])[:5]
201
+ lines.append("lowest density:")
202
+ for r in tail:
203
+ lines.append(f" {r['density']:.2f} {r['lines']:>4}L "
204
+ f"proc={r['procedures']} "
205
+ f"iron={r['iron_law_blocks']} "
206
+ f"deleg={int(r['delegation']['has_signal'])} "
207
+ f"{r['file']}")
208
+ lines.append("")
209
+ return "\n".join(lines)
210
+
211
+
212
+ def main() -> int:
213
+ p = argparse.ArgumentParser()
214
+ p.add_argument("--json", action="store_true")
215
+ p.add_argument("--snapshot", action="store_true",
216
+ help=f"write JSONL to {SNAPSHOT_FILE.relative_to(REPO_ROOT)}")
217
+ args = p.parse_args()
218
+ results = collect()
219
+ if args.snapshot:
220
+ SNAPSHOT_FILE.parent.mkdir(parents=True, exist_ok=True)
221
+ with SNAPSHOT_FILE.open("w", encoding="utf-8") as fh:
222
+ for r in sorted(results, key=lambda x: x["file"]):
223
+ fh.write(json.dumps(r, sort_keys=True) + "\n")
224
+ if args.json:
225
+ print(json.dumps(results, sort_keys=True, indent=2))
226
+ else:
227
+ print(report(results))
228
+ return 0
229
+
230
+
231
+ if __name__ == "__main__":
232
+ raise SystemExit(main())
@@ -9,7 +9,8 @@
9
9
  "properties": {
10
10
  "type": {
11
11
  "type": "string",
12
- "enum": ["always", "auto"]
12
+ "enum": ["always", "auto", "manual"],
13
+ "description": "`always` = injected verbatim every turn (kernel). `auto` = description stub injected, body loaded on trigger match. `manual` = no auto-injection (zero workspace-budget cost); file remains as a reference document linkable from skills/contexts. Introduced by ADR-004 to demote thin pointer-rules without breaking cross-references."
13
14
  },
14
15
  "source": {
15
16
  "type": "string",
@@ -115,7 +115,7 @@ ORDERED_STEP_PATTERN = re.compile(r"^(?:\s*|\#{1,4}\s*)(\d+)\.\s+", re.MULTILINE
115
115
  SECTION_PATTERN = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
116
116
  FRONTMATTER_PATTERN = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
117
117
  DESCRIPTION_PATTERN = re.compile(r'^description:\s*"?(.*?)"?\s*$', re.MULTILINE)
118
- TYPE_PATTERN = re.compile(r'^type:\s*"?(always|auto)"?\s*$', re.MULTILINE)
118
+ TYPE_PATTERN = re.compile(r'^type:\s*"?(always|auto|manual)"?\s*$', re.MULTILINE)
119
119
  SOURCE_PATTERN = re.compile(r'^source:\s*"?(package|project)"?\s*$', re.MULTILINE)
120
120
  STATUS_PATTERN = re.compile(r'^status:\s*"?(active|deprecated|superseded)"?\s*$', re.MULTILINE)
121
121
  REPLACED_BY_PATTERN = re.compile(r'^replaced_by:\s*"?([\w-]+)"?\s*$', re.MULTILINE)
@@ -133,7 +133,7 @@ SENIOR_OUTPUT_PATTERN = re.compile(r"^##\s+Output\s*$", re.MULTILINE)
133
133
  H1_PATTERN = re.compile(r"^# .+", re.MULTILINE)
134
134
  DOUBLE_BLANK_PATTERN = re.compile(r"\n{3,}")
135
135
 
136
- VALID_RULE_TYPES = {"always", "auto"}
136
+ VALID_RULE_TYPES = {"always", "auto", "manual"}
137
137
  VALID_RULE_SOURCES = {"package", "project"}
138
138
  VALID_STATUSES = {"active", "deprecated", "superseded"}
139
139
 
@@ -264,9 +264,9 @@ def _count_code_blocks(text: str) -> int:
264
264
  def _fenced_content_ratio(text: str) -> float:
265
265
  """Return the fraction of non-empty lines that sit inside fenced blocks.
266
266
 
267
- Used as a structural signal: rules / files dominated by verbatim Iron-Law
268
- blocks or worked examples score high and are exempted from raw line-count
269
- warnings (council review 2026-05-06).
267
+ Retained as a helper for backwards compatibility; the size gates use
268
+ :func:`_density_score` from the structural model instead (Phase 3 of
269
+ road-to-structural-linter-reform).
270
270
  """
271
271
  inside = False
272
272
  fenced_lines = 0
@@ -287,6 +287,106 @@ def _fenced_content_ratio(text: str) -> float:
287
287
  return fenced_lines / non_empty
288
288
 
289
289
 
290
+ # --- Structural-density model (docs/contracts/linter-structural-model.md) ---
291
+ # Replaces the raw line/word/fenced-ratio gates with four primitives that
292
+ # distinguish complexity from bloat. Calibrated 2026-05-08 against the full
293
+ # 310-artefact corpus (agents/.density-snapshot.jsonl).
294
+
295
+ PROCEDURE_HEADING_PATTERN = re.compile(
296
+ r"^##\s+Procedure(\s*[:\u2014\-].*)?\s*$", re.MULTILINE
297
+ )
298
+ COMMAND_FRONTMATTER_DELEGATION_KEYS = ("cluster:", "routes_to:")
299
+ MD_LINK_PATTERN = re.compile(r"\[[^\]]+\]\(([^)]+\.md[^)]*)\)")
300
+
301
+
302
+ def _density_score(text: str) -> float:
303
+ """Return structural density 0.0–1.0 — see docs/contracts/linter-structural-model.md.
304
+
305
+ density = structured_lines / non_blank_lines, where structured_lines =
306
+ fenced + table + bullet + numbered + heading. Higher = more structured
307
+ (catalogue, table, code, list); lower = prose-dominant.
308
+ """
309
+ inside_fence = False
310
+ structured = 0
311
+ non_blank = 0
312
+ for raw in text.splitlines():
313
+ stripped = raw.strip()
314
+ if not stripped:
315
+ continue
316
+ non_blank += 1
317
+ if stripped.startswith("```"):
318
+ inside_fence = not inside_fence
319
+ structured += 1
320
+ continue
321
+ if inside_fence:
322
+ structured += 1
323
+ continue
324
+ if stripped.startswith("#"):
325
+ structured += 1
326
+ continue
327
+ if stripped.startswith("|") and stripped.endswith("|"):
328
+ structured += 1
329
+ continue
330
+ if stripped.startswith(("- ", "* ", "+ ")):
331
+ structured += 1
332
+ continue
333
+ if re.match(r"^\d+\.\s", stripped):
334
+ structured += 1
335
+ continue
336
+ if non_blank == 0:
337
+ return 0.0
338
+ return round(structured / non_blank, 3)
339
+
340
+
341
+ def _count_procedure_sections(text: str) -> int:
342
+ """Count `## Procedure` (or `## Procedure: <name>`) blocks in *text*."""
343
+ return len(PROCEDURE_HEADING_PATTERN.findall(text))
344
+
345
+
346
+ def _command_delegation_signal(text: str, frontmatter: Optional[str]) -> bool:
347
+ """Return True when a command has a delegation signal.
348
+
349
+ Signals: frontmatter declares ``cluster:`` or ``routes_to:`` — OR — the
350
+ body contains ≥ 3 markdown links to other ``.md`` files. Either signal
351
+ is sufficient (council review 2026-05-08).
352
+ """
353
+ if frontmatter:
354
+ for key in COMMAND_FRONTMATTER_DELEGATION_KEYS:
355
+ if re.search(rf"^{re.escape(key)}", frontmatter, re.MULTILINE):
356
+ return True
357
+ if len(MD_LINK_PATTERN.findall(text)) >= 3:
358
+ return True
359
+ return False
360
+
361
+
362
+ def _iron_law_blocks(text: str) -> int:
363
+ """Count fenced blocks that look like verbatim Iron-Law imperatives.
364
+
365
+ Heuristic: fenced block whose body has ≥ 30 alphabetical chars and
366
+ ≥ 60 % uppercase across ≥ 1 non-empty line. The 30-char floor filters
367
+ short ALL-CAPS markers (``OK``, ``WIP``); the 60 %-uppercase floor
368
+ catches verbatim imperatives (``NEVER COMMIT.``).
369
+ """
370
+ blocks = 0
371
+ inside = False
372
+ body: list[str] = []
373
+ for raw in text.splitlines():
374
+ if raw.strip().startswith("```"):
375
+ if inside and body:
376
+ non_empty = [b for b in body if b.strip()]
377
+ letters = "".join(non_empty)
378
+ upper = sum(1 for c in letters if c.isalpha() and c.isupper())
379
+ total = sum(1 for c in letters if c.isalpha())
380
+ if total >= 30 and upper / total >= 0.6 and non_empty:
381
+ blocks += 1
382
+ inside = not inside
383
+ body = []
384
+ continue
385
+ if inside:
386
+ body.append(raw)
387
+ return blocks
388
+
389
+
290
390
  def extract_description(text: str) -> Optional[str]:
291
391
  frontmatter = FRONTMATTER_PATTERN.search(text)
292
392
  if not frontmatter:
@@ -561,14 +661,28 @@ def lint_skill(path: Path, text: str) -> LintResult:
561
661
  "Assisted skill has no validation/challenge step in procedure"))
562
662
  suggestions.append("Add a requirement-checking or validation step before implementation")
563
663
 
564
- # --- Size check (see guidelines/agent-infra/size-and-scope.md) ---
565
- # Threshold raised from 300 400 (council review 2026-05-06): reference-rich
566
- # skills (quality-tools 411, ai-council 399, project-analyzer 341) legitimately
567
- # exceed 300 lines without being split-candidates. Structural follow-up tracked
568
- # in agents/roadmaps/road-to-structural-linter-reform.md.
664
+ # --- Size check (docs/contracts/linter-structural-model.md) ---
665
+ # Structural-density gate replaces raw line count (Phase 3 of
666
+ # road-to-structural-linter-reform, 2026-05-08): warn only when the skill
667
+ # is *both* large AND prose-dominant OR ships ≥ 2 independently invocable
668
+ # procedures. Reference catalogues (quality-tools 411 L / density 0.83)
669
+ # pass; multi-procedure skills are flagged for split.
569
670
  total_lines = len(text.splitlines())
570
671
  if total_lines > 400:
571
- issues.append(Issue("warning", "skill_too_large", f"Skill has {total_lines} lines; review for split (see size-and-scope guideline)"))
672
+ density = _density_score(text)
673
+ procedures = _count_procedure_sections(text)
674
+ if density < 0.6 or procedures >= 2:
675
+ reason = (
676
+ f"density {density:.2f} < 0.60"
677
+ if density < 0.6
678
+ else f"{procedures} ## Procedure blocks (≥ 2)"
679
+ )
680
+ issues.append(Issue(
681
+ "warning",
682
+ "skill_too_large",
683
+ f"Skill has {total_lines} lines and {reason}; review for split "
684
+ f"(see linter-structural-model contract)",
685
+ ))
572
686
 
573
687
  # --- Pointer-only / guideline-dependent skill detection ---
574
688
  if procedure_block:
@@ -683,6 +797,12 @@ def lint_router_frontmatter(rule_id: str, frontmatter: str,
683
797
  triggers = _parse_yaml_list(frontmatter, "triggers")
684
798
  routes_to = _parse_yaml_list(frontmatter, "routes_to")
685
799
 
800
+ # Manual rules are reference-only — not auto-injected, not router-routed
801
+ # (ADR-004). Skip router validation so legacy triggers/routes_to fields
802
+ # remain documented in the rule body without forcing maintenance.
803
+ if rule_type == "manual":
804
+ return issues
805
+
686
806
  is_kernel = rule_id in KERNEL_RULE_IDS or rule_type == "always"
687
807
 
688
808
  if is_kernel:
@@ -961,9 +1081,9 @@ def lint_rule(path: Path, text: str) -> LintResult:
961
1081
  # type field
962
1082
  rule_type = extract_frontmatter_field(frontmatter, TYPE_PATTERN)
963
1083
  if rule_type is None:
964
- issues.append(Issue("error", "missing_type", "Frontmatter missing 'type' field (must be 'always' or 'auto')"))
1084
+ issues.append(Issue("error", "missing_type", "Frontmatter missing 'type' field (must be 'always', 'auto', or 'manual')"))
965
1085
  elif rule_type not in VALID_RULE_TYPES:
966
- issues.append(Issue("error", "invalid_type", f"Invalid type '{rule_type}'; must be 'always' or 'auto'"))
1086
+ issues.append(Issue("error", "invalid_type", f"Invalid type '{rule_type}'; must be 'always', 'auto', or 'manual'"))
967
1087
 
968
1088
  # source field
969
1089
  rule_source = extract_frontmatter_field(frontmatter, SOURCE_PATTERN)
@@ -1015,19 +1135,26 @@ def lint_rule(path: Path, text: str) -> LintResult:
1015
1135
  if DOUBLE_BLANK_PATTERN.search(text):
1016
1136
  issues.append(Issue("warning", "double_blank_lines", "File contains double or triple blank lines"))
1017
1137
 
1018
- # --- Content checks (see guidelines/agent-infra/size-and-scope.md) ---
1019
- # Length thresholds gated by fenced-content density (council review 2026-05-06):
1020
- # rules dominated by verbatim Iron-Law blocks / worked examples are protected
1021
- # from the > 40 / > 60 warnings. Hard error at 200 stays unconditional.
1138
+ # --- Content checks (docs/contracts/linter-structural-model.md) ---
1139
+ # Structural-density gate replaces fenced-ratio + dual-threshold (Phase 3
1140
+ # of road-to-structural-linter-reform, 2026-05-08): warn only when the
1141
+ # rule is long, prose-dominant, AND ships no Iron-Law block. Hard error
1142
+ # at 200 lines stays unconditional.
1022
1143
  line_count = len([line for line in text.splitlines() if line.strip()])
1023
1144
  total_lines = len(text.splitlines())
1024
- fenced_ratio = _fenced_content_ratio(text)
1025
1145
  if total_lines > 200:
1026
1146
  issues.append(Issue("error", "rule_too_large", f"Rule has {total_lines} lines (hard limit: 200); must split or move to guideline"))
1027
- elif line_count > 60 and fenced_ratio < 0.30:
1028
- issues.append(Issue("warning", "long_rule", f"Rule has {line_count} non-empty lines (fenced-content {fenced_ratio:.0%}); prefer < 60 (see size-and-scope guideline)"))
1029
- elif line_count > 40 and fenced_ratio < 0.30:
1030
- issues.append(Issue("warning", "long_rule", f"Rule has {line_count} non-empty lines (fenced-content {fenced_ratio:.0%}); rules should be concise"))
1147
+ elif line_count > 60:
1148
+ density = _density_score(text)
1149
+ iron_blocks = _iron_law_blocks(text)
1150
+ if density < 0.5 and iron_blocks == 0:
1151
+ issues.append(Issue(
1152
+ "warning",
1153
+ "long_rule",
1154
+ f"Rule has {line_count} non-empty lines, density {density:.2f} < 0.50, "
1155
+ f"no Iron-Law block; rules should be concise "
1156
+ f"(see linter-structural-model contract)",
1157
+ ))
1031
1158
 
1032
1159
  for bad_sign in RULE_BAD_SIGNS:
1033
1160
  if bad_sign in text:
@@ -1171,17 +1298,25 @@ def lint_command(path: Path, text: str) -> LintResult:
1171
1298
  if not has_steps and not has_numbered:
1172
1299
  issues.append(Issue("warning", "no_steps", "Command has no Steps section or numbered sub-headings"))
1173
1300
 
1174
- # --- Size check (see guidelines/agent-infra/size-and-scope.md) ---
1175
- # Word threshold (1000) gated by structural delegation signal (council review
1176
- # 2026-05-06): well-factored orchestrators with ≥ 5 sub-sections AND ≥ 3 code
1177
- # blocks are exempt the size reflects dispatch breadth, not bloat.
1301
+ # --- Size check (docs/contracts/linter-structural-model.md) ---
1302
+ # Structural-density gate replaces sub-section + code-block heuristic
1303
+ # (Phase 3 of road-to-structural-linter-reform, 2026-05-08): warn only
1304
+ # when the command is large, lacks a delegation signal (frontmatter
1305
+ # cluster:/routes_to: OR ≥ 3 markdown links to other .md files), AND
1306
+ # has density < 0.65.
1178
1307
  word_count = len(text.split())
1179
1308
  if word_count > 1000:
1180
- section_count = len(sections)
1181
- code_block_count = _count_code_blocks(text)
1182
- delegation_signal = section_count >= 5 and code_block_count >= 3
1183
- if not delegation_signal:
1184
- issues.append(Issue("warning", "large_command", f"Command has {word_count} words (target: 200-600, max ~1000); {section_count} sub-sections, {code_block_count} code blocks — lacks delegation structure"))
1309
+ density = _density_score(text)
1310
+ delegated = _command_delegation_signal(text, frontmatter)
1311
+ if not delegated and density < 0.65:
1312
+ issues.append(Issue(
1313
+ "warning",
1314
+ "large_command",
1315
+ f"Command has {word_count} words, density {density:.2f} < 0.65, "
1316
+ f"no delegation signal (frontmatter cluster:/routes_to: or "
1317
+ f"≥ 3 .md links); review for split or delegation "
1318
+ f"(see linter-structural-model contract)",
1319
+ ))
1185
1320
 
1186
1321
  # File must end with exactly one newline
1187
1322
  if not text.endswith("\n"):
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """Phase 6.6 platform spot-check via AI council.
3
+
4
+ Sends the refactored package-root AGENTS.md and the consumer template
5
+ to Sonnet 4.5 + gpt-4o, asks each member to answer five questions
6
+ that simulate a fresh agent landing on the file. Records qualitative
7
+ verdicts in agents/reports/thin-root-platform-spotcheck.md.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ sys.path.insert(0, str(ROOT))
17
+
18
+ from scripts.ai_council.clients import ( # noqa: E402
19
+ AnthropicClient,
20
+ OpenAIClient,
21
+ load_anthropic_key,
22
+ load_openai_key,
23
+ )
24
+ from scripts.ai_council.orchestrator import ( # noqa: E402
25
+ CostBudget,
26
+ CouncilQuestion,
27
+ consult,
28
+ )
29
+ from scripts.ai_council.pricing import load_prices # noqa: E402
30
+
31
+ QUESTIONS = """
32
+ You are evaluating whether the AGENTS.md file below is a sufficient
33
+ entry point for an AI coding agent landing on this repository for
34
+ the first time. You see only the AGENTS.md content; you do NOT have
35
+ file-system access. Answer the following five questions in JSON
36
+ shape `{"q1": {...}, ..., "q5": {...}}` where each value is
37
+ `{"answer": <string>, "confidence": "high"|"medium"|"low",
38
+ "pointer_used": <one of the linked paths from AGENTS.md, or null>}`.
39
+
40
+ Q1. Where do I edit content in this repo / project? (a path)
41
+ Q2. What command do I run to verify everything is green before opening a PR?
42
+ Q3. Where would I find the always-active behavioural rules?
43
+ Q4. If only this file is reachable, what five things must I assume to be true to act safely? (cite the emergency-triage block)
44
+ Q5. What outboard target document would I open to learn the package-self-orientation / the consumer-fill-out guide? (a path)
45
+
46
+ After the JSON, add a short prose verdict (≤ 5 sentences) on:
47
+ - Whether the pointer-following worked (could you cite a path for Q1, Q3, Q5?)
48
+ - Whether the emergency-triage block answered Q4 unambiguously.
49
+ - One concrete improvement you'd make to the AGENTS.md.
50
+
51
+ Do not invent file paths. If a question cannot be answered from the
52
+ file alone, set `"pointer_used": null` and lower confidence.
53
+ """.strip()
54
+
55
+
56
+ def main() -> int:
57
+ package_root = (ROOT / "AGENTS.md").read_text(encoding="utf-8")
58
+ consumer_template = (
59
+ ROOT / ".agent-src.uncompressed" / "templates" / "AGENTS.md"
60
+ ).read_text(encoding="utf-8")
61
+
62
+ artefact = (
63
+ "## Artefact A — package-root AGENTS.md\n\n"
64
+ f"```markdown\n{package_root}\n```\n\n"
65
+ "## Artefact B — consumer-template AGENTS.md\n\n"
66
+ f"```markdown\n{consumer_template}\n```\n\n"
67
+ f"{QUESTIONS}\n"
68
+ )
69
+
70
+ members = [
71
+ AnthropicClient(model="claude-sonnet-4-5", api_key=load_anthropic_key()),
72
+ OpenAIClient(model="gpt-4o", api_key=load_openai_key()),
73
+ ]
74
+
75
+ question = CouncilQuestion(
76
+ mode="files",
77
+ user_prompt=artefact,
78
+ max_tokens=1500,
79
+ )
80
+ budget = CostBudget(max_total_usd=2.00, max_calls=4)
81
+ table = load_prices()
82
+
83
+ print("Running spot-check council …", file=sys.stderr)
84
+ responses = consult(members, question, budget, table=table, rounds=1)
85
+
86
+ out_dir = ROOT / "agents" / "reports"
87
+ out_dir.mkdir(parents=True, exist_ok=True)
88
+ md_path = out_dir / "thin-root-platform-spotcheck.md"
89
+ json_path = out_dir / "thin-root-platform-spotcheck.json"
90
+
91
+ md_lines = [
92
+ "# Thin-Root platform spot-check (Phase 6.6)",
93
+ "",
94
+ "> AI-council proxy for the manual platform spot-check. Two",
95
+ "> external reviewers (Sonnet 4.5, gpt-4o) simulate a fresh",
96
+ "> agent landing on the refactored AGENTS.md and answer five",
97
+ "> orientation questions from the file alone.",
98
+ "",
99
+ "## Verdicts",
100
+ "",
101
+ ]
102
+
103
+ raw = []
104
+ for r in responses:
105
+ body = r.text or f"<error: {r.error}>"
106
+ raw.append({
107
+ "provider": r.provider,
108
+ "model": r.model,
109
+ "tokens_in": r.input_tokens,
110
+ "tokens_out": r.output_tokens,
111
+ "latency_ms": r.latency_ms,
112
+ "error": r.error,
113
+ "text": body,
114
+ })
115
+ md_lines.append(f"### {r.provider} ({r.model})")
116
+ md_lines.append("")
117
+ md_lines.append(f"- tokens in: {r.input_tokens} · out: {r.output_tokens} · latency: {r.latency_ms}ms")
118
+ if r.error:
119
+ md_lines.append(f"- error: `{r.error}`")
120
+ md_lines.append("")
121
+ md_lines.append("```")
122
+ md_lines.append(body[:8000])
123
+ md_lines.append("```")
124
+ md_lines.append("")
125
+
126
+ md_path.write_text("\n".join(md_lines), encoding="utf-8")
127
+ json_path.write_text(json.dumps(raw, indent=2), encoding="utf-8")
128
+ print(f"✅ Wrote {md_path}", file=sys.stderr)
129
+ print(f"✅ Wrote {json_path}", file=sys.stderr)
130
+ return 0
131
+
132
+
133
+ if __name__ == "__main__":
134
+ raise SystemExit(main())
@@ -67,16 +67,12 @@ TARGETS: list[tuple[str, list[tuple[str, str]]]] = [
67
67
  # the raw file count this script computes.
68
68
  ],
69
69
  ),
70
- (
71
- "AGENTS.md",
72
- [
73
- (r"(skills/\s+\()(\d+)( skills\))", "skills"),
74
- (r"(rules/\s+\()(\d+)( rules\))", "rules"),
75
- (r"(commands/\s+\()(\d+)( commands\))", "commands"),
76
- (r"(guidelines/\s+\()(\d+)( guidelines\))", "guidelines"),
77
- (r"(personas/\s+\()(\d+)( personas\))", "personas"),
78
- ],
79
- ),
70
+ # Note: AGENTS.md previously held the per-directory count annotations
71
+ # (`skills/ (N skills)`, `rules/ (N rules)`, ...). The Thin-Root
72
+ # refactor (Phase 6, road-to-augment-limit-fit, 2026-05-08) made
73
+ # AGENTS.md a navigation-only surface — counts now live in README.md
74
+ # and docs/architecture.md. The corresponding pytest sentinel lives
75
+ # in tests/test_readme_hero_counts.py::test_agents_md_is_thin_root_navigation_surface.
80
76
  (
81
77
  "docs/getting-started.md",
82
78
  [