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,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...]")
|