@event4u/agent-config 1.18.0 → 1.20.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 (181) hide show
  1. package/.agent-src/commands/agent-handoff.md +14 -10
  2. package/.agent-src/commands/chat-history/import.md +170 -0
  3. package/.agent-src/commands/chat-history/learn.md +178 -0
  4. package/.agent-src/commands/chat-history/show.md +17 -18
  5. package/.agent-src/commands/chat-history.md +26 -25
  6. package/.agent-src/commands/council/default.md +77 -82
  7. package/.agent-src/commands/create-pr.md +28 -8
  8. package/.agent-src/commands/feature/roadmap.md +22 -0
  9. package/.agent-src/commands/roadmap/create.md +38 -6
  10. package/.agent-src/commands/roadmap/execute.md +36 -9
  11. package/.agent-src/commands/sync-gitignore.md +1 -1
  12. package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
  13. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
  14. package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
  15. package/.agent-src/rules/agent-authority.md +1 -0
  16. package/.agent-src/rules/agent-docs.md +1 -0
  17. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  18. package/.agent-src/rules/architecture.md +1 -0
  19. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  20. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  21. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  22. package/.agent-src/rules/augment-portability.md +1 -0
  23. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  24. package/.agent-src/rules/autonomous-execution.md +1 -0
  25. package/.agent-src/rules/capture-learnings.md +1 -0
  26. package/.agent-src/rules/cli-output-handling.md +2 -2
  27. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  28. package/.agent-src/rules/commit-conventions.md +1 -0
  29. package/.agent-src/rules/commit-policy.md +1 -0
  30. package/.agent-src/rules/context-hygiene.md +22 -0
  31. package/.agent-src/rules/direct-answers.md +11 -2
  32. package/.agent-src/rules/docker-commands.md +1 -0
  33. package/.agent-src/rules/docs-sync.md +1 -0
  34. package/.agent-src/rules/downstream-changes.md +1 -0
  35. package/.agent-src/rules/e2e-testing.md +1 -0
  36. package/.agent-src/rules/guidelines.md +1 -0
  37. package/.agent-src/rules/improve-before-implement.md +1 -0
  38. package/.agent-src/rules/language-and-tone.md +38 -6
  39. package/.agent-src/rules/laravel-translations.md +1 -0
  40. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  41. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  42. package/.agent-src/rules/missing-tool-handling.md +1 -0
  43. package/.agent-src/rules/model-recommendation.md +1 -0
  44. package/.agent-src/rules/no-attribution-footers.md +48 -0
  45. package/.agent-src/rules/no-cheap-questions.md +1 -0
  46. package/.agent-src/rules/no-roadmap-references.md +2 -1
  47. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  48. package/.agent-src/rules/onboarding-gate.md +26 -0
  49. package/.agent-src/rules/package-ci-checks.md +1 -0
  50. package/.agent-src/rules/php-coding.md +1 -0
  51. package/.agent-src/rules/preservation-guard.md +1 -0
  52. package/.agent-src/rules/review-routing-awareness.md +1 -0
  53. package/.agent-src/rules/reviewer-awareness.md +1 -0
  54. package/.agent-src/rules/roadmap-progress-sync.md +22 -0
  55. package/.agent-src/rules/role-mode-adherence.md +2 -2
  56. package/.agent-src/rules/rule-type-governance.md +1 -0
  57. package/.agent-src/rules/runtime-safety.md +1 -0
  58. package/.agent-src/rules/scope-control.md +1 -0
  59. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  60. package/.agent-src/rules/size-enforcement.md +1 -0
  61. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  62. package/.agent-src/rules/skill-quality.md +50 -0
  63. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  64. package/.agent-src/rules/think-before-action.md +1 -0
  65. package/.agent-src/rules/token-efficiency.md +1 -0
  66. package/.agent-src/rules/tool-safety.md +1 -0
  67. package/.agent-src/rules/ui-audit-gate.md +1 -0
  68. package/.agent-src/rules/upstream-proposal.md +1 -0
  69. package/.agent-src/rules/user-interaction.md +22 -5
  70. package/.agent-src/rules/verify-before-complete.md +1 -0
  71. package/.agent-src/skills/ai-council/SKILL.md +4 -5
  72. package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
  73. package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
  74. package/.agent-src/skills/md-language-check/SKILL.md +1 -1
  75. package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
  76. package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
  77. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  78. package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
  79. package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
  80. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
  81. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  82. package/.agent-src/templates/agent-settings.md +21 -26
  83. package/.agent-src/templates/roadmaps.md +8 -3
  84. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +16 -5
  85. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -4
  86. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -4
  87. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
  88. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
  89. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
  90. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  91. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +110 -0
  92. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  93. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  94. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  95. package/.agent-src/templates/skill.md +30 -1
  96. package/.claude-plugin/marketplace.json +8 -4
  97. package/AGENTS.md +44 -3
  98. package/CHANGELOG.md +173 -0
  99. package/README.md +22 -22
  100. package/config/agent-settings.template.yml +42 -13
  101. package/config/gitignore-block.txt +4 -4
  102. package/docs/architecture.md +3 -3
  103. package/docs/catalog.md +18 -13
  104. package/docs/contracts/adr-chat-history-split.md +10 -1
  105. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  106. package/docs/contracts/command-clusters.md +1 -1
  107. package/docs/contracts/cross-wing-handoff.md +133 -0
  108. package/docs/contracts/decision-trace-v1.md +146 -0
  109. package/docs/contracts/file-ownership-matrix.json +348 -126
  110. package/docs/contracts/hook-architecture-v1.md +220 -0
  111. package/docs/contracts/memory-visibility-v1.md +122 -0
  112. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  113. package/docs/contracts/rule-interactions.yml +22 -0
  114. package/docs/customization.md +2 -1
  115. package/docs/development.md +4 -1
  116. package/docs/getting-started.md +21 -29
  117. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
  118. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  119. package/docs/hook-payload-capture.md +221 -0
  120. package/docs/migrations/commands-1.15.0.md +17 -12
  121. package/docs/skills-catalog.md +5 -4
  122. package/llms.txt +4 -3
  123. package/package.json +1 -1
  124. package/scripts/agent-config +45 -1
  125. package/scripts/ai_council/_default_prices.py +4 -4
  126. package/scripts/ai_council/bundler.py +3 -3
  127. package/scripts/ai_council/clients.py +25 -9
  128. package/scripts/ai_council/modes.py +3 -4
  129. package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
  130. package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
  131. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  132. package/scripts/ai_council/pricing.py +10 -9
  133. package/scripts/ai_council/session.py +92 -0
  134. package/scripts/build_rule_trigger_matrix.py +1 -9
  135. package/scripts/capture_showcase_session.py +361 -0
  136. package/scripts/chat_history.py +963 -597
  137. package/scripts/check_always_budget.py +7 -2
  138. package/scripts/check_references.py +12 -2
  139. package/scripts/context_hygiene_hook.py +14 -6
  140. package/scripts/council_cli.py +407 -0
  141. package/scripts/hook_manifest.yaml +217 -0
  142. package/scripts/hooks/__init__.py +1 -0
  143. package/scripts/hooks/augment-chat-history.sh +10 -0
  144. package/scripts/hooks/augment-dispatcher.sh +72 -0
  145. package/scripts/hooks/cline-dispatcher.sh +86 -0
  146. package/scripts/hooks/cowork-dispatcher.sh +98 -0
  147. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  148. package/scripts/hooks/dispatch_hook.py +383 -0
  149. package/scripts/hooks/envelope.py +98 -0
  150. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  151. package/scripts/hooks/state_io.py +122 -0
  152. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  153. package/scripts/hooks_status.py +157 -0
  154. package/scripts/install-hooks.sh +2 -2
  155. package/scripts/install.py +725 -87
  156. package/scripts/install.sh +38 -1
  157. package/scripts/lint_handoffs.py +214 -0
  158. package/scripts/lint_hook_manifest.py +217 -0
  159. package/scripts/lint_one_off_age.py +184 -0
  160. package/scripts/lint_rule_tiers.py +78 -0
  161. package/scripts/lint_showcase_sessions.py +148 -0
  162. package/scripts/minimal_safe_diff_hook.py +245 -0
  163. package/scripts/onboarding_gate_hook.py +13 -8
  164. package/scripts/readme_linter.py +12 -3
  165. package/scripts/redact_hook_capture.py +148 -0
  166. package/scripts/roadmap_progress_hook.py +5 -0
  167. package/scripts/schemas/skill.schema.json +5 -0
  168. package/scripts/skill_linter.py +163 -1
  169. package/scripts/sync_agent_settings.py +32 -129
  170. package/scripts/sync_yaml_rt.py +734 -0
  171. package/scripts/update_prices.py +3 -3
  172. package/scripts/verify_before_complete_hook.py +216 -0
  173. package/.agent-src/commands/chat-history/checkpoint.md +0 -126
  174. package/.agent-src/commands/chat-history/clear.md +0 -103
  175. package/.agent-src/commands/chat-history/resume.md +0 -183
  176. package/.agent-src/rules/chat-history-cadence.md +0 -109
  177. package/.agent-src/rules/chat-history-ownership.md +0 -123
  178. package/.agent-src/rules/chat-history-visibility.md +0 -96
  179. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
  180. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
  181. package/scripts/check_phase_coupling.py +0 -148
