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,671 @@
1
+ """
2
+ LinearProvider - Linear API adapter for Anvil Framework.
3
+
4
+ Wraps the Linear GraphQL API to provide the IssueProvider interface.
5
+ Uses the LINEAR_API_KEY environment variable for authentication.
6
+
7
+ Usage:
8
+ from linear_provider import LinearProvider
9
+
10
+ provider = LinearProvider(team_key="ANV")
11
+ issues = provider.list_issues(status=IssueStatus.TODO)
12
+ """
13
+
14
+ import os
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Optional
18
+ import json
19
+
20
+ try:
21
+ import requests
22
+ except ImportError:
23
+ requests = None
24
+
25
+ try:
26
+ from .issue_models import Issue, IssueStatus, Priority
27
+ from .issue_provider import BaseProvider
28
+ except ImportError:
29
+ from issue_models import Issue, IssueStatus, Priority
30
+ from issue_provider import BaseProvider
31
+
32
+
33
+ class LinearProvider(BaseProvider):
34
+ """
35
+ Linear API adapter implementing IssueProvider interface.
36
+
37
+ Features:
38
+ - Maps Linear workflow states to IssueStatus enum
39
+ - Caches state UUIDs for performance
40
+ - Transforms Linear API responses to Issue objects
41
+ - Supports agent assignment via issue comments (Linear convention)
42
+ """
43
+
44
+ API_URL = "https://api.linear.app/graphql"
45
+
46
+ # Map Linear state types to our IssueStatus enum
47
+ STATE_TYPE_MAP = {
48
+ "backlog": IssueStatus.BACKLOG,
49
+ "unstarted": IssueStatus.TODO,
50
+ "started": IssueStatus.IN_PROGRESS,
51
+ "completed": IssueStatus.DONE,
52
+ "canceled": IssueStatus.CANCELLED,
53
+ }
54
+
55
+ # Map Linear state names (more specific) to our IssueStatus
56
+ STATE_NAME_MAP = {
57
+ "backlog": IssueStatus.BACKLOG,
58
+ "todo": IssueStatus.TODO,
59
+ "in progress": IssueStatus.IN_PROGRESS,
60
+ "in review": IssueStatus.IN_REVIEW,
61
+ "done": IssueStatus.DONE,
62
+ "cancelled": IssueStatus.CANCELLED,
63
+ "canceled": IssueStatus.CANCELLED,
64
+ "duplicate": IssueStatus.CANCELLED,
65
+ }
66
+
67
+ # Map our IssueStatus to Linear state names (for updates)
68
+ STATUS_TO_STATE_NAME = {
69
+ IssueStatus.BACKLOG: "Backlog",
70
+ IssueStatus.TODO: "Todo",
71
+ IssueStatus.IN_PROGRESS: "In Progress",
72
+ IssueStatus.IN_REVIEW: "In Review",
73
+ IssueStatus.DONE: "Done",
74
+ IssueStatus.CANCELLED: "Canceled",
75
+ }
76
+
77
+ # Linear priority (0=urgent, 1=high, 2=medium, 3=low, 4=none)
78
+ # to our Priority enum
79
+ LINEAR_PRIORITY_MAP = {
80
+ 0: Priority.URGENT,
81
+ 1: Priority.HIGH,
82
+ 2: Priority.MEDIUM,
83
+ 3: Priority.LOW,
84
+ 4: Priority.NONE,
85
+ }
86
+
87
+ PRIORITY_TO_LINEAR = {
88
+ Priority.URGENT: 0,
89
+ Priority.HIGH: 1,
90
+ Priority.MEDIUM: 2,
91
+ Priority.LOW: 3,
92
+ Priority.NONE: 4,
93
+ }
94
+
95
+ def __init__(
96
+ self,
97
+ team_key: str = "",
98
+ team_id: str = "",
99
+ api_key: Optional[str] = None
100
+ ):
101
+ """
102
+ Initialize LinearProvider.
103
+
104
+ Args:
105
+ team_key: Linear team key (e.g., "ANV")
106
+ team_id: Linear team UUID (optional, will be looked up from team_key)
107
+ api_key: Linear API key (optional, defaults to LINEAR_API_KEY env var)
108
+ """
109
+ self.team_key = team_key
110
+ self._team_id = team_id
111
+ self.api_key = api_key or os.getenv("LINEAR_API_KEY")
112
+
113
+ if not self.api_key:
114
+ self._available = False
115
+ elif requests is None:
116
+ self._available = False
117
+ else:
118
+ self._available = True
119
+
120
+ self.headers = {
121
+ "Authorization": self.api_key or "",
122
+ "Content-Type": "application/json"
123
+ }
124
+
125
+ # Cache for state UUIDs (populated on first use)
126
+ self._state_cache: dict[str, dict] = {}
127
+ self._state_cache_initialized = False
128
+
129
+ # Cache for label UUIDs (populated on first use)
130
+ self._label_cache: dict[str, str] = {} # name -> id
131
+ self._label_cache_initialized = False
132
+
133
+ @property
134
+ def name(self) -> str:
135
+ return "linear"
136
+
137
+ @property
138
+ def is_available(self) -> bool:
139
+ if not self._available:
140
+ return False
141
+ # Quick connectivity check (cached after first call)
142
+ if not hasattr(self, "_connectivity_checked"):
143
+ try:
144
+ self._query("query { viewer { id } }")
145
+ self._connectivity_checked = True
146
+ except Exception:
147
+ self._connectivity_checked = False
148
+ return self._connectivity_checked
149
+
150
+ def _query(self, query: str, variables: Optional[dict] = None) -> dict:
151
+ """Execute GraphQL query."""
152
+ if requests is None:
153
+ raise ImportError("requests module required for Linear API")
154
+
155
+ payload = {"query": query}
156
+ if variables:
157
+ payload["variables"] = variables
158
+
159
+ response = requests.post(
160
+ self.API_URL,
161
+ headers=self.headers,
162
+ json=payload,
163
+ timeout=30
164
+ )
165
+
166
+ if response.status_code != 200:
167
+ raise Exception(f"Linear API error: {response.status_code}")
168
+
169
+ data = response.json()
170
+ if "errors" in data:
171
+ raise Exception(f"GraphQL error: {json.dumps(data['errors'])}")
172
+
173
+ return data.get("data", {})
174
+
175
+ def _get_team_id(self) -> str:
176
+ """Get team UUID, looking up from team_key if needed."""
177
+ if self._team_id:
178
+ return self._team_id
179
+
180
+ if not self.team_key:
181
+ raise ValueError("team_key or team_id required")
182
+
183
+ # Look up team by key
184
+ query = """
185
+ query {
186
+ teams {
187
+ nodes {
188
+ id
189
+ key
190
+ }
191
+ }
192
+ }
193
+ """
194
+ result = self._query(query)
195
+ for team in result.get("teams", {}).get("nodes", []):
196
+ if team.get("key") == self.team_key:
197
+ self._team_id = team.get("id")
198
+ return self._team_id
199
+
200
+ raise ValueError(f"Team not found: {self.team_key}")
201
+
202
+ def _ensure_state_cache(self):
203
+ """Initialize state cache if needed."""
204
+ if self._state_cache_initialized:
205
+ return
206
+
207
+ team_id = self._get_team_id()
208
+ query = """
209
+ query($teamId: String!) {
210
+ team(id: $teamId) {
211
+ states {
212
+ nodes {
213
+ id
214
+ name
215
+ type
216
+ }
217
+ }
218
+ }
219
+ }
220
+ """
221
+ result = self._query(query, {"teamId": team_id})
222
+ states = result.get("team", {}).get("states", {}).get("nodes", [])
223
+
224
+ for state in states:
225
+ name_lower = state.get("name", "").lower()
226
+ self._state_cache[name_lower] = state
227
+
228
+ self._state_cache_initialized = True
229
+
230
+ def _get_state_id(self, status: IssueStatus) -> Optional[str]:
231
+ """Get Linear state UUID for an IssueStatus."""
232
+ self._ensure_state_cache()
233
+
234
+ state_name = self.STATUS_TO_STATE_NAME.get(status, "").lower()
235
+ if state_name in self._state_cache:
236
+ return self._state_cache[state_name].get("id")
237
+
238
+ return None
239
+
240
+ def _ensure_label_cache(self):
241
+ """Initialize label cache if needed."""
242
+ if self._label_cache_initialized:
243
+ return
244
+
245
+ team_id = self._get_team_id()
246
+ query = """
247
+ query($teamId: String!) {
248
+ team(id: $teamId) {
249
+ labels {
250
+ nodes {
251
+ id
252
+ name
253
+ }
254
+ }
255
+ }
256
+ }
257
+ """
258
+ result = self._query(query, {"teamId": team_id})
259
+ labels = result.get("team", {}).get("labels", {}).get("nodes", [])
260
+
261
+ for label in labels:
262
+ name_lower = label.get("name", "").lower()
263
+ self._label_cache[name_lower] = label.get("id", "")
264
+
265
+ self._label_cache_initialized = True
266
+
267
+ def _get_label_ids(self, label_names: list[str]) -> list[str]:
268
+ """Get Linear label UUIDs for a list of label names.
269
+
270
+ Args:
271
+ label_names: List of label names to look up
272
+
273
+ Returns:
274
+ List of label UUIDs (skips labels that don't exist)
275
+ """
276
+ if not label_names:
277
+ return []
278
+
279
+ self._ensure_label_cache()
280
+
281
+ label_ids = []
282
+ for name in label_names:
283
+ name_lower = name.lower()
284
+ if name_lower in self._label_cache:
285
+ label_ids.append(self._label_cache[name_lower])
286
+ # Skip labels that don't exist rather than failing
287
+
288
+ return label_ids
289
+
290
+ def _map_state_to_status(self, state: dict) -> IssueStatus:
291
+ """Map Linear state to IssueStatus."""
292
+ # Try name first (more specific)
293
+ name_lower = state.get("name", "").lower()
294
+ if name_lower in self.STATE_NAME_MAP:
295
+ return self.STATE_NAME_MAP[name_lower]
296
+
297
+ # Fall back to type
298
+ state_type = state.get("type", "unstarted")
299
+ return self.STATE_TYPE_MAP.get(state_type, IssueStatus.TODO)
300
+
301
+ def _map_priority(self, linear_priority: int) -> Priority:
302
+ """Map Linear priority (0-4) to our Priority enum."""
303
+ return self.LINEAR_PRIORITY_MAP.get(linear_priority, Priority.MEDIUM)
304
+
305
+ def _parse_datetime(self, value: Optional[str]) -> Optional[datetime]:
306
+ """Parse ISO datetime string."""
307
+ if not value:
308
+ return None
309
+ try:
310
+ return datetime.fromisoformat(value.replace('Z', '+00:00'))
311
+ except (ValueError, TypeError):
312
+ return None
313
+
314
+ def _issue_from_linear(self, data: dict) -> Issue:
315
+ """Transform Linear API response to Issue object."""
316
+ state = data.get("state", {})
317
+ assignee = data.get("assignee", {})
318
+ parent = data.get("parent", {})
319
+ labels = data.get("labels", {}).get("nodes", [])
320
+
321
+ # Extract agent assignment from description or comments
322
+ # Convention: [agent:swift-falcon-a3f2] in description
323
+ description = data.get("description", "") or ""
324
+ agent_id = None
325
+ if "[agent:" in description:
326
+ start = description.find("[agent:") + 7
327
+ end = description.find("]", start)
328
+ if end > start:
329
+ agent_id = description[start:end]
330
+
331
+ return Issue(
332
+ id=data.get("id", ""),
333
+ identifier=data.get("identifier", ""),
334
+ title=data.get("title", ""),
335
+ description=description,
336
+ status=self._map_state_to_status(state),
337
+ priority=self._map_priority(data.get("priority", 2)),
338
+ parent_id=parent.get("identifier") if parent else None,
339
+ labels=[l.get("name", "") for l in labels],
340
+ assigned_agent=agent_id,
341
+ created_at=self._parse_datetime(data.get("createdAt")),
342
+ updated_at=self._parse_datetime(data.get("updatedAt")),
343
+ completed_at=self._parse_datetime(data.get("completedAt")),
344
+ estimate=data.get("estimate"),
345
+ provider="linear",
346
+ external_id=data.get("id")
347
+ )
348
+
349
+ # ─────────────────────────────────────────────────────────────────────
350
+ # Read Operations
351
+ # ─────────────────────────────────────────────────────────────────────
352
+
353
+ def list_issues(
354
+ self,
355
+ status: Optional[IssueStatus] = None,
356
+ project: Optional[str] = None,
357
+ limit: int = 50
358
+ ) -> list[Issue]:
359
+ """List issues from Linear, optionally filtered by status."""
360
+ team_id = self._get_team_id()
361
+
362
+ query = """
363
+ query($teamId: String!, $first: Int) {
364
+ team(id: $teamId) {
365
+ issues(first: $first) {
366
+ nodes {
367
+ id
368
+ identifier
369
+ title
370
+ description
371
+ state {
372
+ id
373
+ name
374
+ type
375
+ }
376
+ assignee {
377
+ id
378
+ name
379
+ }
380
+ priority
381
+ estimate
382
+ labels {
383
+ nodes {
384
+ name
385
+ }
386
+ }
387
+ parent {
388
+ identifier
389
+ }
390
+ createdAt
391
+ updatedAt
392
+ completedAt
393
+ }
394
+ }
395
+ }
396
+ }
397
+ """
398
+
399
+ result = self._query(query, {"teamId": team_id, "first": limit})
400
+ nodes = result.get("team", {}).get("issues", {}).get("nodes", [])
401
+
402
+ issues = [self._issue_from_linear(n) for n in nodes]
403
+
404
+ # Filter by status if specified
405
+ if status:
406
+ issues = [i for i in issues if i.status == status]
407
+
408
+ # Sort by priority then created_at
409
+ issues.sort(key=lambda i: (i.priority.value, i.created_at or datetime.min.replace(tzinfo=timezone.utc)))
410
+
411
+ return issues[:limit]
412
+
413
+ def get_issue(self, identifier: str) -> Optional[Issue]:
414
+ """Get a single issue by identifier (e.g., ANV-72)."""
415
+ query = """
416
+ query($id: String!) {
417
+ issue(id: $id) {
418
+ id
419
+ identifier
420
+ title
421
+ description
422
+ state {
423
+ id
424
+ name
425
+ type
426
+ }
427
+ assignee {
428
+ id
429
+ name
430
+ }
431
+ priority
432
+ estimate
433
+ labels {
434
+ nodes {
435
+ name
436
+ }
437
+ }
438
+ parent {
439
+ identifier
440
+ }
441
+ createdAt
442
+ updatedAt
443
+ completedAt
444
+ }
445
+ }
446
+ """
447
+
448
+ try:
449
+ result = self._query(query, {"id": identifier})
450
+ issue_data = result.get("issue")
451
+ if issue_data:
452
+ return self._issue_from_linear(issue_data)
453
+ except Exception:
454
+ pass
455
+
456
+ return None
457
+
458
+ # ─────────────────────────────────────────────────────────────────────
459
+ # Write Operations
460
+ # ─────────────────────────────────────────────────────────────────────
461
+
462
+ def create_issue(
463
+ self,
464
+ title: str,
465
+ description: str = "",
466
+ status: IssueStatus = IssueStatus.TODO,
467
+ priority: Priority = Priority.MEDIUM,
468
+ parent_id: Optional[str] = None,
469
+ labels: Optional[list[str]] = None
470
+ ) -> Issue:
471
+ """Create a new issue in Linear."""
472
+ team_id = self._get_team_id()
473
+
474
+ mutation = """
475
+ mutation($input: IssueCreateInput!) {
476
+ issueCreate(input: $input) {
477
+ success
478
+ issue {
479
+ id
480
+ identifier
481
+ title
482
+ description
483
+ state {
484
+ id
485
+ name
486
+ type
487
+ }
488
+ assignee {
489
+ id
490
+ name
491
+ }
492
+ priority
493
+ labels {
494
+ nodes {
495
+ name
496
+ }
497
+ }
498
+ parent {
499
+ identifier
500
+ }
501
+ createdAt
502
+ updatedAt
503
+ }
504
+ }
505
+ }
506
+ """
507
+
508
+ input_data = {
509
+ "teamId": team_id,
510
+ "title": title,
511
+ "description": description,
512
+ "priority": self.PRIORITY_TO_LINEAR.get(priority, 2)
513
+ }
514
+
515
+ # Get state ID
516
+ state_id = self._get_state_id(status)
517
+ if state_id:
518
+ input_data["stateId"] = state_id
519
+
520
+ # Handle parent (need to resolve identifier to ID)
521
+ if parent_id:
522
+ parent = self.get_issue(parent_id)
523
+ if parent and parent.external_id:
524
+ input_data["parentId"] = parent.external_id
525
+
526
+ # Handle labels (resolve names to UUIDs)
527
+ if labels:
528
+ label_ids = self._get_label_ids(labels)
529
+ if label_ids:
530
+ input_data["labelIds"] = label_ids
531
+
532
+ result = self._query(mutation, {"input": input_data})
533
+ issue_data = result.get("issueCreate", {}).get("issue", {})
534
+
535
+ return self._issue_from_linear(issue_data)
536
+
537
+ def update_issue(
538
+ self,
539
+ identifier: str,
540
+ title: Optional[str] = None,
541
+ description: Optional[str] = None,
542
+ status: Optional[IssueStatus] = None,
543
+ priority: Optional[Priority] = None,
544
+ labels: Optional[list[str]] = None
545
+ ) -> Issue:
546
+ """Update an existing issue in Linear."""
547
+ mutation = """
548
+ mutation($id: String!, $input: IssueUpdateInput!) {
549
+ issueUpdate(id: $id, input: $input) {
550
+ success
551
+ issue {
552
+ id
553
+ identifier
554
+ title
555
+ description
556
+ state {
557
+ id
558
+ name
559
+ type
560
+ }
561
+ assignee {
562
+ id
563
+ name
564
+ }
565
+ priority
566
+ labels {
567
+ nodes {
568
+ name
569
+ }
570
+ }
571
+ parent {
572
+ identifier
573
+ }
574
+ createdAt
575
+ updatedAt
576
+ completedAt
577
+ }
578
+ }
579
+ }
580
+ """
581
+
582
+ input_data = {}
583
+ if title is not None:
584
+ input_data["title"] = title
585
+ if description is not None:
586
+ input_data["description"] = description
587
+ if priority is not None:
588
+ input_data["priority"] = self.PRIORITY_TO_LINEAR.get(priority, 2)
589
+ if status is not None:
590
+ state_id = self._get_state_id(status)
591
+ if state_id:
592
+ input_data["stateId"] = state_id
593
+ # Handle labels (resolve names to UUIDs)
594
+ # Empty list means clear all labels; None means don't change
595
+ if labels is not None:
596
+ label_ids = self._get_label_ids(labels) if labels else []
597
+ input_data["labelIds"] = label_ids
598
+
599
+ if not input_data:
600
+ # Nothing to update, just fetch current state
601
+ issue = self.get_issue(identifier)
602
+ if not issue:
603
+ raise KeyError(f"Issue not found: {identifier}")
604
+ return issue
605
+
606
+ result = self._query(mutation, {"id": identifier, "input": input_data})
607
+ issue_data = result.get("issueUpdate", {}).get("issue", {})
608
+
609
+ if not issue_data:
610
+ raise KeyError(f"Issue not found: {identifier}")
611
+
612
+ return self._issue_from_linear(issue_data)
613
+
614
+ def delete_issue(self, identifier: str) -> bool:
615
+ """Delete an issue (marks as cancelled in Linear)."""
616
+ try:
617
+ self.update_issue(identifier, status=IssueStatus.CANCELLED)
618
+ return True
619
+ except (KeyError, Exception):
620
+ return False
621
+
622
+ # ─────────────────────────────────────────────────────────────────────
623
+ # Agent Integration
624
+ # ─────────────────────────────────────────────────────────────────────
625
+
626
+ def assign_to_agent(self, identifier: str, agent_id: str) -> Issue:
627
+ """
628
+ Assign an issue to an Anvil agent.
629
+
630
+ Uses description convention: [agent:agent-id] appended to description.
631
+ This is a workaround since Linear doesn't have agent assignment natively.
632
+ """
633
+ issue = self.get_issue(identifier)
634
+ if not issue:
635
+ raise KeyError(f"Issue not found: {identifier}")
636
+
637
+ # Remove existing agent tag if present
638
+ description = issue.description or ""
639
+ if "[agent:" in description:
640
+ start = description.find("[agent:")
641
+ end = description.find("]", start)
642
+ # Only remove if closing bracket found
643
+ if end > start:
644
+ description = description[:start] + description[end + 1:]
645
+
646
+ # Add new agent tag
647
+ description = description.strip() + f"\n\n[agent:{agent_id}]"
648
+
649
+ return self.update_issue(identifier, description=description)
650
+
651
+ def unassign_agent(self, identifier: str) -> Issue:
652
+ """Remove agent assignment from an issue."""
653
+ issue = self.get_issue(identifier)
654
+ if not issue:
655
+ raise KeyError(f"Issue not found: {identifier}")
656
+
657
+ description = issue.description or ""
658
+ if "[agent:" in description:
659
+ start = description.find("[agent:")
660
+ end = description.find("]", start)
661
+ # Only remove if closing bracket found
662
+ if end > start:
663
+ description = description[:start] + description[end + 1:]
664
+ description = description.strip()
665
+
666
+ return self.update_issue(identifier, description=description)
667
+
668
+ def get_agent_issues(self, agent_id: str) -> list[Issue]:
669
+ """Get all issues assigned to a specific agent."""
670
+ all_issues = self.list_issues()
671
+ return [i for i in all_issues if i.assigned_agent == agent_id]