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,987 @@
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 typing import Optional
17
+ import json
18
+
19
+ try:
20
+ import requests
21
+ except ImportError:
22
+ requests = None
23
+
24
+ try:
25
+ from .issue_models import Issue, IssueStatus, Priority
26
+ from .issue_provider import BaseProvider
27
+ except ImportError:
28
+ from issue_models import Issue, IssueStatus, Priority
29
+ from issue_provider import BaseProvider
30
+
31
+
32
+ class LinearProvider(BaseProvider):
33
+ """
34
+ Linear API adapter implementing IssueProvider interface.
35
+
36
+ Features:
37
+ - Maps Linear workflow states to IssueStatus enum
38
+ - Caches state UUIDs for performance
39
+ - Transforms Linear API responses to Issue objects
40
+ - Supports agent assignment via issue comments (Linear convention)
41
+ """
42
+
43
+ API_URL = "https://api.linear.app/graphql"
44
+
45
+ # Map Linear state types to our IssueStatus enum
46
+ STATE_TYPE_MAP = {
47
+ "backlog": IssueStatus.BACKLOG,
48
+ "unstarted": IssueStatus.TODO,
49
+ "started": IssueStatus.IN_PROGRESS,
50
+ "completed": IssueStatus.DONE,
51
+ "canceled": IssueStatus.CANCELLED,
52
+ }
53
+
54
+ # Map Linear state names (more specific) to our IssueStatus
55
+ STATE_NAME_MAP = {
56
+ "backlog": IssueStatus.BACKLOG,
57
+ "todo": IssueStatus.TODO,
58
+ "in progress": IssueStatus.IN_PROGRESS,
59
+ "in review": IssueStatus.IN_REVIEW,
60
+ "done": IssueStatus.DONE,
61
+ "cancelled": IssueStatus.CANCELLED,
62
+ "canceled": IssueStatus.CANCELLED,
63
+ "duplicate": IssueStatus.CANCELLED,
64
+ }
65
+
66
+ # Map our IssueStatus to Linear state names (for updates)
67
+ STATUS_TO_STATE_NAME = {
68
+ IssueStatus.BACKLOG: "Backlog",
69
+ IssueStatus.TODO: "Todo",
70
+ IssueStatus.IN_PROGRESS: "In Progress",
71
+ IssueStatus.IN_REVIEW: "In Review",
72
+ IssueStatus.DONE: "Done",
73
+ IssueStatus.CANCELLED: "Canceled",
74
+ }
75
+
76
+ # Linear priority (0=urgent, 1=high, 2=medium, 3=low, 4=none)
77
+ # to our Priority enum
78
+ LINEAR_PRIORITY_MAP = {
79
+ 0: Priority.URGENT,
80
+ 1: Priority.HIGH,
81
+ 2: Priority.MEDIUM,
82
+ 3: Priority.LOW,
83
+ 4: Priority.NONE,
84
+ }
85
+
86
+ PRIORITY_TO_LINEAR = {
87
+ Priority.URGENT: 0,
88
+ Priority.HIGH: 1,
89
+ Priority.MEDIUM: 2,
90
+ Priority.LOW: 3,
91
+ Priority.NONE: 4,
92
+ }
93
+
94
+ def __init__(
95
+ self,
96
+ team_key: str = "",
97
+ team_id: str = "",
98
+ api_key: Optional[str] = None
99
+ ):
100
+ """
101
+ Initialize LinearProvider.
102
+
103
+ Args:
104
+ team_key: Linear team key (e.g., "ANV")
105
+ team_id: Linear team UUID (optional, will be looked up from team_key)
106
+ api_key: Linear API key (optional, defaults to LINEAR_API_KEY env var)
107
+ """
108
+ self.team_key = team_key
109
+ self._team_id = team_id
110
+ self.api_key = api_key or os.getenv("LINEAR_API_KEY")
111
+
112
+ if not self.api_key:
113
+ self._available = False
114
+ elif requests is None:
115
+ self._available = False
116
+ else:
117
+ self._available = True
118
+
119
+ self.headers = {
120
+ "Authorization": self.api_key or "",
121
+ "Content-Type": "application/json"
122
+ }
123
+
124
+ # Cache for state UUIDs (populated on first use)
125
+ self._state_cache: dict[str, dict] = {}
126
+ self._state_cache_initialized = False
127
+
128
+ # Cache for label UUIDs (populated on first use)
129
+ self._label_cache: dict[str, str] = {} # name -> id
130
+ self._label_cache_initialized = False
131
+
132
+ @property
133
+ def name(self) -> str:
134
+ return "linear"
135
+
136
+ @property
137
+ def is_available(self) -> bool:
138
+ if not self._available:
139
+ return False
140
+ # Quick connectivity check (cached after first call)
141
+ if not hasattr(self, "_connectivity_checked"):
142
+ try:
143
+ self._query("query { viewer { id } }")
144
+ self._connectivity_checked = True
145
+ except Exception:
146
+ self._connectivity_checked = False
147
+ return self._connectivity_checked
148
+
149
+ def _query(self, query: str, variables: Optional[dict] = None) -> dict:
150
+ """Execute GraphQL query."""
151
+ if requests is None:
152
+ raise ImportError("requests module required for Linear API")
153
+
154
+ payload = {"query": query}
155
+ if variables:
156
+ payload["variables"] = variables
157
+
158
+ response = requests.post(
159
+ self.API_URL,
160
+ headers=self.headers,
161
+ json=payload,
162
+ timeout=30
163
+ )
164
+
165
+ if response.status_code != 200:
166
+ raise Exception(f"Linear API error: {response.status_code}")
167
+
168
+ data = response.json()
169
+ if "errors" in data:
170
+ raise Exception(f"GraphQL error: {json.dumps(data['errors'])}")
171
+
172
+ return data.get("data", {})
173
+
174
+ def _get_team_id(self) -> str:
175
+ """Get team UUID, looking up from team_key if needed."""
176
+ if self._team_id:
177
+ return self._team_id
178
+
179
+ if not self.team_key:
180
+ raise ValueError("team_key or team_id required")
181
+
182
+ # Look up team by key
183
+ query = """
184
+ query {
185
+ teams {
186
+ nodes {
187
+ id
188
+ key
189
+ }
190
+ }
191
+ }
192
+ """
193
+ result = self._query(query)
194
+ for team in result.get("teams", {}).get("nodes", []):
195
+ if team.get("key") == self.team_key:
196
+ self._team_id = team.get("id")
197
+ return self._team_id
198
+
199
+ raise ValueError(f"Team not found: {self.team_key}")
200
+
201
+ def _ensure_state_cache(self):
202
+ """Initialize state cache if needed."""
203
+ if self._state_cache_initialized:
204
+ return
205
+
206
+ team_id = self._get_team_id()
207
+ query = """
208
+ query($teamId: String!) {
209
+ team(id: $teamId) {
210
+ states {
211
+ nodes {
212
+ id
213
+ name
214
+ type
215
+ }
216
+ }
217
+ }
218
+ }
219
+ """
220
+ result = self._query(query, {"teamId": team_id})
221
+ states = result.get("team", {}).get("states", {}).get("nodes", [])
222
+
223
+ for state in states:
224
+ name_lower = state.get("name", "").lower()
225
+ self._state_cache[name_lower] = state
226
+
227
+ self._state_cache_initialized = True
228
+
229
+ def _get_state_id(self, status: IssueStatus) -> Optional[str]:
230
+ """Get Linear state UUID for an IssueStatus."""
231
+ self._ensure_state_cache()
232
+
233
+ state_name = self.STATUS_TO_STATE_NAME.get(status, "").lower()
234
+ if state_name in self._state_cache:
235
+ return self._state_cache[state_name].get("id")
236
+
237
+ return None
238
+
239
+ def _ensure_label_cache(self):
240
+ """Initialize label cache if needed."""
241
+ if self._label_cache_initialized:
242
+ return
243
+
244
+ team_id = self._get_team_id()
245
+ query = """
246
+ query($teamId: String!) {
247
+ team(id: $teamId) {
248
+ labels {
249
+ nodes {
250
+ id
251
+ name
252
+ }
253
+ }
254
+ }
255
+ }
256
+ """
257
+ result = self._query(query, {"teamId": team_id})
258
+ labels = result.get("team", {}).get("labels", {}).get("nodes", [])
259
+
260
+ for label in labels:
261
+ name_lower = label.get("name", "").lower()
262
+ self._label_cache[name_lower] = label.get("id", "")
263
+
264
+ self._label_cache_initialized = True
265
+
266
+ def _get_label_ids(self, label_names: list[str]) -> list[str]:
267
+ """Get Linear label UUIDs for a list of label names.
268
+
269
+ Args:
270
+ label_names: List of label names to look up
271
+
272
+ Returns:
273
+ List of label UUIDs (skips labels that don't exist)
274
+ """
275
+ if not label_names:
276
+ return []
277
+
278
+ self._ensure_label_cache()
279
+
280
+ label_ids = []
281
+ for name in label_names:
282
+ name_lower = name.lower()
283
+ if name_lower in self._label_cache:
284
+ label_ids.append(self._label_cache[name_lower])
285
+ # Skip labels that don't exist rather than failing
286
+
287
+ return label_ids
288
+
289
+ def _map_state_to_status(self, state: dict) -> IssueStatus:
290
+ """Map Linear state to IssueStatus."""
291
+ # Try name first (more specific)
292
+ name_lower = state.get("name", "").lower()
293
+ if name_lower in self.STATE_NAME_MAP:
294
+ return self.STATE_NAME_MAP[name_lower]
295
+
296
+ # Fall back to type
297
+ state_type = state.get("type", "unstarted")
298
+ return self.STATE_TYPE_MAP.get(state_type, IssueStatus.TODO)
299
+
300
+ def _map_priority(self, linear_priority: int) -> Priority:
301
+ """Map Linear priority (0-4) to our Priority enum."""
302
+ return self.LINEAR_PRIORITY_MAP.get(linear_priority, Priority.MEDIUM)
303
+
304
+ def _parse_datetime(self, value: Optional[str]) -> Optional[datetime]:
305
+ """Parse ISO datetime string."""
306
+ if not value:
307
+ return None
308
+ try:
309
+ return datetime.fromisoformat(value.replace('Z', '+00:00'))
310
+ except (ValueError, TypeError):
311
+ return None
312
+
313
+ def _issue_from_linear(self, data: dict) -> Issue:
314
+ """Transform Linear API response to Issue object."""
315
+ state = data.get("state", {})
316
+ data.get("assignee", {})
317
+ parent = data.get("parent", {})
318
+ labels = data.get("labels", {}).get("nodes", [])
319
+
320
+ # Extract agent assignment from description or comments
321
+ # Convention: [agent:swift-falcon-a3f2] in description
322
+ description = data.get("description", "") or ""
323
+ agent_id = None
324
+ if "[agent:" in description:
325
+ start = description.find("[agent:") + 7
326
+ end = description.find("]", start)
327
+ if end > start:
328
+ agent_id = description[start:end]
329
+
330
+ return Issue(
331
+ id=data.get("id", ""),
332
+ identifier=data.get("identifier", ""),
333
+ title=data.get("title", ""),
334
+ description=description,
335
+ status=self._map_state_to_status(state),
336
+ priority=self._map_priority(data.get("priority", 2)),
337
+ parent_id=parent.get("identifier") if parent else None,
338
+ labels=[lbl.get("name", "") for lbl in labels],
339
+ assigned_agent=agent_id,
340
+ created_at=self._parse_datetime(data.get("createdAt")),
341
+ updated_at=self._parse_datetime(data.get("updatedAt")),
342
+ completed_at=self._parse_datetime(data.get("completedAt")),
343
+ estimate=data.get("estimate"),
344
+ provider="linear",
345
+ external_id=data.get("id")
346
+ )
347
+
348
+ # ─────────────────────────────────────────────────────────────────────
349
+ # Read Operations
350
+ # ─────────────────────────────────────────────────────────────────────
351
+
352
+ def list_issues(
353
+ self,
354
+ status: Optional[IssueStatus] = None,
355
+ project: Optional[str] = None,
356
+ limit: int = 50
357
+ ) -> list[Issue]:
358
+ """List issues from Linear, optionally filtered by status."""
359
+ team_id = self._get_team_id()
360
+
361
+ query = """
362
+ query($teamId: String!, $first: Int) {
363
+ team(id: $teamId) {
364
+ issues(first: $first) {
365
+ nodes {
366
+ id
367
+ identifier
368
+ title
369
+ description
370
+ state {
371
+ id
372
+ name
373
+ type
374
+ }
375
+ assignee {
376
+ id
377
+ name
378
+ }
379
+ priority
380
+ estimate
381
+ labels {
382
+ nodes {
383
+ name
384
+ }
385
+ }
386
+ parent {
387
+ identifier
388
+ }
389
+ createdAt
390
+ updatedAt
391
+ completedAt
392
+ }
393
+ }
394
+ }
395
+ }
396
+ """
397
+
398
+ result = self._query(query, {"teamId": team_id, "first": limit})
399
+ nodes = result.get("team", {}).get("issues", {}).get("nodes", [])
400
+
401
+ issues = [self._issue_from_linear(n) for n in nodes]
402
+
403
+ # Filter by status if specified
404
+ if status:
405
+ issues = [i for i in issues if i.status == status]
406
+
407
+ # Sort by priority then created_at
408
+ issues.sort(key=lambda i: (i.priority.value, i.created_at or datetime.min.replace(tzinfo=timezone.utc)))
409
+
410
+ return issues[:limit]
411
+
412
+ def get_issue(self, identifier: str) -> Optional[Issue]:
413
+ """Get a single issue by identifier (e.g., ANV-72)."""
414
+ query = """
415
+ query($id: String!) {
416
+ issue(id: $id) {
417
+ id
418
+ identifier
419
+ title
420
+ description
421
+ state {
422
+ id
423
+ name
424
+ type
425
+ }
426
+ assignee {
427
+ id
428
+ name
429
+ }
430
+ priority
431
+ estimate
432
+ labels {
433
+ nodes {
434
+ name
435
+ }
436
+ }
437
+ parent {
438
+ identifier
439
+ }
440
+ createdAt
441
+ updatedAt
442
+ completedAt
443
+ }
444
+ }
445
+ """
446
+
447
+ try:
448
+ result = self._query(query, {"id": identifier})
449
+ issue_data = result.get("issue")
450
+ if issue_data:
451
+ return self._issue_from_linear(issue_data)
452
+ except Exception:
453
+ pass
454
+
455
+ return None
456
+
457
+ # ─────────────────────────────────────────────────────────────────────
458
+ # Write Operations
459
+ # ─────────────────────────────────────────────────────────────────────
460
+
461
+ def create_issue(
462
+ self,
463
+ title: str,
464
+ description: str = "",
465
+ status: IssueStatus = IssueStatus.TODO,
466
+ priority: Priority = Priority.MEDIUM,
467
+ parent_id: Optional[str] = None,
468
+ labels: Optional[list[str]] = None
469
+ ) -> Issue:
470
+ """Create a new issue in Linear."""
471
+ team_id = self._get_team_id()
472
+
473
+ mutation = """
474
+ mutation($input: IssueCreateInput!) {
475
+ issueCreate(input: $input) {
476
+ success
477
+ issue {
478
+ id
479
+ identifier
480
+ title
481
+ description
482
+ state {
483
+ id
484
+ name
485
+ type
486
+ }
487
+ assignee {
488
+ id
489
+ name
490
+ }
491
+ priority
492
+ labels {
493
+ nodes {
494
+ name
495
+ }
496
+ }
497
+ parent {
498
+ identifier
499
+ }
500
+ createdAt
501
+ updatedAt
502
+ }
503
+ }
504
+ }
505
+ """
506
+
507
+ input_data = {
508
+ "teamId": team_id,
509
+ "title": title,
510
+ "description": description,
511
+ "priority": self.PRIORITY_TO_LINEAR.get(priority, 2)
512
+ }
513
+
514
+ # Get state ID
515
+ state_id = self._get_state_id(status)
516
+ if state_id:
517
+ input_data["stateId"] = state_id
518
+
519
+ # Handle parent (need to resolve identifier to ID)
520
+ if parent_id:
521
+ parent = self.get_issue(parent_id)
522
+ if parent and parent.external_id:
523
+ input_data["parentId"] = parent.external_id
524
+
525
+ # Handle labels (resolve names to UUIDs)
526
+ if labels:
527
+ label_ids = self._get_label_ids(labels)
528
+ if label_ids:
529
+ input_data["labelIds"] = label_ids
530
+
531
+ result = self._query(mutation, {"input": input_data})
532
+ issue_data = result.get("issueCreate", {}).get("issue", {})
533
+
534
+ return self._issue_from_linear(issue_data)
535
+
536
+ def update_issue(
537
+ self,
538
+ identifier: str,
539
+ title: Optional[str] = None,
540
+ description: Optional[str] = None,
541
+ status: Optional[IssueStatus] = None,
542
+ priority: Optional[Priority] = None,
543
+ labels: Optional[list[str]] = None
544
+ ) -> Issue:
545
+ """Update an existing issue in Linear."""
546
+ mutation = """
547
+ mutation($id: String!, $input: IssueUpdateInput!) {
548
+ issueUpdate(id: $id, input: $input) {
549
+ success
550
+ issue {
551
+ id
552
+ identifier
553
+ title
554
+ description
555
+ state {
556
+ id
557
+ name
558
+ type
559
+ }
560
+ assignee {
561
+ id
562
+ name
563
+ }
564
+ priority
565
+ labels {
566
+ nodes {
567
+ name
568
+ }
569
+ }
570
+ parent {
571
+ identifier
572
+ }
573
+ createdAt
574
+ updatedAt
575
+ completedAt
576
+ }
577
+ }
578
+ }
579
+ """
580
+
581
+ input_data = {}
582
+ if title is not None:
583
+ input_data["title"] = title
584
+ if description is not None:
585
+ input_data["description"] = description
586
+ if priority is not None:
587
+ input_data["priority"] = self.PRIORITY_TO_LINEAR.get(priority, 2)
588
+ if status is not None:
589
+ state_id = self._get_state_id(status)
590
+ if state_id:
591
+ input_data["stateId"] = state_id
592
+ # Handle labels (resolve names to UUIDs)
593
+ # Empty list means clear all labels; None means don't change
594
+ if labels is not None:
595
+ label_ids = self._get_label_ids(labels) if labels else []
596
+ input_data["labelIds"] = label_ids
597
+
598
+ if not input_data:
599
+ # Nothing to update, just fetch current state
600
+ issue = self.get_issue(identifier)
601
+ if not issue:
602
+ raise KeyError(f"Issue not found: {identifier}")
603
+ return issue
604
+
605
+ result = self._query(mutation, {"id": identifier, "input": input_data})
606
+ issue_data = result.get("issueUpdate", {}).get("issue", {})
607
+
608
+ if not issue_data:
609
+ raise KeyError(f"Issue not found: {identifier}")
610
+
611
+ return self._issue_from_linear(issue_data)
612
+
613
+ def delete_issue(self, identifier: str) -> bool:
614
+ """Delete an issue (marks as cancelled in Linear)."""
615
+ try:
616
+ self.update_issue(identifier, status=IssueStatus.CANCELLED)
617
+ return True
618
+ except (KeyError, Exception):
619
+ return False
620
+
621
+ # ─────────────────────────────────────────────────────────────────────
622
+ # Agent Integration
623
+ # ─────────────────────────────────────────────────────────────────────
624
+
625
+ def assign_to_agent(self, identifier: str, agent_id: str) -> Issue:
626
+ """
627
+ Assign an issue to an Anvil agent.
628
+
629
+ Uses description convention: [agent:agent-id] appended to description.
630
+ This is a workaround since Linear doesn't have agent assignment natively.
631
+ """
632
+ issue = self.get_issue(identifier)
633
+ if not issue:
634
+ raise KeyError(f"Issue not found: {identifier}")
635
+
636
+ # Remove existing agent tag if present
637
+ description = issue.description or ""
638
+ if "[agent:" in description:
639
+ start = description.find("[agent:")
640
+ end = description.find("]", start)
641
+ # Only remove if closing bracket found
642
+ if end > start:
643
+ description = description[:start] + description[end + 1:]
644
+
645
+ # Add new agent tag
646
+ description = description.strip() + f"\n\n[agent:{agent_id}]"
647
+
648
+ return self.update_issue(identifier, description=description)
649
+
650
+ def unassign_agent(self, identifier: str) -> Issue:
651
+ """Remove agent assignment from an issue."""
652
+ issue = self.get_issue(identifier)
653
+ if not issue:
654
+ raise KeyError(f"Issue not found: {identifier}")
655
+
656
+ description = issue.description or ""
657
+ if "[agent:" in description:
658
+ start = description.find("[agent:")
659
+ end = description.find("]", start)
660
+ # Only remove if closing bracket found
661
+ if end > start:
662
+ description = description[:start] + description[end + 1:]
663
+ description = description.strip()
664
+
665
+ return self.update_issue(identifier, description=description)
666
+
667
+ def get_agent_issues(self, agent_id: str) -> list[Issue]:
668
+ """Get all issues assigned to a specific agent."""
669
+ all_issues = self.list_issues()
670
+ return [i for i in all_issues if i.assigned_agent == agent_id]
671
+
672
+ # ─────────────────────────────────────────────────────────────────────
673
+ # Child/Subtask Operations (ANV-210: Ralph Linear Integration)
674
+ # ─────────────────────────────────────────────────────────────────────
675
+
676
+ def get_children(self, identifier: str) -> list[Issue]:
677
+ """
678
+ Get all child issues (subtasks) of a parent issue.
679
+
680
+ Args:
681
+ identifier: Parent issue identifier (e.g., "ANV-180")
682
+
683
+ Returns:
684
+ List of child Issue objects, sorted by priority then identifier number.
685
+ """
686
+ query = """
687
+ query($id: String!) {
688
+ issue(id: $id) {
689
+ children {
690
+ nodes {
691
+ id
692
+ identifier
693
+ title
694
+ description
695
+ state {
696
+ id
697
+ name
698
+ type
699
+ }
700
+ assignee {
701
+ id
702
+ name
703
+ }
704
+ priority
705
+ estimate
706
+ labels {
707
+ nodes {
708
+ name
709
+ }
710
+ }
711
+ parent {
712
+ identifier
713
+ }
714
+ createdAt
715
+ updatedAt
716
+ completedAt
717
+ }
718
+ }
719
+ }
720
+ }
721
+ """
722
+ result = self._query(query, {"id": identifier})
723
+ issue_data = result.get("issue")
724
+ if not issue_data:
725
+ return []
726
+
727
+ children_data = issue_data.get("children", {}).get("nodes", [])
728
+ children = [self._issue_from_linear(child) for child in children_data]
729
+
730
+ # Sort by priority (lower = higher priority), then by identifier number
731
+ def sort_key(issue: Issue) -> tuple:
732
+ priority = issue.priority.value if issue.priority is not None else 999
733
+ # Extract numeric part from identifier (e.g., "ANV-182" -> 182)
734
+ try:
735
+ num = int(issue.identifier.split("-")[-1])
736
+ except (ValueError, IndexError):
737
+ num = 999999
738
+ return (priority, num)
739
+
740
+ return sorted(children, key=sort_key)
741
+
742
+ def get_issue_with_children(
743
+ self, identifier: str
744
+ ) -> tuple[Optional[Issue], list[Issue]]:
745
+ """
746
+ Get an issue and all its children in a single API call.
747
+
748
+ Args:
749
+ identifier: Issue identifier (e.g., "ANV-180")
750
+
751
+ Returns:
752
+ Tuple of (parent Issue or None, list of child Issues)
753
+ """
754
+ query = """
755
+ query($id: String!) {
756
+ issue(id: $id) {
757
+ id
758
+ identifier
759
+ title
760
+ description
761
+ state {
762
+ id
763
+ name
764
+ type
765
+ }
766
+ assignee {
767
+ id
768
+ name
769
+ }
770
+ priority
771
+ estimate
772
+ labels {
773
+ nodes {
774
+ name
775
+ }
776
+ }
777
+ parent {
778
+ identifier
779
+ }
780
+ createdAt
781
+ updatedAt
782
+ completedAt
783
+ children {
784
+ nodes {
785
+ id
786
+ identifier
787
+ title
788
+ description
789
+ state {
790
+ id
791
+ name
792
+ type
793
+ }
794
+ assignee {
795
+ id
796
+ name
797
+ }
798
+ priority
799
+ estimate
800
+ labels {
801
+ nodes {
802
+ name
803
+ }
804
+ }
805
+ parent {
806
+ identifier
807
+ }
808
+ createdAt
809
+ updatedAt
810
+ completedAt
811
+ }
812
+ }
813
+ }
814
+ }
815
+ """
816
+ result = self._query(query, {"id": identifier})
817
+ issue_data = result.get("issue")
818
+ if not issue_data:
819
+ return None, []
820
+
821
+ parent = self._issue_from_linear(issue_data)
822
+ children_data = issue_data.get("children", {}).get("nodes", [])
823
+ children = [self._issue_from_linear(child) for child in children_data]
824
+
825
+ # Sort children by priority then identifier
826
+ def sort_key(issue: Issue) -> tuple:
827
+ priority = issue.priority.value if issue.priority is not None else 999
828
+ try:
829
+ num = int(issue.identifier.split("-")[-1])
830
+ except (ValueError, IndexError):
831
+ num = 999999
832
+ return (priority, num)
833
+
834
+ return parent, sorted(children, key=sort_key)
835
+
836
+ # ─────────────────────────────────────────────────────────────────────
837
+ # Project Operations (ANV-210: Ralph Linear Integration)
838
+ # ─────────────────────────────────────────────────────────────────────
839
+
840
+ def get_projects(self, team_key: Optional[str] = None) -> list[dict]:
841
+ """
842
+ List all accessible projects.
843
+
844
+ Projects in Linear are organization-wide (not team-scoped), but can be
845
+ filtered by team association if needed.
846
+
847
+ Args:
848
+ team_key: Optional team key (e.g., "ANV") to filter projects.
849
+ If None, returns all accessible projects.
850
+
851
+ Returns:
852
+ List of project dictionaries with id, name, state, url, and teams.
853
+ """
854
+ query = """
855
+ query {
856
+ projects(first: 50) {
857
+ nodes {
858
+ id
859
+ name
860
+ state
861
+ url
862
+ teams {
863
+ nodes {
864
+ key
865
+ }
866
+ }
867
+ }
868
+ }
869
+ }
870
+ """
871
+ result = self._query(query, {})
872
+ projects = result.get("projects", {}).get("nodes", [])
873
+
874
+ # Optionally filter by team
875
+ if team_key:
876
+ filtered = []
877
+ for proj in projects:
878
+ team_keys = [t.get("key") for t in proj.get("teams", {}).get("nodes", [])]
879
+ if team_key in team_keys:
880
+ filtered.append(proj)
881
+ return filtered
882
+
883
+ return projects
884
+
885
+ def get_project_issues(
886
+ self,
887
+ project_name: str,
888
+ status: Optional[IssueStatus] = None,
889
+ include_done: bool = False,
890
+ limit: int = 100,
891
+ ) -> list[Issue]:
892
+ """
893
+ Get all issues in a Linear project.
894
+
895
+ Args:
896
+ project_name: Name of the project (e.g., "HUD Development")
897
+ status: Optional status filter (e.g., IssueStatus.IN_PROGRESS)
898
+ include_done: Whether to include completed/cancelled issues
899
+ limit: Maximum number of issues to return
900
+
901
+ Returns:
902
+ List of Issue objects, sorted by priority then identifier.
903
+
904
+ Raises:
905
+ KeyError: If project not found.
906
+ """
907
+ # First, find the project by name
908
+ projects = self.get_projects()
909
+ project = None
910
+ for p in projects:
911
+ if p.get("name", "").lower() == project_name.lower():
912
+ project = p
913
+ break
914
+
915
+ if not project:
916
+ raise KeyError(f"Project not found: {project_name}")
917
+
918
+ project_id = project["id"]
919
+
920
+ # Build the issues query with filters
921
+ query = """
922
+ query($projectId: String!, $first: Int!) {
923
+ project(id: $projectId) {
924
+ issues(first: $first) {
925
+ nodes {
926
+ id
927
+ identifier
928
+ title
929
+ description
930
+ state {
931
+ id
932
+ name
933
+ type
934
+ }
935
+ assignee {
936
+ id
937
+ name
938
+ }
939
+ priority
940
+ estimate
941
+ labels {
942
+ nodes {
943
+ name
944
+ }
945
+ }
946
+ parent {
947
+ identifier
948
+ }
949
+ createdAt
950
+ updatedAt
951
+ completedAt
952
+ }
953
+ }
954
+ }
955
+ }
956
+ """
957
+ result = self._query(query, {"projectId": project_id, "first": limit})
958
+ project_data = result.get("project")
959
+ if not project_data:
960
+ return []
961
+
962
+ issues_data = project_data.get("issues", {}).get("nodes", [])
963
+ issues = [self._issue_from_linear(issue) for issue in issues_data]
964
+
965
+ # Filter by status if specified
966
+ if status:
967
+ issues = [i for i in issues if i.status == status]
968
+
969
+ # Filter out done/cancelled unless explicitly included
970
+ if not include_done:
971
+ done_types = {"completed", "canceled", "cancelled"}
972
+ issues = [
973
+ i for i in issues
974
+ if i.status not in {IssueStatus.DONE, IssueStatus.CANCELLED}
975
+ and (not hasattr(i, "_state_type") or i._state_type not in done_types)
976
+ ]
977
+
978
+ # Sort by priority then identifier
979
+ def sort_key(issue: Issue) -> tuple:
980
+ priority = issue.priority.value if issue.priority is not None else 999
981
+ try:
982
+ num = int(issue.identifier.split("-")[-1])
983
+ except (ValueError, IndexError):
984
+ num = 999999
985
+ return (priority, num)
986
+
987
+ return sorted(issues, key=sort_key)