spec-kitty-cli 0.12.1__py3-none-any.whl

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 (242) hide show
  1. spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
  2. spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
  3. spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
  4. spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
  5. spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
  6. specify_cli/__init__.py +171 -0
  7. specify_cli/acceptance.py +627 -0
  8. specify_cli/agent_utils/README.md +157 -0
  9. specify_cli/agent_utils/__init__.py +9 -0
  10. specify_cli/agent_utils/status.py +356 -0
  11. specify_cli/cli/__init__.py +6 -0
  12. specify_cli/cli/commands/__init__.py +46 -0
  13. specify_cli/cli/commands/accept.py +189 -0
  14. specify_cli/cli/commands/agent/__init__.py +22 -0
  15. specify_cli/cli/commands/agent/config.py +382 -0
  16. specify_cli/cli/commands/agent/context.py +191 -0
  17. specify_cli/cli/commands/agent/feature.py +1057 -0
  18. specify_cli/cli/commands/agent/release.py +11 -0
  19. specify_cli/cli/commands/agent/tasks.py +1253 -0
  20. specify_cli/cli/commands/agent/workflow.py +801 -0
  21. specify_cli/cli/commands/context.py +246 -0
  22. specify_cli/cli/commands/dashboard.py +85 -0
  23. specify_cli/cli/commands/implement.py +973 -0
  24. specify_cli/cli/commands/init.py +827 -0
  25. specify_cli/cli/commands/init_help.py +62 -0
  26. specify_cli/cli/commands/merge.py +755 -0
  27. specify_cli/cli/commands/mission.py +240 -0
  28. specify_cli/cli/commands/ops.py +265 -0
  29. specify_cli/cli/commands/orchestrate.py +640 -0
  30. specify_cli/cli/commands/repair.py +175 -0
  31. specify_cli/cli/commands/research.py +165 -0
  32. specify_cli/cli/commands/sync.py +364 -0
  33. specify_cli/cli/commands/upgrade.py +249 -0
  34. specify_cli/cli/commands/validate_encoding.py +186 -0
  35. specify_cli/cli/commands/validate_tasks.py +186 -0
  36. specify_cli/cli/commands/verify.py +310 -0
  37. specify_cli/cli/helpers.py +123 -0
  38. specify_cli/cli/step_tracker.py +91 -0
  39. specify_cli/cli/ui.py +192 -0
  40. specify_cli/core/__init__.py +53 -0
  41. specify_cli/core/agent_context.py +311 -0
  42. specify_cli/core/config.py +96 -0
  43. specify_cli/core/context_validation.py +362 -0
  44. specify_cli/core/dependency_graph.py +351 -0
  45. specify_cli/core/git_ops.py +129 -0
  46. specify_cli/core/multi_parent_merge.py +323 -0
  47. specify_cli/core/paths.py +260 -0
  48. specify_cli/core/project_resolver.py +110 -0
  49. specify_cli/core/stale_detection.py +263 -0
  50. specify_cli/core/tool_checker.py +79 -0
  51. specify_cli/core/utils.py +43 -0
  52. specify_cli/core/vcs/__init__.py +114 -0
  53. specify_cli/core/vcs/detection.py +341 -0
  54. specify_cli/core/vcs/exceptions.py +85 -0
  55. specify_cli/core/vcs/git.py +1304 -0
  56. specify_cli/core/vcs/jujutsu.py +1208 -0
  57. specify_cli/core/vcs/protocol.py +285 -0
  58. specify_cli/core/vcs/types.py +249 -0
  59. specify_cli/core/version_checker.py +261 -0
  60. specify_cli/core/worktree.py +506 -0
  61. specify_cli/dashboard/__init__.py +28 -0
  62. specify_cli/dashboard/diagnostics.py +204 -0
  63. specify_cli/dashboard/handlers/__init__.py +17 -0
  64. specify_cli/dashboard/handlers/api.py +143 -0
  65. specify_cli/dashboard/handlers/base.py +65 -0
  66. specify_cli/dashboard/handlers/features.py +390 -0
  67. specify_cli/dashboard/handlers/router.py +81 -0
  68. specify_cli/dashboard/handlers/static.py +50 -0
  69. specify_cli/dashboard/lifecycle.py +541 -0
  70. specify_cli/dashboard/scanner.py +437 -0
  71. specify_cli/dashboard/server.py +123 -0
  72. specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
  73. specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
  74. specify_cli/dashboard/static/spec-kitty.png +0 -0
  75. specify_cli/dashboard/templates/__init__.py +36 -0
  76. specify_cli/dashboard/templates/index.html +258 -0
  77. specify_cli/doc_generators.py +621 -0
  78. specify_cli/doc_state.py +408 -0
  79. specify_cli/frontmatter.py +384 -0
  80. specify_cli/gap_analysis.py +915 -0
  81. specify_cli/gitignore_manager.py +300 -0
  82. specify_cli/guards.py +145 -0
  83. specify_cli/legacy_detector.py +83 -0
  84. specify_cli/manifest.py +286 -0
  85. specify_cli/merge/__init__.py +63 -0
  86. specify_cli/merge/executor.py +653 -0
  87. specify_cli/merge/forecast.py +215 -0
  88. specify_cli/merge/ordering.py +126 -0
  89. specify_cli/merge/preflight.py +230 -0
  90. specify_cli/merge/state.py +185 -0
  91. specify_cli/merge/status_resolver.py +354 -0
  92. specify_cli/mission.py +654 -0
  93. specify_cli/missions/documentation/command-templates/implement.md +309 -0
  94. specify_cli/missions/documentation/command-templates/plan.md +275 -0
  95. specify_cli/missions/documentation/command-templates/review.md +344 -0
  96. specify_cli/missions/documentation/command-templates/specify.md +206 -0
  97. specify_cli/missions/documentation/command-templates/tasks.md +189 -0
  98. specify_cli/missions/documentation/mission.yaml +113 -0
  99. specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
  100. specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
  101. specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
  102. specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
  103. specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
  104. specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
  105. specify_cli/missions/documentation/templates/plan-template.md +269 -0
  106. specify_cli/missions/documentation/templates/release-template.md +222 -0
  107. specify_cli/missions/documentation/templates/spec-template.md +172 -0
  108. specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
  109. specify_cli/missions/documentation/templates/tasks-template.md +159 -0
  110. specify_cli/missions/research/command-templates/merge.md +388 -0
  111. specify_cli/missions/research/command-templates/plan.md +125 -0
  112. specify_cli/missions/research/command-templates/review.md +144 -0
  113. specify_cli/missions/research/command-templates/tasks.md +225 -0
  114. specify_cli/missions/research/mission.yaml +115 -0
  115. specify_cli/missions/research/templates/data-model-template.md +33 -0
  116. specify_cli/missions/research/templates/plan-template.md +161 -0
  117. specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
  118. specify_cli/missions/research/templates/research/source-register.csv +18 -0
  119. specify_cli/missions/research/templates/research-template.md +35 -0
  120. specify_cli/missions/research/templates/spec-template.md +64 -0
  121. specify_cli/missions/research/templates/task-prompt-template.md +148 -0
  122. specify_cli/missions/research/templates/tasks-template.md +114 -0
  123. specify_cli/missions/software-dev/command-templates/accept.md +75 -0
  124. specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
  125. specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
  126. specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
  127. specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
  128. specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
  129. specify_cli/missions/software-dev/command-templates/implement.md +41 -0
  130. specify_cli/missions/software-dev/command-templates/merge.md +383 -0
  131. specify_cli/missions/software-dev/command-templates/plan.md +171 -0
  132. specify_cli/missions/software-dev/command-templates/review.md +32 -0
  133. specify_cli/missions/software-dev/command-templates/specify.md +321 -0
  134. specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
  135. specify_cli/missions/software-dev/mission.yaml +100 -0
  136. specify_cli/missions/software-dev/templates/plan-template.md +132 -0
  137. specify_cli/missions/software-dev/templates/spec-template.md +116 -0
  138. specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
  139. specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
  140. specify_cli/orchestrator/__init__.py +75 -0
  141. specify_cli/orchestrator/agent_config.py +224 -0
  142. specify_cli/orchestrator/agents/__init__.py +170 -0
  143. specify_cli/orchestrator/agents/augment.py +112 -0
  144. specify_cli/orchestrator/agents/base.py +243 -0
  145. specify_cli/orchestrator/agents/claude.py +112 -0
  146. specify_cli/orchestrator/agents/codex.py +106 -0
  147. specify_cli/orchestrator/agents/copilot.py +137 -0
  148. specify_cli/orchestrator/agents/cursor.py +139 -0
  149. specify_cli/orchestrator/agents/gemini.py +115 -0
  150. specify_cli/orchestrator/agents/kilocode.py +94 -0
  151. specify_cli/orchestrator/agents/opencode.py +132 -0
  152. specify_cli/orchestrator/agents/qwen.py +96 -0
  153. specify_cli/orchestrator/config.py +455 -0
  154. specify_cli/orchestrator/executor.py +642 -0
  155. specify_cli/orchestrator/integration.py +1230 -0
  156. specify_cli/orchestrator/monitor.py +898 -0
  157. specify_cli/orchestrator/scheduler.py +832 -0
  158. specify_cli/orchestrator/state.py +508 -0
  159. specify_cli/orchestrator/testing/__init__.py +122 -0
  160. specify_cli/orchestrator/testing/availability.py +346 -0
  161. specify_cli/orchestrator/testing/fixtures.py +684 -0
  162. specify_cli/orchestrator/testing/paths.py +218 -0
  163. specify_cli/plan_validation.py +107 -0
  164. specify_cli/scripts/debug-dashboard-scan.py +61 -0
  165. specify_cli/scripts/tasks/acceptance_support.py +695 -0
  166. specify_cli/scripts/tasks/task_helpers.py +506 -0
  167. specify_cli/scripts/tasks/tasks_cli.py +848 -0
  168. specify_cli/scripts/validate_encoding.py +180 -0
  169. specify_cli/task_metadata_validation.py +274 -0
  170. specify_cli/tasks_support.py +447 -0
  171. specify_cli/template/__init__.py +47 -0
  172. specify_cli/template/asset_generator.py +206 -0
  173. specify_cli/template/github_client.py +334 -0
  174. specify_cli/template/manager.py +193 -0
  175. specify_cli/template/renderer.py +99 -0
  176. specify_cli/templates/AGENTS.md +190 -0
  177. specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
  178. specify_cli/templates/agent-file-template.md +35 -0
  179. specify_cli/templates/checklist-template.md +42 -0
  180. specify_cli/templates/claudeignore-template +58 -0
  181. specify_cli/templates/command-templates/accept.md +141 -0
  182. specify_cli/templates/command-templates/analyze.md +253 -0
  183. specify_cli/templates/command-templates/checklist.md +352 -0
  184. specify_cli/templates/command-templates/clarify.md +224 -0
  185. specify_cli/templates/command-templates/constitution.md +432 -0
  186. specify_cli/templates/command-templates/dashboard.md +175 -0
  187. specify_cli/templates/command-templates/implement.md +190 -0
  188. specify_cli/templates/command-templates/merge.md +374 -0
  189. specify_cli/templates/command-templates/plan.md +171 -0
  190. specify_cli/templates/command-templates/research.md +88 -0
  191. specify_cli/templates/command-templates/review.md +510 -0
  192. specify_cli/templates/command-templates/specify.md +321 -0
  193. specify_cli/templates/command-templates/status.md +92 -0
  194. specify_cli/templates/command-templates/tasks.md +199 -0
  195. specify_cli/templates/git-hooks/pre-commit +22 -0
  196. specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
  197. specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
  198. specify_cli/templates/plan-template.md +108 -0
  199. specify_cli/templates/spec-template.md +118 -0
  200. specify_cli/templates/task-prompt-template.md +165 -0
  201. specify_cli/templates/tasks-template.md +161 -0
  202. specify_cli/templates/vscode-settings.json +13 -0
  203. specify_cli/text_sanitization.py +225 -0
  204. specify_cli/upgrade/__init__.py +18 -0
  205. specify_cli/upgrade/detector.py +239 -0
  206. specify_cli/upgrade/metadata.py +182 -0
  207. specify_cli/upgrade/migrations/__init__.py +65 -0
  208. specify_cli/upgrade/migrations/base.py +80 -0
  209. specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
  210. specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
  211. specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
  212. specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
  213. specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
  214. specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
  215. specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
  216. specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
  217. specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
  218. specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
  219. specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
  220. specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
  221. specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
  222. specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
  223. specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
  224. specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
  225. specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
  226. specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
  227. specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
  228. specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
  229. specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
  230. specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
  231. specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
  232. specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
  233. specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
  234. specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
  235. specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
  236. specify_cli/upgrade/registry.py +121 -0
  237. specify_cli/upgrade/runner.py +284 -0
  238. specify_cli/validators/__init__.py +14 -0
  239. specify_cli/validators/paths.py +154 -0
  240. specify_cli/validators/research.py +428 -0
  241. specify_cli/verify_enhanced.py +270 -0
  242. specify_cli/workspace_context.py +224 -0
