@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.
- package/README.md +3 -3
- package/package.json +3 -3
- package/pennyfarthing-dist/agents/README.md +1 -1
- package/pennyfarthing-dist/agents/dev.md +1 -1
- package/pennyfarthing-dist/agents/handoff.md +1 -1
- package/pennyfarthing-dist/agents/reviewer-preflight.md +1 -1
- package/pennyfarthing-dist/agents/sm-setup.md +3 -3
- package/pennyfarthing-dist/agents/sm.md +1 -1
- package/pennyfarthing-dist/agents/tea.md +1 -1
- package/pennyfarthing-dist/agents/testing-runner.md +3 -3
- package/pennyfarthing-dist/commands/architect.md +2 -0
- package/pennyfarthing-dist/commands/continue-session.md +2 -2
- package/pennyfarthing-dist/commands/dev.md +2 -0
- package/pennyfarthing-dist/commands/devops.md +2 -0
- package/pennyfarthing-dist/commands/health-check.md +2 -0
- package/pennyfarthing-dist/commands/new-work.md +23 -0
- package/pennyfarthing-dist/commands/orchestrator.md +2 -0
- package/pennyfarthing-dist/commands/parallel-work.md +4 -2
- package/pennyfarthing-dist/commands/pm.md +2 -0
- package/pennyfarthing-dist/commands/reviewer.md +2 -0
- package/pennyfarthing-dist/commands/sm.md +2 -0
- package/pennyfarthing-dist/commands/tea.md +2 -0
- package/pennyfarthing-dist/commands/tech-writer.md +2 -0
- package/pennyfarthing-dist/commands/ux-designer.md +2 -0
- package/pennyfarthing-dist/commands/work.md +2 -0
- package/pennyfarthing-dist/guides/agent-behavior.md +29 -264
- package/pennyfarthing-dist/scripts/core/agent-session.sh +7 -0
- package/pennyfarthing-dist/scripts/core/check-context.sh +140 -226
- package/pennyfarthing-dist/scripts/core/handoff-marker.sh +13 -2
- package/pennyfarthing-dist/scripts/git/worktree-manager.sh +4 -1
- package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -7
- package/pennyfarthing-dist/scripts/hooks/post-merge.sh +4 -11
- package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +3 -8
- package/pennyfarthing-dist/scripts/hooks/pre-push.sh +3 -3
- package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -7
- package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +2 -8
- package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +2 -8
- package/pennyfarthing-dist/scripts/lib/find-root.sh +17 -45
- package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -7
- package/pennyfarthing-dist/scripts/sprint/archive-story.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/available-stories.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/check-story.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/list-future.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +2 -8
- package/pennyfarthing-dist/scripts/tests/test-character-voice.sh +2 -1
- package/pennyfarthing-dist/scripts/workflow/finish-story.sh +4 -9
- package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +2 -8
- package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +2 -8
- package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -7
- package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +2 -8
- package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +2 -8
- package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +2 -8
- package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +2 -8
- package/pennyfarthing-dist/skills/dev-patterns/SKILL.md +1 -1
- package/pennyfarthing-dist/skills/jira/SKILL.md +48 -24
- package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +7 -0
- package/pennyfarthing-dist/skills/sprint/skill.md +30 -30
- package/pennyfarthing-dist/workflows/patch.yaml +68 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/context.py +414 -0
- package/pennyfarthing_scripts/patch_mode.py +449 -0
- package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/tiers.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/cli.py +201 -0
- package/pennyfarthing_scripts/prime/models.py +9 -0
- package/pennyfarthing_scripts/prime/persona.py +41 -0
- package/pennyfarthing_scripts/prime/tiers.py +201 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/test_patch_mode.py +830 -0
- package/pennyfarthing_scripts/tests/test_tiers.py +1090 -0
- package/pennyfarthing_scripts/tests/test_token_counting.py +559 -0
- package/pennyfarthing-dist/scripts/hooks/__pycache__/question_reflector_check.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_bidirectional_sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/output.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/create.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/size.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/story/__pycache__/template.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_cli_modules.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_common.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_jira_package.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_package_structure.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_package.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_story_package.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_workflow_check.cpython-314-pytest-9.0.2.pyc +0 -0
- 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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|