@event4u/agent-config 1.17.0 → 1.19.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 (158) hide show
  1. package/.agent-src/commands/council/default.md +74 -76
  2. package/.agent-src/commands/feature/roadmap.md +22 -0
  3. package/.agent-src/commands/roadmap/create.md +38 -6
  4. package/.agent-src/commands/roadmap/execute.md +36 -9
  5. package/.agent-src/rules/agent-authority.md +1 -0
  6. package/.agent-src/rules/agent-docs.md +1 -0
  7. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  8. package/.agent-src/rules/architecture.md +1 -0
  9. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  10. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  11. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  12. package/.agent-src/rules/augment-portability.md +1 -0
  13. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  14. package/.agent-src/rules/autonomous-execution.md +1 -0
  15. package/.agent-src/rules/capture-learnings.md +1 -0
  16. package/.agent-src/rules/chat-history-cadence.md +34 -0
  17. package/.agent-src/rules/chat-history-ownership.md +1 -0
  18. package/.agent-src/rules/chat-history-visibility.md +1 -0
  19. package/.agent-src/rules/cli-output-handling.md +2 -2
  20. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  21. package/.agent-src/rules/commit-conventions.md +1 -0
  22. package/.agent-src/rules/commit-policy.md +1 -0
  23. package/.agent-src/rules/context-hygiene.md +28 -0
  24. package/.agent-src/rules/direct-answers.md +18 -26
  25. package/.agent-src/rules/docker-commands.md +1 -0
  26. package/.agent-src/rules/docs-sync.md +1 -0
  27. package/.agent-src/rules/downstream-changes.md +1 -0
  28. package/.agent-src/rules/e2e-testing.md +1 -0
  29. package/.agent-src/rules/guidelines.md +1 -0
  30. package/.agent-src/rules/improve-before-implement.md +1 -0
  31. package/.agent-src/rules/language-and-tone.md +1 -0
  32. package/.agent-src/rules/laravel-translations.md +1 -0
  33. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  34. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  35. package/.agent-src/rules/missing-tool-handling.md +1 -0
  36. package/.agent-src/rules/model-recommendation.md +1 -0
  37. package/.agent-src/rules/no-cheap-questions.md +15 -21
  38. package/.agent-src/rules/no-roadmap-references.md +1 -0
  39. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  40. package/.agent-src/rules/onboarding-gate.md +33 -0
  41. package/.agent-src/rules/package-ci-checks.md +1 -0
  42. package/.agent-src/rules/php-coding.md +1 -0
  43. package/.agent-src/rules/preservation-guard.md +1 -0
  44. package/.agent-src/rules/review-routing-awareness.md +1 -0
  45. package/.agent-src/rules/reviewer-awareness.md +1 -0
  46. package/.agent-src/rules/roadmap-progress-sync.md +49 -0
  47. package/.agent-src/rules/role-mode-adherence.md +2 -2
  48. package/.agent-src/rules/rule-type-governance.md +29 -0
  49. package/.agent-src/rules/runtime-safety.md +1 -0
  50. package/.agent-src/rules/scope-control.md +1 -0
  51. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  52. package/.agent-src/rules/size-enforcement.md +1 -0
  53. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  54. package/.agent-src/rules/skill-quality.md +1 -0
  55. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  56. package/.agent-src/rules/think-before-action.md +1 -0
  57. package/.agent-src/rules/token-efficiency.md +1 -0
  58. package/.agent-src/rules/tool-safety.md +1 -0
  59. package/.agent-src/rules/ui-audit-gate.md +1 -0
  60. package/.agent-src/rules/upstream-proposal.md +1 -0
  61. package/.agent-src/rules/user-interaction.md +1 -0
  62. package/.agent-src/rules/verify-before-complete.md +1 -0
  63. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  64. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  65. package/.agent-src/templates/agent-settings.md +16 -0
  66. package/.agent-src/templates/roadmaps.md +12 -3
  67. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +9 -0
  68. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -0
  69. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -0
  70. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  71. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +111 -0
  72. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  73. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  74. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  75. package/.claude-plugin/marketplace.json +1 -1
  76. package/CHANGELOG.md +97 -0
  77. package/README.md +20 -20
  78. package/config/agent-settings.template.yml +23 -0
  79. package/docs/architecture.md +1 -1
  80. package/docs/catalog.md +5 -2
  81. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  82. package/docs/contracts/decision-trace-v1.md +146 -0
  83. package/docs/contracts/file-ownership-matrix.json +7 -0
  84. package/docs/contracts/hook-architecture-v1.md +213 -0
  85. package/docs/contracts/load-context-budget-model.md +80 -0
  86. package/docs/contracts/load-context-schema.md +20 -0
  87. package/docs/contracts/memory-visibility-v1.md +138 -0
  88. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  89. package/docs/contracts/roadmap-complexity-standard.md +137 -0
  90. package/docs/contracts/rule-interactions.yml +22 -0
  91. package/docs/customization.md +1 -0
  92. package/docs/development.md +4 -1
  93. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +134 -0
  94. package/docs/guidelines/agent-infra/direct-answers-demos.md +145 -0
  95. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  96. package/docs/guidelines/agent-infra/verify-before-complete-demos.md +128 -0
  97. package/package.json +1 -1
  98. package/scripts/agent-config +64 -0
  99. package/scripts/ai_council/bundler.py +3 -3
  100. package/scripts/ai_council/clients.py +24 -8
  101. package/scripts/ai_council/one_off_archive/2026-05/README.md +67 -0
  102. package/scripts/ai_council/one_off_archive/2026-05/_one_off_budget_v2_audit.py +206 -0
  103. package/scripts/ai_council/{_one_off_roundtrip.py → one_off_archive/2026-05/_one_off_roundtrip.py} +13 -8
  104. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  105. package/scripts/ai_council/session.py +92 -0
  106. package/scripts/build_rule_trigger_matrix.py +360 -0
  107. package/scripts/capture_showcase_session.py +361 -0
  108. package/scripts/chat_history.py +11 -1
  109. package/scripts/check_always_budget.py +46 -2
  110. package/scripts/check_one_off_location.py +81 -0
  111. package/scripts/check_references.py +6 -0
  112. package/scripts/compress.py +5 -2
  113. package/scripts/context_hygiene_hook.py +181 -0
  114. package/scripts/council_cli.py +357 -0
  115. package/scripts/hook_manifest.yaml +184 -0
  116. package/scripts/hooks/__init__.py +1 -0
  117. package/scripts/hooks/augment-context-hygiene.sh +55 -0
  118. package/scripts/hooks/augment-dispatcher.sh +72 -0
  119. package/scripts/hooks/augment-onboarding-gate.sh +55 -0
  120. package/scripts/hooks/cline-dispatcher.sh +86 -0
  121. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  122. package/scripts/hooks/dispatch_hook.py +348 -0
  123. package/scripts/hooks/envelope.py +98 -0
  124. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  125. package/scripts/hooks/state_io.py +122 -0
  126. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  127. package/scripts/hooks_status.py +146 -0
  128. package/scripts/install.py +728 -51
  129. package/scripts/install.sh +1 -1
  130. package/scripts/lint_examples.py +98 -0
  131. package/scripts/lint_hook_manifest.py +216 -0
  132. package/scripts/lint_one_off_age.py +184 -0
  133. package/scripts/lint_roadmap_complexity.py +127 -0
  134. package/scripts/lint_rule_tiers.py +78 -0
  135. package/scripts/lint_showcase_sessions.py +148 -0
  136. package/scripts/minimal_safe_diff_hook.py +245 -0
  137. package/scripts/onboarding_gate_hook.py +142 -0
  138. package/scripts/readme_linter.py +12 -3
  139. package/scripts/roadmap_progress_hook.py +5 -0
  140. package/scripts/schemas/rule.schema.json +5 -0
  141. package/scripts/sync_agent_settings.py +32 -129
  142. package/scripts/sync_yaml_rt.py +734 -0
  143. package/scripts/verify_before_complete_hook.py +216 -0
  144. /package/scripts/ai_council/{_one_off_2a4_acceptance.py → one_off_archive/2026-05/_one_off_2a4_acceptance.py} +0 -0
  145. /package/scripts/ai_council/{_one_off_context_layer_v1_estimate.py → one_off_archive/2026-05/_one_off_context_layer_v1_estimate.py} +0 -0
  146. /package/scripts/ai_council/{_one_off_context_layer_v1_review.py → one_off_archive/2026-05/_one_off_context_layer_v1_review.py} +0 -0
  147. /package/scripts/ai_council/{_one_off_followups_review.py → one_off_archive/2026-05/_one_off_followups_review.py} +0 -0
  148. /package/scripts/ai_council/{_one_off_nondestructive_inline_audit.py → one_off_archive/2026-05/_one_off_nondestructive_inline_audit.py} +0 -0
  149. /package/scripts/{_one_off_phase4_dispatch_latency.py → ai_council/one_off_archive/2026-05/_one_off_phase4_dispatch_latency.py} +0 -0
  150. /package/scripts/{_one_off_phase6_trigger_jaccard.py → ai_council/one_off_archive/2026-05/_one_off_phase6_trigger_jaccard.py} +0 -0
  151. /package/scripts/ai_council/{_one_off_phase_2a_budget_rebalance.py → one_off_archive/2026-05/_one_off_phase_2a_budget_rebalance.py} +0 -0
  152. /package/scripts/ai_council/{_one_off_phase_2a_post_revert.py → one_off_archive/2026-05/_one_off_phase_2a_post_revert.py} +0 -0
  153. /package/scripts/ai_council/{_one_off_rebalancing_audit.py → one_off_archive/2026-05/_one_off_rebalancing_audit.py} +0 -0
  154. /package/scripts/ai_council/{_one_off_rule_hardening_v1.py → one_off_archive/2026-05/_one_off_rule_hardening_v1.py} +0 -0
  155. /package/scripts/ai_council/{_one_off_structural_open_questions.py → one_off_archive/2026-05/_one_off_structural_open_questions.py} +0 -0
  156. /package/scripts/ai_council/{_one_off_structural_optimization.py → one_off_archive/2026-05/_one_off_structural_optimization.py} +0 -0
  157. /package/scripts/ai_council/{_one_off_structural_v3_gaps.py → one_off_archive/2026-05/_one_off_structural_v3_gaps.py} +0 -0
  158. /package/scripts/ai_council/{_one_off_structural_v3_review.py → one_off_archive/2026-05/_one_off_structural_v3_review.py} +0 -0