@@ -0,0 +1,185 @@
1
+ """Merge state persistence for resume capability.
2
+
3
+ Implements FR-021 through FR-024: persisting merge state to enable
4
+ resuming interrupted merge operations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import subprocess
11
+ from dataclasses import asdict, dataclass, field
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ __all__ = [
17
+ "MergeState",
18
+ "save_state",
19
+ "load_state",
20
+ "clear_state",
21
+ "has_active_merge",
22
+ "get_state_path",
23
+ "detect_git_merge_state",
24
+ ]
25
+
26
+ STATE_FILE = ".kittify/merge-state.json"
27
+
28
+
29
+ @dataclass
30
+ class MergeState:
31
+ """Persisted state for resumable merge operations."""
32
+
33
+ feature_slug: str
34
+ target_branch: str
35
+ wp_order: list[str] # Ordered list of WP IDs to merge
36
+ completed_wps: list[str] = field(default_factory=list)
37
+ current_wp: str | None = None
38
+ has_pending_conflicts: bool = False
39
+ strategy: str = "merge" # "merge", "squash", "rebase"
40
+ started_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
41
+ updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ """Convert to JSON-serializable dict."""
45
+ return asdict(self)
46
+
47
+ @classmethod
48
+ def from_dict(cls, data: dict[str, Any]) -> MergeState:
49
+ """Create from dict (loaded JSON)."""
50
+ return cls(**data)
51
+
52
+ @property
53
+ def remaining_wps(self) -> list[str]:
54
+ """WPs not yet merged."""
55
+ completed_set = set(self.completed_wps)
56
+ return [wp for wp in self.wp_order if wp not in completed_set]
57
+
58
+ @property
59
+ def progress_percent(self) -> float:
60
+ """Completion percentage."""
61
+ if not self.wp_order:
62
+ return 0.0
63
+ return len(self.completed_wps) / len(self.wp_order) * 100
64
+
65
+ def mark_wp_complete(self, wp_id: str) -> None:
66
+ """Mark a WP as successfully merged."""
67
+ if wp_id not in self.completed_wps:
68
+ self.completed_wps.append(wp_id)
69
+ self.current_wp = None
70
+ self.has_pending_conflicts = False
71
+ self.updated_at = datetime.now(UTC).isoformat()
72
+
73
+ def set_current_wp(self, wp_id: str) -> None:
74
+ """Set the currently-merging WP."""
75
+ self.current_wp = wp_id
76
+ self.updated_at = datetime.now(UTC).isoformat()
77
+
78
+ def set_pending_conflicts(self, has_conflicts: bool = True) -> None:
79
+ """Mark that there are pending conflicts to resolve."""
80
+ self.has_pending_conflicts = has_conflicts
81
+ self.updated_at = datetime.now(UTC).isoformat()
82
+
83
+
84
+ def get_state_path(repo_root: Path) -> Path:
85
+ """Get path to merge state file."""
86
+ return repo_root / STATE_FILE
87
+
88
+
89
+ def save_state(state: MergeState, repo_root: Path) -> None:
90
+ """Save merge state to JSON file.
91
+
92
+ Args:
93
+ state: MergeState to persist
94
+ repo_root: Repository root path
95
+ """
96
+ state_path = get_state_path(repo_root)
97
+ state_path.parent.mkdir(parents=True, exist_ok=True)
98
+
99
+ # Update timestamp
100
+ state.updated_at = datetime.now(UTC).isoformat()
101
+
102
+ with open(state_path, "w", encoding="utf-8") as f:
103
+ json.dump(state.to_dict(), f, indent=2)
104
+
105
+
106
+ def load_state(repo_root: Path) -> MergeState | None:
107
+ """Load merge state from JSON file.
108
+
109
+ Args:
110
+ repo_root: Repository root path
111
+
112
+ Returns:
113
+ MergeState if file exists and is valid, None otherwise
114
+ """
115
+ state_path = get_state_path(repo_root)
116
+
117
+ if not state_path.exists():
118
+ return None
119
+
120
+ try:
121
+ with open(state_path, encoding="utf-8") as f:
122
+ data = json.load(f)
123
+ return MergeState.from_dict(data)
124
+ except (json.JSONDecodeError, TypeError, KeyError):
125
+ # Invalid state file - return None, caller should clear
126
+ return None
127
+
128
+
129
+ def clear_state(repo_root: Path) -> bool:
130
+ """Remove merge state file.
131
+
132
+ Args:
133
+ repo_root: Repository root path
134
+
135
+ Returns:
136
+ True if file was removed, False if it didn't exist
137
+ """
138
+ state_path = get_state_path(repo_root)
139
+
140
+ if state_path.exists():
141
+ state_path.unlink()
142
+ return True
143
+ return False
144
+
145
+
146
+ def has_active_merge(repo_root: Path) -> bool:
147
+ """Check if there's an active merge state.
148
+
149
+ Returns True if state file exists and has remaining WPs.
150
+ """
151
+ state = load_state(repo_root)
152
+ if state is None:
153
+ return False
154
+ return len(state.remaining_wps) > 0
155
+
156
+
157
+ def detect_git_merge_state(repo_root: Path) -> bool:
158
+ """Check if git has an active merge in progress.
159
+
160
+ Uses MERGE_HEAD presence to detect mid-merge state.
161
+ """
162
+ result = subprocess.run(
163
+ ["git", "rev-parse", "-q", "--verify", "MERGE_HEAD"],
164
+ cwd=str(repo_root),
165
+ capture_output=True,
166
+ check=False,
167
+ )
168
+ return result.returncode == 0
169
+
170
+
171
+ def abort_git_merge(repo_root: Path) -> bool:
172
+ """Abort an in-progress git merge.
173
+
174
+ Returns:
175
+ True if merge was aborted, False if no merge was in progress
176
+ """
177
+ if not detect_git_merge_state(repo_root):
178
+ return False
179
+
180
+ subprocess.run(
181
+ ["git", "merge", "--abort"],
182
+ cwd=str(repo_root),
183
+ check=False,
184
+ )
185
+ return True
@@ -0,0 +1,354 @@
1
+ """Status file auto-resolution for merge conflicts.
2
+
3
+ Implements FR-012 through FR-016: automatically resolving conflicts in
4
+ status tracking files (lane fields, checkboxes, history arrays).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import re
11
+ import subprocess
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ __all__ = [
19
+ "ConflictRegion",
20
+ "ResolutionResult",
21
+ "parse_conflict_markers",
22
+ "resolve_status_conflicts",
23
+ "get_conflicted_files",
24
+ "is_status_file",
25
+ ]
26
+
27
+
28
+ CONFLICT_PATTERN = re.compile(
29
+ r"^<<<<<<< .*?\n(.*?)^=======\n(.*?)^>>>>>>> .*?\n",
30
+ re.MULTILINE | re.DOTALL,
31
+ )
32
+ CHECKBOX_PATTERN = re.compile(r"^(\s*-\s*\[)([x ])\](.*)$", re.MULTILINE)
33
+ LANE_PATTERN = re.compile(r"^(\s*lane:\s*)([\"']?)(\w+)([\"']?)\s*$", re.MULTILINE)
34
+ HISTORY_BLOCK_PATTERN = re.compile(
35
+ r"^(?P<indent>\s*)history:\s*\n(?P<body>(?:^(?P=indent)\s+-.*\n?)+)",
36
+ re.MULTILINE,
37
+ )
38
+
39
+ STATUS_FILE_PATTERNS = [
40
+ "kitty-specs/*/tasks/*.md",
41
+ "kitty-specs/*/tasks.md",
42
+ "kitty-specs/*/*/tasks/*.md",
43
+ "kitty-specs/*/*/tasks.md",
44
+ ]
45
+
46
+ LANE_PRIORITY = {
47
+ "done": 4,
48
+ "for_review": 3,
49
+ "doing": 2,
50
+ "planned": 1,
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class ConflictRegion:
56
+ """A single conflict region in a file."""
57
+
58
+ start_line: int
59
+ end_line: int
60
+ ours: str
61
+ theirs: str
62
+ original: str
63
+
64
+
65
+ @dataclass
66
+ class ResolutionResult:
67
+ """Result of auto-resolving a status file conflict."""
68
+
69
+ file_path: Path
70
+ resolved: bool
71
+ resolution_type: str
72
+ original_conflicts: int
73
+ resolved_conflicts: int
74
+
75
+
76
+ def parse_conflict_markers(content: str) -> list[ConflictRegion]:
77
+ """Parse conflict markers from file content."""
78
+ regions = []
79
+ for match in CONFLICT_PATTERN.finditer(content):
80
+ regions.append(
81
+ ConflictRegion(
82
+ start_line=content[:match.start()].count("\n"),
83
+ end_line=content[:match.end()].count("\n"),
84
+ ours=match.group(1),
85
+ theirs=match.group(2),
86
+ original=match.group(0),
87
+ )
88
+ )
89
+ return regions
90
+
91
+
92
+ def _preserve_trailing_newline(resolved: str, original: str) -> str:
93
+ if original.endswith("\n") and not resolved.endswith("\n"):
94
+ return resolved + "\n"
95
+ return resolved
96
+
97
+
98
+ def is_status_file(file_path: str) -> bool:
99
+ """Check if file matches status file patterns."""
100
+ for pattern in STATUS_FILE_PATTERNS:
101
+ if fnmatch.fnmatch(file_path, pattern):
102
+ return True
103
+ return False
104
+
105
+
106
+ def extract_lane_value(content: str) -> str | None:
107
+ """Extract lane value from YAML frontmatter content."""
108
+ match = LANE_PATTERN.search(content)
109
+ return match.group(3) if match else None
110
+
111
+
112
+ def replace_lane_value(content: str, lane_value: str) -> str:
113
+ """Replace lane value in content with the provided value."""
114
+ if not LANE_PATTERN.search(content):
115
+ return content
116
+ return LANE_PATTERN.sub(
117
+ lambda match: f"{match.group(1)}{match.group(2)}{lane_value}{match.group(4)}",
118
+ content,
119
+ count=1,
120
+ )
121
+
122
+
123
+ def resolve_lane_conflict(ours: str, theirs: str) -> str | None:
124
+ """Resolve lane conflict by choosing 'more done' value."""
125
+ our_lane = extract_lane_value(ours)
126
+ their_lane = extract_lane_value(theirs)
127
+
128
+ if not our_lane or not their_lane:
129
+ return None
130
+
131
+ our_priority = LANE_PRIORITY.get(our_lane, 0)
132
+ their_priority = LANE_PRIORITY.get(their_lane, 0)
133
+ chosen = their_lane if their_priority > our_priority else our_lane
134
+
135
+ return replace_lane_value(ours, chosen)
136
+
137
+
138
+ def resolve_checkbox_conflict(ours: str, theirs: str) -> str:
139
+ """Resolve checkbox conflict by preferring checked [x]."""
140
+ our_lines = ours.strip().split("\n")
141
+ their_lines = theirs.strip().split("\n")
142
+
143
+ result_lines = []
144
+ max_lines = max(len(our_lines), len(their_lines))
145
+
146
+ for i in range(max_lines):
147
+ our_line = our_lines[i] if i < len(our_lines) else ""
148
+ their_line = their_lines[i] if i < len(their_lines) else ""
149
+
150
+ our_match = CHECKBOX_PATTERN.match(our_line)
151
+ their_match = CHECKBOX_PATTERN.match(their_line)
152
+
153
+ if our_match and their_match:
154
+ if their_match.group(2) == "x" and our_match.group(2) != "x":
155
+ result_lines.append(their_line)
156
+ else:
157
+ result_lines.append(our_line)
158
+ elif their_match and not our_line.strip():
159
+ result_lines.append(their_line)
160
+ else:
161
+ result_lines.append(our_line)
162
+
163
+ return "\n".join(result_lines)
164
+
165
+
166
+ def _parse_history_entries(content: str) -> list[dict[str, Any]] | None:
167
+ match = HISTORY_BLOCK_PATTERN.search(content)
168
+ if not match:
169
+ return []
170
+
171
+ history_yaml = f"history:\n{match.group('body')}"
172
+ try:
173
+ data = yaml.safe_load(history_yaml) or {}
174
+ except Exception:
175
+ return None
176
+
177
+ if not isinstance(data, dict):
178
+ return None
179
+
180
+ entries = data.get("history", [])
181
+ if not isinstance(entries, list):
182
+ return None
183
+
184
+ return [entry for entry in entries if isinstance(entry, dict)]
185
+
186
+
187
+ def _merge_history_entries(
188
+ ours: list[dict[str, Any]], theirs: list[dict[str, Any]]
189
+ ) -> list[dict[str, Any]]:
190
+ merged = ours + theirs
191
+
192
+ seen: set[tuple[str, str, str, str]] = set()
193
+ unique: list[dict[str, Any]] = []
194
+ for entry in merged:
195
+ key = (
196
+ str(entry.get("timestamp", "")),
197
+ str(entry.get("action", "")),
198
+ str(entry.get("lane", "")),
199
+ str(entry.get("agent", "")),
200
+ )
201
+ if key in seen:
202
+ continue
203
+ seen.add(key)
204
+ unique.append(entry)
205
+
206
+ unique.sort(key=lambda entry: str(entry.get("timestamp", "")))
207
+ return unique
208
+
209
+
210
+ def _build_history_block(entries: list[dict[str, Any]], indent: str = "") -> str:
211
+ dumped = yaml.safe_dump(
212
+ {"history": entries},
213
+ sort_keys=False,
214
+ default_flow_style=False,
215
+ ).rstrip("\n")
216
+ if not indent:
217
+ return dumped
218
+ return "\n".join(f"{indent}{line}" if line else line for line in dumped.split("\n"))
219
+
220
+
221
+ def resolve_history_conflict(ours: str, theirs: str) -> str | None:
222
+ """Resolve history array conflict by merging chronologically."""
223
+ our_entries = _parse_history_entries(ours)
224
+ their_entries = _parse_history_entries(theirs)
225
+
226
+ if our_entries is None or their_entries is None:
227
+ return None
228
+
229
+ if not our_entries and not their_entries:
230
+ return None
231
+
232
+ merged = _merge_history_entries(our_entries, their_entries)
233
+
234
+ base = ours if HISTORY_BLOCK_PATTERN.search(ours) else theirs
235
+ match = HISTORY_BLOCK_PATTERN.search(base)
236
+ if not match:
237
+ return None
238
+
239
+ indent = match.group("indent")
240
+ history_block = _build_history_block(merged, indent=indent)
241
+ return base[: match.start()] + history_block + base[match.end() :]
242
+
243
+
244
+ def resolve_status_conflicts(repo_root: Path) -> list[ResolutionResult]:
245
+ """Auto-resolve conflicts in status files after merge."""
246
+ results: list[ResolutionResult] = []
247
+ conflicted = get_conflicted_files(repo_root)
248
+
249
+ for file_path in conflicted:
250
+ rel_path = str(file_path.relative_to(repo_root))
251
+ if not is_status_file(rel_path):
252
+ results.append(
253
+ ResolutionResult(
254
+ file_path=file_path,
255
+ resolved=False,
256
+ resolution_type="manual_required",
257
+ original_conflicts=1,
258
+ resolved_conflicts=0,
259
+ )
260
+ )
261
+ continue
262
+
263
+ try:
264
+ content = file_path.read_text(encoding="utf-8")
265
+ except Exception:
266
+ results.append(
267
+ ResolutionResult(
268
+ file_path=file_path,
269
+ resolved=False,
270
+ resolution_type="error",
271
+ original_conflicts=1,
272
+ resolved_conflicts=0,
273
+ )
274
+ )
275
+ continue
276
+
277
+ regions = parse_conflict_markers(content)
278
+ if not regions:
279
+ continue
280
+
281
+ resolved_content = content
282
+ resolved_count = 0
283
+ resolution_types: set[str] = set()
284
+
285
+ for region in regions:
286
+ if CHECKBOX_PATTERN.search(region.ours) or CHECKBOX_PATTERN.search(region.theirs):
287
+ resolved_region = resolve_checkbox_conflict(region.ours, region.theirs)
288
+ resolved_region = _preserve_trailing_newline(resolved_region, region.original)
289
+ resolved_content = resolved_content.replace(region.original, resolved_region)
290
+ resolved_count += 1
291
+ resolution_types.add("checkbox")
292
+ continue
293
+
294
+ resolved_region = resolve_history_conflict(region.ours, region.theirs)
295
+ if resolved_region is not None:
296
+ lane_resolved = resolve_lane_conflict(region.ours, region.theirs)
297
+ if lane_resolved is not None:
298
+ lane_value = extract_lane_value(lane_resolved)
299
+ if lane_value:
300
+ resolved_region = replace_lane_value(resolved_region, lane_value)
301
+ resolution_types.add("lane")
302
+ resolution_types.add("history")
303
+ resolved_region = _preserve_trailing_newline(resolved_region, region.original)
304
+ resolved_content = resolved_content.replace(region.original, resolved_region)
305
+ resolved_count += 1
306
+ continue
307
+
308
+ lane_resolved = resolve_lane_conflict(region.ours, region.theirs)
309
+ if lane_resolved is not None:
310
+ lane_resolved = _preserve_trailing_newline(lane_resolved, region.original)
311
+ resolved_content = resolved_content.replace(region.original, lane_resolved)
312
+ resolved_count += 1
313
+ resolution_types.add("lane")
314
+
315
+ resolved_all = resolved_count == len(regions)
316
+ if resolved_count:
317
+ file_path.write_text(resolved_content, encoding="utf-8")
318
+ if resolved_all:
319
+ subprocess.run(
320
+ ["git", "add", str(file_path)],
321
+ cwd=str(repo_root),
322
+ check=False,
323
+ )
324
+
325
+ if not resolution_types:
326
+ resolution_types.add("manual_required")
327
+
328
+ results.append(
329
+ ResolutionResult(
330
+ file_path=file_path,
331
+ resolved=resolved_all,
332
+ resolution_type="mixed" if len(resolution_types) > 1 else next(iter(resolution_types)),
333
+ original_conflicts=len(regions),
334
+ resolved_conflicts=resolved_count,
335
+ )
336
+ )
337
+
338
+ return results
339
+
340
+
341
+ def get_conflicted_files(repo_root: Path) -> list[Path]:
342
+ """Get list of files with merge conflicts."""
343
+ result = subprocess.run(
344
+ ["git", "diff", "--name-only", "--diff-filter=U"],
345
+ cwd=str(repo_root),
346
+ capture_output=True,
347
+ text=True,
348
+ check=False,
349
+ )
350
+ files: list[Path] = []
351
+ for line in result.stdout.strip().split("\n"):
352
+ if line:
353
+ files.append(repo_root / line)
354
+ return files