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,515 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ claim_service.py - Transactional Issue Claiming Service (ANV-224)
4
+
5
+ Manages issue claims to prevent multiple agents from working on the same
6
+ Linear issue simultaneously. Claims are session-scoped and automatically
7
+ released when an agent terminates.
8
+
9
+ Usage:
10
+ from claim_service import ClaimService, claim_issue, release_claim
11
+
12
+ # Claim an issue
13
+ success = claim_issue("ANV-100", agent_id="agent-alpha", codename="A1")
14
+
15
+ # Check if claimed
16
+ if is_claimed("ANV-100"):
17
+ claim = get_claim("ANV-100")
18
+ print(f"Claimed by {claim['codename']}")
19
+
20
+ # Release claim
21
+ release_claim("ANV-100", agent_id="agent-alpha")
22
+
23
+ # Release all claims for an agent (called on deregistration)
24
+ release_all_claims("agent-alpha")
25
+ """
26
+
27
+ import fcntl
28
+ import json
29
+ import os
30
+ import re
31
+ import tempfile
32
+ import time
33
+ from contextlib import contextmanager
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+ from typing import Any, Dict, Generator, List, Optional, TypedDict
37
+
38
+
39
+ # =============================================================================
40
+ # Data Types
41
+ # =============================================================================
42
+
43
+ class Claim(TypedDict, total=False):
44
+ """A single issue claim."""
45
+ claimed_by: str # Agent UUID that holds the claim
46
+ claimed_at: str # ISO timestamp of when claim was made
47
+ codename: str # Agent codename (A1, A2, etc.) for display
48
+ project: str # Project path where claim originated
49
+ scope: str # "global" or "project"
50
+ collaborators: List[str] # Agent IDs that have override access
51
+
52
+
53
+ class ClaimsRegistry(TypedDict):
54
+ """The claims.json structure."""
55
+ version: str
56
+ lastUpdated: str
57
+ claims: Dict[str, Claim] # issue_id -> Claim
58
+
59
+
60
+ # =============================================================================
61
+ # File Locking
62
+ # =============================================================================
63
+
64
+ CLAIMS_FILE = Path.home() / ".anvil" / "claims.json"
65
+ CLAIMS_LOCK_FILE = Path.home() / ".anvil" / "claims.lock"
66
+
67
+
68
+ @contextmanager
69
+ def claims_lock(timeout: float = 5.0) -> Generator[None, None, None]:
70
+ """Context manager for atomic claims operations using file locking.
71
+
72
+ Args:
73
+ timeout: Maximum seconds to wait for lock (default 5s)
74
+
75
+ Yields:
76
+ None when lock is acquired
77
+
78
+ Raises:
79
+ TimeoutError: If lock cannot be acquired within timeout
80
+ """
81
+ CLAIMS_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
82
+
83
+ lock_fd = os.open(str(CLAIMS_LOCK_FILE), os.O_CREAT | os.O_RDWR)
84
+ try:
85
+ start_time = time.time()
86
+ while True:
87
+ try:
88
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
89
+ break # Lock acquired
90
+ except BlockingIOError:
91
+ if time.time() - start_time > timeout:
92
+ raise TimeoutError(f"Could not acquire claims lock within {timeout}s")
93
+ time.sleep(0.05) # Wait 50ms and retry
94
+
95
+ yield
96
+ finally:
97
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
98
+ os.close(lock_fd)
99
+
100
+
101
+ # =============================================================================
102
+ # Claim Service
103
+ # =============================================================================
104
+
105
+ # Pattern to detect Linear issue IDs (e.g., ANV-123, PROJ-45)
106
+ LINEAR_ISSUE_PATTERN = re.compile(r'^[A-Z]+-\d+$')
107
+
108
+
109
+ class ClaimService:
110
+ """Service for managing transactional issue claims."""
111
+
112
+ VERSION = "1.0"
113
+
114
+ def __init__(self, base_dir: Optional[str] = None):
115
+ """Initialize the claim service.
116
+
117
+ Args:
118
+ base_dir: Override base directory (default: ~/.anvil)
119
+ """
120
+ if base_dir:
121
+ self.base_dir = Path(base_dir)
122
+ else:
123
+ self.base_dir = Path.home() / ".anvil"
124
+
125
+ self.claims_file = self.base_dir / "claims.json"
126
+ self.base_dir.mkdir(parents=True, exist_ok=True)
127
+
128
+ def _atomic_write(self, filepath: Path, data: Dict[str, Any]) -> None:
129
+ """Write data to file atomically (write temp, then rename)."""
130
+ dir_path = filepath.parent
131
+ dir_path.mkdir(parents=True, exist_ok=True)
132
+
133
+ with tempfile.NamedTemporaryFile(
134
+ mode='w',
135
+ dir=str(dir_path),
136
+ delete=False,
137
+ suffix='.tmp'
138
+ ) as tmp:
139
+ json.dump(data, tmp, indent=2)
140
+ tmp_path = tmp.name
141
+
142
+ os.rename(tmp_path, str(filepath))
143
+
144
+ def _read_claims(self) -> ClaimsRegistry:
145
+ """Read the claims file, creating if it doesn't exist."""
146
+ if not self.claims_file.exists():
147
+ return {
148
+ "version": self.VERSION,
149
+ "lastUpdated": datetime.now(timezone.utc).isoformat(),
150
+ "claims": {}
151
+ }
152
+
153
+ try:
154
+ with open(self.claims_file, 'r') as f:
155
+ return json.load(f)
156
+ except (json.JSONDecodeError, IOError):
157
+ # Corrupted file, return fresh
158
+ return {
159
+ "version": self.VERSION,
160
+ "lastUpdated": datetime.now(timezone.utc).isoformat(),
161
+ "claims": {}
162
+ }
163
+
164
+ def _write_claims(self, data: ClaimsRegistry) -> None:
165
+ """Write the claims file atomically."""
166
+ data["lastUpdated"] = datetime.now(timezone.utc).isoformat()
167
+ self._atomic_write(self.claims_file, data)
168
+
169
+ def _determine_scope(self, issue_id: str) -> str:
170
+ """Determine if an issue should have global or project scope.
171
+
172
+ Linear issues (ANV-123 format) are global scope.
173
+ Ad-hoc work (file paths, custom IDs) are project-scoped.
174
+
175
+ Args:
176
+ issue_id: The issue identifier
177
+
178
+ Returns:
179
+ "global" or "project"
180
+ """
181
+ if LINEAR_ISSUE_PATTERN.match(issue_id):
182
+ return "global"
183
+ return "project"
184
+
185
+ def claim(
186
+ self,
187
+ issue_id: str,
188
+ agent_id: str,
189
+ codename: str,
190
+ project: Optional[str] = None,
191
+ force: bool = False,
192
+ ) -> Dict[str, Any]:
193
+ """Claim an issue for exclusive work.
194
+
195
+ Args:
196
+ issue_id: The issue to claim (e.g., "ANV-100")
197
+ agent_id: UUID of the claiming agent
198
+ codename: Display codename (e.g., "A1")
199
+ project: Project path (used for project-scoped claims)
200
+ force: If True, override existing claim (for collaboration)
201
+
202
+ Returns:
203
+ Dict with:
204
+ - success: bool
205
+ - claim: Claim data if successful
206
+ - error: Error message if failed
207
+ - existing_claim: Existing claim if conflict
208
+ """
209
+ with claims_lock():
210
+ registry = self._read_claims()
211
+ claims = registry.get("claims", {})
212
+ scope = self._determine_scope(issue_id)
213
+ now = datetime.now(timezone.utc).isoformat()
214
+
215
+ # Check existing claim
216
+ if issue_id in claims:
217
+ existing = claims[issue_id]
218
+
219
+ # Same agent reclaiming - just update timestamp
220
+ if existing["claimed_by"] == agent_id:
221
+ existing["claimed_at"] = now
222
+ self._write_claims(registry)
223
+ return {
224
+ "success": True,
225
+ "claim": existing,
226
+ "renewed": True,
227
+ }
228
+
229
+ # Different agent - check if we should allow override
230
+ if force:
231
+ # Add original claimer to collaborators list
232
+ collaborators = existing.get("collaborators", [])
233
+ if agent_id not in collaborators:
234
+ collaborators.append(existing["claimed_by"])
235
+ existing["collaborators"] = collaborators
236
+ existing["claimed_by"] = agent_id
237
+ existing["codename"] = codename
238
+ existing["claimed_at"] = now
239
+ self._write_claims(registry)
240
+ return {
241
+ "success": True,
242
+ "claim": existing,
243
+ "override": True,
244
+ "previous_claimer": collaborators[-1],
245
+ }
246
+
247
+ # Conflict - return info about existing claim
248
+ return {
249
+ "success": False,
250
+ "error": f"Issue {issue_id} is already claimed by {existing['codename']}",
251
+ "existing_claim": existing,
252
+ }
253
+
254
+ # No existing claim - create new one
255
+ new_claim: Claim = {
256
+ "claimed_by": agent_id,
257
+ "claimed_at": now,
258
+ "codename": codename,
259
+ "project": project or os.getcwd(),
260
+ "scope": scope,
261
+ "collaborators": [],
262
+ }
263
+
264
+ claims[issue_id] = new_claim
265
+ registry["claims"] = claims
266
+ self._write_claims(registry)
267
+
268
+ return {
269
+ "success": True,
270
+ "claim": new_claim,
271
+ }
272
+
273
+ def release(self, issue_id: str, agent_id: str) -> bool:
274
+ """Release a claim on an issue.
275
+
276
+ Args:
277
+ issue_id: The issue to release
278
+ agent_id: UUID of the releasing agent (must match claimer)
279
+
280
+ Returns:
281
+ True if claim was released, False if not found or not owned
282
+ """
283
+ with claims_lock():
284
+ registry = self._read_claims()
285
+ claims = registry.get("claims", {})
286
+
287
+ if issue_id not in claims:
288
+ return False
289
+
290
+ claim = claims[issue_id]
291
+
292
+ # Only the claimer or a collaborator can release
293
+ if claim["claimed_by"] != agent_id:
294
+ if agent_id not in claim.get("collaborators", []):
295
+ return False
296
+
297
+ del claims[issue_id]
298
+ registry["claims"] = claims
299
+ self._write_claims(registry)
300
+ return True
301
+
302
+ def release_all(self, agent_id: str) -> int:
303
+ """Release all claims held by an agent.
304
+
305
+ Called during agent deregistration to clean up claims.
306
+
307
+ Args:
308
+ agent_id: UUID of the agent
309
+
310
+ Returns:
311
+ Number of claims released
312
+ """
313
+ with claims_lock():
314
+ registry = self._read_claims()
315
+ claims = registry.get("claims", {})
316
+
317
+ released = 0
318
+ to_remove = []
319
+
320
+ for issue_id, claim in claims.items():
321
+ if claim["claimed_by"] == agent_id:
322
+ to_remove.append(issue_id)
323
+ released += 1
324
+
325
+ for issue_id in to_remove:
326
+ del claims[issue_id]
327
+
328
+ if released > 0:
329
+ registry["claims"] = claims
330
+ self._write_claims(registry)
331
+
332
+ return released
333
+
334
+ def is_claimed(self, issue_id: str, project: Optional[str] = None) -> bool:
335
+ """Check if an issue is claimed.
336
+
337
+ Args:
338
+ issue_id: The issue to check
339
+ project: For project-scoped claims, filter by project
340
+
341
+ Returns:
342
+ True if claimed, False otherwise
343
+ """
344
+ registry = self._read_claims()
345
+ claims = registry.get("claims", {})
346
+
347
+ if issue_id not in claims:
348
+ return False
349
+
350
+ claim = claims[issue_id]
351
+
352
+ # For project-scoped claims, check if it's the same project
353
+ if project and claim.get("scope") == "project":
354
+ if claim.get("project") != project:
355
+ return False
356
+
357
+ return True
358
+
359
+ def get_claim(self, issue_id: str) -> Optional[Claim]:
360
+ """Get claim details for an issue.
361
+
362
+ Args:
363
+ issue_id: The issue to look up
364
+
365
+ Returns:
366
+ Claim data or None if not claimed
367
+ """
368
+ registry = self._read_claims()
369
+ return registry.get("claims", {}).get(issue_id)
370
+
371
+ def get_all_claims(self) -> Dict[str, Claim]:
372
+ """Get all active claims.
373
+
374
+ Returns:
375
+ Dictionary of issue_id -> Claim
376
+ """
377
+ registry = self._read_claims()
378
+ return registry.get("claims", {})
379
+
380
+ def get_claims_by_agent(self, agent_id: str) -> Dict[str, Claim]:
381
+ """Get all claims held by a specific agent.
382
+
383
+ Args:
384
+ agent_id: UUID of the agent
385
+
386
+ Returns:
387
+ Dictionary of issue_id -> Claim for this agent
388
+ """
389
+ registry = self._read_claims()
390
+ claims = registry.get("claims", {})
391
+
392
+ return {
393
+ issue_id: claim
394
+ for issue_id, claim in claims.items()
395
+ if claim["claimed_by"] == agent_id
396
+ }
397
+
398
+ def get_claims_summary(self) -> List[Dict[str, Any]]:
399
+ """Get a summary of all claims for display.
400
+
401
+ Returns:
402
+ List of claim summaries with issue_id, codename, project
403
+ """
404
+ registry = self._read_claims()
405
+ claims = registry.get("claims", {})
406
+
407
+ return [
408
+ {
409
+ "issue_id": issue_id,
410
+ "codename": claim["codename"],
411
+ "project": Path(claim.get("project", "")).name,
412
+ "claimed_at": claim["claimed_at"],
413
+ "scope": claim.get("scope", "global"),
414
+ }
415
+ for issue_id, claim in claims.items()
416
+ ]
417
+
418
+
419
+ # =============================================================================
420
+ # Convenience Functions
421
+ # =============================================================================
422
+
423
+ _service: Optional[ClaimService] = None
424
+
425
+
426
+ def get_claim_service() -> ClaimService:
427
+ """Get singleton claim service instance."""
428
+ global _service
429
+ if _service is None:
430
+ _service = ClaimService()
431
+ return _service
432
+
433
+
434
+ def claim_issue(
435
+ issue_id: str,
436
+ agent_id: str,
437
+ codename: str,
438
+ project: Optional[str] = None,
439
+ force: bool = False,
440
+ ) -> Dict[str, Any]:
441
+ """Claim an issue (convenience function)."""
442
+ return get_claim_service().claim(issue_id, agent_id, codename, project, force)
443
+
444
+
445
+ def release_claim(issue_id: str, agent_id: str) -> bool:
446
+ """Release a claim (convenience function)."""
447
+ return get_claim_service().release(issue_id, agent_id)
448
+
449
+
450
+ def release_all_claims(agent_id: str) -> int:
451
+ """Release all claims for an agent (convenience function)."""
452
+ return get_claim_service().release_all(agent_id)
453
+
454
+
455
+ def is_claimed(issue_id: str, project: Optional[str] = None) -> bool:
456
+ """Check if an issue is claimed (convenience function)."""
457
+ return get_claim_service().is_claimed(issue_id, project)
458
+
459
+
460
+ def get_claim(issue_id: str) -> Optional[Claim]:
461
+ """Get claim details (convenience function)."""
462
+ return get_claim_service().get_claim(issue_id)
463
+
464
+
465
+ def get_all_claims() -> Dict[str, Claim]:
466
+ """Get all claims (convenience function)."""
467
+ return get_claim_service().get_all_claims()
468
+
469
+
470
+ def get_claims_by_agent(agent_id: str) -> Dict[str, Claim]:
471
+ """Get claims for an agent (convenience function)."""
472
+ return get_claim_service().get_claims_by_agent(agent_id)
473
+
474
+
475
+ if __name__ == "__main__":
476
+ import sys
477
+
478
+ service = ClaimService()
479
+
480
+ if len(sys.argv) > 1:
481
+ cmd = sys.argv[1]
482
+
483
+ if cmd == "claim":
484
+ issue_id = sys.argv[2]
485
+ agent_id = sys.argv[3] if len(sys.argv) > 3 else "test-agent"
486
+ codename = sys.argv[4] if len(sys.argv) > 4 else "A1"
487
+ result = service.claim(issue_id, agent_id, codename)
488
+ print(json.dumps(result, indent=2))
489
+
490
+ elif cmd == "release":
491
+ issue_id = sys.argv[2]
492
+ agent_id = sys.argv[3] if len(sys.argv) > 3 else "test-agent"
493
+ success = service.release(issue_id, agent_id)
494
+ print(f"Released: {success}")
495
+
496
+ elif cmd == "list":
497
+ claims = service.get_all_claims()
498
+ if not claims:
499
+ print("No active claims")
500
+ else:
501
+ for issue_id, claim in claims.items():
502
+ print(f" {issue_id}: claimed by {claim['codename']}")
503
+
504
+ elif cmd == "check":
505
+ issue_id = sys.argv[2]
506
+ claim = service.get_claim(issue_id)
507
+ if claim:
508
+ print(f"{issue_id} is claimed by {claim['codename']}")
509
+ else:
510
+ print(f"{issue_id} is not claimed")
511
+
512
+ else:
513
+ print(f"Unknown command: {cmd}")
514
+ else:
515
+ print("Usage: claim_service.py [claim|release|list|check] [args...]")