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,486 @@
1
+ """
2
+ LocalJsonProvider - File-based issue tracking for Anvil Framework.
3
+
4
+ Stores issues as JSON files for users who don't use Linear.
5
+ Provides the same IssueProvider interface for seamless integration.
6
+
7
+ Storage Structure:
8
+ ~/.anvil/issues/
9
+ ├── index.json # Issue index with metadata
10
+ └── LOCAL-001.json # Individual issue files (optional detail storage)
11
+
12
+ <project>/.claude/
13
+ └── issues.json # Project-specific issues (optional)
14
+
15
+ Usage:
16
+ from local_provider import LocalJsonProvider
17
+
18
+ provider = LocalJsonProvider()
19
+ issue = provider.create_issue(title="New feature", priority=Priority.HIGH)
20
+ issues = provider.list_issues(status=IssueStatus.TODO)
21
+ """
22
+
23
+ import fcntl
24
+ import json
25
+ import os
26
+ import tempfile
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Optional
30
+ import uuid
31
+
32
+ try:
33
+ from .issue_models import Issue, IssueStatus, Priority
34
+ from .issue_provider import BaseProvider
35
+ except ImportError:
36
+ from issue_models import Issue, IssueStatus, Priority
37
+ from issue_provider import BaseProvider
38
+
39
+
40
+ class LocalJsonProvider(BaseProvider):
41
+ """
42
+ File-based issue provider using JSON storage.
43
+
44
+ Features:
45
+ - Atomic writes (temp file + rename)
46
+ - File locking for concurrent access
47
+ - Auto-generated identifiers (LOCAL-001, LOCAL-002, etc.)
48
+ - Support for both global and project-specific issues
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ project_path: Optional[Path] = None,
54
+ prefix: str = "LOCAL",
55
+ storage_path: Optional[Path] = None
56
+ ):
57
+ """
58
+ Initialize LocalJsonProvider.
59
+
60
+ Args:
61
+ project_path: Project root for project-specific issues (optional)
62
+ prefix: Identifier prefix (default: "LOCAL")
63
+ storage_path: Custom storage path (default: ~/.anvil/issues)
64
+ """
65
+ self.project_path = project_path or Path.cwd()
66
+ self.prefix = prefix
67
+
68
+ # Global storage location
69
+ if storage_path:
70
+ self.storage_path = storage_path
71
+ else:
72
+ self.storage_path = Path.home() / ".anvil" / "issues"
73
+
74
+ # Ensure storage directory exists
75
+ self.storage_path.mkdir(parents=True, exist_ok=True)
76
+
77
+ # Index file path
78
+ self.index_path = self.storage_path / "index.json"
79
+
80
+ # Initialize index if needed
81
+ self._ensure_index()
82
+
83
+ @property
84
+ def name(self) -> str:
85
+ return "local"
86
+
87
+ @property
88
+ def is_available(self) -> bool:
89
+ return True # Always available for local storage
90
+
91
+ def _ensure_index(self) -> None:
92
+ """Create index file if it doesn't exist."""
93
+ if not self.index_path.exists():
94
+ self._write_index({
95
+ "version": "1.0",
96
+ "prefix": self.prefix,
97
+ "next_id": 1,
98
+ "issues": {}
99
+ })
100
+
101
+ def _read_index(self) -> dict:
102
+ """Read index file with file locking."""
103
+ try:
104
+ with open(self.index_path, 'r') as f:
105
+ fcntl.flock(f.fileno(), fcntl.LOCK_SH)
106
+ try:
107
+ return json.load(f)
108
+ finally:
109
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
110
+ except (FileNotFoundError, json.JSONDecodeError):
111
+ return {
112
+ "version": "1.0",
113
+ "prefix": self.prefix,
114
+ "next_id": 1,
115
+ "issues": {}
116
+ }
117
+
118
+ def _write_index(self, data: dict) -> None:
119
+ """Write index file atomically with file locking."""
120
+ # Write to temp file first
121
+ fd, temp_path = tempfile.mkstemp(
122
+ dir=self.storage_path,
123
+ prefix=".index_",
124
+ suffix=".json"
125
+ )
126
+ try:
127
+ with os.fdopen(fd, 'w') as f:
128
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
129
+ try:
130
+ json.dump(data, f, indent=2, default=str)
131
+ finally:
132
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
133
+
134
+ # Atomic rename
135
+ os.rename(temp_path, self.index_path)
136
+ except Exception:
137
+ # Clean up temp file on error
138
+ try:
139
+ os.unlink(temp_path)
140
+ except OSError:
141
+ pass
142
+ raise
143
+
144
+ def _generate_identifier(self, index: dict) -> tuple[str, int]:
145
+ """Generate next identifier and increment counter."""
146
+ next_id = index.get("next_id", 1)
147
+ prefix = index.get("prefix", self.prefix)
148
+ identifier = f"{prefix}-{next_id:03d}"
149
+ return identifier, next_id + 1
150
+
151
+ def _issue_to_index_entry(self, issue: Issue) -> dict:
152
+ """Convert Issue to compact index entry."""
153
+ return {
154
+ "id": issue.id,
155
+ "title": issue.title,
156
+ "status": issue.status.value,
157
+ "priority": issue.priority.value,
158
+ "parent_id": issue.parent_id,
159
+ "assigned_agent": issue.assigned_agent,
160
+ "labels": issue.labels,
161
+ "estimate": issue.estimate,
162
+ "created_at": issue.created_at.isoformat() if issue.created_at else None,
163
+ "updated_at": issue.updated_at.isoformat() if issue.updated_at else None,
164
+ "completed_at": issue.completed_at.isoformat() if issue.completed_at else None,
165
+ }
166
+
167
+ def _index_entry_to_issue(self, identifier: str, entry: dict) -> Issue:
168
+ """Convert index entry back to Issue object."""
169
+ def parse_dt(val) -> Optional[datetime]:
170
+ if val is None:
171
+ return None
172
+ if isinstance(val, datetime):
173
+ return val
174
+ return datetime.fromisoformat(val.replace('Z', '+00:00'))
175
+
176
+ return Issue(
177
+ id=entry.get("id", identifier),
178
+ identifier=identifier,
179
+ title=entry.get("title", ""),
180
+ description=entry.get("description", ""),
181
+ status=IssueStatus(entry.get("status", "todo")),
182
+ priority=Priority(entry.get("priority", 2)),
183
+ parent_id=entry.get("parent_id"),
184
+ labels=entry.get("labels", []),
185
+ assigned_agent=entry.get("assigned_agent"),
186
+ created_at=parse_dt(entry.get("created_at")) or datetime.now(timezone.utc),
187
+ updated_at=parse_dt(entry.get("updated_at")) or datetime.now(timezone.utc),
188
+ completed_at=parse_dt(entry.get("completed_at")),
189
+ estimate=entry.get("estimate"),
190
+ provider="local",
191
+ external_id=None
192
+ )
193
+
194
+ # ─────────────────────────────────────────────────────────────────────
195
+ # Read Operations
196
+ # ─────────────────────────────────────────────────────────────────────
197
+
198
+ def list_issues(
199
+ self,
200
+ status: Optional[IssueStatus] = None,
201
+ project: Optional[str] = None,
202
+ limit: int = 50
203
+ ) -> list[Issue]:
204
+ """List issues, optionally filtered by status."""
205
+ index = self._read_index()
206
+ issues = []
207
+
208
+ for identifier, entry in index.get("issues", {}).items():
209
+ issue = self._index_entry_to_issue(identifier, entry)
210
+
211
+ # Filter by status if specified
212
+ if status and issue.status != status:
213
+ continue
214
+
215
+ issues.append(issue)
216
+
217
+ # Sort by priority (lower = higher priority), then by created_at
218
+ issues.sort(key=lambda i: (i.priority.value, i.created_at))
219
+
220
+ return issues[:limit]
221
+
222
+ def get_issue(self, identifier: str) -> Optional[Issue]:
223
+ """Get a single issue by identifier."""
224
+ index = self._read_index()
225
+ entry = index.get("issues", {}).get(identifier)
226
+
227
+ if entry is None:
228
+ # Try case-insensitive match
229
+ for key, val in index.get("issues", {}).items():
230
+ if key.upper() == identifier.upper():
231
+ return self._index_entry_to_issue(key, val)
232
+ return None
233
+
234
+ return self._index_entry_to_issue(identifier, entry)
235
+
236
+ def get_ready_issues(self) -> list[Issue]:
237
+ """Get issues ready to work on (Todo, no blocking deps)."""
238
+ issues = self.list_issues(status=IssueStatus.TODO)
239
+ # Sort by priority (lower = higher), then by age (oldest first)
240
+ return sorted(issues, key=lambda i: (i.priority.value, i.created_at))
241
+
242
+ def get_in_progress_issues(self) -> list[Issue]:
243
+ """Get issues currently being worked on."""
244
+ in_progress = self.list_issues(status=IssueStatus.IN_PROGRESS)
245
+ in_review = self.list_issues(status=IssueStatus.IN_REVIEW)
246
+ return in_progress + in_review
247
+
248
+ # ─────────────────────────────────────────────────────────────────────
249
+ # Write Operations
250
+ # ─────────────────────────────────────────────────────────────────────
251
+
252
+ def create_issue(
253
+ self,
254
+ title: str,
255
+ description: str = "",
256
+ status: IssueStatus = IssueStatus.TODO,
257
+ priority: Priority = Priority.MEDIUM,
258
+ parent_id: Optional[str] = None,
259
+ labels: Optional[list[str]] = None
260
+ ) -> Issue:
261
+ """Create a new issue."""
262
+ index = self._read_index()
263
+
264
+ # Generate identifier
265
+ identifier, next_id = self._generate_identifier(index)
266
+
267
+ # Create issue object
268
+ now = datetime.now(timezone.utc)
269
+ issue = Issue(
270
+ id=str(uuid.uuid4()),
271
+ identifier=identifier,
272
+ title=title,
273
+ description=description,
274
+ status=status,
275
+ priority=priority,
276
+ parent_id=parent_id,
277
+ labels=labels or [],
278
+ assigned_agent=None,
279
+ created_at=now,
280
+ updated_at=now,
281
+ completed_at=None,
282
+ estimate=None,
283
+ provider="local",
284
+ external_id=None
285
+ )
286
+
287
+ # Add to index
288
+ index["issues"][identifier] = self._issue_to_index_entry(issue)
289
+ index["next_id"] = next_id
290
+
291
+ # Write index
292
+ self._write_index(index)
293
+
294
+ return issue
295
+
296
+ def update_issue(
297
+ self,
298
+ identifier: str,
299
+ title: Optional[str] = None,
300
+ description: Optional[str] = None,
301
+ status: Optional[IssueStatus] = None,
302
+ priority: Optional[Priority] = None,
303
+ labels: Optional[list[str]] = None
304
+ ) -> Issue:
305
+ """Update an existing issue."""
306
+ index = self._read_index()
307
+
308
+ # Find issue (case-insensitive)
309
+ actual_identifier = None
310
+ for key in index.get("issues", {}).keys():
311
+ if key.upper() == identifier.upper():
312
+ actual_identifier = key
313
+ break
314
+
315
+ if actual_identifier is None:
316
+ raise KeyError(f"Issue not found: {identifier}")
317
+
318
+ entry = index["issues"][actual_identifier]
319
+ issue = self._index_entry_to_issue(actual_identifier, entry)
320
+
321
+ # Apply updates
322
+ if title is not None:
323
+ issue.title = title
324
+ if description is not None:
325
+ issue.description = description
326
+ if status is not None:
327
+ issue.transition_to(status)
328
+ if priority is not None:
329
+ issue.priority = priority
330
+ if labels is not None:
331
+ issue.labels = labels
332
+
333
+ issue.updated_at = datetime.now(timezone.utc)
334
+
335
+ # Update index
336
+ index["issues"][actual_identifier] = self._issue_to_index_entry(issue)
337
+ self._write_index(index)
338
+
339
+ return issue
340
+
341
+ def delete_issue(self, identifier: str) -> bool:
342
+ """Delete an issue (soft delete - marks as cancelled)."""
343
+ try:
344
+ self.update_issue(identifier, status=IssueStatus.CANCELLED)
345
+ return True
346
+ except KeyError:
347
+ return False
348
+
349
+ def hard_delete_issue(self, identifier: str) -> bool:
350
+ """Permanently delete an issue from storage."""
351
+ index = self._read_index()
352
+
353
+ # Find issue (case-insensitive)
354
+ actual_identifier = None
355
+ for key in index.get("issues", {}).keys():
356
+ if key.upper() == identifier.upper():
357
+ actual_identifier = key
358
+ break
359
+
360
+ if actual_identifier is None:
361
+ return False
362
+
363
+ del index["issues"][actual_identifier]
364
+ self._write_index(index)
365
+ return True
366
+
367
+ # ─────────────────────────────────────────────────────────────────────
368
+ # Agent Integration
369
+ # ─────────────────────────────────────────────────────────────────────
370
+
371
+ def assign_to_agent(self, identifier: str, agent_id: str) -> Issue:
372
+ """Assign an issue to an Anvil agent."""
373
+ index = self._read_index()
374
+
375
+ # Find issue
376
+ actual_identifier = None
377
+ for key in index.get("issues", {}).keys():
378
+ if key.upper() == identifier.upper():
379
+ actual_identifier = key
380
+ break
381
+
382
+ if actual_identifier is None:
383
+ raise KeyError(f"Issue not found: {identifier}")
384
+
385
+ entry = index["issues"][actual_identifier]
386
+ issue = self._index_entry_to_issue(actual_identifier, entry)
387
+
388
+ issue.claim(agent_id)
389
+
390
+ # Update index
391
+ index["issues"][actual_identifier] = self._issue_to_index_entry(issue)
392
+ self._write_index(index)
393
+
394
+ return issue
395
+
396
+ def unassign_agent(self, identifier: str) -> Issue:
397
+ """Remove agent assignment from an issue."""
398
+ index = self._read_index()
399
+
400
+ # Find issue
401
+ actual_identifier = None
402
+ for key in index.get("issues", {}).keys():
403
+ if key.upper() == identifier.upper():
404
+ actual_identifier = key
405
+ break
406
+
407
+ if actual_identifier is None:
408
+ raise KeyError(f"Issue not found: {identifier}")
409
+
410
+ entry = index["issues"][actual_identifier]
411
+ issue = self._index_entry_to_issue(actual_identifier, entry)
412
+
413
+ issue.unclaim()
414
+
415
+ # Update index
416
+ index["issues"][actual_identifier] = self._issue_to_index_entry(issue)
417
+ self._write_index(index)
418
+
419
+ return issue
420
+
421
+ def get_agent_issues(self, agent_id: str) -> list[Issue]:
422
+ """Get all issues assigned to a specific agent."""
423
+ all_issues = self.list_issues()
424
+ return [i for i in all_issues if i.assigned_agent == agent_id]
425
+
426
+ # ─────────────────────────────────────────────────────────────────────
427
+ # Bulk Operations
428
+ # ─────────────────────────────────────────────────────────────────────
429
+
430
+ def import_issues(self, issues: list[Issue]) -> int:
431
+ """Import multiple issues at once."""
432
+ index = self._read_index()
433
+ imported = 0
434
+
435
+ for issue in issues:
436
+ # Use existing identifier or generate new one
437
+ if issue.identifier and issue.identifier not in index.get("issues", {}):
438
+ identifier = issue.identifier
439
+ else:
440
+ identifier, next_id = self._generate_identifier(index)
441
+ index["next_id"] = next_id
442
+ issue.identifier = identifier
443
+
444
+ issue.provider = "local"
445
+ index["issues"][identifier] = self._issue_to_index_entry(issue)
446
+ imported += 1
447
+
448
+ self._write_index(index)
449
+ return imported
450
+
451
+ def export_issues(self, status: Optional[IssueStatus] = None) -> list[dict]:
452
+ """Export issues as list of dictionaries."""
453
+ issues = self.list_issues(status=status, limit=9999)
454
+ return [issue.to_dict() for issue in issues]
455
+
456
+ def get_statistics(self) -> dict:
457
+ """Get issue statistics."""
458
+ all_issues = self.list_issues(limit=9999)
459
+
460
+ by_status = {}
461
+ by_priority = {}
462
+ assigned = 0
463
+ unassigned = 0
464
+
465
+ for issue in all_issues:
466
+ # Count by status
467
+ status_name = issue.status.value
468
+ by_status[status_name] = by_status.get(status_name, 0) + 1
469
+
470
+ # Count by priority
471
+ priority_name = issue.priority.to_display()
472
+ by_priority[priority_name] = by_priority.get(priority_name, 0) + 1
473
+
474
+ # Count assigned
475
+ if issue.assigned_agent:
476
+ assigned += 1
477
+ else:
478
+ unassigned += 1
479
+
480
+ return {
481
+ "total": len(all_issues),
482
+ "by_status": by_status,
483
+ "by_priority": by_priority,
484
+ "assigned": assigned,
485
+ "unassigned": unassigned
486
+ }