@event4u/agent-config 1.24.0 → 1.25.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 (72) hide show
  1. package/.agent-src/commands/review-routing.md +7 -10
  2. package/.agent-src/contexts/authority/kernel-rule-edits.md +48 -0
  3. package/.agent-src/contexts/authority/scope-mechanics.md +15 -0
  4. package/.agent-src/contexts/contracts/consumer-agents-md-guide.md +127 -0
  5. package/.agent-src/contexts/contracts/emergency-triage-block.md +53 -0
  6. package/.agent-src/rules/analysis-skill-routing.md +1 -1
  7. package/.agent-src/rules/artifact-drafting-protocol.md +1 -1
  8. package/.agent-src/rules/artifact-engagement-recording.md +1 -1
  9. package/.agent-src/rules/augment-source-of-truth.md +1 -1
  10. package/.agent-src/rules/autonomous-execution.md +1 -1
  11. package/.agent-src/rules/caveman-speak.md +1 -1
  12. package/.agent-src/rules/cli-output-handling.md +1 -1
  13. package/.agent-src/rules/command-suggestion-policy.md +1 -1
  14. package/.agent-src/rules/docs-sync.md +1 -1
  15. package/.agent-src/rules/guidelines.md +1 -1
  16. package/.agent-src/rules/improve-before-implement.md +1 -1
  17. package/.agent-src/rules/invite-challenge.md +1 -1
  18. package/.agent-src/rules/minimal-safe-diff.md +1 -1
  19. package/.agent-src/rules/model-recommendation.md +1 -1
  20. package/.agent-src/rules/no-attribution-footers.md +1 -1
  21. package/.agent-src/rules/no-roadmap-references.md +56 -20
  22. package/.agent-src/rules/onboarding-gate.md +1 -1
  23. package/.agent-src/rules/package-ci-checks.md +1 -1
  24. package/.agent-src/rules/reviewer-awareness.md +9 -2
  25. package/.agent-src/rules/roadmap-progress-sync.md +1 -1
  26. package/.agent-src/rules/scope-control.md +6 -0
  27. package/.agent-src/rules/security-sensitive-stop.md +1 -1
  28. package/.agent-src/rules/size-enforcement.md +1 -1
  29. package/.agent-src/rules/token-optimizer-maintenance.md +1 -1
  30. package/.agent-src/rules/ui-audit-gate.md +1 -1
  31. package/.agent-src/skills/adr-create/SKILL.md +2 -1
  32. package/.agent-src/skills/agents-md-thin-root/SKILL.md +125 -0
  33. package/.agent-src/skills/ai-council/SKILL.md +9 -7
  34. package/.agent-src/skills/review-routing/SKILL.md +3 -4
  35. package/.agent-src/templates/AGENTS.md +18 -148
  36. package/.agent-src/templates/copilot-instructions.md +41 -17
  37. package/.agent-src/templates/github-workflows/pr-risk-review.yml +1 -1
  38. package/.agent-src/templates/scripts/pr_review_routing.py +1 -1
  39. package/.claude-plugin/marketplace.json +2 -1
  40. package/AGENTS.md +18 -216
  41. package/CHANGELOG.md +44 -0
  42. package/README.md +2 -2
  43. package/docs/architecture.md +13 -7
  44. package/docs/catalog.md +26 -27
  45. package/docs/contracts/agents-md-tech-stack.md +74 -0
  46. package/docs/contracts/linear-ai-rules-inclusion.md +1 -1
  47. package/docs/contracts/package-self-orientation.md +135 -0
  48. package/docs/contracts/rule-classification.md +4 -4
  49. package/docs/decisions/ADR-004-rule-governance-pruning.md +240 -0
  50. package/docs/getting-started.md +1 -1
  51. package/docs/guidelines/agent-infra/review-routing-data-format.md +1 -2
  52. package/package.json +1 -1
  53. package/scripts/_p4_migrate.py +5 -5
  54. package/scripts/audit_auto_rules.py +159 -0
  55. package/scripts/audit_likelihood.py +148 -0
  56. package/scripts/audit_overlap.py +145 -0
  57. package/scripts/build_rule_trigger_matrix.py +3 -5
  58. package/scripts/check_augment_description_cap.py +79 -0
  59. package/scripts/check_council_references.py +3 -3
  60. package/scripts/check_kernel_rule_bundle.py +151 -0
  61. package/scripts/check_references.py +21 -1
  62. package/scripts/compile_router.py +3 -0
  63. package/scripts/install.sh +0 -1
  64. package/scripts/lint_agents_md.py +168 -0
  65. package/scripts/measure_augment_budget.py +208 -0
  66. package/scripts/schemas/rule.schema.json +2 -1
  67. package/scripts/skill_linter.py +10 -4
  68. package/scripts/spotcheck_thin_root.py +134 -0
  69. package/scripts/update_counts.py +6 -10
  70. package/.agent-src/rules/no-council-references.md +0 -76
  71. package/.agent-src/rules/review-routing-awareness.md +0 -19
  72. package/.agent-src/templates/copilot-review-instructions.md +0 -76
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """Activation-likelihood heuristic for the Rule-Governance pass
3
+ (Phase 5.3 of road-to-augment-limit-fit).
4
+
5
+ For every auto-rule from `agents/reports/auto-rules-audit.json`:
6
+
7
+ 1. Build a token set from `description`, `triggers[].keyword`,
8
+ `triggers[].intent`, and the rule name itself.
9
+ 2. Index a corpus of skills (`SKILL.md`), contexts
10
+ (`agents/contexts/**/*.md`), guidelines, and command files.
11
+ 3. Score `corpus_hits = sum(1 for token in tokens if token in corpus)`.
12
+ 4. Flag rules with `< 2` corpus hits as "low-likelihood" (their trigger
13
+ surface is so generic that the host LLM is unlikely to find a
14
+ project-local file the rule was written to bridge to).
15
+
16
+ Result is a JSON dump + Markdown section appended to
17
+ `agents/reports/auto-rules-audit.md`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ import sys
25
+ from collections import Counter
26
+ from pathlib import Path
27
+
28
+ REPO_ROOT = Path(__file__).resolve().parent.parent
29
+ REPORT_DIR = REPO_ROOT / "agents" / "reports"
30
+ AUDIT_JSON = REPORT_DIR / "auto-rules-audit.json"
31
+ AUDIT_MD = REPORT_DIR / "auto-rules-audit.md"
32
+ LIKELIHOOD_JSON = REPORT_DIR / "auto-rules-likelihood.json"
33
+
34
+ CORPUS_GLOBS = [
35
+ ".agent-src.uncompressed/skills/**/SKILL.md",
36
+ ".agent-src.uncompressed/commands/**/*.md",
37
+ "agents/contexts/**/*.md",
38
+ "docs/guidelines/**/*.md",
39
+ ]
40
+
41
+ LOW_LIKELIHOOD_HITS = 2
42
+
43
+ STOPWORDS = {
44
+ "the", "and", "for", "with", "when", "use", "or", "of", "to", "a",
45
+ "an", "is", "in", "on", "by", "be", "at", "as", "it", "if", "are",
46
+ "this", "that", "from", "but", "not", "can", "any", "all", "no",
47
+ "after", "before", "during", "user", "agent", "code", "project",
48
+ "via", "into", "onto", "even", "without", "naming", "rule", "rules",
49
+ "skill", "skills", "command", "commands", "files", "file", "doc",
50
+ "docs", "md", "txt",
51
+ }
52
+
53
+
54
+ def tokens(text: str) -> set[str]:
55
+ raw = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", text.lower())
56
+ return {t for t in raw if t not in STOPWORDS and len(t) > 3}
57
+
58
+
59
+ def build_corpus() -> Counter:
60
+ counter: Counter = Counter()
61
+ for glob in CORPUS_GLOBS:
62
+ for path in REPO_ROOT.glob(glob):
63
+ if not path.is_file():
64
+ continue
65
+ try:
66
+ text = path.read_text(encoding="utf-8")
67
+ except UnicodeDecodeError:
68
+ continue
69
+ for tok in tokens(text):
70
+ counter[tok] += 1
71
+ return counter
72
+
73
+
74
+ def score(rule: dict, corpus: Counter) -> dict:
75
+ rule_tokens = (
76
+ tokens(rule["description"])
77
+ | tokens(rule["name"].replace("-", " "))
78
+ | tokens(" ".join(rule["triggers"]["keywords"]))
79
+ | tokens(" ".join(rule["triggers"]["intents"]))
80
+ )
81
+ hits = {t: corpus[t] for t in rule_tokens if corpus[t] > 0}
82
+ return {
83
+ "name": rule["name"],
84
+ "tokens": sorted(rule_tokens),
85
+ "hits": dict(sorted(hits.items(), key=lambda x: -x[1])[:8]),
86
+ "hit_count": len(hits),
87
+ "total_hit_volume": sum(hits.values()),
88
+ "low_likelihood": len(hits) < LOW_LIKELIHOOD_HITS,
89
+ }
90
+
91
+
92
+ def render_md(scores: list[dict]) -> str:
93
+ flagged = [s for s in scores if s["low_likelihood"]]
94
+ lines = [
95
+ "",
96
+ "## Phase 5.3 — Activation likelihood (corpus-keyword)",
97
+ "",
98
+ f"Corpus: skills + commands + contexts + guidelines.",
99
+ f"Low-likelihood threshold: `< {LOW_LIKELIHOOD_HITS}` distinct corpus hits.",
100
+ "",
101
+ f"Rules flagged: **{len(flagged)} / {len(scores)}**.",
102
+ "",
103
+ "### Low-likelihood rules",
104
+ "",
105
+ ]
106
+ if not flagged:
107
+ lines += ["_None._", ""]
108
+ else:
109
+ lines += ["| Rule | Hits | Tokens (top) |", "|------|------|--------------|"]
110
+ for s in sorted(flagged, key=lambda x: x["hit_count"]):
111
+ toks = ", ".join(f"`{t}`" for t in s["tokens"][:6]) or "—"
112
+ lines.append(f"| `{s['name']}` | {s['hit_count']} | {toks} |")
113
+ lines.append("")
114
+ lines += [
115
+ "### Full ranking (lowest hit-count first, top 20)",
116
+ "",
117
+ "| Rule | Distinct hits | Total hit volume |",
118
+ "|------|---------------|------------------|",
119
+ ]
120
+ for s in sorted(scores, key=lambda x: (x["hit_count"], x["total_hit_volume"]))[:20]:
121
+ lines.append(f"| `{s['name']}` | {s['hit_count']} | {s['total_hit_volume']} |")
122
+ lines.append("")
123
+ return "\n".join(lines)
124
+
125
+
126
+ def main() -> int:
127
+ if not AUDIT_JSON.exists():
128
+ print(f"❌ Run audit_auto_rules.py first: missing {AUDIT_JSON}", file=sys.stderr)
129
+ return 1
130
+ rules = json.loads(AUDIT_JSON.read_text(encoding="utf-8"))["rules"]
131
+ corpus = build_corpus()
132
+ scores = [score(r, corpus) for r in rules]
133
+ LIKELIHOOD_JSON.write_text(
134
+ json.dumps({"corpus_size": len(corpus), "scores": scores}, indent=2),
135
+ encoding="utf-8",
136
+ )
137
+ md = AUDIT_MD.read_text(encoding="utf-8") if AUDIT_MD.exists() else ""
138
+ if "## Phase 5.3 — Activation likelihood" in md:
139
+ md = md.split("## Phase 5.3 — Activation likelihood")[0].rstrip() + "\n"
140
+ AUDIT_MD.write_text(md + render_md(scores), encoding="utf-8")
141
+ flagged = [s for s in scores if s["low_likelihood"]]
142
+ print(f"✅ Likelihood scored: {len(scores)} rules, {len(flagged)} low-likelihood.")
143
+ print(f" JSON: {LIKELIHOOD_JSON.relative_to(REPO_ROOT)}")
144
+ return 0
145
+
146
+
147
+ if __name__ == "__main__":
148
+ sys.exit(main())
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env python3
2
+ """Trigger-overlap analysis for the Rule-Governance pass (Phase 5.2 of
3
+ road-to-augment-limit-fit).
4
+
5
+ Reads `agents/reports/auto-rules-audit.json` (produced by
6
+ `audit_auto_rules.py`) and computes:
7
+
8
+ - path-prefix Jaccard similarity (per pair of rules);
9
+ - description-keyword overlap fraction (per pair of rules).
10
+
11
+ Pairs scoring `path_jaccard >= 0.5` OR `keyword_overlap >= 0.4` are
12
+ flagged as merge candidates. Output is appended to
13
+ `agents/reports/auto-rules-audit.md` and a structured JSON list is
14
+ written to `agents/reports/auto-rules-overlap.json` for downstream
15
+ consumers (Phase 5.3 likelihood, 5.4 council walk).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ import sys
23
+ from itertools import combinations
24
+ from pathlib import Path
25
+
26
+ REPO_ROOT = Path(__file__).resolve().parent.parent
27
+ REPORT_DIR = REPO_ROOT / "agents" / "reports"
28
+ AUDIT_JSON = REPORT_DIR / "auto-rules-audit.json"
29
+ AUDIT_MD = REPORT_DIR / "auto-rules-audit.md"
30
+ OVERLAP_JSON = REPORT_DIR / "auto-rules-overlap.json"
31
+
32
+ PATH_THRESHOLD = 0.5
33
+ KEYWORD_THRESHOLD = 0.4
34
+
35
+ STOPWORDS = {
36
+ "the", "and", "for", "with", "when", "use", "or", "of", "to", "a",
37
+ "an", "is", "in", "on", "by", "be", "at", "as", "it", "if", "are",
38
+ "this", "that", "from", "but", "not", "can", "any", "all", "no",
39
+ "after", "before", "during", "user", "agent", "code", "project",
40
+ "via", "into", "onto", "even", "without", "naming",
41
+ }
42
+
43
+
44
+ def keyword_set(text: str) -> set[str]:
45
+ tokens = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}", text.lower())
46
+ return {t for t in tokens if t not in STOPWORDS and not t.isdigit()}
47
+
48
+
49
+ def jaccard(a: set, b: set) -> float:
50
+ if not a and not b:
51
+ return 0.0
52
+ return len(a & b) / len(a | b)
53
+
54
+
55
+ def overlap_fraction(a: set, b: set) -> float:
56
+ """Symmetric overlap as fraction of smaller set."""
57
+ if not a or not b:
58
+ return 0.0
59
+ return len(a & b) / min(len(a), len(b))
60
+
61
+
62
+ def analyse(rules: list[dict]) -> list[dict]:
63
+ pairs: list[dict] = []
64
+ for r in rules:
65
+ r["_paths"] = set(r["triggers"]["path_prefixes"])
66
+ r["_keywords"] = (
67
+ keyword_set(r["description"])
68
+ | keyword_set(" ".join(r["triggers"]["keywords"]))
69
+ | keyword_set(" ".join(r["triggers"]["intents"]))
70
+ )
71
+
72
+ for a, b in combinations(rules, 2):
73
+ pj = jaccard(a["_paths"], b["_paths"])
74
+ ko = overlap_fraction(a["_keywords"], b["_keywords"])
75
+ flagged = pj >= PATH_THRESHOLD or ko >= KEYWORD_THRESHOLD
76
+ if not flagged:
77
+ continue
78
+ pairs.append(
79
+ {
80
+ "rule_a": a["name"],
81
+ "rule_b": b["name"],
82
+ "path_jaccard": round(pj, 3),
83
+ "keyword_overlap": round(ko, 3),
84
+ "shared_paths": sorted(a["_paths"] & b["_paths"]),
85
+ "shared_keywords": sorted(a["_keywords"] & b["_keywords"])[:12],
86
+ "rule_a_desc": a["description"],
87
+ "rule_b_desc": b["description"],
88
+ }
89
+ )
90
+
91
+ return sorted(
92
+ pairs, key=lambda p: -(p["path_jaccard"] + p["keyword_overlap"])
93
+ )
94
+
95
+
96
+ def render_md(pairs: list[dict]) -> str:
97
+ lines = [
98
+ "",
99
+ "## Phase 5.2 — Trigger overlap (Jaccard + keyword)",
100
+ "",
101
+ f"Pairs flagged: **{len(pairs)}** "
102
+ f"(thresholds: path-Jaccard ≥ {PATH_THRESHOLD}, "
103
+ f"keyword-overlap ≥ {KEYWORD_THRESHOLD}).",
104
+ "",
105
+ ]
106
+ if not pairs:
107
+ lines.append("_No pairs over threshold._")
108
+ lines.append("")
109
+ return "\n".join(lines)
110
+ lines += [
111
+ "| # | Rule A | Rule B | Path-J | Keyword-O | Shared keywords |",
112
+ "|---|--------|--------|--------|-----------|-----------------|",
113
+ ]
114
+ for i, p in enumerate(pairs, 1):
115
+ kw = ", ".join(f"`{k}`" for k in p["shared_keywords"][:6]) or "—"
116
+ lines.append(
117
+ f"| {i} | `{p['rule_a']}` | `{p['rule_b']}` | "
118
+ f"{p['path_jaccard']:.2f} | {p['keyword_overlap']:.2f} | {kw} |"
119
+ )
120
+ lines.append("")
121
+ return "\n".join(lines)
122
+
123
+
124
+ def main() -> int:
125
+ if not AUDIT_JSON.exists():
126
+ print(f"❌ Run audit_auto_rules.py first: missing {AUDIT_JSON}", file=sys.stderr)
127
+ return 1
128
+ data = json.loads(AUDIT_JSON.read_text(encoding="utf-8"))
129
+ pairs = analyse(data["rules"])
130
+ OVERLAP_JSON.write_text(
131
+ json.dumps({"pair_count": len(pairs), "pairs": pairs}, indent=2),
132
+ encoding="utf-8",
133
+ )
134
+ md_existing = AUDIT_MD.read_text(encoding="utf-8") if AUDIT_MD.exists() else ""
135
+ if "## Phase 5.2 — Trigger overlap" in md_existing:
136
+ md_existing = md_existing.split("## Phase 5.2 — Trigger overlap")[0].rstrip() + "\n"
137
+ AUDIT_MD.write_text(md_existing + render_md(pairs), encoding="utf-8")
138
+ print(f"✅ Overlap analysis: {len(pairs)} pairs flagged.")
139
+ print(f" JSON: {OVERLAP_JSON.relative_to(REPO_ROOT)}")
140
+ print(f" MD appended: {AUDIT_MD.relative_to(REPO_ROOT)}")
141
+ return 0
142
+
143
+
144
+ if __name__ == "__main__":
145
+ sys.exit(main())
@@ -117,11 +117,9 @@ add("agent-docs.md", "file-edit on agents/docs/, AGENTS.md", "hook",
117
117
  "tool-call", "medium", "2a", notes="Path-pattern based marker")
