@pennyfarthing/core 8.1.0 → 9.0.0

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 (148) hide show
  1. package/README.md +3 -3
  2. package/package.json +3 -3
  3. package/pennyfarthing-dist/agents/README.md +1 -1
  4. package/pennyfarthing-dist/agents/dev.md +1 -1
  5. package/pennyfarthing-dist/agents/handoff.md +1 -1
  6. package/pennyfarthing-dist/agents/reviewer-preflight.md +1 -1
  7. package/pennyfarthing-dist/agents/sm-setup.md +3 -3
  8. package/pennyfarthing-dist/agents/sm.md +1 -1
  9. package/pennyfarthing-dist/agents/tea.md +1 -1
  10. package/pennyfarthing-dist/agents/testing-runner.md +3 -3
  11. package/pennyfarthing-dist/commands/architect.md +2 -0
  12. package/pennyfarthing-dist/commands/continue-session.md +2 -2
  13. package/pennyfarthing-dist/commands/dev.md +2 -0
  14. package/pennyfarthing-dist/commands/devops.md +2 -0
  15. package/pennyfarthing-dist/commands/health-check.md +2 -0
  16. package/pennyfarthing-dist/commands/new-work.md +23 -0
  17. package/pennyfarthing-dist/commands/orchestrator.md +2 -0
  18. package/pennyfarthing-dist/commands/parallel-work.md +4 -2
  19. package/pennyfarthing-dist/commands/pm.md +2 -0
  20. package/pennyfarthing-dist/commands/reviewer.md +2 -0
  21. package/pennyfarthing-dist/commands/sm.md +2 -0
  22. package/pennyfarthing-dist/commands/tea.md +2 -0
  23. package/pennyfarthing-dist/commands/tech-writer.md +2 -0
  24. package/pennyfarthing-dist/commands/ux-designer.md +2 -0
  25. package/pennyfarthing-dist/commands/work.md +2 -0
  26. package/pennyfarthing-dist/guides/agent-behavior.md +29 -264
  27. package/pennyfarthing-dist/scripts/core/agent-session.sh +7 -0
  28. package/pennyfarthing-dist/scripts/core/check-context.sh +140 -226
  29. package/pennyfarthing-dist/scripts/core/handoff-marker.sh +13 -2
  30. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +4 -1
  31. package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -7
  32. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +4 -11
  33. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +3 -8
  34. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +3 -3
  35. package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -7
  36. package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +2 -8
  37. package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +2 -8
  38. package/pennyfarthing-dist/scripts/lib/find-root.sh +17 -45
  39. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -7
  40. package/pennyfarthing-dist/scripts/sprint/archive-story.sh +2 -8
  41. package/pennyfarthing-dist/scripts/sprint/available-stories.sh +2 -8
  42. package/pennyfarthing-dist/scripts/sprint/check-story.sh +2 -8
  43. package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +2 -8
  44. package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +2 -8
  45. package/pennyfarthing-dist/scripts/sprint/list-future.sh +2 -8
  46. package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +2 -8
  47. package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +2 -8
  48. package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +2 -8
  49. package/pennyfarthing-dist/scripts/tests/test-character-voice.sh +2 -1
  50. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +4 -9
  51. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +2 -8
  52. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +2 -8
  53. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -7
  54. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +2 -8
  55. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +2 -8
  56. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +2 -8
  57. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +2 -8
  58. package/pennyfarthing-dist/skills/dev-patterns/SKILL.md +1 -1
  59. package/pennyfarthing-dist/skills/jira/SKILL.md +48 -24
  60. package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +7 -0
  61. package/pennyfarthing-dist/skills/sprint/skill.md +30 -30
  62. package/pennyfarthing-dist/workflows/patch.yaml +68 -0
  63. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  64. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  65. package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
  66. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  67. package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
  68. package/pennyfarthing_scripts/context.py +414 -0
  69. package/pennyfarthing_scripts/patch_mode.py +449 -0
  70. package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
  71. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  72. package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
  73. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  74. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  75. package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
  76. package/pennyfarthing_scripts/prime/__pycache__/tiers.cpython-314.pyc +0 -0
  77. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  78. package/pennyfarthing_scripts/prime/cli.py +201 -0
  79. package/pennyfarthing_scripts/prime/models.py +9 -0
  80. package/pennyfarthing_scripts/prime/persona.py +41 -0
  81. package/pennyfarthing_scripts/prime/tiers.py +201 -0
  82. package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
  83. package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
  84. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  85. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  86. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  87. package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
  88. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  89. package/pennyfarthing_scripts/tests/test_patch_mode.py +830 -0
  90. package/pennyfarthing_scripts/tests/test_tiers.py +1090 -0
  91. package/pennyfarthing_scripts/tests/test_token_counting.py +559 -0
  92. package/pennyfarthing-dist/scripts/hooks/__pycache__/question_reflector_check.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  94. package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
  95. package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
  96. package/pennyfarthing_scripts/__pycache__/jira_bidirectional_sync.cpython-314.pyc +0 -0
  97. package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
  98. package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
  99. package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/__pycache__/output.cpython-314.pyc +0 -0
  101. package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
  102. package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
  103. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  104. package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
  105. package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
  106. package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
  107. package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
  108. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  109. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  110. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  111. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  112. package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
  113. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  114. package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
  115. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  116. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  117. package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
  118. package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
  119. package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
  120. package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
  121. package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
  122. package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
  123. package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
  124. package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
  125. package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
  126. package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
  127. package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/story/__pycache__/__init__.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/story/__pycache__/__main__.cpython-314.pyc +0 -0
  131. package/pennyfarthing_scripts/story/__pycache__/cli.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/story/__pycache__/create.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/story/__pycache__/size.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/story/__pycache__/template.cpython-314.pyc +0 -0
  135. package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
  136. package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  137. package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
  138. package/pennyfarthing_scripts/tests/__pycache__/test_cli_modules.cpython-314-pytest-9.0.2.pyc +0 -0
  139. package/pennyfarthing_scripts/tests/__pycache__/test_common.cpython-314-pytest-9.0.2.pyc +0 -0
  140. package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  141. package/pennyfarthing_scripts/tests/__pycache__/test_jira_package.cpython-314-pytest-9.0.2.pyc +0 -0
  142. package/pennyfarthing_scripts/tests/__pycache__/test_package_structure.cpython-314-pytest-9.0.2.pyc +0 -0
  143. package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
  144. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_package.cpython-314-pytest-9.0.2.pyc +0 -0
  145. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
  146. package/pennyfarthing_scripts/tests/__pycache__/test_story_package.cpython-314-pytest-9.0.2.pyc +0 -0
  147. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
  148. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_cli.cpython-314-pytest-9.0.2.pyc +0 -0
