@event4u/agent-config 1.16.0 → 1.18.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/.agent-src/commands/{agents-audit.md → agents/audit.md} +4 -3
- package/.agent-src/commands/{agents-cleanup.md → agents/cleanup.md} +12 -6
- package/.agent-src/commands/{agents-prepare.md → agents/prepare.md} +4 -3
- package/.agent-src/commands/agents.md +46 -0
- package/.agent-src/commands/{chat-history-checkpoint.md → chat-history/checkpoint.md} +4 -4
- package/.agent-src/commands/{chat-history-clear.md → chat-history/clear.md} +4 -4
- package/.agent-src/commands/{chat-history-resume.md → chat-history/resume.md} +4 -4
- package/.agent-src/commands/chat-history/show.md +107 -0
- package/.agent-src/commands/chat-history.md +33 -89
- package/.agent-src/commands/{commit-in-chunks.md → commit/in-chunks.md} +15 -13
- package/.agent-src/commands/commit.md +22 -2
- package/.agent-src/commands/{context-create.md → context/create.md} +4 -3
- package/.agent-src/commands/{context-refactor.md → context/refactor.md} +4 -3
- package/.agent-src/commands/context.md +44 -0
- package/.agent-src/commands/{copilot-agents-init.md → copilot-agents/init.md} +4 -3
- package/.agent-src/commands/{copilot-agents-optimize.md → copilot-agents/optimize.md} +4 -3
- package/.agent-src/commands/copilot-agents.md +44 -0
- package/.agent-src/commands/council/default.md +221 -0
- package/.agent-src/commands/{council-design.md → council/design.md} +6 -5
- package/.agent-src/commands/{council-optimize.md → council/optimize.md} +7 -6
- package/.agent-src/commands/{council-pr.md → council/pr.md} +6 -5
- package/.agent-src/commands/council.md +47 -212
- package/.agent-src/commands/{create-pr-description.md → create-pr/description-only.md} +4 -2
- package/.agent-src/commands/create-pr.md +26 -5
- package/.agent-src/commands/{feature-dev.md → feature/dev.md} +5 -10
- package/.agent-src/commands/{feature-explore.md → feature/explore.md} +4 -8
- package/.agent-src/commands/{feature-plan.md → feature/plan.md} +4 -8
- package/.agent-src/commands/{feature-refactor.md → feature/refactor.md} +4 -8
- package/.agent-src/commands/{feature-roadmap.md → feature/roadmap.md} +6 -10
- package/.agent-src/commands/feature.md +6 -12
- package/.agent-src/commands/{fix-ci.md → fix/ci.md} +4 -8
- package/.agent-src/commands/{fix-portability.md → fix/portability.md} +4 -8
- package/.agent-src/commands/{fix-pr-bot-comments.md → fix/pr-bots.md} +4 -8
- package/.agent-src/commands/{fix-pr-developer-comments.md → fix/pr-developers.md} +4 -8
- package/.agent-src/commands/{fix-pr-comments.md → fix/pr.md} +7 -11
- package/.agent-src/commands/{fix-references.md → fix/refs.md} +4 -8
- package/.agent-src/commands/{fix-seeder.md → fix/seeder.md} +4 -8
- package/.agent-src/commands/fix.md +7 -13
- package/.agent-src/commands/{do-and-judge.md → judge/on-diff.md} +4 -3
- package/.agent-src/commands/judge/solo.md +90 -0
- package/.agent-src/commands/{do-in-steps.md → judge/steps.md} +4 -3
- package/.agent-src/commands/judge.md +35 -70
- package/.agent-src/commands/{memory-add.md → memory/add.md} +4 -3
- package/.agent-src/commands/{memory-full.md → memory/load.md} +4 -3
- package/.agent-src/commands/{memory-promote.md → memory/promote.md} +4 -3
- package/.agent-src/commands/{propose-memory.md → memory/propose.md} +4 -3
- package/.agent-src/commands/memory.md +48 -0
- package/.agent-src/commands/{module-create.md → module/create.md} +4 -3
- package/.agent-src/commands/{module-explore.md → module/explore.md} +4 -3
- package/.agent-src/commands/module.md +44 -0
- package/.agent-src/commands/{optimize-agents.md → optimize/agents.md} +4 -8
- package/.agent-src/commands/{optimize-augmentignore.md → optimize/augmentignore.md} +4 -9
- package/.agent-src/commands/{optimize-rtk-filters.md → optimize/rtk.md} +4 -8
- package/.agent-src/commands/{optimize-skills.md → optimize/skills.md} +4 -8
- package/.agent-src/commands/optimize.md +4 -10
- package/.agent-src/commands/{override-create.md → override/create.md} +4 -3
- package/.agent-src/commands/{override-manage.md → override/manage.md} +4 -3
- package/.agent-src/commands/override.md +44 -0
- package/.agent-src/commands/{roadmap-create.md → roadmap/create.md} +4 -3
- package/.agent-src/commands/{roadmap-execute.md → roadmap/execute.md} +4 -3
- package/.agent-src/commands/roadmap.md +44 -0
- package/.agent-src/commands/{tests-create.md → tests/create.md} +4 -3
- package/.agent-src/commands/{tests-execute.md → tests/execute.md} +4 -3
- package/.agent-src/commands/tests.md +44 -0
- package/.agent-src/contexts/communication/rules-auto/artifact-engagement-recording-mechanics.md +72 -0
- package/.agent-src/contexts/communication/rules-auto/augment-portability-mechanics.md +79 -0
- package/.agent-src/contexts/communication/rules-auto/augment-source-of-truth-mechanics.md +98 -0
- package/.agent-src/contexts/communication/rules-auto/cli-output-handling-mechanics.md +87 -0
- package/.agent-src/contexts/communication/rules-auto/command-suggestion-policy-mechanics.md +62 -0
- package/.agent-src/contexts/communication/rules-auto/docs-sync-mechanics.md +78 -0
- package/.agent-src/contexts/communication/rules-auto/package-ci-checks-mechanics.md +85 -0
- package/.agent-src/contexts/communication/rules-auto/review-routing-awareness-mechanics.md +65 -0
- package/.agent-src/contexts/communication/rules-auto/roadmap-progress-sync-mechanics.md +78 -0
- package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +62 -0
- package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +55 -0
- package/.agent-src/contexts/communication/rules-auto/ui-audit-gate-mechanics.md +53 -0
- package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +77 -0
- package/.agent-src/contexts/judges/no-consolidate-rationale.md +102 -0
- package/.agent-src/contexts/judges/persona-voice-rubric.md +140 -0
- package/.agent-src/rules/artifact-engagement-recording.md +13 -69
- package/.agent-src/rules/ask-when-uncertain.md +27 -42
- package/.agent-src/rules/augment-portability.md +15 -61
- package/.agent-src/rules/augment-source-of-truth.md +27 -93
- package/.agent-src/rules/cli-output-handling.md +10 -76
- package/.agent-src/rules/command-suggestion-policy.md +18 -59
- package/.agent-src/rules/commit-conventions.md +17 -14
- package/.agent-src/rules/context-hygiene.md +6 -0
- package/.agent-src/rules/direct-answers.md +35 -59
- package/.agent-src/rules/docker-commands.md +5 -5
- package/.agent-src/rules/docs-sync.md +15 -69
- package/.agent-src/rules/language-and-tone.md +48 -72
- package/.agent-src/rules/missing-tool-handling.md +28 -22
- package/.agent-src/rules/no-cheap-questions.md +39 -53
- package/.agent-src/rules/no-roadmap-references.md +73 -0
- package/.agent-src/rules/onboarding-gate.md +7 -0
- package/.agent-src/rules/package-ci-checks.md +21 -61
- package/.agent-src/rules/preservation-guard.md +64 -29
- package/.agent-src/rules/review-routing-awareness.md +24 -43
- package/.agent-src/rules/roadmap-progress-sync.md +31 -65
- package/.agent-src/rules/rule-type-governance.md +28 -0
- package/.agent-src/rules/security-sensitive-stop.md +8 -8
- package/.agent-src/rules/skill-quality.md +16 -48
- package/.agent-src/rules/slash-command-routing-policy.md +7 -4
- package/.agent-src/rules/think-before-action.md +52 -42
- package/.agent-src/rules/tool-safety.md +19 -16
- package/.agent-src/rules/ui-audit-gate.md +24 -38
- package/.agent-src/rules/user-interaction.md +13 -68
- package/.agent-src/skills/ai-council/SKILL.md +2 -0
- package/.agent-src/skills/api-testing/SKILL.md +1 -1
- package/.agent-src/skills/check-refs/SKILL.md +59 -40
- package/.agent-src/skills/conventional-commits-writing/SKILL.md +86 -28
- package/.agent-src/skills/copilot-agents-optimization/SKILL.md +5 -5
- package/.agent-src/skills/developer-like-execution/SKILL.md +4 -4
- package/.agent-src/skills/finishing-a-development-branch/SKILL.md +101 -65
- package/.agent-src/skills/flux/SKILL.md +30 -10
- package/.agent-src/skills/github-ci/SKILL.md +2 -2
- package/.agent-src/skills/judge-code-quality/SKILL.md +7 -8
- package/.agent-src/skills/judge-security-auditor/SKILL.md +4 -5
- package/.agent-src/skills/judge-test-coverage/SKILL.md +3 -4
- package/.agent-src/skills/lint-skills/SKILL.md +57 -39
- package/.agent-src/skills/md-language-check/SKILL.md +61 -39
- package/.agent-src/skills/override-management/SKILL.md +5 -5
- package/.agent-src/skills/quality-tools/SKILL.md +2 -2
- package/.agent-src/skills/react-shadcn-ui/SKILL.md +116 -43
- package/.agent-src/skills/readme-reviewer/SKILL.md +30 -29
- package/.agent-src/skills/readme-writing/SKILL.md +78 -53
- package/.agent-src/skills/readme-writing-package/SKILL.md +50 -47
- package/.agent-src/skills/receiving-code-review/SKILL.md +52 -47
- package/.agent-src/skills/refine-prompt/SKILL.md +0 -1
- package/.agent-src/skills/requesting-code-review/SKILL.md +35 -30
- package/.agent-src/skills/security/SKILL.md +7 -2
- package/.agent-src/skills/security-audit/SKILL.md +7 -3
- package/.agent-src/skills/systematic-debugging/SKILL.md +68 -60
- package/.agent-src/skills/test-driven-development/SKILL.md +59 -57
- package/.agent-src/skills/test-performance/SKILL.md +0 -1
- package/.agent-src/skills/traefik/SKILL.md +4 -4
- package/.agent-src/skills/verify-completion-evidence/SKILL.md +28 -26
- package/.agent-src/templates/roadmaps.md +4 -0
- package/.claude-plugin/marketplace.json +22 -11
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +125 -1
- package/README.md +18 -17
- package/docs/architecture.md +4 -6
- package/docs/catalog.md +67 -39
- package/docs/contracts/STABILITY.md +13 -7
- package/docs/contracts/adr-chat-history-split.md +1 -3
- package/docs/contracts/adr-command-suggestion.md +0 -2
- package/docs/contracts/adr-implement-ticket-runtime.md +1 -2
- package/docs/contracts/adr-product-ui-track.md +3 -6
- package/docs/contracts/adr-prompt-driven-execution.md +3 -4
- package/docs/contracts/agent-memory-contract.md +6 -11
- package/docs/contracts/artifact-engagement-flow.md +6 -9
- package/docs/contracts/command-clusters.md +56 -46
- package/docs/contracts/command-suggestion-flow.md +1 -3
- package/docs/contracts/context-paths.md +99 -0
- package/docs/contracts/file-ownership-matrix.json +6722 -0
- package/docs/contracts/file-ownership-matrix.md +134 -0
- package/docs/contracts/implement-ticket-flow.md +6 -9
- package/docs/contracts/linear-ai-rules-inclusion.md +0 -1
- package/docs/contracts/linear-ai-three-layers.md +0 -2
- package/docs/contracts/load-context-budget-model.md +258 -0
- package/docs/contracts/load-context-schema.md +21 -3
- package/docs/contracts/roadmap-complexity-standard.md +137 -0
- package/docs/contracts/rule-interactions.md +0 -1
- package/docs/contracts/rule-priority-hierarchy.md +1 -1
- package/docs/contracts/ui-track-flow.md +7 -17
- package/docs/customization.md +2 -0
- package/docs/getting-started.md +5 -4
- package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +134 -0
- package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +100 -0
- package/docs/guidelines/agent-infra/direct-answers-demos.md +145 -0
- package/docs/guidelines/agent-infra/verify-before-complete-demos.md +128 -0
- package/package.json +1 -1
- package/scripts/_phase2_shim_helper.py +109 -0
- package/scripts/agent-config +30 -0
- package/scripts/ai_council/one_off_archive/2026-05/README.md +45 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_2a4_acceptance.py +208 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_budget_v2_audit.py +206 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_context_layer_v1_estimate.py +67 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_context_layer_v1_review.py +292 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_followups_review.py +259 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_nondestructive_inline_audit.py +209 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_phase4_dispatch_latency.py +108 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_phase6_trigger_jaccard.py +92 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_phase_2a_budget_rebalance.py +257 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_phase_2a_post_revert.py +197 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_rule_hardening_v1.py +251 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_structural_open_questions.py +232 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_structural_optimization.py +144 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_structural_v3_gaps.py +252 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_structural_v3_review.py +240 -0
- package/scripts/build_rule_trigger_matrix.py +360 -0
- package/scripts/check_always_budget.py +402 -45
- package/scripts/check_cluster_patterns.py +159 -0
- package/scripts/check_command_count_messaging.py +14 -7
- package/scripts/check_context_paths.py +201 -0
- package/scripts/check_no_roadmap_refs.py +155 -0
- package/scripts/check_one_off_location.py +81 -0
- package/scripts/check_phase_coupling.py +148 -0
- package/scripts/check_portability.py +2 -0
- package/scripts/check_references.py +35 -2
- package/scripts/check_safety_floor_untouched.py +125 -0
- package/scripts/command_suggester/loader.py +4 -1
- package/scripts/compress.py +64 -15
- package/scripts/context_hygiene_hook.py +173 -0
- package/scripts/generate_index.py +6 -2
- package/scripts/generate_ownership_matrix.py +323 -0
- package/scripts/hooks/augment-context-hygiene.sh +55 -0
- package/scripts/hooks/augment-onboarding-gate.sh +55 -0
- package/scripts/hooks/augment-roadmap-progress.sh +57 -0
- package/scripts/install.py +105 -45
- package/scripts/lint_examples.py +98 -0
- package/scripts/lint_no_new_atomic_commands.py +12 -11
- package/scripts/lint_roadmap_complexity.py +127 -0
- package/scripts/onboarding_gate_hook.py +137 -0
- package/scripts/requirements-evals.txt +1 -0
- package/scripts/roadmap_progress_hook.py +159 -0
- package/scripts/schemas/command.schema.json +4 -3
- package/scripts/schemas/rule.schema.json +5 -0
- package/scripts/skill_linter.py +1 -0
- package/scripts/sync_agent_settings.py +25 -2
- package/scripts/update_counts.py +7 -0
- /package/scripts/ai_council/{_one_off_rebalancing_audit.py → one_off_archive/2026-05/_one_off_rebalancing_audit.py} +0 -0
- /package/scripts/ai_council/{_one_off_roundtrip.py → one_off_archive/2026-05/_one_off_roundtrip.py} +0 -0
|
@@ -1,50 +1,273 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Always-rule budget gate (Phases 7.1 + 7.4 of road-to-pr-34-followups
|
|
2
|
+
"""Always-rule budget gate (Phases 7.1 + 7.4 of road-to-pr-34-followups,
|
|
3
|
+
extended by Phase 0.2 of road-to-structural-optimization).
|
|
3
4
|
|
|
4
|
-
Enforces
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Enforces the budget contract under **Model (b) literal** — see
|
|
6
|
+
`docs/contracts/load-context-budget-model.md`. Effective size of a
|
|
7
|
+
`type: "always"` rule is its own char count plus the char count of
|
|
8
|
+
every context it loads (transitively, depth ≤ 2).
|
|
7
9
|
|
|
10
|
+
Caps:
|
|
8
11
|
- Warn-at-80% / fail-at-90% global trend gate (Phase 7.1).
|
|
9
|
-
- Per-rule cap (≤ 6,000 chars per always-rule, Phase 7.4)
|
|
10
|
-
|
|
12
|
+
- Per-rule cap (≤ 6,000 chars per always-rule, Phase 7.4) — measured
|
|
13
|
+
on extended size, with a transitional `KNOWN_PER_RULE_BREACHES`
|
|
14
|
+
allowlist that Phase 2A retires.
|
|
15
|
+
- Top-3 cap (top-3 combined ≤ 50% of TOTAL_CAP, Phase 7.4) — extended.
|
|
16
|
+
- Depth-2 nesting cap on `load_context:` chains.
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
Exit codes: 0 = pass (or warn), 1 = fail (≥ 90% utilization,
|
|
17
|
-
per-rule breach, or top-3 breach), 3 = internal error.
|
|
18
|
+
Exit codes: 0 = pass (or warn), 1 = fail (≥ 90% utilization, per-rule
|
|
19
|
+
breach above ceiling, top-3 breach, or depth violation), 3 = internal
|
|
20
|
+
error.
|
|
18
21
|
"""
|
|
19
22
|
|
|
20
23
|
from __future__ import annotations
|
|
21
24
|
|
|
22
25
|
import argparse
|
|
26
|
+
import json
|
|
23
27
|
import sys
|
|
28
|
+
from datetime import datetime, timezone
|
|
24
29
|
from pathlib import Path
|
|
25
30
|
|
|
31
|
+
import yaml
|
|
32
|
+
|
|
26
33
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
27
34
|
RULES_DIR = REPO_ROOT / ".agent-src" / "rules"
|
|
35
|
+
SRC_PREFIX = ".agent-src.uncompressed/"
|
|
36
|
+
COMP_PREFIX = ".agent-src/"
|
|
28
37
|
|
|
29
38
|
TOTAL_CAP = 49_000
|
|
30
|
-
WARN_THRESHOLD = 0.80
|
|
31
|
-
FAIL_THRESHOLD = 0.90
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
WARN_THRESHOLD = 0.80
|
|
40
|
+
FAIL_THRESHOLD = 0.90
|
|
41
|
+
|
|
42
|
+
# Phase 5.2.1 concentration thresholds (non-safety-floor rules only).
|
|
43
|
+
# Beyond the total-budget cap, fail CI when any single non-safety-floor
|
|
44
|
+
# rule exceeds SINGLE_PCT of used budget OR the top-3 non-safety-floor
|
|
45
|
+
# sum exceeds TOP3_PCT of used budget. Prevents post-slim concentration
|
|
46
|
+
# regrowth (risk #12 in road-to-structural-optimization).
|
|
47
|
+
CONCENTRATION_SINGLE_PCT = 0.12
|
|
48
|
+
CONCENTRATION_TOP3_PCT = 0.30
|
|
49
|
+
|
|
50
|
+
# Q3=A locked safety-floor rules — out of scope for slimming and for the
|
|
51
|
+
# concentration check. Their size is intentional (Iron Laws + obligation
|
|
52
|
+
# surface), not drift. See road-to-structural-optimization Phase 5.
|
|
53
|
+
SAFETY_FLOOR_RULES: frozenset[str] = frozenset({
|
|
54
|
+
"non-destructive-by-default.md",
|
|
55
|
+
"commit-policy.md",
|
|
56
|
+
"scope-control.md",
|
|
57
|
+
"verify-before-complete.md",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
# Phase 5.3 — per-rule trend log. JSONL, one record per linter run.
|
|
61
|
+
# Each line: {"ts": iso8601, "total": int, "rules": {name: ext}}.
|
|
62
|
+
TREND_LOG = REPO_ROOT / ".github" / "budget-trend.jsonl"
|
|
63
|
+
TREND_LOG_MAX_RECORDS = 500
|
|
64
|
+
# Phase 0.2 G3 tolerance band — overshoot ≤ 2 % of cap is accepted by
|
|
65
|
+
# the model (b) contract; > 2 % rejects model (b) and escalates. The
|
|
66
|
+
# linter treats the [100 %, 100 % + tolerance] window as a hardened
|
|
67
|
+
# WARN that documents the transition; Phase 2A drops total below 100 %.
|
|
68
|
+
TOLERANCE_BAND = 0.02
|
|
69
|
+
PER_RULE_CAP = 6_000
|
|
70
|
+
TOP3_CAP = TOTAL_CAP // 2
|
|
71
|
+
MAX_DEPTH = 2
|
|
72
|
+
# Phase 1.3 Q2 (road-to-context-layer-maturity) — per-rule context count
|
|
73
|
+
# cap. Counts top-level `load_context:` + `load_context_eager:` entries
|
|
74
|
+
# per rule (not transitive depth). Empirical max in the rule set is 3
|
|
75
|
+
# (autonomous-execution); a 4th declared context is the structural
|
|
76
|
+
# signal that the rule should split, not load more.
|
|
77
|
+
MAX_CONTEXTS_PER_RULE = 3
|
|
78
|
+
|
|
79
|
+
# Recovery band (AI Council session 2026-05-03T12-02-42Z, verdict A1).
|
|
80
|
+
# When enabled, a branch in the 90–100 % gap zone passes as WARN iff its
|
|
81
|
+
# extended total is strictly below the last-green main baseline AND every
|
|
82
|
+
# per-rule / top-3 / depth cap holds. Resolves the paradox where main at
|
|
83
|
+
# 100.6 % passed via TOLERANCE_BAND while a strictly-better branch at
|
|
84
|
+
# 96.8 % failed the gap-zone gate. Phase 5 of road-to-structural-
|
|
85
|
+
# optimization flips this to False and enforces total < TOTAL_CAP strictly.
|
|
86
|
+
RECOVERY_BAND_ENABLED = True
|
|
87
|
+
BASELINE_FILE = REPO_ROOT / ".github" / "budget-baseline.txt"
|
|
88
|
+
|
|
89
|
+
# Transitional allowlist — per-rule extended-size breaches that Phase 2A
|
|
90
|
+
# of road-to-structural-optimization is contracted to retire. Each entry
|
|
91
|
+
# records the measured ceiling on the day Phase 0.2 was committed; a
|
|
92
|
+
# growth above the ceiling fails CI even while the entry remains.
|
|
93
|
+
# When Phase 2A retires a rule, drop its entry here AND in
|
|
94
|
+
# `tests/test_always_budget.py::KNOWN_PER_RULE_BREACHES`.
|
|
95
|
+
KNOWN_PER_RULE_BREACHES: dict[str, int] = {
|
|
96
|
+
"non-destructive-by-default.md": 7_887,
|
|
97
|
+
"scope-control.md": 8_529,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _load_baseline() -> int | None:
|
|
102
|
+
"""Return the last-green main baseline char total, or None if absent.
|
|
103
|
+
|
|
104
|
+
Reads `.github/budget-baseline.txt`; the first non-comment, non-blank
|
|
105
|
+
line is parsed as an integer. Missing file or malformed content
|
|
106
|
+
disables the recovery band silently — the linter falls back to the
|
|
107
|
+
pre-band gate.
|
|
108
|
+
"""
|
|
109
|
+
if not BASELINE_FILE.exists():
|
|
110
|
+
return None
|
|
111
|
+
for line in BASELINE_FILE.read_text(encoding="utf-8").splitlines():
|
|
112
|
+
line = line.strip()
|
|
113
|
+
if not line or line.startswith("#"):
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
return int(line)
|
|
117
|
+
except ValueError:
|
|
118
|
+
return None
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _frontmatter(path: Path) -> dict:
|
|
123
|
+
text = path.read_text(encoding="utf-8")
|
|
124
|
+
if not text.startswith("---\n"):
|
|
125
|
+
return {}
|
|
126
|
+
end = text.find("\n---\n", 4)
|
|
127
|
+
if end == -1:
|
|
128
|
+
return {}
|
|
129
|
+
try:
|
|
130
|
+
return yaml.safe_load(text[4:end]) or {}
|
|
131
|
+
except yaml.YAMLError:
|
|
132
|
+
return {}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_always(path: Path) -> bool:
|
|
136
|
+
return _frontmatter(path).get("type") == "always"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _load_context_paths(path: Path) -> list[str]:
|
|
140
|
+
fm = _frontmatter(path)
|
|
141
|
+
out: list[str] = []
|
|
142
|
+
for key in ("load_context", "load_context_eager"):
|
|
143
|
+
for entry in fm.get(key) or []:
|
|
144
|
+
out.append(str(entry))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _src_to_compressed(entry: str) -> Path:
|
|
149
|
+
if entry.startswith(SRC_PREFIX):
|
|
150
|
+
return REPO_ROOT / (COMP_PREFIX + entry[len(SRC_PREFIX):])
|
|
151
|
+
return REPO_ROOT / entry
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _walk_contexts(rule: Path) -> tuple[set[Path], list[tuple[str, str]]]:
|
|
155
|
+
"""Return (set of context files counted, list of depth-violation chains)."""
|
|
156
|
+
seen: set[Path] = set()
|
|
157
|
+
violations: list[tuple[str, str]] = []
|
|
158
|
+
stack: list[tuple[Path, int, str]] = [(rule, 0, rule.name)]
|
|
159
|
+
while stack:
|
|
160
|
+
node, depth, chain = stack.pop()
|
|
161
|
+
for entry in _load_context_paths(node):
|
|
162
|
+
comp = _src_to_compressed(entry)
|
|
163
|
+
new_chain = f"{chain} → {entry}"
|
|
164
|
+
if depth + 1 > MAX_DEPTH:
|
|
165
|
+
violations.append((rule.name, new_chain))
|
|
166
|
+
continue
|
|
167
|
+
if not comp.exists():
|
|
168
|
+
continue
|
|
169
|
+
if comp in seen:
|
|
170
|
+
continue
|
|
171
|
+
seen.add(comp)
|
|
172
|
+
stack.append((comp, depth + 1, new_chain))
|
|
173
|
+
return seen, violations
|
|
34
174
|
|
|
35
175
|
|
|
36
176
|
def _always_rules() -> list[Path]:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
177
|
+
return sorted(p for p in RULES_DIR.glob("*.md") if _is_always(p))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _all_rules() -> list[Path]:
|
|
181
|
+
return sorted(RULES_DIR.glob("*.md"))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _context_count(rule: Path) -> int:
|
|
185
|
+
fm = _frontmatter(rule)
|
|
186
|
+
lazy = fm.get("load_context") or []
|
|
187
|
+
eager = fm.get("load_context_eager") or []
|
|
188
|
+
return (len(lazy) if isinstance(lazy, list) else 0) + (
|
|
189
|
+
len(eager) if isinstance(eager, list) else 0
|
|
190
|
+
)
|
|
43
191
|
|
|
44
192
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
193
|
+
def _per_rule_count_breaches() -> list[tuple[str, int]]:
|
|
194
|
+
"""Phase 1.3 Q2 — return rules whose declared context count exceeds the cap."""
|
|
195
|
+
out: list[tuple[str, int]] = []
|
|
196
|
+
for rule in _all_rules():
|
|
197
|
+
n = _context_count(rule)
|
|
198
|
+
if n > MAX_CONTEXTS_PER_RULE:
|
|
199
|
+
out.append((rule.name, n))
|
|
200
|
+
return out
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _extended_size(rule: Path) -> tuple[int, list[tuple[str, str]]]:
|
|
204
|
+
raw = rule.stat().st_size
|
|
205
|
+
contexts, violations = _walk_contexts(rule)
|
|
206
|
+
ext = raw + sum(c.stat().st_size for c in contexts)
|
|
207
|
+
return ext, violations
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _concentration_check(
|
|
211
|
+
sizes: list[tuple[str, int, int]],
|
|
212
|
+
total_ext: int,
|
|
213
|
+
) -> tuple[list[tuple[str, int, float]], tuple[int, float] | None]:
|
|
214
|
+
"""Phase 5.2.1 concentration check (non-safety-floor rules only).
|
|
215
|
+
|
|
216
|
+
Returns (single-rule breaches, top-3 breach or None). Q3=A locked
|
|
217
|
+
safety-floor rules are excluded from both numerator and the top-3
|
|
218
|
+
selection — their size is intentional, not drift.
|
|
219
|
+
"""
|
|
220
|
+
non_floor = [
|
|
221
|
+
(name, raw, ext) for name, raw, ext in sizes
|
|
222
|
+
if name not in SAFETY_FLOOR_RULES
|
|
223
|
+
]
|
|
224
|
+
single_cap = total_ext * CONCENTRATION_SINGLE_PCT
|
|
225
|
+
top3_cap = total_ext * CONCENTRATION_TOP3_PCT
|
|
226
|
+
|
|
227
|
+
single_breaches = [
|
|
228
|
+
(name, ext, ext / total_ext)
|
|
229
|
+
for name, _, ext in non_floor
|
|
230
|
+
if ext > single_cap
|
|
231
|
+
]
|
|
232
|
+
top3_sum = sum(ext for _, _, ext in non_floor[:3])
|
|
233
|
+
top3_breach = (
|
|
234
|
+
(top3_sum, top3_sum / total_ext)
|
|
235
|
+
if top3_sum > top3_cap else None
|
|
236
|
+
)
|
|
237
|
+
return single_breaches, top3_breach
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _record_trend(total_ext: int, sizes: list[tuple[str, int, int]]) -> None:
|
|
241
|
+
"""Append the current run to the trend log (Phase 5.3)."""
|
|
242
|
+
TREND_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
record = {
|
|
244
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
245
|
+
"total": total_ext,
|
|
246
|
+
"rules": {name: ext for name, _, ext in sizes},
|
|
247
|
+
}
|
|
248
|
+
lines: list[str] = []
|
|
249
|
+
if TREND_LOG.exists():
|
|
250
|
+
lines = TREND_LOG.read_text(encoding="utf-8").splitlines()
|
|
251
|
+
lines.append(json.dumps(record, separators=(",", ":")))
|
|
252
|
+
if len(lines) > TREND_LOG_MAX_RECORDS:
|
|
253
|
+
lines = lines[-TREND_LOG_MAX_RECORDS:]
|
|
254
|
+
TREND_LOG.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _last_trend() -> dict | None:
|
|
258
|
+
"""Return the most recent trend record, or None if log is empty."""
|
|
259
|
+
if not TREND_LOG.exists():
|
|
260
|
+
return None
|
|
261
|
+
lines = [
|
|
262
|
+
line for line in TREND_LOG.read_text(encoding="utf-8").splitlines()
|
|
263
|
+
if line.strip()
|
|
264
|
+
]
|
|
265
|
+
if not lines:
|
|
266
|
+
return None
|
|
267
|
+
try:
|
|
268
|
+
return json.loads(lines[-1])
|
|
269
|
+
except json.JSONDecodeError:
|
|
270
|
+
return None
|
|
48
271
|
|
|
49
272
|
|
|
50
273
|
def main() -> int:
|
|
@@ -54,6 +277,11 @@ def main() -> int:
|
|
|
54
277
|
action="store_true",
|
|
55
278
|
help="suppress the per-rule breakdown unless threshold is crossed",
|
|
56
279
|
)
|
|
280
|
+
parser.add_argument(
|
|
281
|
+
"--no-trend",
|
|
282
|
+
action="store_true",
|
|
283
|
+
help="skip writing to .github/budget-trend.jsonl (Phase 5.3)",
|
|
284
|
+
)
|
|
57
285
|
args = parser.parse_args()
|
|
58
286
|
|
|
59
287
|
if not RULES_DIR.is_dir():
|
|
@@ -65,44 +293,112 @@ def main() -> int:
|
|
|
65
293
|
print(f"❌ no always-rules found under {RULES_DIR}", file=sys.stderr)
|
|
66
294
|
return 3
|
|
67
295
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
296
|
+
sizes: list[tuple[str, int, int]] = []
|
|
297
|
+
all_violations: list[tuple[str, str]] = []
|
|
298
|
+
for rule in rules:
|
|
299
|
+
ext, violations = _extended_size(rule)
|
|
300
|
+
sizes.append((rule.name, rule.stat().st_size, ext))
|
|
301
|
+
all_violations.extend(violations)
|
|
302
|
+
|
|
303
|
+
sizes.sort(key=lambda x: -x[2])
|
|
304
|
+
total_ext = sum(s[2] for s in sizes)
|
|
305
|
+
pct = total_ext / TOTAL_CAP
|
|
306
|
+
top3 = sum(s[2] for s in sizes[:3])
|
|
72
307
|
top3_breach = top3 > TOP3_CAP
|
|
73
308
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
309
|
+
over_per_rule: list[tuple[str, int]] = []
|
|
310
|
+
grew_over_ceiling: list[tuple[str, int, int]] = []
|
|
311
|
+
for name, _, ext in sizes:
|
|
312
|
+
if ext <= PER_RULE_CAP:
|
|
313
|
+
continue
|
|
314
|
+
ceiling = KNOWN_PER_RULE_BREACHES.get(name)
|
|
315
|
+
if ceiling is None:
|
|
316
|
+
over_per_rule.append((name, ext))
|
|
317
|
+
elif ext > ceiling:
|
|
318
|
+
grew_over_ceiling.append((name, ext, ceiling))
|
|
319
|
+
|
|
320
|
+
in_tolerance = 1.0 <= pct <= 1.0 + TOLERANCE_BAND
|
|
321
|
+
baseline = _load_baseline() if RECOVERY_BAND_ENABLED else None
|
|
322
|
+
in_recovery_band = (
|
|
323
|
+
baseline is not None
|
|
324
|
+
and FAIL_THRESHOLD <= pct < 1.0
|
|
325
|
+
and total_ext < baseline
|
|
326
|
+
)
|
|
327
|
+
single_breaches, top3_concentration_breach = _concentration_check(
|
|
328
|
+
sizes, total_ext
|
|
329
|
+
)
|
|
330
|
+
count_breaches = _per_rule_count_breaches()
|
|
331
|
+
failing = (
|
|
332
|
+
(
|
|
333
|
+
pct >= FAIL_THRESHOLD
|
|
334
|
+
and not in_tolerance
|
|
335
|
+
and not in_recovery_band
|
|
336
|
+
and pct < 1.0
|
|
337
|
+
)
|
|
338
|
+
or pct > 1.0 + TOLERANCE_BAND
|
|
339
|
+
or over_per_rule
|
|
340
|
+
or grew_over_ceiling
|
|
341
|
+
or top3_breach
|
|
342
|
+
or all_violations
|
|
343
|
+
or single_breaches
|
|
344
|
+
or top3_concentration_breach is not None
|
|
345
|
+
or count_breaches
|
|
346
|
+
)
|
|
347
|
+
if failing:
|
|
348
|
+
status, rc = "❌ FAIL", 1
|
|
349
|
+
elif in_tolerance:
|
|
350
|
+
status, rc = "⚠️ WARN (G3 tolerance band)", 0
|
|
351
|
+
elif in_recovery_band:
|
|
352
|
+
status, rc = (
|
|
353
|
+
f"⚠️ WARN (recovery band, baseline {baseline:,})",
|
|
354
|
+
0,
|
|
355
|
+
)
|
|
77
356
|
elif pct >= WARN_THRESHOLD:
|
|
78
|
-
status = "⚠️ WARN"
|
|
79
|
-
rc = 0
|
|
357
|
+
status, rc = "⚠️ WARN", 0
|
|
80
358
|
else:
|
|
81
|
-
status = "✅ OK"
|
|
82
|
-
rc = 0
|
|
359
|
+
status, rc = "✅ OK", 0
|
|
83
360
|
|
|
84
361
|
print(
|
|
85
|
-
f"{status} always-rule budget: {
|
|
86
|
-
f"({pct * 100:.1f}%) across {len(rules)} rule(s)"
|
|
362
|
+
f"{status} always-rule extended budget: {total_ext:,} / "
|
|
363
|
+
f"{TOTAL_CAP:,} chars ({pct * 100:.1f}%) across {len(rules)} rule(s)"
|
|
87
364
|
)
|
|
88
365
|
print(
|
|
89
366
|
f" thresholds: warn {WARN_THRESHOLD * 100:.0f}% · "
|
|
90
367
|
f"fail {FAIL_THRESHOLD * 100:.0f}% · "
|
|
91
|
-
f"per-rule ≤ {PER_RULE_CAP:,} · top-3 ≤ {TOP3_CAP:,}"
|
|
368
|
+
f"per-rule ≤ {PER_RULE_CAP:,} (ext) · top-3 ≤ {TOP3_CAP:,} (ext) · "
|
|
369
|
+
f"depth ≤ {MAX_DEPTH}"
|
|
92
370
|
)
|
|
93
371
|
|
|
94
372
|
if rc != 0 or pct >= WARN_THRESHOLD or not args.quiet:
|
|
95
373
|
print()
|
|
96
|
-
print(f" breakdown (largest first; top-3 sum = {top3:,}):")
|
|
97
|
-
for i, (name,
|
|
98
|
-
mark = " ❌" if size > PER_RULE_CAP else ""
|
|
374
|
+
print(f" breakdown (largest extended first; top-3 sum = {top3:,}):")
|
|
375
|
+
for i, (name, raw, ext) in enumerate(sizes):
|
|
99
376
|
tag = " (top-3)" if i < 3 else ""
|
|
100
|
-
|
|
377
|
+
ceiling = KNOWN_PER_RULE_BREACHES.get(name)
|
|
378
|
+
if ceiling is not None:
|
|
379
|
+
marker = f" ⚠️ allowlisted ≤ {ceiling:,}"
|
|
380
|
+
elif ext > PER_RULE_CAP:
|
|
381
|
+
marker = " ❌ per-rule breach"
|
|
382
|
+
else:
|
|
383
|
+
marker = ""
|
|
384
|
+
print(
|
|
385
|
+
f" ext={ext:>5} raw={raw:>5} {name}{tag}{marker}"
|
|
386
|
+
)
|
|
101
387
|
|
|
102
388
|
if over_per_rule:
|
|
103
389
|
names = ", ".join(f"{n}={s:,}" for n, s in over_per_rule)
|
|
104
390
|
print(
|
|
105
|
-
f"\n Per-rule cap breach (> {PER_RULE_CAP:,} chars):
|
|
391
|
+
f"\n Per-rule cap breach (> {PER_RULE_CAP:,} chars, not allowlisted): "
|
|
392
|
+
f"{names}"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
if grew_over_ceiling:
|
|
396
|
+
details = ", ".join(
|
|
397
|
+
f"{n}={ext:,} > ceiling {ceiling:,}"
|
|
398
|
+
for n, ext, ceiling in grew_over_ceiling
|
|
399
|
+
)
|
|
400
|
+
print(
|
|
401
|
+
f"\n Allowlisted-breach growth (regression): {details}"
|
|
106
402
|
)
|
|
107
403
|
|
|
108
404
|
if top3_breach:
|
|
@@ -111,12 +407,73 @@ def main() -> int:
|
|
|
111
407
|
f"(top-3 must stay ≤ 50% of {TOTAL_CAP:,} total budget)."
|
|
112
408
|
)
|
|
113
409
|
|
|
410
|
+
if all_violations:
|
|
411
|
+
print(
|
|
412
|
+
f"\n Depth-{MAX_DEPTH} nesting cap violations:"
|
|
413
|
+
)
|
|
414
|
+
for rule_name, chain in all_violations:
|
|
415
|
+
print(f" {rule_name}: {chain}")
|
|
416
|
+
|
|
417
|
+
if single_breaches:
|
|
418
|
+
details = ", ".join(
|
|
419
|
+
f"{n}={ext:,} ({frac * 100:.1f}%)"
|
|
420
|
+
for n, ext, frac in single_breaches
|
|
421
|
+
)
|
|
422
|
+
print(
|
|
423
|
+
f"\n Concentration breach (single rule > "
|
|
424
|
+
f"{CONCENTRATION_SINGLE_PCT * 100:.0f}% of used budget, "
|
|
425
|
+
f"non-allowlisted): {details}"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if top3_concentration_breach is not None:
|
|
429
|
+
sum_, frac = top3_concentration_breach
|
|
430
|
+
print(
|
|
431
|
+
f"\n Concentration breach (top-3 non-allowlisted > "
|
|
432
|
+
f"{CONCENTRATION_TOP3_PCT * 100:.0f}% of used budget): "
|
|
433
|
+
f"{sum_:,} ({frac * 100:.1f}%)"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
if count_breaches:
|
|
437
|
+
details = ", ".join(f"{n}={c}" for n, c in count_breaches)
|
|
438
|
+
print(
|
|
439
|
+
f"\n Per-rule context-count cap breach "
|
|
440
|
+
f"(> {MAX_CONTEXTS_PER_RULE} declared contexts, Q2 "
|
|
441
|
+
f"road-to-context-layer-maturity Phase 1.3): {details}"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Phase 5.3 — per-rule trend delta vs. previous run.
|
|
445
|
+
prev = _last_trend()
|
|
446
|
+
if prev is not None and not args.quiet:
|
|
447
|
+
prev_total = prev.get("total")
|
|
448
|
+
prev_rules = prev.get("rules") or {}
|
|
449
|
+
if isinstance(prev_total, int):
|
|
450
|
+
delta_total = total_ext - prev_total
|
|
451
|
+
sign = "+" if delta_total >= 0 else ""
|
|
452
|
+
print(
|
|
453
|
+
f"\n Trend vs. previous run "
|
|
454
|
+
f"({prev.get('ts', '?')}): total {sign}{delta_total:,} chars"
|
|
455
|
+
)
|
|
456
|
+
deltas: list[tuple[str, int, int]] = []
|
|
457
|
+
for name, _, ext in sizes:
|
|
458
|
+
old = prev_rules.get(name)
|
|
459
|
+
if isinstance(old, int) and old != ext:
|
|
460
|
+
deltas.append((name, ext - old, ext))
|
|
461
|
+
if deltas:
|
|
462
|
+
deltas.sort(key=lambda x: -abs(x[1]))
|
|
463
|
+
for name, d, ext in deltas[:5]:
|
|
464
|
+
s = "+" if d >= 0 else ""
|
|
465
|
+
print(f" {name}: {s}{d:,} (now {ext:,})")
|
|
466
|
+
|
|
467
|
+
if not args.no_trend:
|
|
468
|
+
_record_trend(total_ext, sizes)
|
|
469
|
+
|
|
114
470
|
if rc == 1:
|
|
115
471
|
print(
|
|
116
472
|
f"\n Action: trim the offending rule(s) via load_context: "
|
|
117
473
|
f"extraction (see contexts/execution + contexts/authority) "
|
|
118
474
|
f"until utilization drops below {FAIL_THRESHOLD * 100:.0f}% "
|
|
119
|
-
f"and all per-rule / top-3 caps hold."
|
|
475
|
+
f"and all per-rule / top-3 / depth caps hold. See "
|
|
476
|
+
f"docs/contracts/load-context-budget-model.md."
|
|
120
477
|
)
|
|
121
478
|
|
|
122
479
|
return rc
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cluster-pattern compliance check.
|
|
3
|
+
|
|
4
|
+
Compares each cluster dispatcher under
|
|
5
|
+
`.agent-src.uncompressed/commands/<cluster>.md` against the Phase 1
|
|
6
|
+
reference patterns (`fix.md`, `optimize.md`, `feature.md`).
|
|
7
|
+
|
|
8
|
+
Required structure:
|
|
9
|
+
|
|
10
|
+
Frontmatter:
|
|
11
|
+
- `name: <cluster>`
|
|
12
|
+
- `cluster: <cluster>`
|
|
13
|
+
- `disable-model-invocation: true`
|
|
14
|
+
|
|
15
|
+
Body:
|
|
16
|
+
- `# /<cluster>` H1
|
|
17
|
+
- `## Sub-commands` section with a markdown table whose header is
|
|
18
|
+
exactly `Sub-command | Routes to | Purpose`
|
|
19
|
+
- `## Dispatch` section
|
|
20
|
+
- `## Rules` section
|
|
21
|
+
|
|
22
|
+
Cluster files are detected by reading the locked-clusters table in
|
|
23
|
+
`docs/contracts/command-clusters.md` (column-1 backticks).
|
|
24
|
+
|
|
25
|
+
Exit codes: 0 = clean, 1 = pattern violations, 3 = internal error.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
35
|
+
COMMANDS_DIR = ROOT / ".agent-src.uncompressed/commands"
|
|
36
|
+
CONTRACT = ROOT / "docs/contracts/command-clusters.md"
|
|
37
|
+
|
|
38
|
+
REQUIRED_SECTIONS = ["## Sub-commands", "## Dispatch", "## Rules"]
|
|
39
|
+
TABLE_HEADER_RE = re.compile(
|
|
40
|
+
r"\|\s*Sub-command\s*\|\s*Routes to\s*\|\s*Purpose\s*\|", re.IGNORECASE
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class FileReport:
|
|
46
|
+
path: Path
|
|
47
|
+
cluster: str
|
|
48
|
+
errors: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_cluster_table() -> list[tuple[str, str]]:
|
|
52
|
+
"""Return [(cluster_name, kind)] where kind ∈ {"dispatch", "flag"}."""
|
|
53
|
+
text = CONTRACT.read_text(encoding="utf-8")
|
|
54
|
+
in_table = False
|
|
55
|
+
rows: list[tuple[str, str]] = []
|
|
56
|
+
row_re = re.compile(
|
|
57
|
+
r"\|\s*`([a-z][a-z0-9-]*)`\s*\|\s*\d+\s*\|\s*([^|]+)\|"
|
|
58
|
+
)
|
|
59
|
+
for line in text.splitlines():
|
|
60
|
+
if line.startswith("## Locked clusters"):
|
|
61
|
+
in_table = True
|
|
62
|
+
continue
|
|
63
|
+
if in_table and line.startswith("## "):
|
|
64
|
+
break
|
|
65
|
+
if in_table:
|
|
66
|
+
m = row_re.match(line)
|
|
67
|
+
if m:
|
|
68
|
+
name, sub_col = m.group(1), m.group(2).strip().lower()
|
|
69
|
+
kind = "flag" if sub_col.startswith("flag:") else "dispatch"
|
|
70
|
+
rows.append((name, kind))
|
|
71
|
+
return rows
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
75
|
+
if not text.startswith("---\n"):
|
|
76
|
+
return {}, text
|
|
77
|
+
end = text.find("\n---\n", 4)
|
|
78
|
+
if end == -1:
|
|
79
|
+
return {}, text
|
|
80
|
+
fm: dict[str, str] = {}
|
|
81
|
+
for line in text[4:end].splitlines():
|
|
82
|
+
if line and not line.startswith(" ") and ":" in line:
|
|
83
|
+
k, _, v = line.partition(":")
|
|
84
|
+
fm[k.strip()] = v.strip()
|
|
85
|
+
body = text[end + len("\n---\n"):]
|
|
86
|
+
return fm, body
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_dispatcher(cluster: str) -> FileReport:
|
|
90
|
+
path = COMMANDS_DIR / f"{cluster}.md"
|
|
91
|
+
rep = FileReport(path=path, cluster=cluster)
|
|
92
|
+
if not path.exists():
|
|
93
|
+
rep.errors.append(f"dispatcher file missing: {path.relative_to(ROOT)}")
|
|
94
|
+
return rep
|
|
95
|
+
text = path.read_text(encoding="utf-8")
|
|
96
|
+
fm, body = parse_frontmatter(text)
|
|
97
|
+
|
|
98
|
+
# Frontmatter checks.
|
|
99
|
+
if fm.get("name") != cluster:
|
|
100
|
+
rep.errors.append(f"frontmatter `name:` is {fm.get('name')!r}, expected {cluster!r}")
|
|
101
|
+
if fm.get("cluster") != cluster:
|
|
102
|
+
rep.errors.append(f"frontmatter `cluster:` is {fm.get('cluster')!r}, expected {cluster!r}")
|
|
103
|
+
if fm.get("disable-model-invocation") != "true":
|
|
104
|
+
rep.errors.append("frontmatter `disable-model-invocation: true` missing")
|
|
105
|
+
|
|
106
|
+
# H1 check.
|
|
107
|
+
h1 = f"# /{cluster}"
|
|
108
|
+
if h1 not in body.splitlines()[:5]:
|
|
109
|
+
rep.errors.append(f"missing top-level heading {h1!r} in first 5 body lines")
|
|
110
|
+
|
|
111
|
+
# Section presence.
|
|
112
|
+
for section in REQUIRED_SECTIONS:
|
|
113
|
+
if section not in body:
|
|
114
|
+
rep.errors.append(f"missing section header {section!r}")
|
|
115
|
+
|
|
116
|
+
# Sub-commands table header (only meaningful if Sub-commands section exists).
|
|
117
|
+
if "## Sub-commands" in body and not TABLE_HEADER_RE.search(body):
|
|
118
|
+
rep.errors.append(
|
|
119
|
+
"Sub-commands table header must be `| Sub-command | Routes to | Purpose |`"
|
|
120
|
+
)
|
|
121
|
+
return rep
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main() -> int:
|
|
125
|
+
rows = load_cluster_table()
|
|
126
|
+
if not rows:
|
|
127
|
+
print(f"❌ No clusters parsed from {CONTRACT.relative_to(ROOT)}",
|
|
128
|
+
file=sys.stderr)
|
|
129
|
+
return 3
|
|
130
|
+
|
|
131
|
+
dispatch_clusters = [n for n, k in rows if k == "dispatch"]
|
|
132
|
+
flag_clusters = [n for n, k in rows if k == "flag"]
|
|
133
|
+
|
|
134
|
+
reports = [check_dispatcher(n) for n in dispatch_clusters]
|
|
135
|
+
bad = [r for r in reports if r.errors]
|
|
136
|
+
|
|
137
|
+
# Flag clusters: only assert the file exists; legacy shape is preserved.
|
|
138
|
+
flag_missing = [n for n in flag_clusters
|
|
139
|
+
if not (COMMANDS_DIR / f"{n}.md").exists()]
|
|
140
|
+
if flag_missing:
|
|
141
|
+
print(f"❌ Flag-cluster file(s) missing: {flag_missing}")
|
|
142
|
+
return 1
|
|
143
|
+
|
|
144
|
+
if bad:
|
|
145
|
+
print(f"❌ {len(bad)}/{len(reports)} cluster dispatcher(s) deviate "
|
|
146
|
+
f"from the Phase-1 reference pattern:")
|
|
147
|
+
for r in bad:
|
|
148
|
+
print(f" • {r.path.relative_to(ROOT)} (cluster `{r.cluster}`)")
|
|
149
|
+
for err in r.errors:
|
|
150
|
+
print(f" - {err}")
|
|
151
|
+
print(f"\nReference: commands/fix.md, commands/optimize.md, commands/feature.md")
|
|
152
|
+
return 1
|
|
153
|
+
print(f"✅ {len(reports)} cluster dispatcher(s) match the Phase-1 reference "
|
|
154
|
+
f"pattern; {len(flag_clusters)} flag-cluster(s) verified present.")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
sys.exit(main())
|