@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.
- package/.agent-src/commands/agent-handoff.md +14 -10
- package/.agent-src/commands/chat-history/import.md +170 -0
- package/.agent-src/commands/chat-history/learn.md +178 -0
- package/.agent-src/commands/chat-history/show.md +17 -18
- package/.agent-src/commands/chat-history.md +26 -25
- package/.agent-src/commands/council/default.md +77 -82
- package/.agent-src/commands/create-pr.md +28 -8
- package/.agent-src/commands/feature/roadmap.md +22 -0
- package/.agent-src/commands/roadmap/create.md +38 -6
- package/.agent-src/commands/roadmap/execute.md +36 -9
- package/.agent-src/commands/sync-gitignore.md +1 -1
- package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
- package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
- package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
- package/.agent-src/rules/agent-authority.md +1 -0
- package/.agent-src/rules/agent-docs.md +1 -0
- package/.agent-src/rules/analysis-skill-routing.md +1 -0
- package/.agent-src/rules/architecture.md +1 -0
- package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
- package/.agent-src/rules/artifact-engagement-recording.md +1 -0
- package/.agent-src/rules/ask-when-uncertain.md +1 -0
- package/.agent-src/rules/augment-portability.md +1 -0
- package/.agent-src/rules/augment-source-of-truth.md +1 -0
- package/.agent-src/rules/autonomous-execution.md +1 -0
- package/.agent-src/rules/capture-learnings.md +1 -0
- package/.agent-src/rules/cli-output-handling.md +2 -2
- package/.agent-src/rules/command-suggestion-policy.md +1 -0
- package/.agent-src/rules/commit-conventions.md +1 -0
- package/.agent-src/rules/commit-policy.md +1 -0
- package/.agent-src/rules/context-hygiene.md +22 -0
- package/.agent-src/rules/direct-answers.md +11 -2
- package/.agent-src/rules/docker-commands.md +1 -0
- package/.agent-src/rules/docs-sync.md +1 -0
- package/.agent-src/rules/downstream-changes.md +1 -0
- package/.agent-src/rules/e2e-testing.md +1 -0
- package/.agent-src/rules/guidelines.md +1 -0
- package/.agent-src/rules/improve-before-implement.md +1 -0
- package/.agent-src/rules/language-and-tone.md +38 -6
- package/.agent-src/rules/laravel-translations.md +1 -0
- package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
- package/.agent-src/rules/minimal-safe-diff.md +1 -0
- package/.agent-src/rules/missing-tool-handling.md +1 -0
- package/.agent-src/rules/model-recommendation.md +1 -0
- package/.agent-src/rules/no-attribution-footers.md +48 -0
- package/.agent-src/rules/no-cheap-questions.md +1 -0
- package/.agent-src/rules/no-roadmap-references.md +2 -1
- package/.agent-src/rules/non-destructive-by-default.md +1 -0
- package/.agent-src/rules/onboarding-gate.md +26 -0
- package/.agent-src/rules/package-ci-checks.md +1 -0
- package/.agent-src/rules/php-coding.md +1 -0
- package/.agent-src/rules/preservation-guard.md +1 -0
- package/.agent-src/rules/review-routing-awareness.md +1 -0
- package/.agent-src/rules/reviewer-awareness.md +1 -0
- package/.agent-src/rules/roadmap-progress-sync.md +22 -0
- package/.agent-src/rules/role-mode-adherence.md +2 -2
- package/.agent-src/rules/rule-type-governance.md +1 -0
- package/.agent-src/rules/runtime-safety.md +1 -0
- package/.agent-src/rules/scope-control.md +1 -0
- package/.agent-src/rules/security-sensitive-stop.md +1 -0
- package/.agent-src/rules/size-enforcement.md +1 -0
- package/.agent-src/rules/skill-improvement-trigger.md +1 -0
- package/.agent-src/rules/skill-quality.md +50 -0
- package/.agent-src/rules/slash-command-routing-policy.md +39 -0
- package/.agent-src/rules/think-before-action.md +1 -0
- package/.agent-src/rules/token-efficiency.md +1 -0
- package/.agent-src/rules/tool-safety.md +1 -0
- package/.agent-src/rules/ui-audit-gate.md +1 -0
- package/.agent-src/rules/upstream-proposal.md +1 -0
- package/.agent-src/rules/user-interaction.md +22 -5
- package/.agent-src/rules/verify-before-complete.md +1 -0
- package/.agent-src/skills/ai-council/SKILL.md +4 -5
- package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
- package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
- package/.agent-src/skills/md-language-check/SKILL.md +1 -1
- package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
- package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
- package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
- package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
- package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
- package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
- package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
- package/.agent-src/templates/agent-settings.md +21 -26
- package/.agent-src/templates/roadmaps.md +8 -3
- package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +16 -5
- package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -4
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -4
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +110 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
- package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
- package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
- package/.agent-src/templates/skill.md +30 -1
- package/.claude-plugin/marketplace.json +8 -4
- package/AGENTS.md +44 -3
- package/CHANGELOG.md +173 -0
- package/README.md +22 -22
- package/config/agent-settings.template.yml +42 -13
- package/config/gitignore-block.txt +4 -4
- package/docs/architecture.md +3 -3
- package/docs/catalog.md +18 -13
- package/docs/contracts/adr-chat-history-split.md +10 -1
- package/docs/contracts/adr-settings-sync-engine.md +127 -0
- package/docs/contracts/command-clusters.md +1 -1
- package/docs/contracts/cross-wing-handoff.md +133 -0
- package/docs/contracts/decision-trace-v1.md +146 -0
- package/docs/contracts/file-ownership-matrix.json +348 -126
- package/docs/contracts/hook-architecture-v1.md +220 -0
- package/docs/contracts/memory-visibility-v1.md +122 -0
- package/docs/contracts/one-off-script-lifecycle.md +109 -0
- package/docs/contracts/rule-interactions.yml +22 -0
- package/docs/customization.md +2 -1
- package/docs/development.md +4 -1
- package/docs/getting-started.md +21 -29
- package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
- package/docs/guidelines/agent-infra/layered-settings.md +32 -13
- package/docs/hook-payload-capture.md +221 -0
- package/docs/migrations/commands-1.15.0.md +17 -12
- package/docs/skills-catalog.md +5 -4
- package/llms.txt +4 -3
- package/package.json +1 -1
- package/scripts/agent-config +45 -1
- package/scripts/ai_council/_default_prices.py +4 -4
- package/scripts/ai_council/bundler.py +3 -3
- package/scripts/ai_council/clients.py +25 -9
- package/scripts/ai_council/modes.py +3 -4
- package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
- package/scripts/ai_council/pricing.py +10 -9
- package/scripts/ai_council/session.py +92 -0
- package/scripts/build_rule_trigger_matrix.py +1 -9
- package/scripts/capture_showcase_session.py +361 -0
- package/scripts/chat_history.py +963 -597
- package/scripts/check_always_budget.py +7 -2
- package/scripts/check_references.py +12 -2
- package/scripts/context_hygiene_hook.py +14 -6
- package/scripts/council_cli.py +407 -0
- package/scripts/hook_manifest.yaml +217 -0
- package/scripts/hooks/__init__.py +1 -0
- package/scripts/hooks/augment-chat-history.sh +10 -0
- package/scripts/hooks/augment-dispatcher.sh +72 -0
- package/scripts/hooks/cline-dispatcher.sh +86 -0
- package/scripts/hooks/cowork-dispatcher.sh +98 -0
- package/scripts/hooks/cursor-dispatcher.sh +76 -0
- package/scripts/hooks/dispatch_hook.py +383 -0
- package/scripts/hooks/envelope.py +98 -0
- package/scripts/hooks/gemini-dispatcher.sh +117 -0
- package/scripts/hooks/state_io.py +122 -0
- package/scripts/hooks/windsurf-dispatcher.sh +123 -0
- package/scripts/hooks_status.py +157 -0
- package/scripts/install-hooks.sh +2 -2
- package/scripts/install.py +725 -87
- package/scripts/install.sh +38 -1
- package/scripts/lint_handoffs.py +214 -0
- package/scripts/lint_hook_manifest.py +217 -0
- package/scripts/lint_one_off_age.py +184 -0
- package/scripts/lint_rule_tiers.py +78 -0
- package/scripts/lint_showcase_sessions.py +148 -0
- package/scripts/minimal_safe_diff_hook.py +245 -0
- package/scripts/onboarding_gate_hook.py +13 -8
- package/scripts/readme_linter.py +12 -3
- package/scripts/redact_hook_capture.py +148 -0
- package/scripts/roadmap_progress_hook.py +5 -0
- package/scripts/schemas/skill.schema.json +5 -0
- package/scripts/skill_linter.py +163 -1
- package/scripts/sync_agent_settings.py +32 -129
- package/scripts/sync_yaml_rt.py +734 -0
- package/scripts/update_prices.py +3 -3
- package/scripts/verify_before_complete_hook.py +216 -0
- package/.agent-src/commands/chat-history/checkpoint.md +0 -126
- package/.agent-src/commands/chat-history/clear.md +0 -103
- package/.agent-src/commands/chat-history/resume.md +0 -183
- package/.agent-src/rules/chat-history-cadence.md +0 -109
- package/.agent-src/rules/chat-history-ownership.md +0 -123
- package/.agent-src/rules/chat-history-visibility.md +0 -96
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
- package/scripts/check_phase_coupling.py +0 -148
package/scripts/install.sh
CHANGED
|
@@ -344,7 +344,7 @@ clean_stale() {
|
|
|
344
344
|
log_verbose "preserve: $entry"
|
|
345
345
|
continue
|
|
346
346
|
fi
|
|
347
|
-
if is_excluded_rule "$entry" || !
|
|
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())
|