@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +14 -0
- package/docs/contracts/linter-structural-model.md +180 -0
- package/docs/guidelines/agent-infra/size-and-scope.md +18 -12
- package/package.json +1 -1
- package/scripts/measure_density.py +232 -0
- package/scripts/skill_linter.py +156 -27
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 (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
@@ -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())
|
package/scripts/skill_linter.py
CHANGED
|
@@ -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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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 (
|
|
565
|
-
#
|
|
566
|
-
#
|
|
567
|
-
#
|
|
568
|
-
#
|
|
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
|
-
|
|
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 (
|
|
1025
|
-
#
|
|
1026
|
-
#
|
|
1027
|
-
#
|
|
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
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
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 (
|
|
1181
|
-
#
|
|
1182
|
-
# 2026-05-
|
|
1183
|
-
#
|
|
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
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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"):
|