@event4u/agent-config 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.agent-src/commands/agents/cleanup.md +31 -17
  2. package/.agent-src/commands/analyze-reference-repo.md +3 -0
  3. package/.agent-src/commands/commit/in-chunks.md +30 -10
  4. package/.agent-src/commands/commit.md +46 -6
  5. package/.agent-src/commands/compress.md +19 -13
  6. package/.agent-src/commands/cost-report.md +120 -0
  7. package/.agent-src/commands/create-pr/description-only.md +8 -0
  8. package/.agent-src/commands/create-pr.md +95 -80
  9. package/.agent-src/commands/feature/plan.md +13 -7
  10. package/.agent-src/commands/memory/add.md +16 -8
  11. package/.agent-src/commands/memory/promote.md +17 -9
  12. package/.agent-src/commands/optimize/rtk.md +16 -11
  13. package/.agent-src/commands/prepare-for-review.md +12 -6
  14. package/.agent-src/commands/project-analyze.md +31 -20
  15. package/.agent-src/commands/review-changes.md +24 -15
  16. package/.agent-src/commands/roadmap/create.md +14 -9
  17. package/.agent-src/commands/roadmap/process-full.md +41 -1
  18. package/.agent-src/contexts/contracts/frugality-charter.md +57 -0
  19. package/.agent-src/contexts/execution/roadmap-process-loop.md +29 -6
  20. package/.agent-src/rules/architecture.md +9 -0
  21. package/.agent-src/rules/ask-when-uncertain.md +3 -13
  22. package/.agent-src/rules/caveman-speak.md +78 -0
  23. package/.agent-src/rules/direct-answers.md +5 -14
  24. package/.agent-src/rules/markdown-safe-codeblocks.md +6 -7
  25. package/.agent-src/rules/no-cheap-questions.md +4 -14
  26. package/.agent-src/rules/roadmap-progress-sync.md +37 -3
  27. package/.agent-src/rules/token-efficiency.md +5 -7
  28. package/.agent-src/skills/adr-create/SKILL.md +197 -0
  29. package/.agent-src/skills/agent-docs-writing/SKILL.md +23 -1
  30. package/.agent-src/skills/command-writing/SKILL.md +23 -0
  31. package/.agent-src/skills/context-authoring/SKILL.md +23 -0
  32. package/.agent-src/skills/conventional-commits-writing/SKILL.md +23 -0
  33. package/.agent-src/skills/guideline-writing/SKILL.md +22 -0
  34. package/.agent-src/skills/learning-to-rule-or-skill/SKILL.md +9 -0
  35. package/.agent-src/skills/markitdown/SKILL.md +239 -0
  36. package/.agent-src/skills/persona-writing/SKILL.md +153 -0
  37. package/.agent-src/skills/readme-writing/SKILL.md +20 -0
  38. package/.agent-src/skills/readme-writing-package/SKILL.md +19 -0
  39. package/.agent-src/skills/roadmap-writing/SKILL.md +157 -0
  40. package/.agent-src/skills/rule-writing/SKILL.md +22 -0
  41. package/.agent-src/skills/script-writing/SKILL.md +226 -0
  42. package/.agent-src/skills/skill-writing/SKILL.md +23 -0
  43. package/.agent-src/skills/test-driven-development/SKILL.md +24 -0
  44. package/.agent-src/skills/universal-project-analysis/SKILL.md +8 -0
  45. package/.agent-src/templates/agent-settings.md +73 -0
  46. package/.agent-src/templates/command.md +15 -10
  47. package/.agent-src/templates/rule.md +6 -0
  48. package/.agent-src/templates/skill.md +32 -0
  49. package/.claude-plugin/marketplace.json +10 -4
  50. package/AGENTS.md +14 -3
  51. package/CHANGELOG.md +61 -0
  52. package/README.md +5 -5
  53. package/docs/architecture.md +4 -4
  54. package/docs/catalog.md +25 -8
  55. package/docs/customization.md +72 -0
  56. package/docs/decisions/INDEX.md +15 -0
  57. package/docs/getting-started.md +2 -2
  58. package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +27 -19
  59. package/docs/guidelines/agent-infra/carve-out-predicates.md +17 -0
  60. package/docs/guidelines/agent-infra/mcp-request-signing.md +199 -0
  61. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +11 -4
  62. package/package.json +1 -1
  63. package/scripts/_lib/__init__.py +5 -0
  64. package/scripts/_lib/script_output.py +140 -0
  65. package/scripts/adr/regenerate_index.py +79 -0
  66. package/scripts/ai_council/one_off_archive/2026-05/_one_off_add_quiet.py +149 -0
  67. package/scripts/ai_council/one_off_archive/2026-05/_one_off_inject_quiet_flag.py +33 -0
  68. package/scripts/ai_council/one_off_archive/2026-05/_one_off_measure_v2.sh +36 -0
  69. package/scripts/ai_council/one_off_archive/2026-05/_one_off_measure_verbosity.sh +26 -0
  70. package/scripts/ai_council/one_off_archive/2026-05/_one_off_per_task.sh +41 -0
  71. package/scripts/ai_council/one_off_archive/2026-05/_one_off_silent_taskfiles.py +98 -0
  72. package/scripts/check_augmentignore.py +4 -1
  73. package/scripts/check_command_count_messaging.py +4 -1
  74. package/scripts/check_compressed_paths.py +4 -1
  75. package/scripts/check_council_layout.py +4 -1
  76. package/scripts/check_council_references.py +4 -1
  77. package/scripts/check_iron_law_prominence.py +3 -1
  78. package/scripts/check_md_language.py +3 -1
  79. package/scripts/check_memory_proposal.py +3 -1
  80. package/scripts/check_public_catalog_links.py +4 -1
  81. package/scripts/check_reply_consistency.py +8 -2
  82. package/scripts/check_roadmap_trackable.py +4 -1
  83. package/scripts/compile_router.py +27 -0
  84. package/scripts/compress.py +33 -19
  85. package/scripts/cost/budget.mjs +152 -0
  86. package/scripts/cost/track.mjs +144 -0
  87. package/scripts/first-run.sh +3 -9
  88. package/scripts/install-hooks.sh +19 -1
  89. package/scripts/install.py +17 -12
  90. package/scripts/install.sh +19 -8
  91. package/scripts/lint_examples.py +6 -2
  92. package/scripts/lint_handoffs.py +4 -1
  93. package/scripts/lint_load_context.py +4 -1
  94. package/scripts/lint_roadmap_complexity.py +6 -2
  95. package/scripts/lint_rule_interactions.py +4 -1
  96. package/scripts/lint_rule_tiers.py +4 -1
  97. package/scripts/measure_frugality_savings.py +164 -0
  98. package/scripts/measure_markitdown_lift.py +127 -0
  99. package/scripts/runtime_dispatcher.py +11 -0
  100. package/scripts/skill_linter.py +207 -2
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """Phase 0 baseline harness for road-to-trim-frugality-canon.
3
+
4
+ Measures the *current state* of the frugality canon along four
5
+ deterministic axes. Output: JSONL baseline appended to
6
+ agents/.frugality-baseline.jsonl (gitignored).
7
+
8
+ Metrics:
9
+ A. footprint — per-rule char/token count, kernel/tier breakdown
10
+ B. fillers — filler-phrase prevalence in chat-history corpus
11
+ (heuristic signal, not full transcript)
12
+ C. compression — uncompressed → compressed char delta per rule
13
+ D. redundancy — cross-ref overlap across "Interactions:" /
14
+ "See also" sections in the canon
15
+
16
+ Trim phases re-run this harness after each PR. Decline condition fires
17
+ if metric B regresses (filler prevalence increases) or metric C drops
18
+ below current baseline by >10% per rule.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ CANON_RULES = [
28
+ ("direct-answers", "kernel"),
29
+ ("no-cheap-questions", "kernel"),
30
+ ("ask-when-uncertain", "kernel"),
31
+ ("user-interaction", "tier_1"),
32
+ ("caveman-speak", "tier_1"),
33
+ ("token-efficiency", "tier_2"),
34
+ ]
35
+ CHARTER = "frugality-charter"
36
+
37
+ FILLER_PATTERNS = [
38
+ r"\bgreat question\b", r"\bfascinating\b", r"\bexcellent point\b",
39
+ r"\blet me\s+(check|look|find|verify|investigate|see)\b",
40
+ r"\bnow\s+(i'll|i will|let's)\b",
41
+ r"\bgoing to\s+(check|run|use|call|invoke)\b",
42
+ r"\bperfect\b!?", r"\bawesome\b!?",
43
+ r"\bhere's what i\b", r"\bfound it\b",
44
+ r"^\s*(ok|okay|alright)[!,.]\s",
45
+ ]
46
+ FILLER_RE = re.compile("|".join(FILLER_PATTERNS), re.IGNORECASE | re.MULTILINE)
47
+
48
+ # Cross-ref section headers to count for redundancy metric
49
+ XREF_HEADERS = re.compile(r"^##\s+(Interactions|See also|Related)\s*$", re.MULTILINE)
50
+
51
+
52
+ def _read(path: Path) -> str:
53
+ return path.read_text() if path.exists() else ""
54
+
55
+
56
+ def metric_a_footprint(root: Path) -> dict:
57
+ """Per-rule char count, tier classification, total kernel %."""
58
+ rows = []
59
+ kernel_total = 0
60
+ tier1_total = 0
61
+ tier2_total = 0
62
+ for name, tier in CANON_RULES:
63
+ compressed = root / ".agent-src" / "rules" / f"{name}.md"
64
+ chars = len(_read(compressed))
65
+ tokens = chars // 4 # rough 4-char/token approximation
66
+ rows.append({"rule": name, "tier": tier, "chars": chars, "tokens_approx": tokens})
67
+ if tier == "kernel":
68
+ kernel_total += chars
69
+ elif tier == "tier_1":
70
+ tier1_total += chars
71
+ elif tier == "tier_2":
72
+ tier2_total += chars
73
+ charter_chars = len(_read(root / ".agent-src" / "contexts" / "contracts" / f"{CHARTER}.md"))
74
+ return {
75
+ "rules": rows,
76
+ "kernel_total_chars": kernel_total,
77
+ "tier_1_total_chars": tier1_total,
78
+ "tier_2_total_chars": tier2_total,
79
+ "charter_chars": charter_chars,
80
+ "kernel_budget_chars": 26000,
81
+ "kernel_pct": round(100 * kernel_total / 26000, 2),
82
+ }
83
+
84
+
85
+ def metric_b_fillers(corpus: Path) -> dict:
86
+ """Filler-phrase hits per agent turn in chat-history corpus."""
87
+ if not corpus.exists():
88
+ return {"corpus_present": False}
89
+ lines = corpus.read_text().splitlines()
90
+ agent_turns = 0
91
+ filler_hits = 0
92
+ total_chars = 0
93
+ for ln in lines[1:]:
94
+ try:
95
+ d = json.loads(ln)
96
+ except json.JSONDecodeError:
97
+ continue
98
+ if d.get("t") != "agent":
99
+ continue
100
+ text = d.get("text", "")
101
+ agent_turns += 1
102
+ total_chars += len(text)
103
+ filler_hits += len(FILLER_RE.findall(text))
104
+ return {
105
+ "corpus_present": True,
106
+ "agent_turns": agent_turns,
107
+ "filler_hits_total": filler_hits,
108
+ "filler_hits_per_turn": round(filler_hits / max(agent_turns, 1), 3),
109
+ "agent_chars_total": total_chars,
110
+ "patterns_count": len(FILLER_PATTERNS),
111
+ "note": "chat-history texts are digests, not full transcripts; signal not output volume",
112
+ }
113
+
114
+
115
+ def metric_c_compression(root: Path) -> dict:
116
+ """Uncompressed → compressed char delta per rule."""
117
+ rows = []
118
+ for name, _ in CANON_RULES:
119
+ un = len(_read(root / ".agent-src.uncompressed" / "rules" / f"{name}.md"))
120
+ co = len(_read(root / ".agent-src" / "rules" / f"{name}.md"))
121
+ delta = un - co
122
+ ratio = round(co / un, 3) if un else 0
123
+ rows.append({"rule": name, "uncompressed_chars": un, "compressed_chars": co, "delta": delta, "ratio": ratio})
124
+ return {"rules": rows}
125
+
126
+
127
+ def metric_d_redundancy(root: Path) -> dict:
128
+ """Cross-ref section count + total xref-block size."""
129
+ rows = []
130
+ for name, _ in CANON_RULES:
131
+ path = root / ".agent-src.uncompressed" / "rules" / f"{name}.md"
132
+ text = _read(path)
133
+ xref_count = len(XREF_HEADERS.findall(text))
134
+ # naive: chars after last xref header to EOF
135
+ m = list(XREF_HEADERS.finditer(text))
136
+ xref_block_chars = (len(text) - m[-1].start()) if m else 0
137
+ rows.append({"rule": name, "xref_sections": xref_count, "xref_block_chars": xref_block_chars})
138
+ return {"rules": rows, "total_xref_chars": sum(r["xref_block_chars"] for r in rows)}
139
+
140
+
141
+ def main() -> int:
142
+ root = Path(__file__).resolve().parent.parent
143
+ corpus = root / "agents" / ".agent-chat-history"
144
+
145
+ record = {
146
+ "schema_version": 1,
147
+ "ts": datetime.now(tz=timezone.utc).isoformat(timespec="seconds"),
148
+ "phase": "phase_0_baseline",
149
+ "metric_a_footprint": metric_a_footprint(root),
150
+ "metric_b_fillers": metric_b_fillers(corpus),
151
+ "metric_c_compression": metric_c_compression(root),
152
+ "metric_d_redundancy": metric_d_redundancy(root),
153
+ }
154
+
155
+ out = root / "agents" / ".frugality-baseline.jsonl"
156
+ with out.open("a") as fh:
157
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
158
+ print(json.dumps(record, indent=2, ensure_ascii=False))
159
+ print(f"\nappended → {out.relative_to(root)}", flush=True)
160
+ return 0
161
+
162
+
163
+ if __name__ == "__main__":
164
+ raise SystemExit(main())
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """Measure markitdown's token-saving lift on the bundled corpus.
3
+
4
+ Runs against `tests/fixtures/markitdown-corpus/`. By default (no flags) the
5
+ script computes the baseline-only — raw byte size and a tokens-per-4-bytes
6
+ estimate — without calling `markitdown-mcp`. With `--convert`, the script
7
+ tries to invoke `markitdown` (CLI binary) via subprocess and computes the
8
+ converted-Markdown token estimate plus the ratio per file.
9
+
10
+ Stdlib-only. Never installs anything. Never invokes a network host. Never
11
+ calls `markitdown-mcp` over HTTP — only through the `markitdown` CLI on
12
+ the user's PATH (peer-side install per the skill's Step 1 recipes).
13
+
14
+ Exit codes:
15
+ 0 — baseline produced (always, when fixtures exist)
16
+ 2 — corpus not found
17
+ 3 — `--convert` was requested but `markitdown` is not on PATH
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ REPO_ROOT = Path(__file__).resolve().parent.parent
29
+ CORPUS = REPO_ROOT / "tests" / "fixtures" / "markitdown-corpus"
30
+ TOKEN_PER_BYTES = 4 # rough OpenAI/Anthropic tokenizer-of-thumb
31
+
32
+
33
+ def _baseline_tokens(p: Path) -> int:
34
+ return max(1, p.stat().st_size // TOKEN_PER_BYTES)
35
+
36
+
37
+ def _converted_tokens(p: Path, *, binary: str) -> int | None:
38
+ try:
39
+ out = subprocess.run(
40
+ [binary, str(p)],
41
+ capture_output=True,
42
+ check=False,
43
+ text=True,
44
+ timeout=30,
45
+ )
46
+ except (OSError, subprocess.TimeoutExpired):
47
+ return None
48
+ if out.returncode != 0:
49
+ return None
50
+ chars = len(out.stdout)
51
+ if chars == 0:
52
+ return None
53
+ return max(1, chars // TOKEN_PER_BYTES)
54
+
55
+
56
+ def _format_ratio(baseline: int, converted: int | None) -> str:
57
+ if converted is None or converted == 0:
58
+ return "—"
59
+ ratio = baseline / converted
60
+ return f"{ratio:.1f}×"
61
+
62
+
63
+ def main() -> int:
64
+ parser = argparse.ArgumentParser(description="Measure markitdown lift on the bundled corpus.")
65
+ parser.add_argument(
66
+ "--convert",
67
+ action="store_true",
68
+ help="Invoke `markitdown <fixture>` per file and compute the converted-token ratio.",
69
+ )
70
+ parser.add_argument(
71
+ "--binary",
72
+ default="markitdown",
73
+ help="Name or path of the markitdown CLI binary (default: markitdown).",
74
+ )
75
+ args = parser.parse_args()
76
+
77
+ if not CORPUS.is_dir():
78
+ print(f"ERROR: corpus not found at {CORPUS}", file=sys.stderr)
79
+ print(
80
+ "Generate it: python3 tests/fixtures/markitdown-corpus/_generate.py",
81
+ file=sys.stderr,
82
+ )
83
+ return 2
84
+
85
+ fixtures = sorted(p for p in CORPUS.iterdir() if p.is_file() and p.suffix in {".pdf", ".pptx", ".docx", ".xlsx"})
86
+ if not fixtures:
87
+ print(f"ERROR: no fixtures in {CORPUS}", file=sys.stderr)
88
+ return 2
89
+
90
+ binary_path: str | None = None
91
+ if args.convert:
92
+ binary_path = shutil.which(args.binary)
93
+ if binary_path is None:
94
+ print(
95
+ f"ERROR: --convert requested but `{args.binary}` not on PATH.\n"
96
+ "Install peer-side per the skill's Step 1 recipes "
97
+ "(Docker / pipx / uv) and re-run.",
98
+ file=sys.stderr,
99
+ )
100
+ return 3
101
+
102
+ print(f"Corpus: {CORPUS.relative_to(REPO_ROOT)} ({len(fixtures)} files)")
103
+ print(f"Mode: {'convert (peer markitdown CLI)' if binary_path else 'baseline-only'}")
104
+ if binary_path:
105
+ print(f"Binary: {binary_path}")
106
+ print()
107
+ header = f"{'fixture':<32} {'bytes':>7} {'baseline tok':>13} {'converted tok':>14} {'ratio':>7}"
108
+ print(header)
109
+ print("-" * len(header))
110
+ for p in fixtures:
111
+ size = p.stat().st_size
112
+ base = _baseline_tokens(p)
113
+ converted = _converted_tokens(p, binary=binary_path) if binary_path else None
114
+ ratio = _format_ratio(base, converted)
115
+ conv_str = f"{converted}" if converted is not None else "—"
116
+ print(f"{p.name:<32} {size:>7} {base:>13} {conv_str:>14} {ratio:>7}")
117
+ print()
118
+ if not binary_path:
119
+ print(
120
+ "Re-run with --convert (after installing markitdown-mcp peer-side per the skill's "
121
+ "Step 1 recipes) for the actual ratio."
122
+ )
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main())
@@ -30,6 +30,7 @@ from typing import List, Optional
30
30
  sys.path.insert(0, str(Path(__file__).resolve().parent))
31
31
  from runtime_registry import SkillRuntime, build_registry
32
32
  from runtime_handler import ExecutionResult, HandlerError, execute_shell
33
+ from _lib.script_output import resolve_level # type: ignore[import-not-found]
33
34
 
34
35
 
35
36
  @dataclass
@@ -187,6 +188,16 @@ def _print_execution(result: ExecutionResult, fmt: str) -> None:
187
188
  if fmt == "json":
188
189
  print(json.dumps(asdict(result), indent=2))
189
190
  return
191
+ level = resolve_level()
192
+ if level == "silent" and result.is_success:
193
+ return
194
+ if level == "minimal" and result.is_success:
195
+ marker = "✅" if result.is_success else "❌"
196
+ print(
197
+ f"{marker} {result.skill_name} · {result.handler} · "
198
+ f"exit={result.exit_code} ({result.duration_ms}ms)"
199
+ )
200
+ return
190
201
  print(f"Skill: {result.skill_name}")
191
202
  print(f"Handler: {result.handler}")
192
203
  print(f"Command: {' '.join(result.command)}")
@@ -76,6 +76,26 @@ RULE_BAD_SIGNS = [
76
76
  "## Gotchas",
77
77
  ]
78
78
 
79
+ # --- Frugality charter validator (see road-to-token-frugality Phase 0.4) ---
80
+ # Layer 1 = writer-cite check (every writer skill carries the section + link).
81
+ # Layer 2 = charter index integrity (the four canonical rules referenced by
82
+ # the charter resolve to real H2/H3 anchors in the rule files).
83
+
84
+ FRUGALITY_WRITER_SKILLS = {
85
+ "skill-writing", "rule-writing", "command-writing",
86
+ "guideline-writing", "context-authoring", "agent-docs-writing",
87
+ "conventional-commits-writing", "readme-writing",
88
+ "readme-writing-package", "adr-create",
89
+ "persona-writing", "roadmap-writing", "script-writing",
90
+ }
91
+ FRUGALITY_CHARTER_RELPATH = "contexts/communication/frugality-charter.md"
92
+ FRUGALITY_CHARTER_INDEX_RULES = {
93
+ "direct-answers.md": "iron-law-3",
94
+ "user-interaction.md": "iron-law-1",
95
+ "no-cheap-questions.md": "pre-send-self-check",
96
+ "token-efficiency.md": "the-iron-laws",
97
+ }
98
+
79
99
  VAGUE_VALIDATION_PATTERNS = [
80
100
  r"\bcheck if it works\b",
81
101
  r"\bverify it works\b",
@@ -1381,6 +1401,15 @@ def gather_all_candidate_files(root: Path) -> list[Path]:
1381
1401
  if not f.is_symlink():
1382
1402
  candidates.append(f)
1383
1403
 
1404
+ # Frugality charter (Phase 0.4 Layer 2). Lives in contexts/, not
1405
+ # walked by the artifact-type loops above, but still needs the
1406
+ # index-integrity check.
1407
+ for base in (root / ".agent-src.uncompressed", root / ".agent-src"):
1408
+ charter = base / FRUGALITY_CHARTER_RELPATH
1409
+ if charter.exists() and not charter.is_symlink():
1410
+ candidates.append(charter)
1411
+ break
1412
+
1384
1413
  return sorted(set(candidates))
1385
1414
 
1386
1415
 
@@ -1860,6 +1889,156 @@ def lint_verification_maturity(path: Path, text: str, artifact_type: str) -> Lis
1860
1889
  # --- Governance & packaging checks ---
1861
1890
 
1862
1891
 
1892
+ # --- Frugality validator helpers + Layers 1 & 2 ---
1893
+
1894
+ def _heading_to_slug(heading: str) -> str:
1895
+ """Slugify a markdown heading using GitHub's algorithm: lowercase,
1896
+ drop punctuation (em-dash, period, etc.), spaces -> hyphens,
1897
+ preserve adjacent hyphens (so `Iron Law 3 — Brevity` becomes
1898
+ `iron-law-3--brevity`, matching the anchor GitHub renders)."""
1899
+ s = heading.strip().lower()
1900
+ s = re.sub(r"[^a-z0-9 \-]", "", s)
1901
+ s = s.replace(" ", "-")
1902
+ return s.strip("-")
1903
+
1904
+
1905
+ def _extract_heading_slugs(text: str) -> set[str]:
1906
+ """Return the set of slugs for every H2/H3 heading in a markdown body."""
1907
+ slugs: set[str] = set()
1908
+ for line in text.splitlines():
1909
+ if line.startswith("## ") or line.startswith("### "):
1910
+ heading = line.split(" ", 1)[1].strip()
1911
+ slugs.add(_heading_to_slug(heading))
1912
+ return slugs
1913
+
1914
+
1915
+ def _skill_id_from_path(path: Path) -> Optional[str]:
1916
+ """Extract the writer-skill id from a SKILL.md path. Returns the
1917
+ parent-directory name, or None if the file is not a SKILL.md."""
1918
+ if path.name.lower() != "skill.md":
1919
+ return None
1920
+ return path.parent.name
1921
+
1922
+
1923
+ def _is_frugality_charter(path: Path) -> bool:
1924
+ """True iff the path ends in the canonical charter relpath, regardless
1925
+ of whether it lives under .agent-src/ or .agent-src.uncompressed/."""
1926
+ norm = str(path).replace("\\", "/")
1927
+ return norm.endswith("/" + FRUGALITY_CHARTER_RELPATH)
1928
+
1929
+
1930
+ # Section header recognised by Layer 1. Literal H2 only — sub-headings
1931
+ # inside the section do not count as the section itself.
1932
+ _FRUGALITY_STANDARDS_PATTERN = re.compile(
1933
+ r"^##\s+Frugality Standards\s*$", re.MULTILINE
1934
+ )
1935
+ _FRUGALITY_CHARTER_LINK_PATTERN = re.compile(
1936
+ r"\]\([^)]*frugality-charter\.md[^)]*\)"
1937
+ )
1938
+
1939
+
1940
+ def lint_frugality_writer_cite(path: Path, text: str,
1941
+ artifact_type: str) -> List[Issue]:
1942
+ """Layer 1 — every writer skill must carry a `## Frugality Standards`
1943
+ section that links to the charter. No-op for non-writer skills and
1944
+ non-skill artifacts."""
1945
+ if artifact_type != "skill":
1946
+ return []
1947
+ skill_id = _skill_id_from_path(path)
1948
+ if skill_id is None or skill_id not in FRUGALITY_WRITER_SKILLS:
1949
+ return []
1950
+ issues: List[Issue] = []
1951
+ section_match = _FRUGALITY_STANDARDS_PATTERN.search(text)
1952
+ if not section_match:
1953
+ issues.append(Issue(
1954
+ "error", "frugality_section_missing",
1955
+ "Writer skill must carry a `## Frugality Standards` section "
1956
+ "(road-to-token-frugality Phase 0.4 Layer 1)",
1957
+ ))
1958
+ return issues
1959
+ # Section body = from match-end to next H2 or EOF.
1960
+ body_start = section_match.end()
1961
+ next_h2 = re.search(r"^##\s+", text[body_start:], re.MULTILINE)
1962
+ body_end = body_start + next_h2.start() if next_h2 else len(text)
1963
+ body = text[body_start:body_end]
1964
+ if not _FRUGALITY_CHARTER_LINK_PATTERN.search(body):
1965
+ issues.append(Issue(
1966
+ "error", "frugality_charter_cite_missing",
1967
+ "`## Frugality Standards` section must link to "
1968
+ "`frugality-charter.md` (road-to-token-frugality Phase 0.4 "
1969
+ "Layer 1)",
1970
+ ))
1971
+ return issues
1972
+
1973
+
1974
+ # Markdown link pattern: [text](path#anchor) — anchor optional.
1975
+ _MD_LINK_PATTERN = re.compile(
1976
+ r"\[[^\]]+\]\(([^)#]+)(?:#([^)]+))?\)"
1977
+ )
1978
+
1979
+
1980
+ def lint_frugality_charter_index(path: Path, text: str) -> List[Issue]:
1981
+ """Layer 2 — every cited anchor must resolve to a real H2/H3 heading
1982
+ in the target rule file, AND each of the four canonical rules must
1983
+ be cited at least once with the required canonical anchor substring.
1984
+ Additional citations to the same rule (net-new sections referencing
1985
+ other anchors) are validated for resolution but do not need the
1986
+ canonical substring."""
1987
+ if not _is_frugality_charter(path):
1988
+ return []
1989
+ issues: List[Issue] = []
1990
+ rules_dir = path.parent.parent.parent / "rules"
1991
+ rule_slugs_cache: dict[str, set[str]] = {}
1992
+ canonical_satisfied: set[str] = set()
1993
+ for link_match in _MD_LINK_PATTERN.finditer(text):
1994
+ link_path, link_anchor = link_match.group(1), link_match.group(2)
1995
+ rule_name = Path(link_path).name
1996
+ if rule_name not in FRUGALITY_CHARTER_INDEX_RULES:
1997
+ continue
1998
+ if link_anchor is None:
1999
+ continue
2000
+ anchor_lc = link_anchor.lower()
2001
+ required_substr = FRUGALITY_CHARTER_INDEX_RULES[rule_name]
2002
+ if required_substr in anchor_lc:
2003
+ canonical_satisfied.add(rule_name)
2004
+ if rule_name not in rule_slugs_cache:
2005
+ rule_file = rules_dir / rule_name
2006
+ if not rule_file.exists():
2007
+ issues.append(Issue(
2008
+ "error", "frugality_charter_rule_missing",
2009
+ f"Charter cites {rule_name} but the rule file does "
2010
+ f"not exist at {rule_file}",
2011
+ ))
2012
+ rule_slugs_cache[rule_name] = set()
2013
+ continue
2014
+ try:
2015
+ rule_text = rule_file.read_text(encoding="utf-8")
2016
+ except OSError as e:
2017
+ issues.append(Issue(
2018
+ "error", "frugality_charter_rule_unreadable",
2019
+ f"Cannot read {rule_name}: {e}",
2020
+ ))
2021
+ rule_slugs_cache[rule_name] = set()
2022
+ continue
2023
+ rule_slugs_cache[rule_name] = _extract_heading_slugs(rule_text)
2024
+ if anchor_lc not in rule_slugs_cache[rule_name]:
2025
+ issues.append(Issue(
2026
+ "error", "frugality_charter_anchor_unresolved",
2027
+ f"Charter cites {rule_name}#{link_anchor} but no H2/H3 "
2028
+ f"heading with that slug exists in the rule file",
2029
+ ))
2030
+ missing = set(FRUGALITY_CHARTER_INDEX_RULES) - canonical_satisfied
2031
+ for rule_name in sorted(missing):
2032
+ required_substr = FRUGALITY_CHARTER_INDEX_RULES[rule_name]
2033
+ issues.append(Issue(
2034
+ "error", "frugality_charter_canonical_missing",
2035
+ f"Charter index lacks a canonical citation of {rule_name} "
2036
+ f"with anchor containing '{required_substr}' "
2037
+ f"(road-to-token-frugality Phase 0.4 Layer 2)",
2038
+ ))
2039
+ return issues
2040
+
2041
+
1863
2042
  def lint_governance(path: Path, text: str, artifact_type: str, repo_root: Path | None = None) -> List[Issue]:
1864
2043
  """Check governance and packaging consistency.
1865
2044
 
@@ -2139,6 +2318,17 @@ def lint_file(path: Path, repo_root: Path | None = None) -> LintResult:
2139
2318
  elif artifact_type == "persona":
2140
2319
  result = lint_persona(display_path, text)
2141
2320
  else:
2321
+ # Frugality charter lives in contexts/ (artifact_type == unknown)
2322
+ # but still needs Layer 2 index-integrity validation.
2323
+ if _is_frugality_charter(path):
2324
+ charter_issues = lint_frugality_charter_index(path, text)
2325
+ return LintResult(
2326
+ file=str(display_path),
2327
+ artifact_type="unknown",
2328
+ status=classify_status(charter_issues),
2329
+ issues=charter_issues,
2330
+ suggestions=[],
2331
+ )
2142
2332
  return lint_unknown(display_path, text)
2143
2333
 
2144
2334
  # Post-processing: frontmatter schema validation (errors). Runs first
@@ -2195,10 +2385,20 @@ def lint_file(path: Path, repo_root: Path | None = None) -> LintResult:
2195
2385
  result.issues.extend(malice_issues)
2196
2386
  result.status = classify_status(result.issues)
2197
2387
 
2388
+ # Post-processing: frugality validator Layer 1 (writer-cite). Errors
2389
+ # if a writer skill lacks the `## Frugality Standards` section or its
2390
+ # link to the charter.
2391
+ frugality_issues = lint_frugality_writer_cite(
2392
+ display_path, text, artifact_type
2393
+ )
2394
+ if frugality_issues:
2395
+ result.issues.extend(frugality_issues)
2396
+ result.status = classify_status(result.issues)
2397
+
2198
2398
  return result
2199
2399
 
2200
2400
 
2201
- def format_text(results: list[LintResult]) -> str:
2401
+ def format_text(results: list[LintResult], quiet: bool = False) -> str:
2202
2402
  lines: list[str] = []
2203
2403
  # Phase 5.2: malice findings render in the spec shape
2204
2404
  # ``<path>:<line>:malice:<pattern>:<matched>`` ahead of the badge
@@ -2216,7 +2416,10 @@ def format_text(results: list[LintResult]) -> str:
2216
2416
  if malice_total:
2217
2417
  lines.append("")
2218
2418
 
2419
+ # P10.5: quiet mode skips PASS-without-issues; malice + WARN/FAIL still rendered.
2219
2420
  for result in results:
2421
+ if quiet and result.status == "pass" and not result.issues and not result.suggestions:
2422
+ continue
2220
2423
  badge = {"pass": "[PASS]", "pass_with_warnings": "[WARN]", "fail": "[FAIL]"}[result.status]
2221
2424
  lines.append(f"{badge} {result.file} ({result.artifact_type})")
2222
2425
  if result.issues:
@@ -2458,6 +2661,8 @@ def parse_args() -> argparse.Namespace:
2458
2661
  parser.add_argument("--strict-warnings", action="store_true", help="Return non-zero on warnings")
2459
2662
  parser.add_argument("--report", action="store_true", help="Output quality score report")
2460
2663
  parser.add_argument("--repo-root", default=".", help="Repository root")
2664
+ parser.add_argument("--quiet", action="store_true",
2665
+ help="suppress per-file PASS lines; keep malice + WARN/FAIL + summary (P10.5)")
2461
2666
  return parser.parse_args()
2462
2667
 
2463
2668
 
@@ -2604,7 +2809,7 @@ def main() -> int:
2604
2809
  elif args.format == "json":
2605
2810
  print(format_json(results))
2606
2811
  else:
2607
- print(format_text(results))
2812
+ print(format_text(results, quiet=args.quiet))
2608
2813
 
2609
2814
  return compute_exit_code(results, strict_warnings=args.strict_warnings)
2610
2815