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.
Files changed (190) hide show
  1. package/README.md +719 -0
  2. package/VERSION +1 -0
  3. package/docs/ANVIL-REPO-IMPLEMENTATION-PLAN.md +441 -0
  4. package/docs/FIRST-SKILL-TUTORIAL.md +408 -0
  5. package/docs/INSTALLATION-RETRO-NOTES.md +458 -0
  6. package/docs/INSTALLATION.md +984 -0
  7. package/docs/anvil-hud.md +469 -0
  8. package/docs/anvil-init.md +255 -0
  9. package/docs/anvil-state.md +210 -0
  10. package/docs/boris-cherny-ralph-wiggum-insights.md +608 -0
  11. package/docs/command-reference.md +2022 -0
  12. package/docs/hooks-tts.md +368 -0
  13. package/docs/implementation-guide.md +810 -0
  14. package/docs/linear-github-integration.md +247 -0
  15. package/docs/local-issues.md +677 -0
  16. package/docs/patterns/README.md +419 -0
  17. package/docs/planning-responsibilities.md +139 -0
  18. package/docs/session-workflow.md +573 -0
  19. package/docs/simplification-plan-template.md +297 -0
  20. package/docs/simplification-principles.md +129 -0
  21. package/docs/specifications/CCS-RALPH-INTEGRATION-DESIGN.md +633 -0
  22. package/docs/specifications/CCS-RESEARCH-REPORT.md +169 -0
  23. package/docs/specifications/PLAN-ANV-verification-ralph-wiggum.md +403 -0
  24. package/docs/specifications/PLAN-parallel-tracks-anvil-memory-ccs.md +494 -0
  25. package/docs/specifications/SPEC-ANV-VRW/component-01-verify.md +208 -0
  26. package/docs/specifications/SPEC-ANV-VRW/component-02-stop-gate.md +226 -0
  27. package/docs/specifications/SPEC-ANV-VRW/component-03-posttooluse.md +209 -0
  28. package/docs/specifications/SPEC-ANV-VRW/component-04-ralph-wiggum.md +604 -0
  29. package/docs/specifications/SPEC-ANV-VRW/component-05-atomic-actions.md +311 -0
  30. package/docs/specifications/SPEC-ANV-VRW/component-06-verify-subagent.md +264 -0
  31. package/docs/specifications/SPEC-ANV-VRW/component-07-claude-md.md +363 -0
  32. package/docs/specifications/SPEC-ANV-VRW/index.md +182 -0
  33. package/docs/specifications/SPEC-ANV-anvil-memory.md +573 -0
  34. package/docs/specifications/SPEC-ANV-context-checkpoints.md +781 -0
  35. package/docs/specifications/SPEC-ANV-verification-ralph-wiggum.md +789 -0
  36. package/docs/sync.md +122 -0
  37. package/global/CLAUDE.md +140 -0
  38. package/global/agents/verify-app.md +164 -0
  39. package/global/commands/anvil-settings.md +527 -0
  40. package/global/commands/anvil-sync.md +121 -0
  41. package/global/commands/change.md +197 -0
  42. package/global/commands/clarify.md +252 -0
  43. package/global/commands/cleanup.md +292 -0
  44. package/global/commands/commit-push-pr.md +207 -0
  45. package/global/commands/decay-review.md +127 -0
  46. package/global/commands/discover.md +158 -0
  47. package/global/commands/doc-coverage.md +122 -0
  48. package/global/commands/evidence.md +307 -0
  49. package/global/commands/explore.md +121 -0
  50. package/global/commands/force-exit.md +135 -0
  51. package/global/commands/handoff.md +191 -0
  52. package/global/commands/healthcheck.md +302 -0
  53. package/global/commands/hud.md +84 -0
  54. package/global/commands/insights.md +319 -0
  55. package/global/commands/linear-setup.md +184 -0
  56. package/global/commands/lint-fix.md +198 -0
  57. package/global/commands/orient.md +510 -0
  58. package/global/commands/plan.md +228 -0
  59. package/global/commands/ralph.md +346 -0
  60. package/global/commands/ready.md +182 -0
  61. package/global/commands/release.md +305 -0
  62. package/global/commands/retro.md +96 -0
  63. package/global/commands/shard.md +166 -0
  64. package/global/commands/spec.md +227 -0
  65. package/global/commands/sprint.md +184 -0
  66. package/global/commands/tasks.md +228 -0
  67. package/global/commands/test-and-commit.md +151 -0
  68. package/global/commands/validate.md +132 -0
  69. package/global/commands/verify.md +251 -0
  70. package/global/commands/weekly-review.md +156 -0
  71. package/global/hooks/__pycache__/ralph_context_monitor.cpython-314.pyc +0 -0
  72. package/global/hooks/__pycache__/statusline_agent_sync.cpython-314.pyc +0 -0
  73. package/global/hooks/anvil_memory_observe.ts +322 -0
  74. package/global/hooks/anvil_memory_session.ts +166 -0
  75. package/global/hooks/anvil_memory_stop.ts +187 -0
  76. package/global/hooks/parse_transcript.py +116 -0
  77. package/global/hooks/post_merge_cleanup.sh +132 -0
  78. package/global/hooks/post_tool_format.sh +215 -0
  79. package/global/hooks/ralph_context_monitor.py +240 -0
  80. package/global/hooks/ralph_stop.sh +502 -0
  81. package/global/hooks/statusline.sh +1110 -0
  82. package/global/hooks/statusline_agent_sync.py +224 -0
  83. package/global/hooks/stop_gate.sh +250 -0
  84. package/global/lib/.claude/anvil-state.json +21 -0
  85. package/global/lib/__pycache__/agent_registry.cpython-314.pyc +0 -0
  86. package/global/lib/__pycache__/claim_service.cpython-314.pyc +0 -0
  87. package/global/lib/__pycache__/coderabbit_service.cpython-314.pyc +0 -0
  88. package/global/lib/__pycache__/config_service.cpython-314.pyc +0 -0
  89. package/global/lib/__pycache__/coordination_service.cpython-314.pyc +0 -0
  90. package/global/lib/__pycache__/doc_coverage_service.cpython-314.pyc +0 -0
  91. package/global/lib/__pycache__/gate_logger.cpython-314.pyc +0 -0
  92. package/global/lib/__pycache__/github_service.cpython-314.pyc +0 -0
  93. package/global/lib/__pycache__/hygiene_service.cpython-314.pyc +0 -0
  94. package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
  95. package/global/lib/__pycache__/issue_provider.cpython-314.pyc +0 -0
  96. package/global/lib/__pycache__/linear_data_service.cpython-314.pyc +0 -0
  97. package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
  98. package/global/lib/__pycache__/local_provider.cpython-314.pyc +0 -0
  99. package/global/lib/__pycache__/quality_service.cpython-314.pyc +0 -0
  100. package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
  101. package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
  102. package/global/lib/__pycache__/transcript_parser.cpython-314.pyc +0 -0
  103. package/global/lib/__pycache__/verification_runner.cpython-314.pyc +0 -0
  104. package/global/lib/__pycache__/verify_iteration.cpython-314.pyc +0 -0
  105. package/global/lib/__pycache__/verify_subagent.cpython-314.pyc +0 -0
  106. package/global/lib/agent_registry.py +995 -0
  107. package/global/lib/anvil-state.sh +435 -0
  108. package/global/lib/claim_service.py +515 -0
  109. package/global/lib/coderabbit_service.py +314 -0
  110. package/global/lib/config_service.py +423 -0
  111. package/global/lib/coordination_service.py +331 -0
  112. package/global/lib/doc_coverage_service.py +1305 -0
  113. package/global/lib/gate_logger.py +316 -0
  114. package/global/lib/github_service.py +310 -0
  115. package/global/lib/handoff_generator.py +775 -0
  116. package/global/lib/hygiene_service.py +712 -0
  117. package/global/lib/issue_models.py +257 -0
  118. package/global/lib/issue_provider.py +339 -0
  119. package/global/lib/linear_data_service.py +210 -0
  120. package/global/lib/linear_provider.py +987 -0
  121. package/global/lib/linear_provider.py.backup +671 -0
  122. package/global/lib/local_provider.py +486 -0
  123. package/global/lib/orient_fast.py +457 -0
  124. package/global/lib/quality_service.py +470 -0
  125. package/global/lib/ralph_prompt_generator.py +563 -0
  126. package/global/lib/ralph_state.py +1202 -0
  127. package/global/lib/state_manager.py +417 -0
  128. package/global/lib/transcript_parser.py +597 -0
  129. package/global/lib/verification_runner.py +557 -0
  130. package/global/lib/verify_iteration.py +490 -0
  131. package/global/lib/verify_subagent.py +250 -0
  132. package/global/skills/README.md +155 -0
  133. package/global/skills/quality-gates/SKILL.md +252 -0
  134. package/global/skills/skill-template/SKILL.md +109 -0
  135. package/global/skills/testing-strategies/SKILL.md +337 -0
  136. package/global/templates/CHANGE-template.md +105 -0
  137. package/global/templates/HANDOFF-template.md +63 -0
  138. package/global/templates/PLAN-template.md +111 -0
  139. package/global/templates/SPEC-template.md +93 -0
  140. package/global/templates/ralph/PROMPT.md.template +89 -0
  141. package/global/templates/ralph/fix_plan.md.template +31 -0
  142. package/global/templates/ralph/progress.txt.template +23 -0
  143. package/global/tests/__pycache__/test_doc_coverage.cpython-314.pyc +0 -0
  144. package/global/tests/test_doc_coverage.py +520 -0
  145. package/global/tests/test_issue_models.py +299 -0
  146. package/global/tests/test_local_provider.py +323 -0
  147. package/global/tools/README.md +178 -0
  148. package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
  149. package/global/tools/anvil-hud.py +3622 -0
  150. package/global/tools/anvil-hud.py.bak +3318 -0
  151. package/global/tools/anvil-issue.py +432 -0
  152. package/global/tools/anvil-memory/CLAUDE.md +49 -0
  153. package/global/tools/anvil-memory/README.md +42 -0
  154. package/global/tools/anvil-memory/bun.lock +25 -0
  155. package/global/tools/anvil-memory/bunfig.toml +9 -0
  156. package/global/tools/anvil-memory/package.json +23 -0
  157. package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +535 -0
  158. package/global/tools/anvil-memory/src/__tests__/ccs/edge-cases.test.ts +645 -0
  159. package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +363 -0
  160. package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +8 -0
  161. package/global/tools/anvil-memory/src/__tests__/ccs/integration.test.ts +417 -0
  162. package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +571 -0
  163. package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +440 -0
  164. package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +252 -0
  165. package/global/tools/anvil-memory/src/__tests__/commands.test.ts +657 -0
  166. package/global/tools/anvil-memory/src/__tests__/db.test.ts +641 -0
  167. package/global/tools/anvil-memory/src/__tests__/hooks.test.ts +272 -0
  168. package/global/tools/anvil-memory/src/__tests__/performance.test.ts +427 -0
  169. package/global/tools/anvil-memory/src/__tests__/test-utils.ts +113 -0
  170. package/global/tools/anvil-memory/src/commands/checkpoint.ts +197 -0
  171. package/global/tools/anvil-memory/src/commands/get.ts +115 -0
  172. package/global/tools/anvil-memory/src/commands/init.ts +94 -0
  173. package/global/tools/anvil-memory/src/commands/observe.ts +163 -0
  174. package/global/tools/anvil-memory/src/commands/search.ts +112 -0
  175. package/global/tools/anvil-memory/src/db.ts +638 -0
  176. package/global/tools/anvil-memory/src/index.ts +205 -0
  177. package/global/tools/anvil-memory/src/types.ts +122 -0
  178. package/global/tools/anvil-memory/tsconfig.json +29 -0
  179. package/global/tools/ralph-loop.sh +359 -0
  180. package/package.json +45 -0
  181. package/scripts/anvil +822 -0
  182. package/scripts/extract_patterns.py +222 -0
  183. package/scripts/init-project.sh +541 -0
  184. package/scripts/install.sh +229 -0
  185. package/scripts/postinstall.js +41 -0
  186. package/scripts/rollback.sh +188 -0
  187. package/scripts/sync.sh +623 -0
  188. package/scripts/test-statusline.sh +248 -0
  189. package/scripts/update_claude_md.py +224 -0
  190. 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()