@@ -0,0 +1,449 @@
1
+ """Patch Mode - Interrupt-Driven Bug Fix Workflow.
2
+
3
+ Story: 74-1 - Implement Patch Mode
4
+ Epic: epic-74 (Patch Mode Workflow)
5
+
6
+ This module provides the core functionality for Patch Mode, allowing developers
7
+ to interrupt their current workflow to fix blocking bugs and then resume work
8
+ with full context preserved.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import subprocess
15
+ import time
16
+ from dataclasses import dataclass, asdict
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ from pennyfarthing_scripts.common.config import get_project_root
24
+
25
+
26
+ @dataclass
27
+ class PatchState:
28
+ """State preserved when entering patch mode."""
29
+
30
+ story_id: str
31
+ workflow: str
32
+ phase: str
33
+ agent: str
34
+ feature_branch: str
35
+
36
+ def to_yaml(self) -> str:
37
+ """Serialize state to YAML string."""
38
+ return yaml.dump(asdict(self), default_flow_style=False)
39
+
40
+ @classmethod
41
+ def from_yaml(cls, yaml_str: str) -> "PatchState":
42
+ """Deserialize state from YAML string."""
43
+ data = yaml.safe_load(yaml_str)
44
+ return cls(**data)
45
+
46
+
47
+ class PatchStack:
48
+ """Stack of patch states for nested patch support."""
49
+
50
+ def __init__(self, stack_file: Path | None = None) -> None:
51
+ """Initialize patch stack.
52
+
53
+ Args:
54
+ stack_file: Path to patch-stack.yaml file
55
+ """
56
+ if stack_file is None:
57
+ root = get_project_root()
58
+ stack_file = root / ".session" / "patch-stack.yaml"
59
+
60
+ self.stack_file = stack_file
61
+ self._stack: list[PatchState] = []
62
+
63
+ # Load existing stack from file if it exists
64
+ if self.stack_file.exists():
65
+ self._load()
66
+
67
+ def _load(self) -> None:
68
+ """Load stack from file."""
69
+ content = yaml.safe_load(self.stack_file.read_text())
70
+ if content and "stack" in content:
71
+ self._stack = [
72
+ PatchState(**item) for item in content["stack"]
73
+ ]
74
+
75
+ def _save(self) -> None:
76
+ """Save stack to file."""
77
+ # Ensure parent directory exists
78
+ self.stack_file.parent.mkdir(parents=True, exist_ok=True)
79
+
80
+ content = {
81
+ "stack": [asdict(state) for state in self._stack]
82
+ }
83
+ self.stack_file.write_text(yaml.dump(content, default_flow_style=False))
84
+
85
+ def push(self, state: PatchState) -> None:
86
+ """Push state onto stack."""
87
+ self._stack.append(state)
88
+ self._save()
89
+
90
+ def pop(self) -> PatchState:
91
+ """Pop state from stack."""
92
+ if not self._stack:
93
+ raise IndexError("Cannot pop from empty patch stack")
94
+ state = self._stack.pop()
95
+ self._save()
96
+ return state
97
+
98
+ def depth(self) -> int:
99
+ """Return current stack depth."""
100
+ return len(self._stack)
101
+
102
+ def peek(self) -> PatchState | None:
103
+ """Peek at top of stack without popping."""
104
+ return self._stack[-1] if self._stack else None
105
+
106
+
107
+ def get_patch_stack(stack_file: Path | None = None) -> PatchStack:
108
+ """Get the patch stack instance.
109
+
110
+ Args:
111
+ stack_file: Optional path to stack file
112
+
113
+ Returns:
114
+ PatchStack instance
115
+ """
116
+ return PatchStack(stack_file=stack_file)
117
+
118
+
119
+ def _sanitize_branch_name(description: str) -> str:
120
+ """Sanitize description for use in branch name.
121
+
122
+ Args:
123
+ description: Raw description string
124
+
125
+ Returns:
126
+ Sanitized string safe for git branch names
127
+ """
128
+ # Replace special characters with dashes
129
+ sanitized = re.sub(r'[^a-zA-Z0-9\s-]', '', description)
130
+ # Replace spaces with dashes
131
+ sanitized = re.sub(r'\s+', '-', sanitized)
132
+ # Remove multiple consecutive dashes
133
+ sanitized = re.sub(r'-+', '-', sanitized)
134
+ # Lowercase and strip
135
+ sanitized = sanitized.lower().strip('-')
136
+ # Truncate if too long
137
+ return sanitized[:50]
138
+
139
+
140
+ def create_patch_branch(
141
+ description: str,
142
+ feature_branch: str,
143
+ repo_path: Path | None = None,
144
+ ) -> str:
145
+ """Create a patch branch from the current feature branch.
146
+
147
+ Args:
148
+ description: Description for branch name
149
+ feature_branch: Current feature branch
150
+ repo_path: Path to git repository
151
+
152
+ Returns:
153
+ Name of created patch branch
154
+
155
+ Raises:
156
+ ValueError: If description is empty or produces empty branch name
157
+ """
158
+ if not description or not description.strip():
159
+ raise ValueError("Patch description cannot be empty")
160
+
161
+ timestamp = int(time.time())
162
+ sanitized_desc = _sanitize_branch_name(description)
163
+
164
+ if not sanitized_desc:
165
+ raise ValueError(
166
+ f"Patch description '{description}' produces empty branch name after sanitization"
167
+ )
168
+
169
+ branch_name = f"patch/{sanitized_desc}-{timestamp}"
170
+
171
+ cwd = str(repo_path) if repo_path else None
172
+
173
+ # Create and checkout the new branch from current HEAD (feature branch)
174
+ result = subprocess.run(
175
+ ["git", "checkout", "-b", branch_name],
176
+ capture_output=True,
177
+ text=True,
178
+ cwd=cwd,
179
+ )
180
+
181
+ if result.returncode != 0:
182
+ raise RuntimeError(f"Git branch creation failed: {result.stderr}")
183
+
184
+ return branch_name
185
+
186
+
187
+ def merge_patch_branch(
188
+ patch_branch: str,
189
+ feature_branch: str,
190
+ repo_path: Path | None = None,
191
+ ) -> None:
192
+ """Merge patch branch back to feature branch and delete it.
193
+
194
+ Args:
195
+ patch_branch: Name of patch branch
196
+ feature_branch: Target feature branch
197
+ repo_path: Path to git repository
198
+ """
199
+ cwd = str(repo_path) if repo_path else None
200
+
201
+ # Checkout feature branch
202
+ result = subprocess.run(
203
+ ["git", "checkout", feature_branch],
204
+ capture_output=True,
205
+ text=True,
206
+ cwd=cwd,
207
+ )
208
+ if result.returncode != 0:
209
+ raise RuntimeError(f"Git checkout failed: {result.stderr}")
210
+
211
+ # Merge patch branch
212
+ result = subprocess.run(
213
+ ["git", "merge", patch_branch],
214
+ capture_output=True,
215
+ text=True,
216
+ cwd=cwd,
217
+ )
218
+ if result.returncode != 0:
219
+ raise RuntimeError(f"Git merge failed: {result.stderr}")
220
+
221
+ # Delete patch branch
222
+ result = subprocess.run(
223
+ ["git", "branch", "-d", patch_branch],
224
+ capture_output=True,
225
+ text=True,
226
+ cwd=cwd,
227
+ )
228
+ if result.returncode != 0:
229
+ raise RuntimeError(f"Git branch delete failed: {result.stderr}")
230
+
231
+
232
+ def enter_patch_mode(
233
+ description: str,
234
+ story_id: str,
235
+ workflow: str,
236
+ phase: str,
237
+ agent: str,
238
+ feature_branch: str,
239
+ repo_path: Path | None = None,
240
+ stack_file: Path | None = None,
241
+ ) -> dict[str, Any]:
242
+ """Enter patch mode by saving state and creating patch branch.
243
+
244
+ Args:
245
+ description: Description of the patch/fix
246
+ story_id: Current story ID
247
+ workflow: Current workflow type
248
+ phase: Current workflow phase
249
+ agent: Current agent
250
+ feature_branch: Current feature branch name
251
+ repo_path: Path to git repository
252
+ stack_file: Path to patch-stack.yaml
253
+
254
+ Returns:
255
+ Dict with patch_branch and other metadata
256
+ """
257
+ # Save current state to stack
258
+ state = PatchState(
259
+ story_id=story_id,
260
+ workflow=workflow,
261
+ phase=phase,
262
+ agent=agent,
263
+ feature_branch=feature_branch,
264
+ )
265
+
266
+ stack = get_patch_stack(stack_file)
267
+ stack.push(state)
268
+
269
+ # Create patch branch
270
+ try:
271
+ patch_branch = create_patch_branch(
272
+ description=description,
273
+ feature_branch=feature_branch,
274
+ repo_path=repo_path,
275
+ )
276
+ except Exception as e:
277
+ # Rollback stack on failure
278
+ stack.pop()
279
+ raise RuntimeError(f"Git error: {e}") from e
280
+
281
+ return {
282
+ "patch_branch": patch_branch,
283
+ "agent": "dev", # Patch mode is always dev
284
+ "story_id": story_id,
285
+ "description": description,
286
+ }
287
+
288
+
289
+ def exit_patch_mode(
290
+ patch_branch: str | None = None,
291
+ repo_path: Path | None = None,
292
+ stack_file: Path | None = None,
293
+ ) -> dict[str, Any]:
294
+ """Exit patch mode by merging and restoring state.
295
+
296
+ Args:
297
+ patch_branch: Name of patch branch to merge
298
+ repo_path: Path to git repository
299
+ stack_file: Path to patch-stack.yaml
300
+
301
+ Returns:
302
+ Dict with restored state and handoff_marker
303
+ """
304
+ stack = get_patch_stack(stack_file)
305
+
306
+ # Peek at state first - don't pop until operations succeed
307
+ state = stack.peek()
308
+ if state is None:
309
+ raise RuntimeError("Cannot exit patch mode: stack is empty")
310
+
311
+ # Merge patch branch back to feature branch if provided
312
+ # Do this BEFORE popping state so we can recover on failure
313
+ if patch_branch:
314
+ merge_patch_branch(
315
+ patch_branch=patch_branch,
316
+ feature_branch=state.feature_branch,
317
+ repo_path=repo_path,
318
+ )
319
+
320
+ # Only pop after successful merge
321
+ stack.pop()
322
+
323
+ # Build handoff marker for original agent
324
+ handoff_marker = f"<!-- CYCLIST:HANDOFF:/{state.agent} -->"
325
+
326
+ return {
327
+ "story_id": state.story_id,
328
+ "workflow": state.workflow,
329
+ "phase": state.phase,
330
+ "agent": state.agent,
331
+ "feature_branch": state.feature_branch,
332
+ "handoff_marker": handoff_marker,
333
+ "relay_mode_disabled": False,
334
+ }
335
+
336
+
337
+ def restore_workflow_state(
338
+ repo_path: Path | None = None,
339
+ stack_file: Path | None = None,
340
+ ) -> dict[str, Any]:
341
+ """Restore workflow state from patch stack.
342
+
343
+ Args:
344
+ repo_path: Path to git repository
345
+ stack_file: Path to patch-stack.yaml
346
+
347
+ Returns:
348
+ Dict with restored state
349
+ """
350
+ stack = get_patch_stack(stack_file)
351
+
352
+ # Peek at state first - don't pop until checkout succeeds
353
+ state = stack.peek()
354
+ if state is None:
355
+ raise RuntimeError("Cannot restore workflow state: stack is empty")
356
+
357
+ cwd = str(repo_path) if repo_path else None
358
+
359
+ # Checkout the original feature branch
360
+ result = subprocess.run(
361
+ ["git", "checkout", state.feature_branch],
362
+ capture_output=True,
363
+ text=True,
364
+ cwd=cwd,
365
+ )
366
+
367
+ if result.returncode != 0:
368
+ raise RuntimeError(f"Git checkout failed: {result.stderr}")
369
+
370
+ # Only pop after successful checkout
371
+ stack.pop()
372
+
373
+ return {
374
+ "story_id": state.story_id,
375
+ "workflow": state.workflow,
376
+ "phase": state.phase,
377
+ "agent": state.agent,
378
+ "feature_branch": state.feature_branch,
379
+ }
380
+
381
+
382
+ def log_patch_to_session(
383
+ session_file: Path,
384
+ description: str,
385
+ commit_sha: str,
386
+ ) -> None:
387
+ """Log completed patch to session file.
388
+
389
+ Args:
390
+ session_file: Path to session markdown file
391
+ description: Patch description
392
+ commit_sha: Git commit SHA
393
+ """
394
+ content = session_file.read_text()
395
+
396
+ # Find the Patches section and add entry
397
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
398
+ patch_entry = f"- **{timestamp}**: {description} (`{commit_sha}`)\n"
399
+
400
+ # Look for the marker comment or Patches header
401
+ if "<!-- Patches applied during this story will be logged here -->" in content:
402
+ content = content.replace(
403
+ "<!-- Patches applied during this story will be logged here -->",
404
+ f"<!-- Patches applied during this story will be logged here -->\n{patch_entry}"
405
+ )
406
+ elif "## Patches" in content:
407
+ # Insert after the Patches header
408
+ idx = content.find("## Patches")
409
+ next_newline = content.find("\n", idx)
410
+ content = content[:next_newline + 1] + "\n" + patch_entry + content[next_newline + 1:]
411
+ else:
412
+ # Append at end
413
+ content += f"\n## Patches\n\n{patch_entry}"
414
+
415
+ session_file.write_text(content)
416
+
417
+
418
+ def generate_patch_commit_message(
419
+ description: str,
420
+ story_id: str,
421
+ ) -> str:
422
+ """Generate commit message for patch.
423
+
424
+ Format: fix(patch): description [from:STORY-ID]
425
+
426
+ Args:
427
+ description: Patch description
428
+ story_id: Story ID the patch is from
429
+
430
+ Returns:
431
+ Formatted commit message
432
+ """
433
+ return f"""fix(patch): {description} [from:{story_id}]
434
+
435
+ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
436
+ """
437
+
438
+
439
+ def is_in_patch_mode(stack_file: Path | None = None) -> bool:
440
+ """Check if currently in patch mode.
441
+
442
+ Args:
443
+ stack_file: Path to patch-stack.yaml
444
+
445
+ Returns:
446
+ True if patch stack is non-empty
447
+ """
448
+ stack = get_patch_stack(stack_file)
449
+ return stack.depth() > 0