118
118
  add("upstream-proposal.md", "skill/rule create event", "hook", "output",
119
119
  "medium", "2a", notes="Marker after new artifact lands")
120
- add("review-routing-awareness.md", "PR-prep / risk flagging", "hook",
121
- "output", "medium", "2a",
122
- notes="Marker when /create-pr or risk-tagging keywords detected")
123
- add("reviewer-awareness.md", "PR-prep", "hook", "output",
124
- "medium", "2a", notes="Reviewer-suggestion marker at PR creation")
120
+ add("reviewer-awareness.md", "PR-prep / reviewer-suggestion / risk flagging",
121
+ "hook", "output", "medium", "2a",
122
+ notes="Reviewer-suggestion + risk-tagging marker at PR creation; consolidates former review-routing-awareness")
125
123
  add("security-sensitive-stop.md", "file-edit on auth/billing/secrets paths",
126
124
  "hook", "tool-call", "low", "2a",
127
125
  notes="Path-pattern based marker — strong candidate for low-cost hook")
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """Auto-rule description-length CI gate (Phase 1.3 of
3
+ road-to-augment-limit-fit).
4
+
5
+ For every `type: auto` rule under `.agent-src.uncompressed/rules/`,
6
+ fail CI when the frontmatter `description:` exceeds DESC_CAP chars.
7
+
8
+ Why: Augment injects each auto-rule's description into the
9
+ workspace-guidelines registry stub. Empirical 2026-05-08 budget
10
+ analysis showed this channel consuming 25 % of the 49,512-char
11
+ ceiling. Capping descriptions guards future drift.
12
+
13
+ Source of truth: `.agent-src.uncompressed/rules/`. The compressed
14
+ projection is regenerated; the source dictates what ships.
15
+
16
+ Exit codes: 0 = pass, 1 = at least one rule over cap.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ REPO_ROOT = Path(__file__).resolve().parent.parent
26
+ RULES_DIR = REPO_ROOT / ".agent-src.uncompressed" / "rules"
27
+ DESC_CAP = 150
28
+
29
+
30
+ def parse_frontmatter(text: str) -> dict[str, str]:
31
+ if not text.startswith("---\n"):
32
+ return {}
33
+ end = text.find("\n---", 4)
34
+ if end < 0:
35
+ return {}
36
+ fm: dict[str, str] = {}
37
+ for line in text[4:end].splitlines():
38
+ m = re.match(r"^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$", line)
39
+ if m:
40
+ fm[m.group(1)] = m.group(2).strip().strip('"').strip("'")
41
+ return fm
42
+
43
+
44
+ def main() -> int:
45
+ failures: list[tuple[str, int, str]] = []
46
+ checked = 0
47
+
48
+ for path in sorted(RULES_DIR.glob("*.md")):
49
+ text = path.read_text()
50
+ fm = parse_frontmatter(text)
51
+ if fm.get("type") != "auto":
52
+ continue
53
+ desc = fm.get("description", "")
54
+ checked += 1
55
+ if len(desc) > DESC_CAP:
56
+ failures.append((path.name, len(desc), desc))
57
+
58
+ if failures:
59
+ print(
60
+ f"❌ {len(failures)} auto-rule description(s) exceed {DESC_CAP} chars:\n",
61
+ file=sys.stderr,
62
+ )
63
+ for name, dlen, desc in sorted(failures, key=lambda x: -x[1]):
64
+ print(f" [{dlen:>3}] {name}", file=sys.stderr)
65
+ print(f" {desc}", file=sys.stderr)
66
+ print(
67
+ f"\n Guard rationale: each char in an auto-rule description "
68
+ f"costs one char in the\n Augment workspace-guidelines budget "
69
+ f"(cap 49,512). Trim to ≤ {DESC_CAP}.",
70
+ file=sys.stderr,
71
+ )
72
+ return 1
73
+
74
+ print(f"✅ All {checked} auto-rule descriptions ≤ {DESC_CAP} chars.")
75
+ return 0
76
+
77
+
78
+ if __name__ == "__main__":
79
+ sys.exit(main())
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """CI guard for the `no-council-references` rule.
2
+ """CI guard for the council clause of the `no-roadmap-references` rule.
3
3
 
4
4
  Council artefacts under `agents/council-{questions,responses,sessions}/`
5
5
  are gitignored, local-only, and auto-pruned. A link to a specific
@@ -68,7 +68,7 @@ ALLOWLIST_PREFIXES: tuple[str, ...] = (
68
68
  # the SKIP_DIRS contract in scripts/check_references.py).
69
69
  "agents/analysis/",
70
70
  # The rule itself documents forbidden vs. allowed forms.
71
- ".agent-src.uncompressed/rules/no-council-references.md",
71
+ ".agent-src.uncompressed/rules/no-roadmap-references.md",
72
72
  # ai-council skill documents the output-path schema.
73
73
  ".agent-src.uncompressed/skills/ai-council/",
74
74
  # Council commands document the output-path schema.
@@ -134,7 +134,7 @@ def main() -> int:
134
134
  for path, ln, ref in violations:
135
135
  print(f" - {path.as_posix()}:{ln}: {ref}")
136
136
  print(
137
- "\nRule: .agent-src/rules/no-council-references.md\n"
137
+ "\nRule: .agent-src/rules/no-roadmap-references.md (council clause)\n"
138
138
  "Fix: inline the convergence summary (members + date) instead of\n"
139
139
  "linking the file. Append "
140
140
  "<!-- council-ref-allowed: <reason> --> on the same line to\n"
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """check_kernel_rule_bundle — Phase 4.2 of road-to-always-budget-relief.
3
+
4
+ Fails when a single PR (or commit range) modifies more than one
5
+ kernel rule under `.agent-src.uncompressed/rules/`. Override via the
6
+ PR label `bundled-always-rules-acknowledged`.
7
+
8
+ Kernel set is the locked 9-rule list in
9
+ `docs/contracts/rule-classification.md` § 3.1, mirrored as
10
+ `KERNEL_RULES` below. The list is short and stable; on kernel-set
11
+ change, update both files in the same PR.
12
+
13
+ Inputs:
14
+ --base-ref REF git ref to diff against (default: origin/main, then main)
15
+ --label NAME PR label that overrides the gate (default:
16
+ bundled-always-rules-acknowledged)
17
+ --event-path P GitHub event JSON (defaults to $GITHUB_EVENT_PATH)
18
+ --files F [F …] override changed-file list (testing only)
19
+
20
+ Exit codes: 0 = pass · 1 = fail (> 1 kernel rule, no override) ·
21
+ 3 = internal error.
22
+
23
+ Source: `agents/contexts/adr-always-budget-relief-strategy.md`.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import subprocess
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ KERNEL_RULES = frozenset({
35
+ "agent-authority.md",
36
+ "ask-when-uncertain.md",
37
+ "commit-policy.md",
38
+ "direct-answers.md",
39
+ "language-and-tone.md",
40
+ "no-cheap-questions.md",
41
+ "non-destructive-by-default.md",
42
+ "scope-control.md",
43
+ "verify-before-complete.md",
44
+ })
45
+
46
+ KERNEL_DIR = ".agent-src.uncompressed/rules"
47
+ DEFAULT_LABEL = "bundled-always-rules-acknowledged"
48
+
49
+
50
+ def _git_changed_files(base_ref: str) -> list[str]:
51
+ try:
52
+ out = subprocess.check_output(
53
+ ["git", "diff", "--name-only", f"{base_ref}...HEAD"],
54
+ stderr=subprocess.STDOUT,
55
+ text=True,
56
+ )
57
+ except subprocess.CalledProcessError as exc:
58
+ print(f"❌ git diff failed: {exc.output.strip()}", file=sys.stderr)
59
+ return []
60
+ return [line for line in out.splitlines() if line.strip()]
61
+
62
+
63
+ def _resolve_base_ref(explicit: str | None) -> str:
64
+ if explicit:
65
+ return explicit
66
+ for candidate in ("origin/main", "origin/master", "main", "master"):
67
+ try:
68
+ subprocess.check_output(
69
+ ["git", "rev-parse", "--verify", candidate],
70
+ stderr=subprocess.DEVNULL,
71
+ )
72
+ return candidate
73
+ except subprocess.CalledProcessError:
74
+ continue
75
+ return "HEAD~1"
76
+
77
+
78
+ def _pr_labels(event_path: str | None) -> list[str]:
79
+ path = event_path or os.environ.get("GITHUB_EVENT_PATH")
80
+ if not path or not Path(path).exists():
81
+ return []
82
+ try:
83
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
84
+ except (OSError, json.JSONDecodeError):
85
+ return []
86
+ pr = data.get("pull_request") or {}
87
+ return [lbl.get("name", "") for lbl in pr.get("labels", []) if lbl.get("name")]
88
+
89
+
90
+ def _kernel_changes(files: list[str]) -> list[str]:
91
+ hits: list[str] = []
92
+ for path in files:
93
+ if not path.startswith(f"{KERNEL_DIR}/"):
94
+ continue
95
+ name = Path(path).name
96
+ if name in KERNEL_RULES:
97
+ hits.append(path)
98
+ return sorted(set(hits))
99
+
100
+
101
+ def main(argv: list[str] | None = None) -> int:
102
+ ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
103
+ ap.add_argument("--base-ref", default=None)
104
+ ap.add_argument("--label", default=DEFAULT_LABEL)
105
+ ap.add_argument("--event-path", default=None)
106
+ ap.add_argument("--files", nargs="*", default=None)
107
+ args = ap.parse_args(argv)
108
+
109
+ files = args.files or _git_changed_files(_resolve_base_ref(args.base_ref))
110
+ hits = _kernel_changes(files)
111
+
112
+ if len(hits) <= 1:
113
+ if hits:
114
+ print(f"✅ OK kernel-rule bundle: 1 rule touched ({hits[0]})")
115
+ else:
116
+ print("✅ OK kernel-rule bundle: no kernel rule touched")
117
+ return 0
118
+
119
+ labels = _pr_labels(args.event_path)
120
+ if args.label in labels:
121
+ print(
122
+ f"✅ OK kernel-rule bundle: {len(hits)} rules touched but "
123
+ f"label '{args.label}' present"
124
+ )
125
+ for h in hits:
126
+ print(f" · {h}")
127
+ return 0
128
+
129
+ print(
130
+ f"❌ FAIL kernel-rule bundle: {len(hits)} kernel rules touched in "
131
+ f"one PR — slow-rollout requires one-rule-per-PR.",
132
+ file=sys.stderr,
133
+ )
134
+ print(" Touched:", file=sys.stderr)
135
+ for h in hits:
136
+ print(f" · {h}", file=sys.stderr)
137
+ print(
138
+ f" Override: add the label '{args.label}' on the PR and "
139
+ f"document the bundle rationale in the PR body.",
140
+ file=sys.stderr,
141
+ )
142
+ print(
143
+ " Source: agents/contexts/adr-always-budget-relief-strategy.md "
144
+ "(Phase 4.2).",
145
+ file=sys.stderr,
146
+ )
147
+ return 1
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main())
@@ -228,6 +228,10 @@ def check_file(filepath: Path, artifacts: dict[str, set[str]], root: Path) -> Li
228
228
  ))
