@event4u/agent-config 1.25.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Shared agent configuration \u2014 skills for AI coding tools (Claude Code, Augment, Cursor, Cline, Windsurf, Gemini CLI).",
9
- "version": "1.25.0"
9
+ "version": "1.26.0"
10
10
  },
11
11
  "plugins": [
12
12
  {
package/CHANGELOG.md CHANGED
@@ -318,6 +318,20 @@ our recommendation order, not its support status.
318
318
  users" tension without removing any path that an existing user
319
319
  might rely on.
320
320
 
321
+ ## [1.26.0](https://github.com/event4u-app/agent-config/compare/1.25.0...1.26.0) (2026-05-08)
322
+
323
+ ### Features
324
+
325
+ * **linter:** replace size heuristics with structural-density model ([95584ac](https://github.com/event4u-app/agent-config/commit/95584ac5e74948b71a9d13ff5ec6870c110be489))
326
+
327
+ ### Documentation
328
+
329
+ * **contracts:** add linter structural model + update size-and-scope ([32fa8b2](https://github.com/event4u-app/agent-config/commit/32fa8b2b7cc65148f7bc28fb782f20670d6640bc))
330
+
331
+ ### Chores
332
+
333
+ * gitignore density logs + archive completed structural-linter roadmap ([0a94ece](https://github.com/event4u-app/agent-config/commit/0a94ece8ac724386a5d49451b1e0d3058f2644cf))
334
+
321
335
  ## [1.25.0](https://github.com/event4u-app/agent-config/compare/1.24.0...1.25.0) (2026-05-08)
322
336
 
323
337
  ### Features
@@ -0,0 +1,180 @@
1
+ ---
2
+ stability: beta
3
+ ---
4
+
5
+ # Linter Structural Model
6
+
7
+ **Status:** LOCKED — shipped 2026-05-08 on
8
+ `feat/road-to-structural-linter-reform`. The linter now applies the
9
+ structural model to skills, rules, and commands.
10
+
11
+ ## Why a structural model
12
+
13
+ Council convergence (Sonnet + GPT-4o, 2026-05-06): raw line / word
14
+ counts produce ratchet drift. Three failure modes that the pure-size
15
+ gate cannot distinguish:
16
+
17
+ - A 500-line skill with **one** 10-step procedure (legitimate) vs a
18
+ 500-line skill with **ten** independent procedures (split candidate).
19
+ - A 1700-word command that **delegates** to a cluster (legitimate
20
+ orchestrator) vs a 1700-word command that **inlines** the work.
21
+ - A 60-line rule whose body is a **verbatim Iron-Law block**
22
+ (legitimate) vs a 60-line rule that is **prose explanation**
23
+ (split candidate).
24
+
25
+ The structural model replaces the size threshold with four primitives.
26
+
27
+ ## Primitives
28
+
29
+ ### 1. Density score (0.0 – 1.0)
30
+
31
+ ```
32
+ density = structured_lines / total_non_blank_lines
33
+ ```
34
+
35
+ `structured_lines` = lines inside fenced blocks + markdown-table rows
36
+ + bullet-list lines + numbered-list lines + section-heading lines.
37
+ Higher = more structured (catalogue, table, code, list); lower =
38
+ prose-dominant.
39
+
40
+ ### 2. Multi-workflow detector (skills only)
41
+
42
+ Skills with **≥ 2 `## Procedure`** (or `## Procedure: <name>`)
43
+ sections ship multiple independently invocable procedures. Combined
44
+ with size, this is the cluster-split signal.
45
+
46
+ ### 3. Delegation detector (commands only)
47
+
48
+ Command has a delegation signal when **either** holds:
49
+
50
+ - frontmatter declares `cluster:` or `routes_to:`
51
+ - body contains ≥ 3 markdown links to other `.md` files
52
+
53
+ Absence of both signals on a large command = inlined logic.
54
+
55
+ ### 4. Iron-Law block detector (rules only)
56
+
57
+ A fenced block is an Iron-Law block when its body has **≥ 30
58
+ alphabetical characters** with **≥ 60 % uppercase** across **≥ 1
59
+ non-empty line**. The 30-character floor filters single ALL-CAPS
60
+ markers (`OK`, `WIP`); the 60 % uppercase floor catches verbatim
61
+ imperatives (`NEVER COMMIT.`).
62
+
63
+ ## Phase 1 calibration (2026-05-08)
64
+
65
+ Sweep covered all 310 lintable artifacts via
66
+ [`scripts/measure_density.py`](../../scripts/measure_density.py); raw
67
+ data lives at `agents/.density-snapshot.jsonl` (local-only — re-run
68
+ `python3 scripts/measure_density.py --root .agent-src --jsonl
69
+ agents/.density-snapshot.jsonl` to regenerate).
70
+
71
+ | Type | Count | Avg density | Median | Bucket [0.4-0.6] | Bucket [0.6-1.0] |
72
+ |---|---|---|---|---|---|
73
+ | skill | 142 | 0.76 | 0.78 | 22 | 119 |
74
+ | command | 103 | 0.59 | 0.57 | 46 | 45 |
75
+ | rule | 58 | 0.47 | 0.48 | 25 | 11 |
76
+ | persona | 7 | 0.38 | 0.38 | 1 | 0 |
77
+
78
+ Iron-Law detector recall on 9 canonical Iron-Law rules: **8 / 9** (all
79
+ except `agent-authority`, which uses a markdown-table index instead of
80
+ a fenced block — correct miss).
81
+
82
+ `quality-tools` (411 lines, single workflow): density **0.83**, single
83
+ procedure → no warning under the new model. ✓ roadmap success criterion.
84
+
85
+ `optimize/augmentignore.md` (1679 words): delegation signal **present**
86
+ (frontmatter `routes_to:`) → no warning under the new model. ✓ roadmap
87
+ success criterion.
88
+
89
+ Of 13 commands ≥ 1000 words, only **2** lack a delegation signal —
90
+ both are candidates for Phase 4.1 review (`compress.md`,
91
+ `project-analyze.md`; the latter has density 0.86, exempt under the
92
+ density-AND-delegation gate).
93
+
94
+ ## Warn rules (shipped Phase 3, 2026-05-08)
95
+
96
+ | Artifact | Warn condition |
97
+ |---|---|
98
+ | **skill** | `lines > 400` AND (`density < 0.6` OR `procedures ≥ 2`) |
99
+ | **command** | `words > 1000` AND no delegation signal AND `density < 0.65` |
100
+ | **rule** | `lines > 60` AND `density < 0.5` AND `iron_law_blocks == 0` |
101
+
102
+ The 200-line rule **error** stays unconditional. No new frontmatter
103
+ keys ship — the four structural primitives are the contract.
104
+
105
+ Calibration sweep on the 2026-05-08 corpus (310 artifacts):
106
+
107
+ | Type | Old warns | New warns | New band | Δ |
108
+ |---|---|---|---|---|
109
+ | rule | 23 | 2 | 3.4 % | −91 % |
110
+ | skill | 2 | 1 | 0.7 % | −50 % |
111
+ | command | 9 | 1 | 1.0 % | −89 % |
112
+ | **total** | **34** | **4** | **1.3 %** | **−88 %** |
113
+
114
+ Pass rate: 186 → 209 (`pass`); 124 → 101 (`pass_with_warnings`); 0
115
+ errors. Each remaining warning is a genuine structural defect:
116
+
117
+ - `compress.md` (1569 words, density 0.58, no delegation signal) —
118
+ inlined logic in a non-orchestrator command.
119
+ - `artifact-drafting-protocol.md` rule (65 lines, density 0.37, no
120
+ Iron-Law block) — prose-dominant long rule.
121
+ - `minimal-safe-diff.md` rule (69 lines, density 0.41, no Iron-Law
122
+ block) — prose-dominant long rule.
123
+ - `ai-council/SKILL.md` (525 lines, density 0.37) — orchestrator
124
+ skill below the density floor; refactor candidate.
125
+
126
+ Roadmap target ≤ 10 % rule-warning band. ✓ (3.4 %)
127
+
128
+ ## Frontmatter contract — Phase 2 decisions (2026-05-08)
129
+
130
+ AI Council run (Claude Sonnet 4.5 + GPT-4o, 2 rounds, $0.046; raw
131
+ transcript local-only per the council-references convention).
132
+
133
+ **Key 1 — `iron_law:` frontmatter — DECISION: Option A (auto-detect, no tag).**
134
+
135
+ Both council members converged on Option A. The detector recall on
136
+ the canonical 9-rule set is 8 / 9, and the one miss
137
+ (`agent-authority`) uses a markdown-table priority index that is
138
+ **not** an Iron-Law imperative — its body delegates to the rules it
139
+ indexes. The detector is correct to skip it. No `iron_law:`
140
+ frontmatter key is added.
141
+
142
+ **Key 2 — `density_exempt:` frontmatter — DECISION: Option A (no flag).**
143
+
144
+ Council split:
145
+
146
+ - Sonnet 4.5: Reject any flag. Add **type-based density floors**
147
+ (orchestrators 0.35, executors 0.6, imperatives 0.4) so the
148
+ detector classifies structurally instead of relying on author
149
+ declarations.
150
+ - GPT-4o: Adopt Option C (`density_exempt: true` + required
151
+ `density_exempt_reason:`) with periodic re-audit.
152
+
153
+ Sonnet's structural argument carries: an escape hatch for a 1-in-142
154
+ corpus case ships maintenance debt across every future artifact that
155
+ brushes the boundary. The single failing skill (`ai-council`,
156
+ density 0.36) is a documentation-heavy reference-orchestrator and is
157
+ left as a Phase-4 review candidate — either restructure the skill or
158
+ add orchestrator-aware type-floors as a follow-up. No
159
+ `density_exempt:` key is added in Phase 3.
160
+
161
+ The Phase-3 implementation therefore ships **zero new frontmatter
162
+ keys** — the structural primitives are the contract.
163
+
164
+ ## Out of scope
165
+
166
+ - Hard error thresholds beyond the 200-line rule cap.
167
+ - Automatic refactoring of artifacts that fail the new model.
168
+ - Cross-artifact dependency counts (a skill linking 4 other skills is
169
+ `routes_to` doing its job, not a defect).
170
+
171
+ ## References
172
+
173
+ - `scripts/measure_density.py` — Phase 1.1 measurement tool.
174
+ - `agents/.density-snapshot.jsonl` — full per-artifact metrics
175
+ (gitignored, re-run the measurement script to regenerate).
176
+ - `scripts/skill_linter.py` — structural-model implementation
177
+ (`_density_score`, `_count_procedure_sections`,
178
+ `_command_delegation_signal`, `_iron_law_blocks`).
179
+ - `docs/guidelines/agent-infra/size-and-scope.md` — guideline now
180
+ describes the structural model; Option 2 transition notes removed.
@@ -33,10 +33,14 @@ Size is a signal — not the goal.
33
33
  - Acceptable: **< 100–120 lines**
34
34
  - Hard limit: **< 200 lines**
35
35
 
36
- Linter (council review 2026-05-06): the > 40 / > 60 line warnings are
37
- **density-gated** — rules with ≥ 30 % fenced content (verbatim Iron-Law
38
- blocks, worked-example fences) are exempt from the line-count warning.
39
- The 200-line hard error stays unconditional.
36
+ Linter (structural model, 2026-05-08 see
37
+ [`docs/contracts/linter-structural-model.md`](../../contracts/linter-structural-model.md)):
38
+ the long-rule warning fires only when the rule is **> 60 non-empty
39
+ lines AND density < 0.50 AND ships no Iron-Law block**. Rules whose
40
+ body is a verbatim ALL-CAPS imperative (`commit-policy`,
41
+ `ask-when-uncertain`, `direct-answers`) are auto-exempt — no
42
+ frontmatter flag required. The 200-line hard error stays
43
+ unconditional.
40
44
 
41
45
  Reason:
42
46
  - Loaded frequently
@@ -48,10 +52,11 @@ Reason:
48
52
  ## Skills
49
53
 
50
54
  - Target: **300–900 words**
51
- - Warning: **> 400 lines** (raised from 300, council review 2026-05-06)
52
- - Strong split signal: reference-rich skills (analyzer, quality-tool
53
- catalog, council orchestration) may legitimately sit between 300 and
54
- 400 lines without being split-candidates
55
+ - Warning: **> 400 lines AND (density < 0.60 OR 2 `## Procedure`
56
+ blocks)** structural model, 2026-05-08
57
+ - Reference-rich skills with high density (`quality-tools` at 0.83,
58
+ catalogue-style skills) pass without splitting; the multi-procedure
59
+ trigger flags genuine cluster-split candidates regardless of size
55
60
 
56
61
  Focus:
57
62
  - scanability
@@ -64,10 +69,11 @@ Focus:
64
69
 
65
70
  - Target: **200–600 words**
66
71
  - Acceptable: **up to ~1000 words**
67
- - Warning: **> 1000 words AND lacks delegation structure** (< 5
68
- sub-sections OR < 3 code blocks). Well-factored orchestrators with 5
69
- sub-sections AND 3 code blocks are exempt the size reflects
70
- dispatch breadth, not bloat (council review 2026-05-06).
72
+ - Warning: **> 1000 words AND no delegation signal AND density < 0.65**
73
+ structural model, 2026-05-08. A delegation signal is either
74
+ frontmatter (`cluster:` / `routes_to:`) OR 3 markdown links to
75
+ other `.md` files. Well-factored orchestrators pass automatically;
76
+ inlined logic in a non-orchestrator command warns.
71
77
 
72
78
  Commands orchestrate — not implement.
73
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "Shared agent configuration \u2014 skills, rules, commands, guidelines, and templates for AI coding tools",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -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())
@@ -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:
@@ -1021,19 +1135,26 @@ def lint_rule(path: Path, text: str) -> LintResult:
1021
1135
  if DOUBLE_BLANK_PATTERN.search(text):
1022
1136
  issues.append(Issue("warning", "double_blank_lines", "File contains double or triple blank lines"))
1023
1137
 
1024
- # --- Content checks (see guidelines/agent-infra/size-and-scope.md) ---
1025
- # Length thresholds gated by fenced-content density (council review 2026-05-06):
1026
- # rules dominated by verbatim Iron-Law blocks / worked examples are protected
1027
- # 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.
1028
1143
  line_count = len([line for line in text.splitlines() if line.strip()])
1029
1144
  total_lines = len(text.splitlines())
1030
- fenced_ratio = _fenced_content_ratio(text)
1031
1145
  if total_lines > 200:
1032
1146
  issues.append(Issue("error", "rule_too_large", f"Rule has {total_lines} lines (hard limit: 200); must split or move to guideline"))
1033
- elif line_count > 60 and fenced_ratio < 0.30:
1034
- 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)"))
1035
- elif line_count > 40 and fenced_ratio < 0.30:
1036
- 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
+ ))
1037
1158
 
1038
1159
  for bad_sign in RULE_BAD_SIGNS:
1039
1160
  if bad_sign in text:
@@ -1177,17 +1298,25 @@ def lint_command(path: Path, text: str) -> LintResult:
1177
1298
  if not has_steps and not has_numbered:
1178
1299
  issues.append(Issue("warning", "no_steps", "Command has no Steps section or numbered sub-headings"))
1179
1300
 
1180
- # --- Size check (see guidelines/agent-infra/size-and-scope.md) ---
1181
- # Word threshold (1000) gated by structural delegation signal (council review
1182
- # 2026-05-06): well-factored orchestrators with ≥ 5 sub-sections AND ≥ 3 code
1183
- # 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.
1184
1307
  word_count = len(text.split())
1185
1308
  if word_count > 1000:
1186
- section_count = len(sections)
1187
- code_block_count = _count_code_blocks(text)
1188
- delegation_signal = section_count >= 5 and code_block_count >= 3
1189
- if not delegation_signal:
1190
- 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
+ ))
1191
1320
 
1192
1321
  # File must end with exactly one newline
1193
1322
  if not text.endswith("\n"):