anvil-dev-framework 0.1.6
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/README.md +719 -0
- package/VERSION +1 -0
- package/docs/ANVIL-REPO-IMPLEMENTATION-PLAN.md +441 -0
- package/docs/FIRST-SKILL-TUTORIAL.md +408 -0
- package/docs/INSTALLATION-RETRO-NOTES.md +458 -0
- package/docs/INSTALLATION.md +984 -0
- package/docs/anvil-hud.md +469 -0
- package/docs/anvil-init.md +255 -0
- package/docs/anvil-state.md +210 -0
- package/docs/boris-cherny-ralph-wiggum-insights.md +608 -0
- package/docs/command-reference.md +2022 -0
- package/docs/hooks-tts.md +368 -0
- package/docs/implementation-guide.md +810 -0
- package/docs/linear-github-integration.md +247 -0
- package/docs/local-issues.md +677 -0
- package/docs/patterns/README.md +419 -0
- package/docs/planning-responsibilities.md +139 -0
- package/docs/session-workflow.md +573 -0
- package/docs/simplification-plan-template.md +297 -0
- package/docs/simplification-principles.md +129 -0
- package/docs/specifications/CCS-RALPH-INTEGRATION-DESIGN.md +633 -0
- package/docs/specifications/CCS-RESEARCH-REPORT.md +169 -0
- package/docs/specifications/PLAN-ANV-verification-ralph-wiggum.md +403 -0
- package/docs/specifications/PLAN-parallel-tracks-anvil-memory-ccs.md +494 -0
- package/docs/specifications/SPEC-ANV-VRW/component-01-verify.md +208 -0
- package/docs/specifications/SPEC-ANV-VRW/component-02-stop-gate.md +226 -0
- package/docs/specifications/SPEC-ANV-VRW/component-03-posttooluse.md +209 -0
- package/docs/specifications/SPEC-ANV-VRW/component-04-ralph-wiggum.md +604 -0
- package/docs/specifications/SPEC-ANV-VRW/component-05-atomic-actions.md +311 -0
- package/docs/specifications/SPEC-ANV-VRW/component-06-verify-subagent.md +264 -0
- package/docs/specifications/SPEC-ANV-VRW/component-07-claude-md.md +363 -0
- package/docs/specifications/SPEC-ANV-VRW/index.md +182 -0
- package/docs/specifications/SPEC-ANV-anvil-memory.md +573 -0
- package/docs/specifications/SPEC-ANV-context-checkpoints.md +781 -0
- package/docs/specifications/SPEC-ANV-verification-ralph-wiggum.md +789 -0
- package/docs/sync.md +122 -0
- package/global/CLAUDE.md +140 -0
- package/global/agents/verify-app.md +164 -0
- package/global/commands/anvil-settings.md +527 -0
- package/global/commands/anvil-sync.md +121 -0
- package/global/commands/change.md +197 -0
- package/global/commands/clarify.md +252 -0
- package/global/commands/cleanup.md +292 -0
- package/global/commands/commit-push-pr.md +207 -0
- package/global/commands/decay-review.md +127 -0
- package/global/commands/discover.md +158 -0
- package/global/commands/doc-coverage.md +122 -0
- package/global/commands/evidence.md +307 -0
- package/global/commands/explore.md +121 -0
- package/global/commands/force-exit.md +135 -0
- package/global/commands/handoff.md +191 -0
- package/global/commands/healthcheck.md +302 -0
- package/global/commands/hud.md +84 -0
- package/global/commands/insights.md +319 -0
- package/global/commands/linear-setup.md +184 -0
- package/global/commands/lint-fix.md +198 -0
- package/global/commands/orient.md +510 -0
- package/global/commands/plan.md +228 -0
- package/global/commands/ralph.md +346 -0
- package/global/commands/ready.md +182 -0
- package/global/commands/release.md +305 -0
- package/global/commands/retro.md +96 -0
- package/global/commands/shard.md +166 -0
- package/global/commands/spec.md +227 -0
- package/global/commands/sprint.md +184 -0
- package/global/commands/tasks.md +228 -0
- package/global/commands/test-and-commit.md +151 -0
- package/global/commands/validate.md +132 -0
- package/global/commands/verify.md +251 -0
- package/global/commands/weekly-review.md +156 -0
- package/global/hooks/__pycache__/ralph_context_monitor.cpython-314.pyc +0 -0
- package/global/hooks/__pycache__/statusline_agent_sync.cpython-314.pyc +0 -0
- package/global/hooks/anvil_memory_observe.ts +322 -0
- package/global/hooks/anvil_memory_session.ts +166 -0
- package/global/hooks/anvil_memory_stop.ts +187 -0
- package/global/hooks/parse_transcript.py +116 -0
- package/global/hooks/post_merge_cleanup.sh +132 -0
- package/global/hooks/post_tool_format.sh +215 -0
- package/global/hooks/ralph_context_monitor.py +240 -0
- package/global/hooks/ralph_stop.sh +502 -0
- package/global/hooks/statusline.sh +1110 -0
- package/global/hooks/statusline_agent_sync.py +224 -0
- package/global/hooks/stop_gate.sh +250 -0
- package/global/lib/.claude/anvil-state.json +21 -0
- package/global/lib/__pycache__/agent_registry.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/claim_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/coderabbit_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/config_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/coordination_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/doc_coverage_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/gate_logger.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/github_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/hygiene_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/issue_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/linear_data_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/local_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/quality_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/transcript_parser.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verification_runner.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verify_iteration.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verify_subagent.cpython-314.pyc +0 -0
- package/global/lib/agent_registry.py +995 -0
- package/global/lib/anvil-state.sh +435 -0
- package/global/lib/claim_service.py +515 -0
- package/global/lib/coderabbit_service.py +314 -0
- package/global/lib/config_service.py +423 -0
- package/global/lib/coordination_service.py +331 -0
- package/global/lib/doc_coverage_service.py +1305 -0
- package/global/lib/gate_logger.py +316 -0
- package/global/lib/github_service.py +310 -0
- package/global/lib/handoff_generator.py +775 -0
- package/global/lib/hygiene_service.py +712 -0
- package/global/lib/issue_models.py +257 -0
- package/global/lib/issue_provider.py +339 -0
- package/global/lib/linear_data_service.py +210 -0
- package/global/lib/linear_provider.py +987 -0
- package/global/lib/linear_provider.py.backup +671 -0
- package/global/lib/local_provider.py +486 -0
- package/global/lib/orient_fast.py +457 -0
- package/global/lib/quality_service.py +470 -0
- package/global/lib/ralph_prompt_generator.py +563 -0
- package/global/lib/ralph_state.py +1202 -0
- package/global/lib/state_manager.py +417 -0
- package/global/lib/transcript_parser.py +597 -0
- package/global/lib/verification_runner.py +557 -0
- package/global/lib/verify_iteration.py +490 -0
- package/global/lib/verify_subagent.py +250 -0
- package/global/skills/README.md +155 -0
- package/global/skills/quality-gates/SKILL.md +252 -0
- package/global/skills/skill-template/SKILL.md +109 -0
- package/global/skills/testing-strategies/SKILL.md +337 -0
- package/global/templates/CHANGE-template.md +105 -0
- package/global/templates/HANDOFF-template.md +63 -0
- package/global/templates/PLAN-template.md +111 -0
- package/global/templates/SPEC-template.md +93 -0
- package/global/templates/ralph/PROMPT.md.template +89 -0
- package/global/templates/ralph/fix_plan.md.template +31 -0
- package/global/templates/ralph/progress.txt.template +23 -0
- package/global/tests/__pycache__/test_doc_coverage.cpython-314.pyc +0 -0
- package/global/tests/test_doc_coverage.py +520 -0
- package/global/tests/test_issue_models.py +299 -0
- package/global/tests/test_local_provider.py +323 -0
- package/global/tools/README.md +178 -0
- package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
- package/global/tools/anvil-hud.py +3622 -0
- package/global/tools/anvil-hud.py.bak +3318 -0
- package/global/tools/anvil-issue.py +432 -0
- package/global/tools/anvil-memory/CLAUDE.md +49 -0
- package/global/tools/anvil-memory/README.md +42 -0
- package/global/tools/anvil-memory/bun.lock +25 -0
- package/global/tools/anvil-memory/bunfig.toml +9 -0
- package/global/tools/anvil-memory/package.json +23 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +535 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/edge-cases.test.ts +645 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +363 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +8 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/integration.test.ts +417 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +571 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +440 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +252 -0
- package/global/tools/anvil-memory/src/__tests__/commands.test.ts +657 -0
- package/global/tools/anvil-memory/src/__tests__/db.test.ts +641 -0
- package/global/tools/anvil-memory/src/__tests__/hooks.test.ts +272 -0
- package/global/tools/anvil-memory/src/__tests__/performance.test.ts +427 -0
- package/global/tools/anvil-memory/src/__tests__/test-utils.ts +113 -0
- package/global/tools/anvil-memory/src/commands/checkpoint.ts +197 -0
- package/global/tools/anvil-memory/src/commands/get.ts +115 -0
- package/global/tools/anvil-memory/src/commands/init.ts +94 -0
- package/global/tools/anvil-memory/src/commands/observe.ts +163 -0
- package/global/tools/anvil-memory/src/commands/search.ts +112 -0
- package/global/tools/anvil-memory/src/db.ts +638 -0
- package/global/tools/anvil-memory/src/index.ts +205 -0
- package/global/tools/anvil-memory/src/types.ts +122 -0
- package/global/tools/anvil-memory/tsconfig.json +29 -0
- package/global/tools/ralph-loop.sh +359 -0
- package/package.json +45 -0
- package/scripts/anvil +822 -0
- package/scripts/extract_patterns.py +222 -0
- package/scripts/init-project.sh +541 -0
- package/scripts/install.sh +229 -0
- package/scripts/postinstall.js +41 -0
- package/scripts/rollback.sh +188 -0
- package/scripts/sync.sh +623 -0
- package/scripts/test-statusline.sh +248 -0
- package/scripts/update_claude_md.py +224 -0
- package/scripts/verify.sh +255 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Repository Hygiene Service (ANV-237)
|
|
4
|
+
|
|
5
|
+
Detects stale PRs, orphan branches, and accumulated stashes to help
|
|
6
|
+
maintain repository health. Integrates with /orient, /healthcheck,
|
|
7
|
+
and provides data for /cleanup command.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Issue reference patterns for linking stashes/branches to Linear issues
|
|
23
|
+
ISSUE_PATTERNS = [
|
|
24
|
+
r"(ANV-\d+)", # Direct reference
|
|
25
|
+
r"feature/(ANV-\d+)", # Branch name
|
|
26
|
+
r"\[(ANV-\d+)\]", # Bracketed reference
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class HygieneConfig:
|
|
32
|
+
"""Configuration for hygiene checks."""
|
|
33
|
+
|
|
34
|
+
enabled: bool = True
|
|
35
|
+
stash_threshold: int = 5 # Warn when stash count exceeds this
|
|
36
|
+
stash_age_days: int = 7 # Suggest cleanup for stashes older than this
|
|
37
|
+
check_linear_sync: bool = True # Cross-reference PR/Linear status
|
|
38
|
+
check_merge_conflicts: bool = True # Check PRs for conflicts
|
|
39
|
+
auto_prune_remotes: bool = True # Run git fetch --prune automatically
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_dict(cls, data: dict) -> "HygieneConfig":
|
|
43
|
+
"""Create config from dictionary (e.g., from anvil.yaml)."""
|
|
44
|
+
return cls(
|
|
45
|
+
enabled=data.get("enabled", True),
|
|
46
|
+
stash_threshold=data.get("stash_threshold", 5),
|
|
47
|
+
stash_age_days=data.get("stash_age_days", 7),
|
|
48
|
+
check_linear_sync=data.get("check_linear_sync", True),
|
|
49
|
+
check_merge_conflicts=data.get("check_merge_conflicts", True),
|
|
50
|
+
auto_prune_remotes=data.get("auto_prune_remotes", True),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def default(cls) -> "HygieneConfig":
|
|
55
|
+
"""Return default configuration."""
|
|
56
|
+
return cls()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class StashInfo:
|
|
61
|
+
"""Information about a git stash entry."""
|
|
62
|
+
|
|
63
|
+
index: int
|
|
64
|
+
message: str
|
|
65
|
+
branch: str
|
|
66
|
+
date: Optional[datetime]
|
|
67
|
+
issue_refs: list[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def age_days(self) -> Optional[int]:
|
|
71
|
+
"""Return age in days, or None if date unknown."""
|
|
72
|
+
if self.date is None:
|
|
73
|
+
return None
|
|
74
|
+
# Handle timezone-aware dates from git
|
|
75
|
+
now = datetime.now(timezone.utc)
|
|
76
|
+
if self.date.tzinfo is None:
|
|
77
|
+
# Date is naive, make it UTC
|
|
78
|
+
date = self.date.replace(tzinfo=timezone.utc)
|
|
79
|
+
else:
|
|
80
|
+
date = self.date
|
|
81
|
+
delta = now - date
|
|
82
|
+
return delta.days
|
|
83
|
+
|
|
84
|
+
def is_old(self, threshold_days: int) -> bool:
|
|
85
|
+
"""Check if stash is older than threshold."""
|
|
86
|
+
age = self.age_days
|
|
87
|
+
return age is not None and age > threshold_days
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class BranchInfo:
|
|
92
|
+
"""Information about a local branch."""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
last_commit: str
|
|
96
|
+
last_commit_date: Optional[datetime]
|
|
97
|
+
tracking_branch: Optional[str] # The remote tracking branch
|
|
98
|
+
is_gone: bool # True if remote was deleted
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def issue_refs(self) -> list[str]:
|
|
102
|
+
"""Extract issue references from branch name."""
|
|
103
|
+
return extract_issue_refs(self.name)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class PRInfo:
|
|
108
|
+
"""Information about a GitHub pull request."""
|
|
109
|
+
|
|
110
|
+
number: int
|
|
111
|
+
title: str
|
|
112
|
+
branch: str
|
|
113
|
+
mergeable: str # MERGEABLE, CONFLICTING, UNKNOWN
|
|
114
|
+
linked_issues: list[str] = field(default_factory=list)
|
|
115
|
+
issue_statuses: dict[str, str] = field(default_factory=dict) # issue_key -> status
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def is_stale(self) -> bool:
|
|
119
|
+
"""Check if PR is for a Done Linear issue."""
|
|
120
|
+
return any(
|
|
121
|
+
status.lower() == "done" for status in self.issue_statuses.values()
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def has_conflicts(self) -> bool:
|
|
126
|
+
"""Check if PR has merge conflicts."""
|
|
127
|
+
return self.mergeable == "CONFLICTING"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class HygieneReport:
|
|
132
|
+
"""Complete hygiene check results."""
|
|
133
|
+
|
|
134
|
+
stash_count: int
|
|
135
|
+
stash_threshold: int
|
|
136
|
+
old_stashes: list[StashInfo] # Stashes older than threshold
|
|
137
|
+
all_stashes: list[StashInfo] # All stashes for reference
|
|
138
|
+
orphan_branches: list[BranchInfo] # Branches with gone remotes
|
|
139
|
+
stale_prs: list[PRInfo] # PRs for Done Linear issues
|
|
140
|
+
conflict_prs: list[PRInfo] # PRs with merge conflicts
|
|
141
|
+
timestamp: datetime
|
|
142
|
+
errors: list[str] = field(default_factory=list) # Any errors during checks
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def has_issues(self) -> bool:
|
|
146
|
+
"""Check if any hygiene issues were found."""
|
|
147
|
+
return (
|
|
148
|
+
self.stash_count > self.stash_threshold
|
|
149
|
+
or len(self.old_stashes) > 0
|
|
150
|
+
or len(self.orphan_branches) > 0
|
|
151
|
+
or len(self.stale_prs) > 0
|
|
152
|
+
or len(self.conflict_prs) > 0
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def stash_status(self) -> str:
|
|
157
|
+
"""Return status indicator for stash count."""
|
|
158
|
+
if self.stash_count <= self.stash_threshold:
|
|
159
|
+
return "PASS"
|
|
160
|
+
return "WARN"
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def branch_status(self) -> str:
|
|
164
|
+
"""Return status indicator for orphan branches."""
|
|
165
|
+
if len(self.orphan_branches) == 0:
|
|
166
|
+
return "PASS"
|
|
167
|
+
else:
|
|
168
|
+
return "FAIL"
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def pr_status(self) -> str:
|
|
172
|
+
"""Return status indicator for PRs."""
|
|
173
|
+
if len(self.stale_prs) > 0:
|
|
174
|
+
return "FAIL"
|
|
175
|
+
elif len(self.conflict_prs) > 0:
|
|
176
|
+
return "WARN"
|
|
177
|
+
else:
|
|
178
|
+
return "PASS"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def extract_issue_refs(text: str) -> list[str]:
|
|
182
|
+
"""Extract Linear issue references from text."""
|
|
183
|
+
refs = set()
|
|
184
|
+
for pattern in ISSUE_PATTERNS:
|
|
185
|
+
matches = re.findall(pattern, text, re.IGNORECASE)
|
|
186
|
+
refs.update(match.upper() for match in matches)
|
|
187
|
+
return sorted(refs)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def run_command(cmd: list[str], timeout: int = 30) -> tuple[str, str, int]:
|
|
191
|
+
"""Run a command and return stdout, stderr, returncode."""
|
|
192
|
+
try:
|
|
193
|
+
result = subprocess.run(
|
|
194
|
+
cmd,
|
|
195
|
+
capture_output=True,
|
|
196
|
+
text=True,
|
|
197
|
+
timeout=timeout,
|
|
198
|
+
)
|
|
199
|
+
return result.stdout, result.stderr, result.returncode
|
|
200
|
+
except subprocess.TimeoutExpired:
|
|
201
|
+
return "", "Command timed out", 1
|
|
202
|
+
except FileNotFoundError:
|
|
203
|
+
return "", f"Command not found: {cmd[0]}", 1
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_stash_list() -> list[StashInfo]:
|
|
207
|
+
"""Get list of all stashes with metadata."""
|
|
208
|
+
stashes = []
|
|
209
|
+
|
|
210
|
+
# Get stash list with dates
|
|
211
|
+
stdout, stderr, rc = run_command(
|
|
212
|
+
["git", "stash", "list", "--date=iso-strict"]
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if rc != 0 or not stdout.strip():
|
|
216
|
+
return stashes
|
|
217
|
+
|
|
218
|
+
for line in stdout.strip().split("\n"):
|
|
219
|
+
if not line:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Parse format: stash@{2026-01-11T12:00:00-05:00}: On branch-name: message
|
|
223
|
+
# Or: stash@{0}: On branch-name: message (if no date format)
|
|
224
|
+
match = re.match(
|
|
225
|
+
r"stash@\{(\d+)\}:\s*(?:On\s+)?([^:]+):\s*(.+)",
|
|
226
|
+
line,
|
|
227
|
+
)
|
|
228
|
+
if not match:
|
|
229
|
+
# Try alternative format with ISO date
|
|
230
|
+
match = re.match(
|
|
231
|
+
r"stash@\{([^}]+)\}:\s*(?:On\s+)?([^:]+):\s*(.+)",
|
|
232
|
+
line,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if match:
|
|
236
|
+
index_or_date = match.group(1)
|
|
237
|
+
branch = match.group(2).strip()
|
|
238
|
+
message = match.group(3).strip()
|
|
239
|
+
|
|
240
|
+
# Determine index and date
|
|
241
|
+
try:
|
|
242
|
+
index = int(index_or_date)
|
|
243
|
+
date = None
|
|
244
|
+
except ValueError:
|
|
245
|
+
# It's a date string, need to get index separately
|
|
246
|
+
index = len(stashes)
|
|
247
|
+
try:
|
|
248
|
+
date = datetime.fromisoformat(index_or_date.replace("Z", "+00:00"))
|
|
249
|
+
except ValueError:
|
|
250
|
+
date = None
|
|
251
|
+
|
|
252
|
+
issue_refs = extract_issue_refs(f"{branch} {message}")
|
|
253
|
+
|
|
254
|
+
stashes.append(
|
|
255
|
+
StashInfo(
|
|
256
|
+
index=index,
|
|
257
|
+
message=message,
|
|
258
|
+
branch=branch,
|
|
259
|
+
date=date,
|
|
260
|
+
issue_refs=issue_refs,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# If we couldn't get dates, try to get them from reflog
|
|
265
|
+
if stashes and all(s.date is None for s in stashes):
|
|
266
|
+
stdout2, _, _ = run_command(
|
|
267
|
+
["git", "reflog", "show", "stash", "--date=iso-strict", "--format=%gd|%ci"]
|
|
268
|
+
)
|
|
269
|
+
if stdout2:
|
|
270
|
+
date_lines = stdout2.strip().split("\n")
|
|
271
|
+
for i, line in enumerate(date_lines):
|
|
272
|
+
if i < len(stashes) and "|" in line:
|
|
273
|
+
try:
|
|
274
|
+
date_str = line.split("|")[1].strip()
|
|
275
|
+
# Git format: "2026-01-11 12:00:00 -0500"
|
|
276
|
+
# ISO format: "2026-01-11T12:00:00-0500"
|
|
277
|
+
stashes[i].date = datetime.fromisoformat(
|
|
278
|
+
date_str.replace(" ", "T", 1).replace(" ", "")
|
|
279
|
+
)
|
|
280
|
+
except (ValueError, IndexError):
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
return stashes
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_orphan_branches(auto_prune: bool = True) -> list[BranchInfo]:
|
|
287
|
+
"""Get list of local branches with deleted remotes."""
|
|
288
|
+
branches = []
|
|
289
|
+
|
|
290
|
+
# Optionally prune remotes first
|
|
291
|
+
if auto_prune:
|
|
292
|
+
run_command(["git", "fetch", "--prune"], timeout=60)
|
|
293
|
+
|
|
294
|
+
# Get branch list with tracking info
|
|
295
|
+
stdout, stderr, rc = run_command(["git", "branch", "-vv"])
|
|
296
|
+
|
|
297
|
+
if rc != 0:
|
|
298
|
+
return branches
|
|
299
|
+
|
|
300
|
+
for line in stdout.strip().split("\n"):
|
|
301
|
+
if not line.strip():
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
# Check if this branch has a gone remote
|
|
305
|
+
if ": gone]" not in line:
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
# Parse format: * branch-name abc1234 [origin/branch: gone] commit message
|
|
309
|
+
# Or: branch-name abc1234 [origin/branch: gone] commit message
|
|
310
|
+
line = line.lstrip("* ").strip()
|
|
311
|
+
parts = line.split()
|
|
312
|
+
if len(parts) < 2:
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
branch_name = parts[0]
|
|
316
|
+
commit_hash = parts[1]
|
|
317
|
+
|
|
318
|
+
# Extract tracking branch
|
|
319
|
+
tracking_match = re.search(r"\[([^\]:]+):", line)
|
|
320
|
+
tracking_branch = tracking_match.group(1) if tracking_match else None
|
|
321
|
+
|
|
322
|
+
# Get commit date
|
|
323
|
+
commit_date = None
|
|
324
|
+
date_stdout, _, _ = run_command(
|
|
325
|
+
["git", "log", "-1", "--format=%ci", commit_hash]
|
|
326
|
+
)
|
|
327
|
+
if date_stdout.strip():
|
|
328
|
+
try:
|
|
329
|
+
commit_date = datetime.fromisoformat(
|
|
330
|
+
date_stdout.strip().replace(" ", "T").replace(" ", "")
|
|
331
|
+
)
|
|
332
|
+
except ValueError:
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
branches.append(
|
|
336
|
+
BranchInfo(
|
|
337
|
+
name=branch_name,
|
|
338
|
+
last_commit=commit_hash,
|
|
339
|
+
last_commit_date=commit_date,
|
|
340
|
+
tracking_branch=tracking_branch,
|
|
341
|
+
is_gone=True,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
return branches
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def get_open_prs() -> list[PRInfo]:
|
|
349
|
+
"""Get list of open PRs with mergeable status."""
|
|
350
|
+
prs = []
|
|
351
|
+
|
|
352
|
+
# Check if gh CLI is available
|
|
353
|
+
stdout, stderr, rc = run_command(
|
|
354
|
+
[
|
|
355
|
+
"gh",
|
|
356
|
+
"pr",
|
|
357
|
+
"list",
|
|
358
|
+
"--state",
|
|
359
|
+
"open",
|
|
360
|
+
"--json",
|
|
361
|
+
"number,title,headRefName,mergeable",
|
|
362
|
+
"--limit",
|
|
363
|
+
"50",
|
|
364
|
+
]
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if rc != 0:
|
|
368
|
+
return prs
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
pr_data = json.loads(stdout)
|
|
372
|
+
except json.JSONDecodeError:
|
|
373
|
+
return prs
|
|
374
|
+
|
|
375
|
+
for pr in pr_data:
|
|
376
|
+
linked_issues = extract_issue_refs(f"{pr.get('title', '')} {pr.get('headRefName', '')}")
|
|
377
|
+
|
|
378
|
+
prs.append(
|
|
379
|
+
PRInfo(
|
|
380
|
+
number=pr.get("number", 0),
|
|
381
|
+
title=pr.get("title", ""),
|
|
382
|
+
branch=pr.get("headRefName", ""),
|
|
383
|
+
mergeable=pr.get("mergeable", "UNKNOWN"),
|
|
384
|
+
linked_issues=linked_issues,
|
|
385
|
+
)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return prs
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def get_linear_issue_status(issue_key: str) -> Optional[str]:
|
|
392
|
+
"""Get Linear issue status. Returns None if unavailable.
|
|
393
|
+
|
|
394
|
+
NOTE: This depends on the external Linear skill installed at
|
|
395
|
+
~/.claude/skills/linear-skill/. If not present, returns None gracefully.
|
|
396
|
+
Install the skill via: cp -r path/to/linear-skill ~/.claude/skills/
|
|
397
|
+
"""
|
|
398
|
+
script_path = os.path.expanduser("~/.claude/skills/linear-skill/scripts/linear.py")
|
|
399
|
+
|
|
400
|
+
if not os.path.exists(script_path):
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
stdout, stderr, rc = run_command(
|
|
404
|
+
["python3", script_path, "get-issue", "--id", issue_key],
|
|
405
|
+
timeout=10,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if rc != 0:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
data = json.loads(stdout)
|
|
413
|
+
return data.get("state", {}).get("name")
|
|
414
|
+
except json.JSONDecodeError:
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def check_pr_staleness(prs: list[PRInfo], check_linear: bool = True) -> None:
|
|
419
|
+
"""Update PRs with Linear issue statuses to detect staleness."""
|
|
420
|
+
if not check_linear:
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
for pr in prs:
|
|
424
|
+
for issue_key in pr.linked_issues:
|
|
425
|
+
status = get_linear_issue_status(issue_key)
|
|
426
|
+
if status:
|
|
427
|
+
pr.issue_statuses[issue_key] = status
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def generate_hygiene_report(config: Optional[HygieneConfig] = None) -> HygieneReport:
|
|
431
|
+
"""Generate complete hygiene report."""
|
|
432
|
+
if config is None:
|
|
433
|
+
config = HygieneConfig.default()
|
|
434
|
+
|
|
435
|
+
errors: list[str] = []
|
|
436
|
+
|
|
437
|
+
# Get stashes
|
|
438
|
+
try:
|
|
439
|
+
all_stashes = get_stash_list()
|
|
440
|
+
except Exception as e:
|
|
441
|
+
all_stashes = []
|
|
442
|
+
errors.append(f"Failed to get stashes: {e}")
|
|
443
|
+
|
|
444
|
+
old_stashes = [s for s in all_stashes if s.is_old(config.stash_age_days)]
|
|
445
|
+
|
|
446
|
+
# Get orphan branches
|
|
447
|
+
try:
|
|
448
|
+
orphan_branches = get_orphan_branches(auto_prune=config.auto_prune_remotes)
|
|
449
|
+
except Exception as e:
|
|
450
|
+
orphan_branches = []
|
|
451
|
+
errors.append(f"Failed to get branches: {e}")
|
|
452
|
+
|
|
453
|
+
# Get PRs
|
|
454
|
+
try:
|
|
455
|
+
all_prs = get_open_prs()
|
|
456
|
+
except Exception as e:
|
|
457
|
+
all_prs = []
|
|
458
|
+
errors.append(f"Failed to get PRs: {e}")
|
|
459
|
+
|
|
460
|
+
# Check PR staleness against Linear
|
|
461
|
+
if config.check_linear_sync:
|
|
462
|
+
try:
|
|
463
|
+
check_pr_staleness(all_prs, check_linear=True)
|
|
464
|
+
except Exception as e:
|
|
465
|
+
errors.append(f"Failed to check Linear sync: {e}")
|
|
466
|
+
|
|
467
|
+
stale_prs = [pr for pr in all_prs if pr.is_stale]
|
|
468
|
+
conflict_prs = [pr for pr in all_prs if pr.has_conflicts and not pr.is_stale]
|
|
469
|
+
|
|
470
|
+
return HygieneReport(
|
|
471
|
+
stash_count=len(all_stashes),
|
|
472
|
+
stash_threshold=config.stash_threshold,
|
|
473
|
+
old_stashes=old_stashes,
|
|
474
|
+
all_stashes=all_stashes,
|
|
475
|
+
orphan_branches=orphan_branches,
|
|
476
|
+
stale_prs=stale_prs,
|
|
477
|
+
conflict_prs=conflict_prs,
|
|
478
|
+
timestamp=datetime.now(timezone.utc),
|
|
479
|
+
errors=errors,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def load_config_from_file(config_path: Optional[Path] = None) -> HygieneConfig:
|
|
484
|
+
"""Load hygiene config from anvil.yaml."""
|
|
485
|
+
if config_path is None:
|
|
486
|
+
# Try common locations
|
|
487
|
+
candidates = [
|
|
488
|
+
Path(".claude/anvil.yaml"),
|
|
489
|
+
Path("anvil.yaml"),
|
|
490
|
+
]
|
|
491
|
+
for candidate in candidates:
|
|
492
|
+
if candidate.exists():
|
|
493
|
+
config_path = candidate
|
|
494
|
+
break
|
|
495
|
+
|
|
496
|
+
if config_path is None or not config_path.exists():
|
|
497
|
+
return HygieneConfig.default()
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
import yaml
|
|
501
|
+
|
|
502
|
+
with open(config_path) as f:
|
|
503
|
+
data = yaml.safe_load(f) or {}
|
|
504
|
+
hygiene_config = data.get("hygiene", {})
|
|
505
|
+
return HygieneConfig.from_dict(hygiene_config)
|
|
506
|
+
except ImportError:
|
|
507
|
+
return HygieneConfig.default() # yaml not available
|
|
508
|
+
except Exception:
|
|
509
|
+
return HygieneConfig.default()
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def format_report_text(report: HygieneReport) -> str:
|
|
513
|
+
"""Format hygiene report as text for display."""
|
|
514
|
+
lines = []
|
|
515
|
+
lines.append("## Repository Hygiene Report")
|
|
516
|
+
lines.append("")
|
|
517
|
+
lines.append(f"**Generated**: {report.timestamp.strftime('%Y-%m-%d %H:%M')}")
|
|
518
|
+
lines.append("")
|
|
519
|
+
|
|
520
|
+
# Summary table
|
|
521
|
+
lines.append("| Check | Status | Details |")
|
|
522
|
+
lines.append("|-------|--------|---------|")
|
|
523
|
+
lines.append(
|
|
524
|
+
f"| Stashes | {report.stash_status} | "
|
|
525
|
+
f"{report.stash_count} total (threshold: {report.stash_threshold}) |"
|
|
526
|
+
)
|
|
527
|
+
lines.append(
|
|
528
|
+
f"| Orphan branches | {report.branch_status} | "
|
|
529
|
+
f"{len(report.orphan_branches)} with gone remotes |"
|
|
530
|
+
)
|
|
531
|
+
lines.append(
|
|
532
|
+
f"| Stale PRs | {report.pr_status} | "
|
|
533
|
+
f"{len(report.stale_prs)} for Done issues, "
|
|
534
|
+
f"{len(report.conflict_prs)} with conflicts |"
|
|
535
|
+
)
|
|
536
|
+
lines.append("")
|
|
537
|
+
|
|
538
|
+
# Old stashes
|
|
539
|
+
if report.old_stashes:
|
|
540
|
+
lines.append(f"### Old Stashes ({len(report.old_stashes)})")
|
|
541
|
+
lines.append("")
|
|
542
|
+
for stash in report.old_stashes:
|
|
543
|
+
age = f"{stash.age_days}d" if stash.age_days else "?"
|
|
544
|
+
refs = ", ".join(stash.issue_refs) if stash.issue_refs else "no refs"
|
|
545
|
+
lines.append(f"- `stash@{{{stash.index}}}`: {stash.message} ({age}, {refs})")
|
|
546
|
+
lines.append("")
|
|
547
|
+
|
|
548
|
+
# Orphan branches
|
|
549
|
+
if report.orphan_branches:
|
|
550
|
+
lines.append(f"### Orphan Branches ({len(report.orphan_branches)})")
|
|
551
|
+
lines.append("")
|
|
552
|
+
for branch in report.orphan_branches:
|
|
553
|
+
refs = ", ".join(branch.issue_refs) if branch.issue_refs else ""
|
|
554
|
+
lines.append(f"- `{branch.name}` ({branch.last_commit[:7]}) {refs}")
|
|
555
|
+
lines.append("")
|
|
556
|
+
|
|
557
|
+
# Stale PRs
|
|
558
|
+
if report.stale_prs:
|
|
559
|
+
lines.append(f"### Stale PRs ({len(report.stale_prs)})")
|
|
560
|
+
lines.append("")
|
|
561
|
+
for pr in report.stale_prs:
|
|
562
|
+
done_issues = [k for k, v in pr.issue_statuses.items() if v.lower() == "done"]
|
|
563
|
+
lines.append(f"- PR #{pr.number}: {pr.title}")
|
|
564
|
+
lines.append(f" - Done issues: {', '.join(done_issues)}")
|
|
565
|
+
lines.append(f" - Suggestion: `gh pr close {pr.number}`")
|
|
566
|
+
lines.append("")
|
|
567
|
+
|
|
568
|
+
# Conflict PRs
|
|
569
|
+
if report.conflict_prs:
|
|
570
|
+
lines.append(f"### PRs with Conflicts ({len(report.conflict_prs)})")
|
|
571
|
+
lines.append("")
|
|
572
|
+
for pr in report.conflict_prs:
|
|
573
|
+
lines.append(f"- PR #{pr.number}: {pr.title}")
|
|
574
|
+
lines.append(" - Suggestion: Rebase or close if obsolete")
|
|
575
|
+
lines.append("")
|
|
576
|
+
|
|
577
|
+
# Errors
|
|
578
|
+
if report.errors:
|
|
579
|
+
lines.append("### Errors During Check")
|
|
580
|
+
lines.append("")
|
|
581
|
+
for error in report.errors:
|
|
582
|
+
lines.append(f"- {error}")
|
|
583
|
+
lines.append("")
|
|
584
|
+
|
|
585
|
+
# Recommendation
|
|
586
|
+
if report.has_issues:
|
|
587
|
+
lines.append("---")
|
|
588
|
+
lines.append("Run `/cleanup` to address these issues.")
|
|
589
|
+
|
|
590
|
+
return "\n".join(lines)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def format_report_json(report: HygieneReport) -> str:
|
|
594
|
+
"""Format hygiene report as JSON."""
|
|
595
|
+
data = {
|
|
596
|
+
"timestamp": report.timestamp.isoformat(),
|
|
597
|
+
"stash_count": report.stash_count,
|
|
598
|
+
"stash_threshold": report.stash_threshold,
|
|
599
|
+
"has_issues": report.has_issues,
|
|
600
|
+
"old_stashes": [
|
|
601
|
+
{
|
|
602
|
+
"index": s.index,
|
|
603
|
+
"message": s.message,
|
|
604
|
+
"branch": s.branch,
|
|
605
|
+
"age_days": s.age_days,
|
|
606
|
+
"issue_refs": s.issue_refs,
|
|
607
|
+
}
|
|
608
|
+
for s in report.old_stashes
|
|
609
|
+
],
|
|
610
|
+
"orphan_branches": [
|
|
611
|
+
{
|
|
612
|
+
"name": b.name,
|
|
613
|
+
"last_commit": b.last_commit,
|
|
614
|
+
"issue_refs": b.issue_refs,
|
|
615
|
+
}
|
|
616
|
+
for b in report.orphan_branches
|
|
617
|
+
],
|
|
618
|
+
"stale_prs": [
|
|
619
|
+
{
|
|
620
|
+
"number": p.number,
|
|
621
|
+
"title": p.title,
|
|
622
|
+
"branch": p.branch,
|
|
623
|
+
"linked_issues": p.linked_issues,
|
|
624
|
+
"issue_statuses": p.issue_statuses,
|
|
625
|
+
}
|
|
626
|
+
for p in report.stale_prs
|
|
627
|
+
],
|
|
628
|
+
"conflict_prs": [
|
|
629
|
+
{
|
|
630
|
+
"number": p.number,
|
|
631
|
+
"title": p.title,
|
|
632
|
+
"branch": p.branch,
|
|
633
|
+
}
|
|
634
|
+
for p in report.conflict_prs
|
|
635
|
+
],
|
|
636
|
+
"errors": report.errors,
|
|
637
|
+
}
|
|
638
|
+
return json.dumps(data, indent=2)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def main():
|
|
642
|
+
"""CLI entry point."""
|
|
643
|
+
import argparse
|
|
644
|
+
|
|
645
|
+
parser = argparse.ArgumentParser(description="Repository Hygiene Service")
|
|
646
|
+
parser.add_argument(
|
|
647
|
+
"--check",
|
|
648
|
+
action="store_true",
|
|
649
|
+
help="Run hygiene check and display report",
|
|
650
|
+
)
|
|
651
|
+
parser.add_argument(
|
|
652
|
+
"--json",
|
|
653
|
+
action="store_true",
|
|
654
|
+
help="Output as JSON instead of text",
|
|
655
|
+
)
|
|
656
|
+
parser.add_argument(
|
|
657
|
+
"--config",
|
|
658
|
+
type=Path,
|
|
659
|
+
help="Path to config file (anvil.yaml)",
|
|
660
|
+
)
|
|
661
|
+
parser.add_argument(
|
|
662
|
+
"--stash-threshold",
|
|
663
|
+
type=int,
|
|
664
|
+
help="Override stash count threshold",
|
|
665
|
+
)
|
|
666
|
+
parser.add_argument(
|
|
667
|
+
"--stash-age",
|
|
668
|
+
type=int,
|
|
669
|
+
help="Override stash age threshold (days)",
|
|
670
|
+
)
|
|
671
|
+
parser.add_argument(
|
|
672
|
+
"--no-linear",
|
|
673
|
+
action="store_true",
|
|
674
|
+
help="Skip Linear API checks",
|
|
675
|
+
)
|
|
676
|
+
parser.add_argument(
|
|
677
|
+
"--no-prune",
|
|
678
|
+
action="store_true",
|
|
679
|
+
help="Skip git fetch --prune",
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
args = parser.parse_args()
|
|
683
|
+
|
|
684
|
+
if not args.check:
|
|
685
|
+
parser.print_help()
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
# Load config
|
|
689
|
+
config = load_config_from_file(args.config)
|
|
690
|
+
|
|
691
|
+
# Apply overrides
|
|
692
|
+
if args.stash_threshold is not None:
|
|
693
|
+
config.stash_threshold = args.stash_threshold
|
|
694
|
+
if args.stash_age is not None:
|
|
695
|
+
config.stash_age_days = args.stash_age
|
|
696
|
+
if args.no_linear:
|
|
697
|
+
config.check_linear_sync = False
|
|
698
|
+
if args.no_prune:
|
|
699
|
+
config.auto_prune_remotes = False
|
|
700
|
+
|
|
701
|
+
# Generate report
|
|
702
|
+
report = generate_hygiene_report(config)
|
|
703
|
+
|
|
704
|
+
# Output
|
|
705
|
+
if args.json:
|
|
706
|
+
print(format_report_json(report))
|
|
707
|
+
else:
|
|
708
|
+
print(format_report_text(report))
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
if __name__ == "__main__":
|
|
712
|
+
main()
|