229
229
 
230
230
  in_code_block = False
231
+ # Track whether we are inside an unchecked-TODO bullet (multi-line
232
+ # roadmap items wrap continuation text under the `- [ ]` line and
233
+ # those continuation lines must inherit the forward-ref exemption).
234
+ in_unchecked_todo = False
231
235
  for i, line in enumerate(text.splitlines(), 1):
232
236
  stripped = line.strip()
233
237
  if stripped.startswith("```"):
@@ -237,9 +241,25 @@ def check_file(filepath: Path, artifacts: dict[str, set[str]], root: Path) -> Li
237
241
  continue
238
242
 
239
243
  # Unchecked TODO checkboxes document future work — their refs are
240
- # forward-looking and will not resolve yet.
244
+ # forward-looking and will not resolve yet. Track multi-line bullets:
245
+ # any `- [ ]` opens a TODO context; a new top-level bullet, heading,
246
+ # or blank line closes it.
241
247
  if UNCHECKED_TODO_PATTERN.match(line):
248
+ in_unchecked_todo = True
242
249
  continue
250
+ if in_unchecked_todo:
251
+ if not stripped:
252
+ in_unchecked_todo = False
253
+ continue
254
+ # A new bullet (checked or unchecked) or a heading closes the
255
+ # current TODO context. An indented continuation line keeps it.
256
+ if re.match(r"^[-*+]\s+\[", line) or stripped.startswith("#"):
257
+ in_unchecked_todo = False
258
+ elif line[:1] in (" ", "\t"):
259
+ # Indented continuation of the unchecked TODO — skip.
260
+ continue
261
+ else:
262
+ in_unchecked_todo = False
243
263
 