@@ -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"
@@ -563,6 +563,40 @@ copy_if_missing() {
563
563
  cp "$source" "$target"
564
564
  }
565
565
 
566
+ # Migrate legacy infra files from project root to agents/.
567
+ # Pre-2.x layout: .agent-chat-history (+ .bak), .agent-prices.md lived at
568
+ # the project root. They now live under agents/. Move them in place before
569
+ # any other content sync so the updated gitignore block (which lists
570
+ # /agents/.agent-chat-history*) and the chat-history hooks operate on the
571
+ # already-migrated layout. Idempotent: skips silently if the target already
572
+ # exists; never overwrites.
573
+ migrate_legacy_root_infra() {
574
+ local project_root="$1"
575
+ local agents_dir="$project_root/agents"
576
+ local items=(".agent-chat-history" ".agent-chat-history.bak" ".agent-prices.md")
577
+
578
+ for name in "${items[@]}"; do
579
+ local old="$project_root/$name"
580
+ local new="$agents_dir/$name"
581
+
582
+ [[ -e "$old" ]] || continue
583
+
584
+ if [[ -e "$new" ]]; then
585
+ log_warn "Legacy $name found at project root, but agents/$name already exists — leaving root copy in place"
586
+ continue
587
+ fi
588
+
589
+ if $DRY_RUN; then
590
+ log_verbose "would migrate $name → agents/$name"
591
+ continue
592
+ fi
593
+
594
+ mkdir -p "$agents_dir"
595
+ mv "$old" "$new"
596
+ log_info "Migrated $name → agents/$name"
597
+ done
598
+ }
599
+
566
600
  # Ensure .gitignore contains the managed agent-config block.