@@ -344,7 +344,7 @@ clean_stale() {
344
344
  log_verbose "preserve: $entry"
345
345
  continue
346
346
  fi
347
- if is_excluded_rule "$entry" || ! echo "$source_manifest" | grep -qxF "$entry"; then
347
+ if is_excluded_rule "$entry" || ! grep -qxF -- "$entry" <<<"$source_manifest"; then
348
348
  local path="$target_dir/$entry"
349
349
  if $DRY_RUN; then
350
350
  log_verbose "remove stale: $entry"
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """Phase 3.4 demo-shape linter — wrong / right / why per demo.
3
+
4
+ Cap: ≤ 100 LOC, stdlib only. Hooked into `task ci` via
5
+ `Taskfile.yml` ▸ `check-examples-shape`. Validates every
6
+ `docs/guidelines/agent-infra/*-demos.md`: frontmatter keys
7
+ (`demo_for:`, `layer: pattern-memory`, `prose_delta:` with before /
8
+ after char counts), and each `## Demo N` section having Wrong /
9
+ Right shape headings, a `**Failure mode:**` line, and a Why-it-works
10
+ explanation (heading or inline).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ REPO_ROOT = Path(__file__).resolve().parent.parent
19
+ DEMO_GLOB = "docs/guidelines/agent-infra/*-demos.md"
20
+ REQUIRED_FM_KEYS = ("demo_for:", "layer: pattern-memory", "prose_delta:")
21
+ REQUIRED_FM_DELTA = ("rule_chars_before:", "rule_chars_after:")
22
+
23
+
24
+ def _frontmatter(text: str) -> str:
25
+ if not text.startswith("---\n"):
26
+ return ""
27
+ end = text.find("\n---\n", 4)
28
+ return text[4:end] if end != -1 else ""
29
+
30
+
31
+ def _check_frontmatter(fm: str, problems: list[str]) -> None:
32
+ for key in (*REQUIRED_FM_KEYS, *REQUIRED_FM_DELTA):
33
+ if key not in fm:
34
+ problems.append(f"frontmatter missing: {key!r}")
35
+
36
+
37
+ def _check_demo_sections(text: str, problems: list[str]) -> None:
38
+ demo_pat = re.compile(r"^## Demo \d+\b.*$", re.MULTILINE)
39
+ demo_starts = [m.start() for m in demo_pat.finditer(text)]
40
+ if not demo_starts:
41
+ problems.append("no '## Demo N — …' sections found")
42
+ return
43
+ bounds = demo_starts + [len(text)]
44
+ for i, start in enumerate(demo_starts):
45
+ section = text[start:bounds[i + 1]]
46
+ title = section.splitlines()[0]
47
+ if "### Wrong shape" not in section:
48
+ problems.append(f"{title!r}: missing '### Wrong shape'")
49
+ if "### Right shape" not in section:
50
+ problems.append(f"{title!r}: missing '### Right shape'")
51
+ if "**Failure mode:**" not in section:
52
+ problems.append(f"{title!r}: missing '**Failure mode:**' line")
53
+ has_why_section = "### Why it works" in section
54
+ has_why_inline = "**Why it works:**" in section
55
+ if not (has_why_section or has_why_inline):
56
+ problems.append(
57
+ f"{title!r}: missing 'Why it works' explanation "
58
+ "(### Why it works or **Why it works:** inline)"
59
+ )
60
+
61
+
62
+ def lint_demo(path: Path) -> list[str]:
63
+ text = path.read_text(encoding="utf-8")
64
+ problems: list[str] = []
65
+ fm = _frontmatter(text)
66
+ if not fm:
67
+ problems.append("missing YAML frontmatter (--- block at top)")
68
+ else:
69
+ _check_frontmatter(fm, problems)
70
+ _check_demo_sections(text, problems)
71
+ return problems
72
+
73
+
74
+ def main() -> int:
75
+ demos = sorted(REPO_ROOT.glob(DEMO_GLOB))
76
+ if not demos:
77
+ print(f"❌ no demo files matched {DEMO_GLOB}", file=sys.stderr)
78
+ return 1
79
+ failed = 0
80
+ for demo in demos:
81
+ rel = demo.relative_to(REPO_ROOT)
82
+ problems = lint_demo(demo)
83
+ if problems:
84
+ failed += 1
85
+ print(f"❌ {rel}", file=sys.stderr)
86
+ for p in problems:
87
+ print(f" - {p}", file=sys.stderr)
88
+ else:
89
+ print(f"✅ {rel}")
90
+ if failed:
91
+ print(f"\n❌ {failed} demo file(s) failed shape lint", file=sys.stderr)
92
+ return 1
93
+ print(f"\n✅ {len(demos)} demo file(s) shape-clean")
94
+ return 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ sys.exit(main())
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ """Lint `scripts/hook_manifest.yaml`.
3
+
4
+ CI gate per roadmap step 7.10. Hard-fails on:
5
+
6
+ - missing or malformed top-level keys (`schema_version`, `concerns`,
7
+ `platforms`)
8
+ - a concern entry referencing a non-existent script file
9
+ - a platform binding referencing an unknown concern name
10
+ - a platform binding referencing an unknown event (outside the
11
+ vocabulary in `docs/contracts/hook-architecture-v1.md`)
12
+ - a `native_event_aliases` block referencing an unknown agent-config
13
+ event or an unknown platform
14
+ - a `scripts/hooks/<platform>-dispatcher.sh` trampoline that exists on
15
+ disk without a corresponding non-empty platform block in the
16
+ manifest (orphan trampoline)
17
+
18
+ Soft-warns on:
19
+
20
+ - platform blocks set to `null` / empty (Phase 7.5–7.8 placeholders)
21
+ - concerns declared but not bound to any platform (dead concern)
22
+
23
+ Exit codes:
24
+ 0 — clean (warnings allowed)
25
+ 1 — at least one hard failure
26
+ 2 — file or schema-load error
27
+
28
+ Invocation:
29
+
30
+ python3 scripts/lint_hook_manifest.py [--manifest PATH] [--strict]
31
+
32
+ `--strict` upgrades warnings to errors. Wired into `task ci` via the
33
+ `lint-hook-manifest` task.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ REPO_ROOT = Path(__file__).resolve().parent.parent
42
+ DEFAULT_MANIFEST = REPO_ROOT / "scripts" / "hook_manifest.yaml"
43
+ HOOKS_DIR = REPO_ROOT / "scripts" / "hooks"
44
+
45
+ # Canonical event vocabulary — keep in lock-step with
46
+ # docs/contracts/hook-architecture-v1.md and dispatch_hook.EVENT_VOCABULARY.
47
+ # `agent_error` added in Round 2 (2026-05-04) — synthetic event the
48
+ # wrapper fires on host crashes outside a concern.
49
+ EVENT_VOCABULARY: set[str] = {
50
+ "session_start", "session_end",
51
+ "user_prompt_submit",
52
+ "pre_tool_use", "post_tool_use",
53
+ "stop", "pre_compact",
54
+ "agent_error",
55
+ }
56
+
57
+ # Known platform identifiers. New platforms MUST be added here as they
58
+ # land — the linter is the gate that proves no orphan slot escapes.
59
+ KNOWN_PLATFORMS: set[str] = {
60
+ "augment", "claude", "cursor", "cline", "windsurf", "gemini", "copilot",
61
+ }
62
+
63
+
64
+ def _load_manifest(path: Path) -> dict:
65
+ """Reuse the dispatcher's loader so the linter sees exactly what
66
+ the runtime sees — including the fallback parser when PyYAML is
67
+ not installed."""
68
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
69
+ from hooks.dispatch_hook import _load_yaml # noqa: E402
70
+ return _load_yaml(path)
71
+
72
+
73
+ def _check_concerns(manifest: dict, errors: list[str]) -> set[str]:
74
+ concerns = manifest.get("concerns") or {}
75
+ if not isinstance(concerns, dict) or not concerns:
76
+ errors.append("manifest: 'concerns' must be a non-empty mapping")
77
+ return set()
78
+ names: set[str] = set()
79
+ for name, spec in concerns.items():
80
+ if not isinstance(spec, dict):
81
+ errors.append(f"concerns.{name}: must be a mapping, got {type(spec).__name__}")
82
+ continue
83
+ script = spec.get("script")
84
+ if not script or not isinstance(script, str):
85
+ errors.append(f"concerns.{name}: 'script' must be a relative path")
86
+ continue
87
+ if not (REPO_ROOT / script).is_file():
88
+ errors.append(f"concerns.{name}: script not found at '{script}'")
89
+ names.add(name)
90
+ return names
91
+
92
+
93
+ def _check_platforms(manifest: dict, concern_names: set[str],
94
+ errors: list[str], warnings: list[str]) -> set[str]:
95
+ platforms = manifest.get("platforms") or {}
96
+ if not isinstance(platforms, dict) or not platforms:
97
+ errors.append("manifest: 'platforms' must be a non-empty mapping")
98
+ return set()
99
+ bound: set[str] = set()
100
+ for plat, block in platforms.items():
101
+ if plat not in KNOWN_PLATFORMS:
102
+ errors.append(f"platforms.{plat}: unknown platform "
103
+ f"(allowed: {sorted(KNOWN_PLATFORMS)})")
104
+ continue
105
+ if block is None:
106
+ warnings.append(f"platforms.{plat}: placeholder (no events bound)")
107
+ continue
108
+ if not isinstance(block, dict):
109
+ errors.append(f"platforms.{plat}: must be mapping or null")
110
+ continue
111
+ if block.get("fallback_only"):
112
+ continue # Copilot — intentional, no event surface
113
+ for event, names in block.items():
114
+ if event not in EVENT_VOCABULARY:
115
+ errors.append(f"platforms.{plat}.{event}: unknown event "
116
+ f"(allowed: {sorted(EVENT_VOCABULARY)})")
117
+ continue
118
+ if not isinstance(names, list):
119
+ errors.append(f"platforms.{plat}.{event}: must be a list of concern names")
120
+ continue
121
+ for n in names:
122
+ if n not in concern_names:
123
+ errors.append(f"platforms.{plat}.{event}: unknown concern '{n}'")
124
+ else:
125
+ bound.add(n)
126
+ return bound
127
+
128
+
129
+ def _check_aliases(manifest: dict, errors: list[str]) -> None:
130
+ aliases = manifest.get("native_event_aliases") or {}
131
+ if not isinstance(aliases, dict):
132
+ errors.append("native_event_aliases: must be a mapping")
133
+ return
134
+ for plat, mapping in aliases.items():
135
+ if plat not in KNOWN_PLATFORMS:
136
+ errors.append(f"native_event_aliases.{plat}: unknown platform")
137
+ continue
138
+ if not isinstance(mapping, dict):
139
+ errors.append(f"native_event_aliases.{plat}: must be a mapping")
140
+ continue
141
+ for native, target in mapping.items():
142
+ if target not in EVENT_VOCABULARY:
143
+ errors.append(f"native_event_aliases.{plat}.{native}: "
144
+ f"target '{target}' not in vocabulary")
145
+
146
+
147
+ def _check_orphan_trampolines(manifest: dict, errors: list[str]) -> None:
148
+ """A `<platform>-dispatcher.sh` on disk MUST have a non-null,
149
+ non-empty manifest block — otherwise the trampoline runs but no
150
+ concerns fire (silent no-op, hardest class of bug to debug)."""
151
+ if not HOOKS_DIR.is_dir():
152
+ return
153
+ platforms = manifest.get("platforms") or {}
154
+ for entry in sorted(HOOKS_DIR.iterdir()):
155
+ if not entry.name.endswith("-dispatcher.sh"):
156
+ continue
157
+ plat = entry.name[: -len("-dispatcher.sh")]
158
+ if plat not in KNOWN_PLATFORMS:
159
+ errors.append(f"orphan trampoline {entry.name}: unknown platform '{plat}'")
160
+ continue
161
+ block = platforms.get(plat)
162
+ if block is None or (isinstance(block, dict)
163
+ and not any(k in EVENT_VOCABULARY for k in block)):
164
+ errors.append(f"orphan trampoline {entry.name}: "
165
+ f"platform '{plat}' has no event bindings in manifest")
166
+
167
+
168
+ def _check_dead_concerns(concern_names: set[str], bound: set[str],
169
+ warnings: list[str]) -> None:
170
+ for n in sorted(concern_names - bound):
171
+ warnings.append(f"concerns.{n}: declared but not bound to any platform")
172
+
173
+
174
+ def lint(manifest_path: Path, *, strict: bool) -> int:
175
+ if not manifest_path.is_file():
176
+ sys.stderr.write(f"lint_hook_manifest: file not found: {manifest_path}\n")
177
+ return 2
178
+ try:
179
+ manifest = _load_manifest(manifest_path)
180
+ except Exception as exc: # pragma: no cover — covered by malformed-yaml test
181
+ sys.stderr.write(f"lint_hook_manifest: load error: {exc}\n")
182
+ return 2
183
+ if not isinstance(manifest, dict) or manifest.get("schema_version") != 1:
184
+ sys.stderr.write("lint_hook_manifest: schema_version must be 1\n")
185
+ return 1
186
+
187
+ errors: list[str] = []
188
+ warnings: list[str] = []
189
+ concern_names = _check_concerns(manifest, errors)
190
+ bound = _check_platforms(manifest, concern_names, errors, warnings)
191
+ _check_aliases(manifest, errors)
192
+ _check_orphan_trampolines(manifest, errors)
193
+ _check_dead_concerns(concern_names, bound, warnings)
194
+
195
+ for w in warnings:
196
+ sys.stderr.write(f"warn: {w}\n")
197
+ for e in errors:
198
+ sys.stderr.write(f"error: {e}\n")
199
+
200
+ if errors:
201
+ return 1
202
+ if strict and warnings:
203
+ return 1
204
+ return 0
205
+
206
+
207
+ def main(argv: list[str] | None = None) -> int:
208
+ parser = argparse.ArgumentParser(description=__doc__)
209
+ parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
210
+ parser.add_argument("--strict", action="store_true")
211
+ args = parser.parse_args(argv)
212
+ return lint(args.manifest, strict=args.strict)
213
+
214
+
215
+ if __name__ == "__main__":
216
+ raise SystemExit(main())
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env python3
2
+ """One-off-script age linter.
3
+
4
+ Scans `scripts/_one_off/<YYYY-MM>/_one_off_*.py` and enforces the
5
+ TTL policy from `docs/contracts/one-off-script-lifecycle.md`:
6
+
7
+ * Age ≤ 60 days → active, silent.
8
+ * 60 < Age ≤ 90 → warning, exit 0.
9
+ * Age > 90 → hard fail, exit 1 (purge candidate).
10
+
11
+ Scripts MAY extend their TTL exactly once via a frontmatter block:
12
+
13
+ \"\"\"
14
+ ---
15
+ ttl_extended_until: YYYY-MM-DD
16
+ ttl_reason: <free text>
17
+ ---
18
+ \"\"\"
19
+
20
+ The extended date is honoured up to 180 days past the month-directory
21
+ date. Anything beyond hard-fails with no second extension.
22
+
23
+ Exit codes: 0 = clean (incl. warnings), 1 = hard fail, 3 = internal error.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import re
30
+ import sys
31
+ from dataclasses import asdict, dataclass
32
+ from datetime import date, datetime, timezone
33
+ from pathlib import Path
34
+
35
+ ROOT = Path(__file__).resolve().parent.parent
36
+ ONE_OFF_DIR = ROOT / "scripts" / "_one_off"
37
+
38
+ NAME_RE = re.compile(r"^_one_off_[a-z0-9-]+\.py$")
39
+ MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
40
+ TTL_RE = re.compile(
41
+ r"---\s*\n\s*ttl_extended_until:\s*(\d{4}-\d{2}-\d{2})\s*\n",
42
+ re.MULTILINE,
43
+ )
44
+
45
+ WARN_DAYS = 60
46
+ HARD_DAYS = 90
47
+ EXTEND_CAP_DAYS = 180
48
+
49
+
50
+ @dataclass
51
+ class Finding:
52
+ path: str
53
+ age_days: int
54
+ severity: str # "warn" | "fail"
55
+ reason: str
56
+
57
+
58
+ def _today_utc() -> date:
59
+ return datetime.now(timezone.utc).date()
60
+
61
+
62
+ def _month_anchor(month_dir: str) -> date | None:
63
+ if not MONTH_RE.match(month_dir):
64
+ return None
65
+ y, m = map(int, month_dir.split("-"))
66
+ try:
67
+ return date(y, m, 1)
68
+ except ValueError:
69
+ return None
70
+
71
+
72
+ def _read_extension(path: Path) -> date | None:
73
+ try:
74
+ head = path.read_text(encoding="utf-8")[:1024]
75
+ except OSError:
76
+ return None
77
+ m = TTL_RE.search(head)
78
+ if not m:
79
+ return None
80
+ try:
81
+ return datetime.strptime(m.group(1), "%Y-%m-%d").date()
82
+ except ValueError:
83
+ return None
84
+
85
+
86
+ def scan(root: Path, today: date | None = None) -> list[Finding]:
87
+ today = today or _today_utc()
88
+ base = root / "scripts" / "_one_off"
89
+ if not base.exists():
90
+ return []
91
+ out: list[Finding] = []
92
+ for month_dir in sorted(base.iterdir()):
93
+ if not month_dir.is_dir():
94
+ continue
95
+ anchor = _month_anchor(month_dir.name)
96
+ if anchor is None:
97
+ out.append(Finding(
98
+ path=str(month_dir.relative_to(root)),
99
+ age_days=-1,
100
+ severity="fail",
101
+ reason="invalid month directory name (expect YYYY-MM)",
102
+ ))
103
+ continue
104
+ for f in sorted(month_dir.iterdir()):
105
+ if f.name == "README.md" or f.is_dir():
106
+ continue
107
+ if not NAME_RE.match(f.name):
108
+ out.append(Finding(
109
+ path=str(f.relative_to(root)),
110
+ age_days=-1,
111
+ severity="fail",
112
+ reason="filename does not match _one_off_<slug>.py",
113
+ ))
114
+ continue
115
+ age = (today - anchor).days
116
+ extension = _read_extension(f)
117
+ if extension is not None:
118
+ cap = (extension - anchor).days
119
+ if cap > EXTEND_CAP_DAYS:
120
+ out.append(Finding(
121
+ path=str(f.relative_to(root)),
122
+ age_days=age,
123
+ severity="fail",
124
+ reason=f"ttl_extended_until exceeds 180-day cap ({cap}d)",
125
+ ))
126
+ continue
127
+ if age <= cap:
128
+ continue # extension still valid, silent
129
+ if age > HARD_DAYS:
130
+ out.append(Finding(
131
+ path=str(f.relative_to(root)),
132
+ age_days=age,
133
+ severity="fail",
134
+ reason=f"age {age}d exceeds {HARD_DAYS}-day hard limit",
135
+ ))
136
+ elif age > WARN_DAYS:
137
+ out.append(Finding(
138
+ path=str(f.relative_to(root)),
139
+ age_days=age,
140
+ severity="warn",
141
+ reason=f"age {age}d in soft window ({WARN_DAYS}–{HARD_DAYS}d)",
142
+ ))
143
+ return out
144
+
145
+
146
+ def format_text(findings: list[Finding]) -> str:
147
+ if not findings:
148
+ return "✅ No one-off-script age violations."
149
+ lines = []
150
+ fails = [f for f in findings if f.severity == "fail"]
151
+ warns = [f for f in findings if f.severity == "warn"]
152
+ if fails:
153
+ lines.append(f"❌ {len(fails)} one-off script(s) past hard limit:")
154
+ for f in fails:
155
+ lines.append(f" 🔴 {f.path} → {f.reason}")
156
+ if warns:
157
+ lines.append(f"⚠️ {len(warns)} one-off script(s) in soft window:")
158
+ for f in warns:
159
+ lines.append(f" 🟡 {f.path} → {f.reason}")
160
+ lines.append(
161
+ "\nPurge candidates per docs/contracts/one-off-script-lifecycle.md."
162
+ )
163
+ return "\n".join(lines)
164
+
165
+
166
+ def main() -> int:
167
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
168
+ parser.add_argument("--format", choices=["text", "json"], default="text")
169
+ parser.add_argument("--root", type=Path, default=ROOT)
170
+ args = parser.parse_args()
171
+ try:
172
+ findings = scan(args.root)
173
+ except Exception as e: # pragma: no cover
174
+ print(f"Internal error: {e}", file=sys.stderr)
175
+ return 3
176
+ if args.format == "json":
177
+ print(json.dumps([asdict(f) for f in findings], indent=2))
178
+ else:
179
+ print(format_text(findings))
180
+ return 1 if any(f.severity == "fail" for f in findings) else 0
181
+
182
+
183
+ if __name__ == "__main__":
184
+ sys.exit(main())
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """Phase 5.2 roadmap-complexity linter.
3
+
4
+ Enforces the measurable subset of
5
+ `docs/contracts/roadmap-complexity-standard.md`:
6
+
7
+ - every `agents/roadmaps/*.md` declares `complexity: lightweight`
8
+ or `complexity: structural` in frontmatter;
9
+ - lightweight roadmaps have ≤ 600 total lines and ≤ 6 `## Phase N`
10
+ headings, and contain no `## Council Round N` / `### Verdict`
11
+ sections;
12
+ - structural roadmaps have no upper cap, but the tag must be
13
+ declared.
14
+
15
+ Cap: ≤ 150 LOC, stdlib only. Hooked into `task ci` via
16
+ `task lint-roadmap-complexity`.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ REPO_ROOT = Path(__file__).resolve().parent.parent
25
+ ROADMAP_GLOB = "agents/roadmaps/*.md"
26
+ LIGHTWEIGHT_LINE_CAP = 600
27
+ LIGHTWEIGHT_PHASE_CAP = 6
28
+
29
+ PHASE_PAT = re.compile(r"^## Phase \d+\b", re.MULTILINE)
30
+ COUNCIL_PAT = re.compile(r"^## Council Round \d+\b", re.MULTILINE)
31
+ VERDICT_PAT = re.compile(r"^### Verdict\b", re.MULTILINE)
32
+ COMPLEXITY_PAT = re.compile(
33
+ r"^complexity:\s*(lightweight|structural)\s*$", re.MULTILINE
34
+ )
35
+
36
+
37
+ def _frontmatter(text: str) -> str:
38
+ if not text.startswith("---\n"):
39
+ return ""
40
+ end = text.find("\n---\n", 4)
41
+ return text[4:end] if end != -1 else ""
42
+
43
+
44
+ def _read_complexity(fm: str) -> str | None:
45
+ m = COMPLEXITY_PAT.search(fm)
46
+ return m.group(1) if m else None
47
+
48
+
49
+ def _check_lightweight(text: str, line_count: int, problems: list[str]) -> None:
50
+ if line_count > LIGHTWEIGHT_LINE_CAP:
51
+ problems.append(
52
+ f"lightweight cap exceeded: {line_count} lines "
53
+ f"(max {LIGHTWEIGHT_LINE_CAP}); consider tagging structural "
54
+ f"or trimming"
55
+ )
56
+ phases = len(PHASE_PAT.findall(text))
57
+ if phases > LIGHTWEIGHT_PHASE_CAP:
58
+ problems.append(
59
+ f"lightweight phase cap exceeded: {phases} phases "
60
+ f"(max {LIGHTWEIGHT_PHASE_CAP})"
61
+ )
62
+ if COUNCIL_PAT.search(text):
63
+ problems.append(
64
+ "lightweight roadmap contains '## Council Round N' "
65
+ "block — council debates belong in structural roadmaps"
66
+ )
67
+ if VERDICT_PAT.search(text):
68
+ problems.append(
69
+ "lightweight roadmap contains '### Verdict' block — "
70
+ "council verdicts belong in structural roadmaps"
71
+ )
72
+
73
+
74
+ def lint_roadmap(path: Path) -> list[str]:
75
+ text = path.read_text(encoding="utf-8")
76
+ line_count = text.count("\n") + (1 if text and not text.endswith("\n") else 0)
77
+ problems: list[str] = []
78
+ fm = _frontmatter(text)
79
+ complexity = _read_complexity(fm) if fm else None
80
+ if complexity is None:
81
+ problems.append(
82
+ "missing 'complexity:' frontmatter "
83
+ "(must declare 'lightweight' or 'structural')"
84
+ )
85
+ return problems
86
+ if complexity == "lightweight":
87
+ _check_lightweight(text, line_count, problems)
88
+ return problems
89
+
90
+
91
+ def main() -> int:
92
+ roadmaps = sorted(REPO_ROOT.glob(ROADMAP_GLOB))
93
+ if not roadmaps:
94
+ print(f"❌ no roadmaps matched {ROADMAP_GLOB}", file=sys.stderr)
95
+ return 1
96
+ failed = 0
97
+ summary: list[tuple[str, str]] = []
98
+ for roadmap in roadmaps:
99
+ rel = roadmap.relative_to(REPO_ROOT)
100
+ problems = lint_roadmap(roadmap)
101
+ text = roadmap.read_text(encoding="utf-8")
102
+ complexity = _read_complexity(_frontmatter(text)) or "untagged"
103
+ summary.append((str(rel), complexity))
104
+ if problems:
105
+ failed += 1
106
+ print(f"❌ {rel} [{complexity}]", file=sys.stderr)
107
+ for p in problems:
108
+ print(f" - {p}", file=sys.stderr)
109
+ else:
110
+ print(f"✅ {rel} [{complexity}]")
111
+ print()
112
+ light = sum(1 for _, c in summary if c == "lightweight")
113
+ structural = sum(1 for _, c in summary if c == "structural")
114
+ untagged = sum(1 for _, c in summary if c == "untagged")
115
+ print(
116
+ f"summary: {light} lightweight · {structural} structural · "
117
+ f"{untagged} untagged · {len(summary)} total"
118
+ )
119
+ if failed:
120
+ print(f"\n❌ {failed} roadmap(s) failed complexity lint", file=sys.stderr)
121
+ return 1
122
+ print(f"\n✅ {len(roadmaps)} roadmap(s) complexity-clean")
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main())