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,775 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ handoff_generator.py - CCS Handoff Document Generator (ANV-195)
4
+
5
+ Generates structured handoff documents for checkpoint continuity.
6
+ Collects session state (git, Linear, files) and generates markdown
7
+ documents with checkpoint metadata and resume instructions.
8
+
9
+ Usage:
10
+ from global.lib.handoff_generator import HandoffGenerator
11
+
12
+ # Generate handoff document
13
+ generator = HandoffGenerator(project_path="/path/to/project")
14
+ handoff = generator.generate(
15
+ trigger_level="L2",
16
+ context_percent=85,
17
+ linear_issue="ANV-123",
18
+ summary="Implementing feature X"
19
+ )
20
+
21
+ # Save to file
22
+ filepath = generator.save(handoff)
23
+ """
24
+
25
+ import json
26
+ import os
27
+ import subprocess
28
+ from dataclasses import dataclass, field
29
+ from datetime import datetime, timezone
30
+ from typing import List, Optional, TypedDict
31
+
32
+
33
+ # =============================================================================
34
+ # Configuration
35
+ # =============================================================================
36
+
37
+ DEFAULT_HANDOFF_DIR = ".claude/handoffs"
38
+ CHECKPOINT_LEVELS = {"L1": "Warning", "L2": "Critical", "L3": "Emergency"}
39
+
40
+
41
+ # =============================================================================
42
+ # Type Definitions
43
+ # =============================================================================
44
+
45
+
46
+ class GitState(TypedDict, total=False):
47
+ """Git repository state."""
48
+
49
+ branch: str
50
+ commit_hash: str
51
+ commit_message: str
52
+ has_uncommitted: bool
53
+ staged_files: List[str]
54
+ modified_files: List[str]
55
+ untracked_files: List[str]
56
+
57
+
58
+ class LinearState(TypedDict, total=False):
59
+ """Linear issue state."""
60
+
61
+ identifier: str
62
+ title: str
63
+ state: str
64
+ description: str
65
+ assignee: Optional[str]
66
+ url: str
67
+
68
+
69
+ class FileProgress(TypedDict, total=False):
70
+ """File modification progress."""
71
+
72
+ path: str
73
+ lines_changed: int
74
+ status: str # added, modified, deleted
75
+
76
+
77
+ class HandoffDocument(TypedDict):
78
+ """Complete handoff document structure."""
79
+
80
+ # Metadata (YAML frontmatter)
81
+ session_date: str
82
+ session_time: str
83
+ branch: str
84
+ linear_issues: str
85
+ checkpoint_trigger: str
86
+ context_at_checkpoint: int
87
+
88
+ # Content sections
89
+ title: str
90
+ summary: str
91
+ completed_items: List[str]
92
+ in_progress_items: List[str]
93
+ next_steps: List[str]
94
+ git_state: GitState
95
+ linear_state: Optional[LinearState]
96
+ files_touched: List[FileProgress]
97
+ critical_context: List[str]
98
+
99
+
100
+ # =============================================================================
101
+ # HandoffGenerator Class
102
+ # =============================================================================
103
+
104
+
105
+ @dataclass
106
+ class HandoffGenerator:
107
+ """Generate handoff documents for CCS checkpoints (ANV-195)."""
108
+
109
+ project_path: str = field(default_factory=os.getcwd)
110
+ handoff_dir: str = DEFAULT_HANDOFF_DIR
111
+
112
+ def __post_init__(self):
113
+ """Ensure project path is absolute."""
114
+ self.project_path = os.path.abspath(self.project_path)
115
+
116
+ # -------------------------------------------------------------------------
117
+ # Git State Collection
118
+ # -------------------------------------------------------------------------
119
+
120
+ def get_git_state(self) -> GitState:
121
+ """Collect current git repository state."""
122
+ state: GitState = {
123
+ "branch": "",
124
+ "commit_hash": "",
125
+ "commit_message": "",
126
+ "has_uncommitted": False,
127
+ "staged_files": [],
128
+ "modified_files": [],
129
+ "untracked_files": [],
130
+ }
131
+
132
+ try:
133
+ # Get current branch
134
+ result = subprocess.run(
135
+ ["git", "branch", "--show-current"],
136
+ capture_output=True,
137
+ text=True,
138
+ cwd=self.project_path,
139
+ timeout=5,
140
+ )
141
+ if result.returncode == 0:
142
+ state["branch"] = result.stdout.strip()
143
+
144
+ # Get commit hash and message
145
+ result = subprocess.run(
146
+ ["git", "log", "-1", "--format=%H|%s"],
147
+ capture_output=True,
148
+ text=True,
149
+ cwd=self.project_path,
150
+ timeout=5,
151
+ )
152
+ if result.returncode == 0 and "|" in result.stdout:
153
+ parts = result.stdout.strip().split("|", 1)
154
+ state["commit_hash"] = parts[0][:8] # Short hash
155
+ state["commit_message"] = parts[1] if len(parts) > 1 else ""
156
+
157
+ # Get staged files
158
+ result = subprocess.run(
159
+ ["git", "diff", "--name-only", "--cached"],
160
+ capture_output=True,
161
+ text=True,
162
+ cwd=self.project_path,
163
+ timeout=5,
164
+ )
165
+ if result.returncode == 0 and result.stdout.strip():
166
+ state["staged_files"] = result.stdout.strip().split("\n")
167
+
168
+ # Get modified files (not staged)
169
+ result = subprocess.run(
170
+ ["git", "diff", "--name-only"],
171
+ capture_output=True,
172
+ text=True,
173
+ cwd=self.project_path,
174
+ timeout=5,
175
+ )
176
+ if result.returncode == 0 and result.stdout.strip():
177
+ state["modified_files"] = result.stdout.strip().split("\n")
178
+
179
+ # Get untracked files
180
+ result = subprocess.run(
181
+ ["git", "ls-files", "--others", "--exclude-standard"],
182
+ capture_output=True,
183
+ text=True,
184
+ cwd=self.project_path,
185
+ timeout=5,
186
+ )
187
+ if result.returncode == 0 and result.stdout.strip():
188
+ state["untracked_files"] = result.stdout.strip().split("\n")
189
+
190
+ # Determine if there are uncommitted changes
191
+ state["has_uncommitted"] = bool(
192
+ state["staged_files"]
193
+ or state["modified_files"]
194
+ or state["untracked_files"]
195
+ )
196
+
197
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError):
198
+ pass # Return partial state on error
199
+
200
+ return state
201
+
202
+ def get_file_progress(self) -> List[FileProgress]:
203
+ """Get detailed file modification info."""
204
+ files: List[FileProgress] = []
205
+
206
+ try:
207
+ # Get diff stats for staged and unstaged
208
+ result = subprocess.run(
209
+ ["git", "diff", "--stat", "--numstat", "HEAD"],
210
+ capture_output=True,
211
+ text=True,
212
+ cwd=self.project_path,
213
+ timeout=10,
214
+ )
215
+ if result.returncode == 0:
216
+ for line in result.stdout.strip().split("\n"):
217
+ if not line or "\t" not in line:
218
+ continue
219
+ parts = line.split("\t")
220
+ if len(parts) >= 3:
221
+ added = int(parts[0]) if parts[0].isdigit() else 0
222
+ deleted = int(parts[1]) if parts[1].isdigit() else 0
223
+ filepath = parts[2]
224
+ files.append(
225
+ {
226
+ "path": filepath,
227
+ "lines_changed": added + deleted,
228
+ "status": "modified",
229
+ }
230
+ )
231
+
232
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError):
233
+ pass
234
+
235
+ return files
236
+
237
+ # -------------------------------------------------------------------------
238
+ # Linear State Collection
239
+ # -------------------------------------------------------------------------
240
+
241
+ def get_linear_state(self, issue_id: str) -> Optional[LinearState]:
242
+ """Fetch Linear issue state using the linear.py script."""
243
+ if not issue_id:
244
+ return None
245
+
246
+ try:
247
+ # Use the linear.py script from the linear-skill
248
+ script_path = os.path.expanduser(
249
+ "~/.claude/skills/linear-skill/scripts/linear.py"
250
+ )
251
+
252
+ if not os.path.exists(script_path):
253
+ return None
254
+
255
+ result = subprocess.run(
256
+ ["python3", script_path, "get-issue", "--id", issue_id],
257
+ capture_output=True,
258
+ text=True,
259
+ timeout=10,
260
+ )
261
+
262
+ if result.returncode == 0:
263
+ data = json.loads(result.stdout)
264
+ assignee_data = data.get("assignee")
265
+ assignee_name = assignee_data.get("name") if assignee_data else None
266
+ state_data = data.get("state")
267
+ state_name = state_data.get("name", "Unknown") if state_data else "Unknown"
268
+ description = data.get("description") or ""
269
+
270
+ return {
271
+ "identifier": data.get("identifier", issue_id),
272
+ "title": data.get("title", ""),
273
+ "state": state_name,
274
+ "description": description[:200], # Truncate
275
+ "assignee": assignee_name,
276
+ "url": data.get("url", ""),
277
+ }
278
+
279
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError, json.JSONDecodeError):
280
+ pass
281
+
282
+ return {"identifier": issue_id, "title": "", "state": "Unknown"}
283
+
284
+ # -------------------------------------------------------------------------
285
+ # Document Generation
286
+ # -------------------------------------------------------------------------
287
+
288
+ def generate(
289
+ self,
290
+ trigger_level: str = "L2",
291
+ context_percent: int = 0,
292
+ linear_issue: str = "",
293
+ summary: str = "",
294
+ completed_items: Optional[List[str]] = None,
295
+ in_progress_items: Optional[List[str]] = None,
296
+ next_steps: Optional[List[str]] = None,
297
+ critical_context: Optional[List[str]] = None,
298
+ ) -> HandoffDocument:
299
+ """Generate a complete handoff document.
300
+
301
+ Args:
302
+ trigger_level: CCS level that triggered checkpoint (L1, L2, L3)
303
+ context_percent: Context percentage at checkpoint
304
+ linear_issue: Linear issue ID (e.g., "ANV-195")
305
+ summary: Brief summary of work done
306
+ completed_items: List of completed tasks
307
+ in_progress_items: List of in-progress tasks
308
+ next_steps: Recommended next actions
309
+ critical_context: Important context for resumption
310
+
311
+ Returns:
312
+ HandoffDocument with all sections populated
313
+ """
314
+ now = datetime.now(timezone.utc)
315
+ git_state = self.get_git_state()
316
+ linear_state = self.get_linear_state(linear_issue) if linear_issue else None
317
+ files_touched = self.get_file_progress()
318
+
319
+ # Generate title
320
+ level_desc = CHECKPOINT_LEVELS.get(trigger_level, "Checkpoint")
321
+ if linear_state and linear_state.get("title"):
322
+ title = f"Session Handoff: {linear_state['identifier']} - {linear_state['title']}"
323
+ elif summary:
324
+ title = f"Session Handoff: {summary[:50]}"
325
+ else:
326
+ title = f"Session Handoff: {level_desc} Checkpoint"
327
+
328
+ # Generate default next steps if not provided
329
+ if not next_steps:
330
+ next_steps = self._generate_default_next_steps(
331
+ trigger_level, git_state, linear_issue
332
+ )
333
+
334
+ return {
335
+ # Metadata
336
+ "session_date": now.strftime("%Y-%m-%d"),
337
+ "session_time": now.strftime("%H:%M"),
338
+ "branch": git_state.get("branch", "unknown"),
339
+ "linear_issues": linear_issue,
340
+ "checkpoint_trigger": f"{trigger_level} (context at {context_percent}%)",
341
+ "context_at_checkpoint": context_percent,
342
+ # Content
343
+ "title": title,
344
+ "summary": summary,
345
+ "completed_items": completed_items or [],
346
+ "in_progress_items": in_progress_items or [],
347
+ "next_steps": next_steps,
348
+ "git_state": git_state,
349
+ "linear_state": linear_state,
350
+ "files_touched": files_touched,
351
+ "critical_context": critical_context or [],
352
+ }
353
+
354
+ def _generate_default_next_steps(
355
+ self, trigger_level: str, git_state: GitState, linear_issue: str
356
+ ) -> List[str]:
357
+ """Generate default next steps based on state."""
358
+ steps = []
359
+
360
+ # Context-level specific guidance
361
+ if trigger_level == "L3":
362
+ steps.append("URGENT: Review handoff immediately - context was near limit")
363
+ elif trigger_level == "L2":
364
+ steps.append("Review handoff and continue from in-progress items")
365
+ else:
366
+ steps.append("Continue from where the session left off")
367
+
368
+ # Git-based suggestions
369
+ if git_state.get("has_uncommitted"):
370
+ uncommitted_count = (
371
+ len(git_state.get("staged_files", []))
372
+ + len(git_state.get("modified_files", []))
373
+ )
374
+ steps.append(
375
+ f"Review {uncommitted_count} uncommitted file(s) before continuing"
376
+ )
377
+
378
+ # Linear-based suggestions
379
+ if linear_issue:
380
+ steps.append(f"Check Linear issue {linear_issue} for latest status")
381
+
382
+ return steps
383
+
384
+ # -------------------------------------------------------------------------
385
+ # Markdown Rendering
386
+ # -------------------------------------------------------------------------
387
+
388
+ def render_markdown(self, doc: HandoffDocument) -> str:
389
+ """Render handoff document as markdown."""
390
+ lines = []
391
+
392
+ # YAML frontmatter
393
+ lines.append("---")
394
+ lines.append(f"session_date: {doc['session_date']}")
395
+ lines.append(f"session_time: {doc['session_time']}")
396
+ lines.append(f"branch: {doc['branch']}")
397
+ if doc["linear_issues"]:
398
+ lines.append(f"linear_issues: {doc['linear_issues']}")
399
+ lines.append(f"checkpoint_trigger: {doc['checkpoint_trigger']}")
400
+ lines.append(f"context_at_checkpoint: {doc['context_at_checkpoint']}%")
401
+ lines.append("---")
402
+ lines.append("")
403
+
404
+ # Title
405
+ lines.append(f"# {doc['title']}")
406
+ lines.append("")
407
+
408
+ # Summary
409
+ if doc["summary"]:
410
+ lines.append("## Session Summary")
411
+ lines.append(doc["summary"])
412
+ lines.append("")
413
+
414
+ # Checkpoint Reason
415
+ lines.append("## Checkpoint Reason")
416
+ lines.append(f"Context checkpoint triggered at {doc['checkpoint_trigger']}")
417
+ lines.append("")
418
+
419
+ # Completed items
420
+ if doc["completed_items"]:
421
+ lines.append("## Completed This Session")
422
+ for item in doc["completed_items"]:
423
+ lines.append(f"- [x] {item}")
424
+ lines.append("")
425
+
426
+ # In progress items
427
+ if doc["in_progress_items"]:
428
+ lines.append("## In Progress (Not Complete)")
429
+ for item in doc["in_progress_items"]:
430
+ lines.append(f"- [ ] {item}")
431
+ lines.append("")
432
+
433
+ # Git State
434
+ git = doc["git_state"]
435
+ lines.append("## Git State")
436
+ lines.append(f"- **Branch**: {git.get('branch', 'unknown')}")
437
+ lines.append(f"- **Last commit**: {git.get('commit_hash', '')} {git.get('commit_message', '')}")
438
+ lines.append(f"- **Uncommitted changes**: {'Yes' if git.get('has_uncommitted') else 'No'}")
439
+
440
+ if git.get("staged_files"):
441
+ lines.append(f"- **Staged files**: {', '.join(git['staged_files'][:5])}")
442
+ if len(git["staged_files"]) > 5:
443
+ lines.append(f" - ...and {len(git['staged_files']) - 5} more")
444
+
445
+ if git.get("modified_files"):
446
+ lines.append(f"- **Modified files**: {', '.join(git['modified_files'][:5])}")
447
+ if len(git["modified_files"]) > 5:
448
+ lines.append(f" - ...and {len(git['modified_files']) - 5} more")
449
+ lines.append("")
450
+
451
+ # Linear State
452
+ if doc["linear_state"]:
453
+ linear = doc["linear_state"]
454
+ lines.append("## Linear Issue State")
455
+ lines.append(f"- **Issue**: {linear.get('identifier', 'Unknown')}")
456
+ lines.append(f"- **Title**: {linear.get('title', 'Unknown')}")
457
+ lines.append(f"- **Status**: {linear.get('state', 'Unknown')}")
458
+ if linear.get("url"):
459
+ lines.append(f"- **URL**: {linear['url']}")
460
+ lines.append("")
461
+
462
+ # Files touched with progress
463
+ if doc["files_touched"]:
464
+ lines.append("## Files Modified")
465
+ for f in doc["files_touched"][:10]:
466
+ lines.append(f"- `{f['path']}` ({f['lines_changed']} lines)")
467
+ if len(doc["files_touched"]) > 10:
468
+ lines.append(f"- ...and {len(doc['files_touched']) - 10} more files")
469
+ lines.append("")
470
+
471
+ # Critical context
472
+ if doc["critical_context"]:
473
+ lines.append("## Critical Context for Next Session")
474
+ for ctx in doc["critical_context"]:
475
+ lines.append(f"- {ctx}")
476
+ lines.append("")
477
+
478
+ # Next steps
479
+ if doc["next_steps"]:
480
+ lines.append("## Recommended Next Steps")
481
+ for i, step in enumerate(doc["next_steps"], 1):
482
+ lines.append(f"{i}. {step}")
483
+ lines.append("")
484
+
485
+ return "\n".join(lines)
486
+
487
+ # -------------------------------------------------------------------------
488
+ # File Operations
489
+ # -------------------------------------------------------------------------
490
+
491
+ def save(self, doc: HandoffDocument, filename: Optional[str] = None) -> str:
492
+ """Save handoff document to file.
493
+
494
+ Args:
495
+ doc: HandoffDocument to save
496
+ filename: Optional filename (auto-generated if not provided)
497
+
498
+ Returns:
499
+ Path to saved file
500
+ """
501
+ # Ensure handoff directory exists
502
+ handoff_path = os.path.join(self.project_path, self.handoff_dir)
503
+ os.makedirs(handoff_path, exist_ok=True)
504
+
505
+ # Generate filename if not provided
506
+ if not filename:
507
+ filename = f"{doc['session_date']}-{doc['session_time'].replace(':', '')}.md"
508
+
509
+ filepath = os.path.join(handoff_path, filename)
510
+
511
+ # Render and save
512
+ content = self.render_markdown(doc)
513
+ with open(filepath, "w", encoding="utf-8") as f:
514
+ f.write(content)
515
+
516
+ return filepath
517
+
518
+ def create_wip_commit(
519
+ self,
520
+ trigger_level: str = "L2",
521
+ context_percent: int = 0,
522
+ linear_issue: str = "",
523
+ ) -> tuple[bool, str]:
524
+ """Create a WIP commit at checkpoint if there are uncommitted changes.
525
+
526
+ Args:
527
+ trigger_level: CCS level that triggered checkpoint (L1, L2, L3)
528
+ context_percent: Context percentage at checkpoint
529
+ linear_issue: Linear issue ID (e.g., "ANV-195")
530
+
531
+ Returns:
532
+ Tuple of (success, message) where message is commit hash or error
533
+ """
534
+ try:
535
+ # Check if there are uncommitted changes
536
+ result = subprocess.run(
537
+ ["git", "status", "--porcelain"],
538
+ capture_output=True,
539
+ text=True,
540
+ cwd=self.project_path,
541
+ timeout=5,
542
+ )
543
+ if result.returncode != 0:
544
+ return False, "Failed to check git status"
545
+
546
+ if not result.stdout.strip():
547
+ return False, "No uncommitted changes to commit"
548
+
549
+ # Stage all changes
550
+ result = subprocess.run(
551
+ ["git", "add", "-A"],
552
+ capture_output=True,
553
+ text=True,
554
+ cwd=self.project_path,
555
+ timeout=10,
556
+ )
557
+ if result.returncode != 0:
558
+ return False, f"Failed to stage changes: {result.stderr}"
559
+
560
+ # Build commit message
561
+ issue_part = f"{linear_issue} - " if linear_issue else ""
562
+ commit_msg = f"[WIP] {issue_part}checkpoint ({trigger_level} at {context_percent}%)"
563
+
564
+ # Create the commit
565
+ result = subprocess.run(
566
+ ["git", "commit", "-m", commit_msg],
567
+ capture_output=True,
568
+ text=True,
569
+ cwd=self.project_path,
570
+ timeout=30,
571
+ )
572
+ if result.returncode != 0:
573
+ return False, f"Failed to create commit: {result.stderr}"
574
+
575
+ # Get the commit hash
576
+ result = subprocess.run(
577
+ ["git", "rev-parse", "--short", "HEAD"],
578
+ capture_output=True,
579
+ text=True,
580
+ cwd=self.project_path,
581
+ timeout=5,
582
+ )
583
+ if result.returncode == 0:
584
+ commit_hash = result.stdout.strip()
585
+ return True, commit_hash
586
+
587
+ return True, "Commit created (hash unknown)"
588
+
589
+ except subprocess.TimeoutExpired:
590
+ return False, "Git command timed out"
591
+ except subprocess.SubprocessError as e:
592
+ return False, f"Git error: {e}"
593
+
594
+ def create_checkpoint_comment(
595
+ self,
596
+ linear_issue: str,
597
+ trigger_level: str = "L2",
598
+ context_percent: int = 0,
599
+ handoff_file: str = "",
600
+ completed_items: Optional[List[str]] = None,
601
+ in_progress_items: Optional[List[str]] = None,
602
+ ) -> tuple[bool, str]:
603
+ """Create a checkpoint comment on a Linear issue.
604
+
605
+ Args:
606
+ linear_issue: Linear issue ID (e.g., "ANV-197")
607
+ trigger_level: CCS level that triggered checkpoint (L1, L2, L3)
608
+ context_percent: Context percentage at checkpoint
609
+ handoff_file: Path to handoff document (relative to project)
610
+ completed_items: List of completed tasks
611
+ in_progress_items: List of in-progress tasks
612
+
613
+ Returns:
614
+ Tuple of (success, message) where message is comment ID or error
615
+ """
616
+ if not linear_issue:
617
+ return False, "No Linear issue specified"
618
+
619
+ # Build comment body
620
+ level_desc = CHECKPOINT_LEVELS.get(trigger_level, "Checkpoint")
621
+ lines = [
622
+ f"## 🔶 Context Checkpoint ({trigger_level})",
623
+ "",
624
+ f"**Trigger**: {level_desc} at {context_percent}% context usage",
625
+ f"**Timestamp**: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
626
+ ]
627
+
628
+ # Add handoff file reference
629
+ if handoff_file:
630
+ lines.append(f"**Handoff**: `{handoff_file}`")
631
+
632
+ # Add git state summary
633
+ git_state = self.get_git_state()
634
+ if git_state.get("branch"):
635
+ lines.append(f"**Branch**: `{git_state['branch']}`")
636
+ if git_state.get("commit_hash"):
637
+ lines.append(
638
+ f"**Last Commit**: `{git_state['commit_hash']}` "
639
+ f"{git_state.get('commit_message', '')}"
640
+ )
641
+
642
+ # Add progress summary
643
+ if completed_items:
644
+ lines.append("")
645
+ lines.append("### Completed")
646
+ for item in completed_items[:5]: # Limit to 5 items
647
+ lines.append(f"- [x] {item}")
648
+ if len(completed_items) > 5:
649
+ lines.append(f"- ...and {len(completed_items) - 5} more")
650
+
651
+ if in_progress_items:
652
+ lines.append("")
653
+ lines.append("### In Progress")
654
+ for item in in_progress_items[:5]: # Limit to 5 items
655
+ lines.append(f"- [ ] {item}")
656
+ if len(in_progress_items) > 5:
657
+ lines.append(f"- ...and {len(in_progress_items) - 5} more")
658
+
659
+ # Add resume instructions
660
+ lines.extend(
661
+ [
662
+ "",
663
+ "---",
664
+ "*Session checkpointed due to context limits. "
665
+ "Resume with handoff document.*",
666
+ ]
667
+ )
668
+
669
+ comment_body = "\n".join(lines)
670
+
671
+ # Use linear.py script to create comment
672
+ try:
673
+ script_path = os.path.expanduser(
674
+ "~/.claude/skills/linear-skill/scripts/linear.py"
675
+ )
676
+
677
+ if not os.path.exists(script_path):
678
+ return False, "Linear skill script not found"
679
+
680
+ # Create comment via Linear API
681
+ result = subprocess.run(
682
+ [
683
+ "python3",
684
+ script_path,
685
+ "create-comment",
686
+ "--issue",
687
+ linear_issue,
688
+ "--body",
689
+ comment_body,
690
+ ],
691
+ capture_output=True,
692
+ text=True,
693
+ timeout=15,
694
+ )
695
+
696
+ if result.returncode == 0:
697
+ return True, f"Comment created on {linear_issue}"
698
+ else:
699
+ return False, f"Failed to create comment: {result.stderr}"
700
+
701
+ except subprocess.TimeoutExpired:
702
+ return False, "Linear API timed out"
703
+ except subprocess.SubprocessError as e:
704
+ return False, f"Linear API error: {e}"
705
+
706
+
707
+ # =============================================================================
708
+ # CLI Interface
709
+ # =============================================================================
710
+
711
+
712
+ def main():
713
+ """CLI entry point for testing."""
714
+ import argparse
715
+
716
+ parser = argparse.ArgumentParser(description="Generate CCS handoff document")
717
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
718
+
719
+ # Handoff command (default behavior)
720
+ handoff_parser = subparsers.add_parser("handoff", help="Generate handoff document")
721
+ handoff_parser.add_argument("--level", default="L2", help="Checkpoint level (L1, L2, L3)")
722
+ handoff_parser.add_argument("--percent", type=int, default=85, help="Context percentage")
723
+ handoff_parser.add_argument("--issue", default="", help="Linear issue ID")
724
+ handoff_parser.add_argument("--summary", default="", help="Session summary")
725
+ handoff_parser.add_argument("--save", action="store_true", help="Save to file")
726
+ handoff_parser.add_argument("--project", default=".", help="Project path")
727
+
728
+ # WIP commit command
729
+ wip_parser = subparsers.add_parser("wip", help="Create WIP checkpoint commit")
730
+ wip_parser.add_argument("--level", default="L2", help="Checkpoint level (L1, L2, L3)")
731
+ wip_parser.add_argument("--percent", type=int, default=85, help="Context percentage")
732
+ wip_parser.add_argument("--issue", default="", help="Linear issue ID")
733
+ wip_parser.add_argument("--project", default=".", help="Project path")
734
+
735
+ # Legacy support: if no command, treat as handoff
736
+ parser.add_argument("--level", default="L2", help="Checkpoint level (L1, L2, L3)")
737
+ parser.add_argument("--percent", type=int, default=85, help="Context percentage")
738
+ parser.add_argument("--issue", default="", help="Linear issue ID")
739
+ parser.add_argument("--summary", default="", help="Session summary")
740
+ parser.add_argument("--save", action="store_true", help="Save to file")
741
+ parser.add_argument("--project", default=".", help="Project path")
742
+
743
+ args = parser.parse_args()
744
+
745
+ generator = HandoffGenerator(project_path=args.project)
746
+
747
+ if args.command == "wip":
748
+ success, message = generator.create_wip_commit(
749
+ trigger_level=args.level,
750
+ context_percent=args.percent,
751
+ linear_issue=args.issue,
752
+ )
753
+ if success:
754
+ print(f"WIP commit created: {message}")
755
+ else:
756
+ print(f"No commit created: {message}")
757
+ exit(1)
758
+ else:
759
+ # Default: generate handoff document
760
+ doc = generator.generate(
761
+ trigger_level=args.level,
762
+ context_percent=args.percent,
763
+ linear_issue=args.issue,
764
+ summary=args.summary,
765
+ )
766
+
767
+ if args.save:
768
+ filepath = generator.save(doc)
769
+ print(f"Saved to: {filepath}")
770
+ else:
771
+ print(generator.render_markdown(doc))
772
+
773
+
774
+ if __name__ == "__main__":
775
+ main()