567
601
  # Delegates to scripts/sync_gitignore.py so the installer and the
568
602
  # standalone /sync-gitignore command share one source of truth
@@ -632,6 +666,9 @@ main() {
632
666
  $DRY_RUN && ! $QUIET && echo " Mode: DRY RUN"
633
667
  echo ""
634
668
 
669
+ # 0. Migrate legacy infra files (root → agents/) before any content sync.
670
+ migrate_legacy_root_infra "$TARGET_DIR"
671
+
635
672
  # 1. Hybrid sync payload → target/.augment/
636
673
  sync_hybrid "$SOURCE_PAYLOAD" "$TARGET_DIR/.augment"
637
674
  log_info "Synced .augment/ (rules copied, rest symlinked)"
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Lint cross-wing handoffs declared in senior-tier skills' ``## Related Skills`` blocks.
3
+
4
+ Builds a directed graph from every ``tier: senior`` skill's Related Skills
5
+ block (markdown links pointing at peer ``SKILL.md`` files), then enforces
6
+ the rules from ``docs/contracts/cross-wing-handoff.md`` § 4:
7
+
8
+ handoff_cycle — graph must be a DAG.
9
+ handoff_dangling — every linked target must exist.
10
+ handoff_tier_mismatch — senior may delegate only to senior.
11
+
12
+ Hooked into ``task lint-handoffs`` and ``task ci`` (between ``lint-skills``
13
+ and ``test``). Output mirrors ``scripts/skill_linter.py``: ``file:line:reason``.
14
+
15
+ Exit codes:
16
+ 0 no violations
17
+ 1 one or more violations
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import sys
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Iterable
26
+
27
+ REPO = Path(__file__).resolve().parents[1]
28
+ SKILLS_DIR = REPO / ".agent-src.uncompressed" / "skills"
29
+
30
+ LINK_RE = re.compile(r"\[`?([a-z0-9][a-z0-9-]*)`?\]\(([^)]+SKILL\.md)\)")
31
+ RELATED_HEADING_RE = re.compile(r"^##\s+Related\s+Skills\s*$", re.IGNORECASE)
32
+ NEXT_HEADING_RE = re.compile(r"^##\s+\S")
33
+ WHEN_USE_RE = re.compile(r"^\*\*WHEN\s+to\s+use\s+this\*\*\s*$", re.IGNORECASE)
34
+ WHEN_NOT_RE = re.compile(r"^\*\*WHEN\s+NOT\s+to\s+use\s+this\*\*\s*$", re.IGNORECASE)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Violation:
39
+ file: Path
40
+ line: int
41
+ code: str
42
+ message: str
43
+
44
+ def render(self, repo: Path) -> str:
45
+ rel = self.file.relative_to(repo) if self.file.is_absolute() else self.file
46
+ return f"{rel}:{self.line}:{self.code}: {self.message}"
47
+
48
+
49
+ def parse_frontmatter_tier(text: str) -> str | None:
50
+ if not text.startswith("---\n"):
51
+ return None
52
+ end = text.find("\n---\n", 4)
53
+ if end == -1:
54
+ return None
55
+ for raw in text[4:end].splitlines():
56
+ if ":" not in raw:
57
+ continue
58
+ key, _, val = raw.partition(":")
59
+ if key.strip() == "tier":
60
+ return val.strip().strip('"').strip("'")
61
+ return None
62
+
63
+
64
+ def extract_related_block(text: str) -> tuple[int, list[tuple[int, str]]] | None:
65
+ """Return (block_start_line, [(line, raw_line), ...]) for ``## Related Skills``."""
66
+ lines = text.splitlines()
67
+ start: int | None = None
68
+ for idx, line in enumerate(lines):
69
+ if RELATED_HEADING_RE.match(line):
70
+ start = idx
71
+ break
72
+ if start is None:
73
+ return None
74
+ body: list[tuple[int, str]] = []
75
+ for idx in range(start + 1, len(lines)):
76
+ if NEXT_HEADING_RE.match(lines[idx]):
77
+ break
78
+ body.append((idx + 1, lines[idx]))
79
+ return start + 1, body
80
+
81
+
82
+ def split_when_subblocks(body: list[tuple[int, str]]) -> tuple[
83
+ list[tuple[int, str]], list[tuple[int, str]]
84
+ ]:
85
+ """Split a ``## Related Skills`` body into (when_to_use, when_not_to_use).
86
+
87
+ WHEN-to-use links are composition (delegation) edges — graph for cycles.
88
+ WHEN-NOT-to-use links are alternative pointers (peer cognition the user
89
+ picks instead) — never composition edges. Lines outside both sub-blocks
90
+ are treated as WHEN-to-use for backward compatibility.
91
+ """
92
+ when_use: list[tuple[int, str]] = []
93
+ when_not: list[tuple[int, str]] = []
94
+ current = when_use
95
+ for lineno, raw in body:
96
+ if WHEN_USE_RE.match(raw):
97
+ current = when_use
98
+ continue
99
+ if WHEN_NOT_RE.match(raw):
100
+ current = when_not
101
+ continue
102
+ current.append((lineno, raw))
103
+ return when_use, when_not
104
+
105
+
106
+ def extract_links(body: list[tuple[int, str]]) -> list[tuple[int, str, str]]:
107
+ """Yield ``(line, slug, target_path)`` for every markdown link in the block."""
108
+ out: list[tuple[int, str, str]] = []
109
+ for lineno, raw in body:
110
+ for match in LINK_RE.finditer(raw):
111
+ out.append((lineno, match.group(1), match.group(2)))
112
+ return out
113
+
114
+
115
+ def resolve_target(skill_file: Path, link: str) -> Path:
116
+ return (skill_file.parent / link).resolve()
117
+
118
+
119
+ def detect_cycles(graph: dict[Path, set[Path]]) -> list[list[Path]]:
120
+ cycles: list[list[Path]] = []
121
+ visited: set[Path] = set()
122
+ stack: list[Path] = []
123
+ on_stack: set[Path] = set()
124
+
125
+ def dfs(node: Path) -> None:
126
+ if node in on_stack:
127
+ i = stack.index(node)
128
+ cycles.append(stack[i:] + [node])
129
+ return
130
+ if node in visited:
131
+ return
132
+ visited.add(node)
133
+ on_stack.add(node)
134
+ stack.append(node)
135
+ for nxt in graph.get(node, ()):
136
+ dfs(nxt)
137
+ stack.pop()
138
+ on_stack.discard(node)
139
+
140
+ for node in list(graph):
141
+ dfs(node)
142
+ return cycles
143
+
144
+
145
+ def lint(skills_dir: Path) -> list[Violation]:
146
+ senior_skills: dict[Path, str] = {}
147
+ all_skills: dict[Path, str] = {}
148
+ for skill_md in sorted(skills_dir.rglob("SKILL.md")):
149
+ text = skill_md.read_text(encoding="utf-8")
150
+ tier = parse_frontmatter_tier(text)
151
+ all_skills[skill_md.resolve()] = tier or ""
152
+ if tier == "senior":
153
+ senior_skills[skill_md.resolve()] = text
154
+
155
+ violations: list[Violation] = []
156
+ graph: dict[Path, set[Path]] = {}
157
+
158
+ for skill_path, text in senior_skills.items():
159
+ block = extract_related_block(text)
160
+ if block is None:
161
+ continue
162
+ _, body = block
163
+ when_use, when_not = split_when_subblocks(body)
164
+
165
+ # WHEN-to-use links: composition edges (graph) + dangling/tier checks.
166
+ for lineno, slug, link in extract_links(when_use):
167
+ target = resolve_target(skill_path, link)
168
+ graph.setdefault(skill_path, set()).add(target)
169
+ if target not in all_skills:
170
+ violations.append(Violation(skill_path, lineno, "handoff_dangling",
171
+ f"link to `{slug}` resolves to missing file {link}"))
172
+ continue
173
+ if all_skills[target] != "senior":
174
+ violations.append(Violation(skill_path, lineno, "handoff_tier_mismatch",
175
+ f"senior skill links to non-senior `{slug}` "
176
+ f"(tier={all_skills[target] or 'unset'!r})"))
177
+
178
+ # WHEN-NOT-to-use links: alternative pointers, NOT composition edges.
179
+ # Dangling + tier-mismatch still apply (a broken alternative is wrong);
180
+ # cycles do not (mutual "use X instead" pointers are intentional).
181
+ for lineno, slug, link in extract_links(when_not):
182
+ target = resolve_target(skill_path, link)
183
+ if target not in all_skills:
184
+ violations.append(Violation(skill_path, lineno, "handoff_dangling",
185
+ f"link to `{slug}` resolves to missing file {link}"))
186
+ continue
187
+ if all_skills[target] != "senior":
188
+ violations.append(Violation(skill_path, lineno, "handoff_tier_mismatch",
189
+ f"senior skill links to non-senior `{slug}` "
190
+ f"(tier={all_skills[target] or 'unset'!r})"))
191
+
192
+ for cycle in detect_cycles(graph):
193
+ names = " → ".join(p.parent.name for p in cycle)
194
+ violations.append(Violation(cycle[0], 1, "handoff_cycle",
195
+ f"composition cycle: {names}"))
196
+ return violations
197
+
198
+
199
+ def main(argv: list[str] | None = None) -> int:
200
+ skills_dir = SKILLS_DIR
201
+ if argv:
202
+ skills_dir = Path(argv[0]).resolve()
203
+ violations = lint(skills_dir)
204
+ if not violations:
205
+ print(f"✅ lint_handoffs: no violations under {skills_dir.relative_to(REPO)}")
206
+ return 0
207
+ for v in violations:
208
+ print(v.render(REPO))
209
+ print(f"\n❌ lint_handoffs: {len(violations)} violation(s)", file=sys.stderr)
210
+ return 1
211
+
212
+
213
+ if __name__ == "__main__":
214
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,217 @@
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", "cowork",
61
+ "cursor", "cline", "windsurf", "gemini", "copilot",
62
+ }
63
+
64
+
65
+ def _load_manifest(path: Path) -> dict:
66
+ """Reuse the dispatcher's loader so the linter sees exactly what
67
+ the runtime sees — including the fallback parser when PyYAML is
68
+ not installed."""
69
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
70
+ from hooks.dispatch_hook import _load_yaml # noqa: E402
71
+ return _load_yaml(path)
72
+
73
+
74
+ def _check_concerns(manifest: dict, errors: list[str]) -> set[str]:
75
+ concerns = manifest.get("concerns") or {}
76
+ if not isinstance(concerns, dict) or not concerns:
77
+ errors.append("manifest: 'concerns' must be a non-empty mapping")
78
+ return set()
79
+ names: set[str] = set()
80
+ for name, spec in concerns.items():
81
+ if not isinstance(spec, dict):
82
+ errors.append(f"concerns.{name}: must be a mapping, got {type(spec).__name__}")
83
+ continue
84
+ script = spec.get("script")
85
+ if not script or not isinstance(script, str):
86
+ errors.append(f"concerns.{name}: 'script' must be a relative path")
87
+ continue
88
+ if not (REPO_ROOT / script).is_file():
89
+ errors.append(f"concerns.{name}: script not found at '{script}'")
90
+ names.add(name)
91
+ return names
92
+
93
+
94
+ def _check_platforms(manifest: dict, concern_names: set[str],
95
+ errors: list[str], warnings: list[str]) -> set[str]:
96
+ platforms = manifest.get("platforms") or {}
97
+ if not isinstance(platforms, dict) or not platforms:
98
+ errors.append("manifest: 'platforms' must be a non-empty mapping")
99
+ return set()
100
+ bound: set[str] = set()
101
+ for plat, block in platforms.items():
102
+ if plat not in KNOWN_PLATFORMS:
103
+ errors.append(f"platforms.{plat}: unknown platform "
104
+ f"(allowed: {sorted(KNOWN_PLATFORMS)})")
105
+ continue
106
+ if block is None:
107
+ warnings.append(f"platforms.{plat}: placeholder (no events bound)")
108
+ continue
109
+ if not isinstance(block, dict):
110
+ errors.append(f"platforms.{plat}: must be mapping or null")
111
+ continue
112
+ if block.get("fallback_only"):
113
+ continue # Copilot — intentional, no event surface
114
+ for event, names in block.items():
115
+ if event not in EVENT_VOCABULARY:
116
+ errors.append(f"platforms.{plat}.{event}: unknown event "
117
+ f"(allowed: {sorted(EVENT_VOCABULARY)})")
118
+ continue
119
+ if not isinstance(names, list):
120
+ errors.append(f"platforms.{plat}.{event}: must be a list of concern names")
121
+ continue
122
+ for n in names:
123
+ if n not in concern_names:
124
+ errors.append(f"platforms.{plat}.{event}: unknown concern '{n}'")
125
+ else:
126
+ bound.add(n)
127
+ return bound
128
+
129
+
130
+ def _check_aliases(manifest: dict, errors: list[str]) -> None:
131
+ aliases = manifest.get("native_event_aliases") or {}
132
+ if not isinstance(aliases, dict):
133
+ errors.append("native_event_aliases: must be a mapping")
134
+ return
135
+ for plat, mapping in aliases.items():
136
+ if plat not in KNOWN_PLATFORMS:
137
+ errors.append(f"native_event_aliases.{plat}: unknown platform")
138
+ continue
139
+ if not isinstance(mapping, dict):
140
+ errors.append(f"native_event_aliases.{plat}: must be a mapping")
141
+ continue
142
+ for native, target in mapping.items():
143
+ if target not in EVENT_VOCABULARY:
144
+ errors.append(f"native_event_aliases.{plat}.{native}: "
145
+ f"target '{target}' not in vocabulary")
146
+
147
+
148
+ def _check_orphan_trampolines(manifest: dict, errors: list[str]) -> None:
149
+ """A `<platform>-dispatcher.sh` on disk MUST have a non-null,
150
+ non-empty manifest block — otherwise the trampoline runs but no
151
+ concerns fire (silent no-op, hardest class of bug to debug)."""
152
+ if not HOOKS_DIR.is_dir():
153
+ return
154
+ platforms = manifest.get("platforms") or {}
155
+ for entry in sorted(HOOKS_DIR.iterdir()):
156
+ if not entry.name.endswith("-dispatcher.sh"):
157
+ continue
158
+ plat = entry.name[: -len("-dispatcher.sh")]
159
+ if plat not in KNOWN_PLATFORMS:
160
+ errors.append(f"orphan trampoline {entry.name}: unknown platform '{plat}'")
161
+ continue
162
+ block = platforms.get(plat)
163
+ if block is None or (isinstance(block, dict)
164
+ and not any(k in EVENT_VOCABULARY for k in block)):
165
+ errors.append(f"orphan trampoline {entry.name}: "
166
+ f"platform '{plat}' has no event bindings in manifest")
167
+
168
+
169
+ def _check_dead_concerns(concern_names: set[str], bound: set[str],
170
+ warnings: list[str]) -> None:
171
+ for n in sorted(concern_names - bound):
172
+ warnings.append(f"concerns.{n}: declared but not bound to any platform")
173
+
174
+
175
+ def lint(manifest_path: Path, *, strict: bool) -> int:
176
+ if not manifest_path.is_file():
177
+ sys.stderr.write(f"lint_hook_manifest: file not found: {manifest_path}\n")
178
+ return 2
179
+ try:
180
+ manifest = _load_manifest(manifest_path)
181
+ except Exception as exc: # pragma: no cover — covered by malformed-yaml test
182
+ sys.stderr.write(f"lint_hook_manifest: load error: {exc}\n")
183
+ return 2
184
+ if not isinstance(manifest, dict) or manifest.get("schema_version") != 1:
185
+ sys.stderr.write("lint_hook_manifest: schema_version must be 1\n")
186
+ return 1
187
+
188
+ errors: list[str] = []
189
+ warnings: list[str] = []
190
+ concern_names = _check_concerns(manifest, errors)
191
+ bound = _check_platforms(manifest, concern_names, errors, warnings)
192
+ _check_aliases(manifest, errors)
193
+ _check_orphan_trampolines(manifest, errors)
194
+ _check_dead_concerns(concern_names, bound, warnings)
195
+
196
+ for w in warnings:
197
+ sys.stderr.write(f"warn: {w}\n")
198
+ for e in errors:
199
+ sys.stderr.write(f"error: {e}\n")
200
+
201
+ if errors:
202
+ return 1
203
+ if strict and warnings:
204
+ return 1
205
+ return 0
206
+
207
+
208
+ def main(argv: list[str] | None = None) -> int:
209
+ parser = argparse.ArgumentParser(description=__doc__)
210
+ parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
211
+ parser.add_argument("--strict", action="store_true")
212
+ args = parser.parse_args(argv)
213
+ return lint(args.manifest, strict=args.strict)
214
+
215
+
216
+ if __name__ == "__main__":
217
+ 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())