@event4u/agent-config 1.25.0 → 1.27.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.
@@ -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"):