244
264
  # File path references
245
265
  for m in PATH_PATTERN.finditer(line):
@@ -125,6 +125,9 @@ def _collect(rules_dir: Path) -> dict:
125
125
  if not COMPILE_TIME_TOGGLES[rule_id](settings):
126
126
  continue
127
127
  rule_type = str(fm.get("type", "auto"))
128
+ # Manual rules are reference-only (ADR-004) — no router emission.
129
+ if rule_type == "manual":
130
+ continue
128
131
  tier = _resolve_tier(rule_type, fm.get("tier", ""))
129
132
  if tier not in ALLOWED_TIERS:
130
133
  continue
@@ -728,7 +728,6 @@ main() {
728
728
  # into consumer projects.
729
729
  copy_if_missing "$SOURCE_PAYLOAD/templates/AGENTS.md" "$TARGET_DIR/AGENTS.md"
730
730
  copy_if_missing "$SOURCE_PAYLOAD/templates/copilot-instructions.md" "$TARGET_DIR/.github/copilot-instructions.md"
731
- copy_if_missing "$SOURCE_PAYLOAD/templates/copilot-review-instructions.md" "$TARGET_DIR/.github/copilot-review-instructions.md"
732
731
 
733
732
  # 3. Create tool-specific symlinks
734
733
  create_tool_symlinks "$TARGET_DIR"