deviatdd 2.5.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.
- deviatdd-2.5.1.dist-info/METADATA +386 -0
- deviatdd-2.5.1.dist-info/RECORD +124 -0
- deviatdd-2.5.1.dist-info/WHEEL +4 -0
- deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
- deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
- deviate/__init__.py +3 -0
- deviate/cli/__init__.py +824 -0
- deviate/cli/_common.py +155 -0
- deviate/cli/adhoc.py +183 -0
- deviate/cli/constitution.py +113 -0
- deviate/cli/feature.py +94 -0
- deviate/cli/init.py +485 -0
- deviate/cli/inspect.py +208 -0
- deviate/cli/macro.py +937 -0
- deviate/cli/meso.py +1894 -0
- deviate/cli/micro.py +3249 -0
- deviate/cli/review.py +441 -0
- deviate/core/__init__.py +0 -0
- deviate/core/_shared.py +20 -0
- deviate/core/agent.py +505 -0
- deviate/core/cache_discipline.py +65 -0
- deviate/core/commands.py +202 -0
- deviate/core/commit.py +86 -0
- deviate/core/complexity.py +36 -0
- deviate/core/constitution.py +85 -0
- deviate/core/contract.py +17 -0
- deviate/core/convention.py +147 -0
- deviate/core/epic.py +73 -0
- deviate/core/issues.py +46 -0
- deviate/core/prd.py +18 -0
- deviate/core/profile.py +33 -0
- deviate/core/repo.py +47 -0
- deviate/core/run_logger.py +50 -0
- deviate/core/tasks_ledger.py +69 -0
- deviate/core/treesitter/__init__.py +31 -0
- deviate/core/treesitter/analysis.py +457 -0
- deviate/core/treesitter/models.py +35 -0
- deviate/core/treesitter/parser.py +184 -0
- deviate/core/treesitter/queries/bash.scm +7 -0
- deviate/core/treesitter/queries/c_sharp.scm +11 -0
- deviate/core/treesitter/queries/cpp.scm +9 -0
- deviate/core/treesitter/queries/css.scm +4 -0
- deviate/core/treesitter/queries/dockerfile.scm +8 -0
- deviate/core/treesitter/queries/elixir.scm +6 -0
- deviate/core/treesitter/queries/go.scm +9 -0
- deviate/core/treesitter/queries/hcl.scm +3 -0
- deviate/core/treesitter/queries/html.scm +3 -0
- deviate/core/treesitter/queries/javascript.scm +12 -0
- deviate/core/treesitter/queries/json.scm +4 -0
- deviate/core/treesitter/queries/kotlin.scm +8 -0
- deviate/core/treesitter/queries/markdown.scm +4 -0
- deviate/core/treesitter/queries/python.scm +10 -0
- deviate/core/treesitter/queries/rust.scm +12 -0
- deviate/core/treesitter/queries/sql.scm +7 -0
- deviate/core/treesitter/queries/swift.scm +10 -0
- deviate/core/treesitter/queries/toml.scm +3 -0
- deviate/core/treesitter/queries/tsx.scm +14 -0
- deviate/core/treesitter/queries/typescript.scm +14 -0
- deviate/core/treesitter/queries/yaml.scm +4 -0
- deviate/core/validation.py +153 -0
- deviate/core/worktree.py +141 -0
- deviate/main.py +4 -0
- deviate/prompts/__init__.py +0 -0
- deviate/prompts/assembly.py +122 -0
- deviate/prompts/auto/__init__.py +1 -0
- deviate/prompts/auto/execute.md +62 -0
- deviate/prompts/auto/explore.md +103 -0
- deviate/prompts/auto/green.md +137 -0
- deviate/prompts/auto/judge.md +260 -0
- deviate/prompts/auto/plan.md +127 -0
- deviate/prompts/auto/prd.md +108 -0
- deviate/prompts/auto/red.md +156 -0
- deviate/prompts/auto/refactor.md +166 -0
- deviate/prompts/auto/research.md +111 -0
- deviate/prompts/auto/shard.md +90 -0
- deviate/prompts/auto/specify.md +77 -0
- deviate/prompts/auto/tasks.md +191 -0
- deviate/prompts/commands/deviate-adhoc.md +216 -0
- deviate/prompts/commands/deviate-architecture.md +147 -0
- deviate/prompts/commands/deviate-constitution.md +215 -0
- deviate/prompts/commands/deviate-e2e.md +264 -0
- deviate/prompts/commands/deviate-execute.md +223 -0
- deviate/prompts/commands/deviate-explore.md +253 -0
- deviate/prompts/commands/deviate-flows.md +226 -0
- deviate/prompts/commands/deviate-green.md +217 -0
- deviate/prompts/commands/deviate-hotfix.md +188 -0
- deviate/prompts/commands/deviate-init.md +170 -0
- deviate/prompts/commands/deviate-judge.md +193 -0
- deviate/prompts/commands/deviate-merge.md +221 -0
- deviate/prompts/commands/deviate-plan.md +158 -0
- deviate/prompts/commands/deviate-pr.md +144 -0
- deviate/prompts/commands/deviate-prd.md +216 -0
- deviate/prompts/commands/deviate-prune.md +260 -0
- deviate/prompts/commands/deviate-red.md +231 -0
- deviate/prompts/commands/deviate-refactor.md +211 -0
- deviate/prompts/commands/deviate-release.md +123 -0
- deviate/prompts/commands/deviate-research.md +359 -0
- deviate/prompts/commands/deviate-review.md +289 -0
- deviate/prompts/commands/deviate-shard.md +226 -0
- deviate/prompts/commands/deviate-tasks.md +281 -0
- deviate/prompts/commands/deviate-triage.md +141 -0
- deviate/prompts/constitution_seed.md +51 -0
- deviate/prompts/core/core.md +21 -0
- deviate/prompts/core/macro-auto.md +59 -0
- deviate/prompts/core/macro-command.md +54 -0
- deviate/prompts/core/macro-skill.md +54 -0
- deviate/prompts/core/meso-auto.md +63 -0
- deviate/prompts/core/meso-command.md +58 -0
- deviate/prompts/core/meso-skill.md +58 -0
- deviate/prompts/core/micro-auto.md +76 -0
- deviate/prompts/core/micro-command.md +67 -0
- deviate/prompts/core/micro-skill.md +67 -0
- deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
- deviate/prompts/governance/__init__.py +0 -0
- deviate/prompts/governance/agents_seed.md +1 -0
- deviate/prompts/governance/claudemd_seed.md +1 -0
- deviate/prompts/governance/graphite_seed.md +18 -0
- deviate/prompts/governance/libref_seed.md +3 -0
- deviate/state/__init__.py +0 -0
- deviate/state/config.py +257 -0
- deviate/state/ledger.py +407 -0
- deviate/ui/__init__.py +5 -0
- deviate/ui/monitor.py +187 -0
- deviate/ui/render.py +26 -0
deviate/cli/meso.py
ADDED
|
@@ -0,0 +1,1894 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from contextlib import chdir
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from deviate.cli._common import (
|
|
15
|
+
_build_slim_prompt,
|
|
16
|
+
_extract_epic_num,
|
|
17
|
+
_extract_issue_num,
|
|
18
|
+
_handle_missing_dot_dir,
|
|
19
|
+
console,
|
|
20
|
+
with_json_quiet,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from deviate.core.agent import (
|
|
24
|
+
AgentBackend,
|
|
25
|
+
AgentSubprocessError,
|
|
26
|
+
resolve_agent_to_backend,
|
|
27
|
+
)
|
|
28
|
+
from deviate.core._shared import git_env as _git_env
|
|
29
|
+
from deviate.core.commit import commit_artifact
|
|
30
|
+
from deviate.core.convention import format_commit_message
|
|
31
|
+
from deviate.core.constitution import extract_commands
|
|
32
|
+
from deviate.core.issues import claim_issue
|
|
33
|
+
from deviate.core.repo import gather_git_state
|
|
34
|
+
from deviate.core.worktree import (
|
|
35
|
+
branch_exists_on_remote,
|
|
36
|
+
create_worktree,
|
|
37
|
+
remove_worktree,
|
|
38
|
+
)
|
|
39
|
+
from deviate.state.config import (
|
|
40
|
+
AgentConfig,
|
|
41
|
+
SessionState,
|
|
42
|
+
_load_deviate_config_toml,
|
|
43
|
+
resolve_graphite_config,
|
|
44
|
+
resolve_model_for_phase,
|
|
45
|
+
)
|
|
46
|
+
from deviate.core.treesitter import extract_file_structure
|
|
47
|
+
from deviate.state.ledger import (
|
|
48
|
+
IssueRecord,
|
|
49
|
+
TaskRecord,
|
|
50
|
+
append_issue_transition,
|
|
51
|
+
append_task_record,
|
|
52
|
+
resolve_issue_record,
|
|
53
|
+
select_next_unblocked_issue,
|
|
54
|
+
select_unblocked_candidates,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Helpers
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve_dot_deviate() -> Path:
|
|
65
|
+
return Path(".deviate")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_specs_root() -> Path:
|
|
69
|
+
return Path("specs")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_session(phase: str) -> tuple[SessionState, Path]:
|
|
73
|
+
dot_dir = _resolve_dot_deviate()
|
|
74
|
+
if not dot_dir.exists():
|
|
75
|
+
_handle_missing_dot_dir(phase)
|
|
76
|
+
session_path = dot_dir / "session.json"
|
|
77
|
+
session = SessionState.load(session_path)
|
|
78
|
+
return session, session_path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_session_accept(
|
|
82
|
+
*phases: str, force: bool = False
|
|
83
|
+
) -> tuple[SessionState, Path]:
|
|
84
|
+
"""Load session — state is purely descriptive, no phase gating."""
|
|
85
|
+
dot_dir = _resolve_dot_deviate()
|
|
86
|
+
if not dot_dir.exists():
|
|
87
|
+
_handle_missing_dot_dir(phases[0] if phases else "?")
|
|
88
|
+
session_path = dot_dir / "session.json"
|
|
89
|
+
session = SessionState.load(session_path)
|
|
90
|
+
return session, session_path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _save_session(session: SessionState, session_path: Path, phase: str) -> None:
|
|
94
|
+
session.save(session_path)
|
|
95
|
+
console.print(f"[green]{phase}[/] session advanced to {phase} phase")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resolve_bucket_dir(source_file: str) -> str:
|
|
99
|
+
"""Extract the epic bucket slug from a source_file path.
|
|
100
|
+
|
|
101
|
+
Expects ``source_file`` to follow the pattern ``specs/<epic>/issues/<file>.md``.
|
|
102
|
+
Returns the second-to-last path component (the epic directory name).
|
|
103
|
+
"""
|
|
104
|
+
return PurePosixPath(source_file).parent.parent.name
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _source_stem(source_file: str) -> str:
|
|
108
|
+
"""Extract the issue slug (filename stem) from a source_file path."""
|
|
109
|
+
return PurePosixPath(source_file).stem
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_issue_completed(issue_id: str, ledger_path: Path) -> bool:
|
|
113
|
+
record = resolve_issue_record(issue_id, ledger_path)
|
|
114
|
+
if record is None:
|
|
115
|
+
return False
|
|
116
|
+
return record.status == "COMPLETED"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _find_issue_file(issue_id: str) -> Path | None:
|
|
120
|
+
"""Resolve the spec-enriched issue file for *issue_id*.
|
|
121
|
+
|
|
122
|
+
The issue file IS the spec — it contains ``[USER_STORIES_LEDGER]``,
|
|
123
|
+
``[ATDD_ACCEPTANCE_CRITERIA]``, and all other spec sections embedded
|
|
124
|
+
as markdown sections. No separate ``spec.md`` exists.
|
|
125
|
+
"""
|
|
126
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
127
|
+
record = resolve_issue_record(issue_id, ledger_path)
|
|
128
|
+
if record is None or not record.source_file:
|
|
129
|
+
return None
|
|
130
|
+
issue_path = Path(record.source_file)
|
|
131
|
+
if issue_path.exists():
|
|
132
|
+
return issue_path
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read_spec_content(spec_path: str) -> str | None:
|
|
137
|
+
try:
|
|
138
|
+
pp = Path(spec_path)
|
|
139
|
+
if pp.is_file():
|
|
140
|
+
return pp.read_text(encoding="utf-8")
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parse_workstation_paths(spec_content: str) -> list[str]:
|
|
147
|
+
"""Extract workstation file paths from an issue spec's System Topology Mapping section.
|
|
148
|
+
|
|
149
|
+
Looks for ``## System Topology Mapping`` → ``- **Primary Architectural Workstations**:``
|
|
150
|
+
and extracts backtick-quoted paths from the subsequent bullet list.
|
|
151
|
+
"""
|
|
152
|
+
paths: list[str] = []
|
|
153
|
+
in_topology = False
|
|
154
|
+
in_workstations = False
|
|
155
|
+
|
|
156
|
+
for line in spec_content.splitlines():
|
|
157
|
+
if (
|
|
158
|
+
line.startswith("## ")
|
|
159
|
+
and in_topology
|
|
160
|
+
and "System Topology Mapping" not in line
|
|
161
|
+
):
|
|
162
|
+
break
|
|
163
|
+
if "## System Topology Mapping" in line:
|
|
164
|
+
in_topology = True
|
|
165
|
+
continue
|
|
166
|
+
if not in_topology:
|
|
167
|
+
continue
|
|
168
|
+
stripped = line.strip()
|
|
169
|
+
if stripped.startswith("- **Primary Architectural Workstations**"):
|
|
170
|
+
in_workstations = True
|
|
171
|
+
continue
|
|
172
|
+
if in_workstations:
|
|
173
|
+
if stripped.startswith("- ") and "`" in stripped:
|
|
174
|
+
m = re.search(r"`([^`]+)`", stripped)
|
|
175
|
+
if m:
|
|
176
|
+
paths.append(m.group(1))
|
|
177
|
+
elif not stripped.startswith("- ") and not stripped.startswith(" "):
|
|
178
|
+
in_workstations = False
|
|
179
|
+
return paths
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _extract_workstation_file_structures(
|
|
183
|
+
spec_path: str, repo_root: Path
|
|
184
|
+
) -> tuple[dict[str, dict], list[str]]:
|
|
185
|
+
"""Extract file structure data for workstation paths in the spec's topology mapping.
|
|
186
|
+
|
|
187
|
+
Reads the spec content at *spec_path*, parses workstation file paths from
|
|
188
|
+
the ``System Topology Mapping`` section, and calls ``extract_file_structure()``
|
|
189
|
+
on each existing file.
|
|
190
|
+
|
|
191
|
+
Returns ``(structures, workstation_paths)`` where:
|
|
192
|
+
- ``structures`` is a dict keyed by relative workstation path (only existing files)
|
|
193
|
+
- ``workstation_paths`` is the full list of paths parsed from the spec
|
|
194
|
+
"""
|
|
195
|
+
structures: dict[str, dict] = {}
|
|
196
|
+
try:
|
|
197
|
+
spec_content = Path(spec_path).read_text(encoding="utf-8")
|
|
198
|
+
except OSError:
|
|
199
|
+
return structures, []
|
|
200
|
+
workstation_paths = _parse_workstation_paths(spec_content)
|
|
201
|
+
for wpath in workstation_paths:
|
|
202
|
+
full_path = repo_root / wpath
|
|
203
|
+
if full_path.is_file():
|
|
204
|
+
fs = extract_file_structure(str(full_path))
|
|
205
|
+
if fs.language:
|
|
206
|
+
structures[wpath] = {
|
|
207
|
+
"language": fs.language,
|
|
208
|
+
"symbols": fs.symbols,
|
|
209
|
+
"imports": fs.imports,
|
|
210
|
+
}
|
|
211
|
+
return structures, workstation_paths
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _format_file_structure_markdown(structures: dict[str, dict]) -> str:
|
|
215
|
+
"""Format extracted file structures as structured markdown.
|
|
216
|
+
|
|
217
|
+
Each entry is rendered as::
|
|
218
|
+
|
|
219
|
+
### `path` (Language: <lang>)
|
|
220
|
+
- **Symbols**:
|
|
221
|
+
- kind `name`
|
|
222
|
+
- **Imports**:
|
|
223
|
+
- `import_path`
|
|
224
|
+
"""
|
|
225
|
+
lines: list[str] = []
|
|
226
|
+
for wpath, info in structures.items():
|
|
227
|
+
lang = info.get("language", "?")
|
|
228
|
+
lines.append(f"### `{wpath}` (Language: {lang})")
|
|
229
|
+
symbols = info.get("symbols", [])
|
|
230
|
+
if symbols:
|
|
231
|
+
lines.append("- **Symbols**:")
|
|
232
|
+
for s in symbols:
|
|
233
|
+
kind = s.get("kind", "?")
|
|
234
|
+
name = s.get("name", "?")
|
|
235
|
+
lines.append(f" - {kind} `{name}`")
|
|
236
|
+
imports = info.get("imports", [])
|
|
237
|
+
if imports:
|
|
238
|
+
lines.append("- **Imports**:")
|
|
239
|
+
for imp in imports:
|
|
240
|
+
lines.append(f" - `{imp}`")
|
|
241
|
+
if not symbols and not imports:
|
|
242
|
+
lines.append(" *(no symbols or imports extracted)*")
|
|
243
|
+
return "\n".join(lines)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _resolve_constitution_commands(
|
|
247
|
+
repo_root: Path,
|
|
248
|
+
) -> tuple[str, str, str]:
|
|
249
|
+
const_path = repo_root / "specs" / "constitution.md"
|
|
250
|
+
constitution_path = str(const_path) if const_path.exists() else ""
|
|
251
|
+
test_command = ""
|
|
252
|
+
lint_command = ""
|
|
253
|
+
if const_path.exists():
|
|
254
|
+
cmds = extract_commands(const_path)
|
|
255
|
+
test_command = cmds.get("test_command", "")
|
|
256
|
+
lint_command = cmds.get("lint_command", "")
|
|
257
|
+
return constitution_path, test_command, lint_command
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
TYPE_MAP: dict[str, str] = {
|
|
261
|
+
"feature": "feat",
|
|
262
|
+
"bug": "fix",
|
|
263
|
+
"chore": "chore",
|
|
264
|
+
"refactor": "refactor",
|
|
265
|
+
"docs": "docs",
|
|
266
|
+
"test": "test",
|
|
267
|
+
"perf": "perf",
|
|
268
|
+
"style": "style",
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _pr_title(issue_id: str, record_title: str, record_type: str = "feature") -> str:
|
|
273
|
+
"""Build a conventional-commit PR title suitable for squash-merge.
|
|
274
|
+
|
|
275
|
+
Takes the raw issue title (e.g. \"[FR-001] CLI Initialization\") and strips
|
|
276
|
+
any bracketed prefix like [FR-NNN] so the final title reads as a clean
|
|
277
|
+
conventional commit subject.
|
|
278
|
+
"""
|
|
279
|
+
commit_type = TYPE_MAP.get(record_type, "feat")
|
|
280
|
+
desc = re.sub(r"^\[[A-Z]+-\d+\]\s*", "", record_title).strip()
|
|
281
|
+
return f"{commit_type}({issue_id}): {desc}"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _derive_pr_metadata(
|
|
285
|
+
branch_name: str, issue_id: str, record_title: str, record_type: str = "feature"
|
|
286
|
+
) -> tuple[str, str, str]:
|
|
287
|
+
pr_title = _pr_title(issue_id, record_title, record_type)
|
|
288
|
+
pr_body = ""
|
|
289
|
+
base_branch = "main"
|
|
290
|
+
return pr_title, pr_body, base_branch
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _resolve_and_validate_issue(issue_id: str, phase: str) -> IssueRecord:
|
|
294
|
+
dot_dir = _resolve_dot_deviate()
|
|
295
|
+
if not dot_dir.exists():
|
|
296
|
+
_handle_missing_dot_dir(phase)
|
|
297
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
298
|
+
record = resolve_issue_record(issue_id, ledger_path)
|
|
299
|
+
if record is None:
|
|
300
|
+
console.print(f"[red]INVALID_ISSUE_ID[/] {issue_id}")
|
|
301
|
+
raise typer.Exit(code=1)
|
|
302
|
+
return record
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _setup_mise(worktree_path: Path | None = None) -> None:
|
|
306
|
+
"""Run mise trust && install && setup if mise is on PATH."""
|
|
307
|
+
repo = worktree_path or Path.cwd()
|
|
308
|
+
try:
|
|
309
|
+
subprocess.run(["mise", "--version"], capture_output=True, check=True)
|
|
310
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
311
|
+
console.print("[yellow]MISE_WARN[/] mise not found on PATH, skipping setup")
|
|
312
|
+
return
|
|
313
|
+
try:
|
|
314
|
+
subprocess.run(["mise", "trust"], cwd=repo, check=True, capture_output=True)
|
|
315
|
+
console.print("[green]MISE[/] trust applied")
|
|
316
|
+
subprocess.run(["mise", "install"], cwd=repo, check=True, capture_output=True)
|
|
317
|
+
console.print("[green]MISE[/] install complete")
|
|
318
|
+
subprocess.run(
|
|
319
|
+
["mise", "run", "setup"], cwd=repo, check=True, capture_output=True
|
|
320
|
+
)
|
|
321
|
+
console.print("[green]MISE[/] setup complete")
|
|
322
|
+
except subprocess.CalledProcessError as e:
|
|
323
|
+
console.print(f"[yellow]MISE_WARN[/] setup step failed — {e}")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
_AGENT_DIRS = (".claude", ".opencode", ".factory")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _sync_agent_dirs_to_worktree(repo_root: Path, worktree_path: Path) -> None:
|
|
330
|
+
"""Copy agent skill directories from repo root to worktree.
|
|
331
|
+
|
|
332
|
+
This ensures worktrees have the same skills (.claude/, .opencode/,
|
|
333
|
+
.factory/) as the main repository so deviate commands work inside
|
|
334
|
+
the worktree without re-running ``deviate setup``.
|
|
335
|
+
"""
|
|
336
|
+
for agent_dir in _AGENT_DIRS:
|
|
337
|
+
src = repo_root / agent_dir
|
|
338
|
+
dst = worktree_path / agent_dir
|
|
339
|
+
if src.exists():
|
|
340
|
+
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
341
|
+
console.print(f"[green]SYNC[/] {agent_dir}/ → worktree")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
# Specify — legacy positional-argument API
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _specify_legacy(issue_id: str) -> None:
|
|
350
|
+
console.print(
|
|
351
|
+
"[yellow]DEPRECATED[/] 'deviate specify' is deprecated. "
|
|
352
|
+
"The SPECIFY phase has been merged into 'deviate shard'. "
|
|
353
|
+
"Use 'deviate shard' instead — shard now produces spec-enriched "
|
|
354
|
+
"issue files directly."
|
|
355
|
+
)
|
|
356
|
+
raise typer.Exit(code=0)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# Pre-flight helpers for _specify_pre
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _read_issue_body(source_file: str, repo_root: Path) -> str:
|
|
365
|
+
path = repo_root / source_file
|
|
366
|
+
if not path.exists():
|
|
367
|
+
return ""
|
|
368
|
+
return path.read_text(encoding="utf-8")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _validate_prd_traceability(issue_body: str, prd_path: Path) -> tuple[str, str]:
|
|
372
|
+
"""Validate that FR references in issue_body exist in the PRD.
|
|
373
|
+
Returns (status, details)."""
|
|
374
|
+
if not prd_path.exists():
|
|
375
|
+
return ("FAIL", "PRD not found — traceability cannot be validated")
|
|
376
|
+
prd_frs = set()
|
|
377
|
+
try:
|
|
378
|
+
prd_text = prd_path.read_text(encoding="utf-8")
|
|
379
|
+
for m in re.finditer(r"FR-\d+(?:[_-]\d+)?", prd_text):
|
|
380
|
+
prd_frs.add(m.group(0))
|
|
381
|
+
except Exception:
|
|
382
|
+
return ("FAIL", "PRD unreadable")
|
|
383
|
+
issue_frs = set()
|
|
384
|
+
for m in re.finditer(r"FR-\d+(?:[_-]\d+)?", issue_body):
|
|
385
|
+
issue_frs.add(m.group(0))
|
|
386
|
+
if not issue_frs:
|
|
387
|
+
return ("WARN", "No FR references found in issue body")
|
|
388
|
+
missing = issue_frs - prd_frs
|
|
389
|
+
if missing:
|
|
390
|
+
return ("FAIL", f"Missing in PRD: {', '.join(sorted(missing))}")
|
|
391
|
+
return ("PASS", "All FRs present in PRD")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _emit_contract(
|
|
395
|
+
status: str,
|
|
396
|
+
phase: str,
|
|
397
|
+
issue_id: str,
|
|
398
|
+
issue_title: str,
|
|
399
|
+
issue_body: str,
|
|
400
|
+
epic_slug: str,
|
|
401
|
+
issue_slug: str,
|
|
402
|
+
branch_name: str,
|
|
403
|
+
worktree_path: str,
|
|
404
|
+
spec_target: str,
|
|
405
|
+
spec_target_abs: str,
|
|
406
|
+
prd_requirements: list[str],
|
|
407
|
+
traceability_status: str,
|
|
408
|
+
traceability_details: str,
|
|
409
|
+
constitution_path: str,
|
|
410
|
+
constitution_test_command: str,
|
|
411
|
+
constitution_lint_command: str,
|
|
412
|
+
repo_root: str,
|
|
413
|
+
git_branch: str,
|
|
414
|
+
timestamp: str,
|
|
415
|
+
) -> str:
|
|
416
|
+
contract = {
|
|
417
|
+
"status": status,
|
|
418
|
+
"phase": phase,
|
|
419
|
+
"issue_id": issue_id,
|
|
420
|
+
"issue_title": issue_title,
|
|
421
|
+
"issue_body": issue_body,
|
|
422
|
+
"epic_slug": epic_slug,
|
|
423
|
+
"issue_slug": issue_slug,
|
|
424
|
+
"branch_name": branch_name,
|
|
425
|
+
"worktree_full": worktree_path,
|
|
426
|
+
"spec_target": spec_target,
|
|
427
|
+
"spec_target_abs": spec_target_abs,
|
|
428
|
+
"prd_requirements": prd_requirements,
|
|
429
|
+
"traceability_status": traceability_status,
|
|
430
|
+
"traceability_details": traceability_details,
|
|
431
|
+
"constitution_path": constitution_path,
|
|
432
|
+
"constitution_test_command": constitution_test_command,
|
|
433
|
+
"constitution_lint_command": constitution_lint_command,
|
|
434
|
+
"repo_root": repo_root,
|
|
435
|
+
"git_branch": git_branch,
|
|
436
|
+
"timestamp": timestamp,
|
|
437
|
+
}
|
|
438
|
+
return json.dumps(contract, indent=2)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# Helpers
|
|
443
|
+
# ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _is_linked_worktree(cwd: Path | None = None) -> bool:
|
|
447
|
+
"""True if *cwd* is inside a linked (non-main) git worktree.
|
|
448
|
+
|
|
449
|
+
Distinguishes linked worktrees (``.git`` is a file containing
|
|
450
|
+
``/worktrees/``) from git submodules (``.git`` is a file containing
|
|
451
|
+
``/modules/``) and main repos (``.git`` is a directory).
|
|
452
|
+
"""
|
|
453
|
+
cwd = cwd or Path.cwd()
|
|
454
|
+
git_path = cwd / ".git"
|
|
455
|
+
if not git_path.exists():
|
|
456
|
+
return False
|
|
457
|
+
if git_path.is_dir():
|
|
458
|
+
return False # Main worktree — .git is a directory
|
|
459
|
+
try:
|
|
460
|
+
content = git_path.read_text(encoding="utf-8").strip()
|
|
461
|
+
return "/worktrees/" in content
|
|
462
|
+
except Exception:
|
|
463
|
+
return False
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# ---------------------------------------------------------------------------
|
|
467
|
+
# Specify — new pre/post subcommand behavior
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _try_claim_issue(
|
|
472
|
+
issue: IssueRecord,
|
|
473
|
+
repo_root: Path,
|
|
474
|
+
ledger_path: Path,
|
|
475
|
+
remote: str | None = None,
|
|
476
|
+
force: bool = False,
|
|
477
|
+
dry_run: bool = False,
|
|
478
|
+
) -> dict | None:
|
|
479
|
+
"""Attempt to claim a single issue end-to-end.
|
|
480
|
+
|
|
481
|
+
Returns a metadata dict on success, ``None`` if the issue cannot be
|
|
482
|
+
claimed (branch on remote, worktree error, or push race).
|
|
483
|
+
"""
|
|
484
|
+
resolved_id = issue.issue_id
|
|
485
|
+
epic_slug = _resolve_bucket_dir(issue.source_file)
|
|
486
|
+
issue_slug = _source_stem(issue.source_file)
|
|
487
|
+
branch = f"feat/{epic_slug}/{issue_slug}"
|
|
488
|
+
spec_target_rel = f"specs/{epic_slug}/{issue_slug}/spec.md"
|
|
489
|
+
|
|
490
|
+
console.print(f"[green]EPIC[/] {epic_slug}")
|
|
491
|
+
console.print(f"[green]SLUG[/] {issue_slug}")
|
|
492
|
+
console.print(f"[green]BRANCH[/] {branch}")
|
|
493
|
+
|
|
494
|
+
# ── Remote branch check (non-dry-run only) ──────────────────────────
|
|
495
|
+
if not dry_run and remote is not None:
|
|
496
|
+
if branch_exists_on_remote(branch, repo=repo_root, remote=remote):
|
|
497
|
+
console.print(
|
|
498
|
+
f"[yellow]BRANCH_ON_REMOTE[/] {branch} — issue likely "
|
|
499
|
+
f"already claimed elsewhere"
|
|
500
|
+
)
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
# ── Dry-run / create worktree ──────────────────────────────────────
|
|
504
|
+
worktree_path: str
|
|
505
|
+
if dry_run:
|
|
506
|
+
console.print("[yellow]DRY_RUN[/] skipping worktree creation and claim")
|
|
507
|
+
worktree_path = str(repo_root)
|
|
508
|
+
else:
|
|
509
|
+
wt_path = repo_root / ".worktrees" / branch
|
|
510
|
+
try:
|
|
511
|
+
created = create_worktree(branch, wt_path, repo=repo_root)
|
|
512
|
+
console.print(
|
|
513
|
+
f"[green]WORKTREE[/] "
|
|
514
|
+
f"{'detected at' if created != wt_path else 'created at'} "
|
|
515
|
+
f"{created}"
|
|
516
|
+
)
|
|
517
|
+
worktree_path = str(created)
|
|
518
|
+
except RuntimeError as e:
|
|
519
|
+
console.print(f"[yellow]WORKTREE_ERROR[/] {e}")
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
# ── Mise setup ─────────────────────────────────────────────────
|
|
523
|
+
_setup_mise(Path(worktree_path))
|
|
524
|
+
|
|
525
|
+
# ── Sync agent skill directories to worktree ──────────────────
|
|
526
|
+
_sync_agent_dirs_to_worktree(repo_root, Path(worktree_path))
|
|
527
|
+
|
|
528
|
+
# ── Claim issue (write directly to worktree ledger) ────────────
|
|
529
|
+
wt_ledger_path = Path(worktree_path) / "specs" / "issues.jsonl"
|
|
530
|
+
claimed = claim_issue(resolved_id, wt_ledger_path)
|
|
531
|
+
if claimed:
|
|
532
|
+
console.print(f"[green]CLAIMED[/] {resolved_id} → SPECIFIED")
|
|
533
|
+
else:
|
|
534
|
+
console.print(
|
|
535
|
+
f"[yellow]CLAIM_SKIP[/] {resolved_id} already claimed or skipped"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# ── Create spec target directory in worktree ───────────────────
|
|
539
|
+
wt_spec_dir = Path(worktree_path) / Path(spec_target_rel).parent
|
|
540
|
+
wt_spec_dir.mkdir(parents=True, exist_ok=True)
|
|
541
|
+
|
|
542
|
+
# ── Detect remote if not specified ────────────────────────────
|
|
543
|
+
if remote is None:
|
|
544
|
+
try:
|
|
545
|
+
r = subprocess.run(
|
|
546
|
+
["git", "remote", "get-url", "origin"],
|
|
547
|
+
cwd=repo_root,
|
|
548
|
+
capture_output=True,
|
|
549
|
+
text=True,
|
|
550
|
+
env=_git_env(),
|
|
551
|
+
)
|
|
552
|
+
if r.returncode == 0:
|
|
553
|
+
remote = "origin"
|
|
554
|
+
except Exception:
|
|
555
|
+
pass
|
|
556
|
+
|
|
557
|
+
# ── Commit and push claim ──────────────────────────────────────
|
|
558
|
+
if claimed:
|
|
559
|
+
try:
|
|
560
|
+
subprocess.run(
|
|
561
|
+
["git", "add", "specs/issues.jsonl"],
|
|
562
|
+
cwd=worktree_path,
|
|
563
|
+
env=_git_env(),
|
|
564
|
+
check=True,
|
|
565
|
+
capture_output=True,
|
|
566
|
+
)
|
|
567
|
+
epic_num = _extract_epic_num(epic_slug)
|
|
568
|
+
issue_num = _extract_issue_num(resolved_id)
|
|
569
|
+
commit_msg = format_commit_message(
|
|
570
|
+
f"chore({epic_num}-{issue_num}): claim {resolved_id}",
|
|
571
|
+
Path(worktree_path),
|
|
572
|
+
)
|
|
573
|
+
# --no-verify: claim only touches specs/issues.jsonl (not .py),
|
|
574
|
+
# so hooks are no-ops; bypass avoids worktree hook config issues.
|
|
575
|
+
subprocess.run(
|
|
576
|
+
[
|
|
577
|
+
"git",
|
|
578
|
+
"commit",
|
|
579
|
+
"--no-verify",
|
|
580
|
+
"-m",
|
|
581
|
+
commit_msg,
|
|
582
|
+
],
|
|
583
|
+
cwd=worktree_path,
|
|
584
|
+
env=_git_env(),
|
|
585
|
+
check=True,
|
|
586
|
+
capture_output=True,
|
|
587
|
+
)
|
|
588
|
+
except subprocess.CalledProcessError:
|
|
589
|
+
console.print("[yellow]COMMIT_CLAIM_SKIP[/] could not commit claim")
|
|
590
|
+
|
|
591
|
+
try:
|
|
592
|
+
# --no-verify: pre-push hook checks only .py files; claim push
|
|
593
|
+
# has none, so bypass avoids unnecessary hook execution in worktree.
|
|
594
|
+
subprocess.run(
|
|
595
|
+
["git", "push", "--no-verify", "-u", remote, branch],
|
|
596
|
+
cwd=worktree_path,
|
|
597
|
+
env=_git_env(),
|
|
598
|
+
check=True,
|
|
599
|
+
capture_output=True,
|
|
600
|
+
)
|
|
601
|
+
console.print(f"[green]PUSHED[/] {branch} pushed to {remote}")
|
|
602
|
+
except subprocess.CalledProcessError:
|
|
603
|
+
if force:
|
|
604
|
+
console.print("[yellow]PUSH_FAILED[/] continuing (--force)")
|
|
605
|
+
else:
|
|
606
|
+
console.print(
|
|
607
|
+
f"[yellow]PUSH_FAILED[/] {branch} — race or remote error"
|
|
608
|
+
)
|
|
609
|
+
remove_worktree(branch, Path(worktree_path), repo=repo_root)
|
|
610
|
+
return None
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
"resolved_id": resolved_id,
|
|
614
|
+
"issue": issue,
|
|
615
|
+
"epic_slug": epic_slug,
|
|
616
|
+
"issue_slug": issue_slug,
|
|
617
|
+
"branch": branch,
|
|
618
|
+
"spec_target_rel": spec_target_rel,
|
|
619
|
+
"worktree_path": worktree_path,
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@with_json_quiet
|
|
624
|
+
def _specify_pre(
|
|
625
|
+
issue_id: str | None = None,
|
|
626
|
+
force: bool = False,
|
|
627
|
+
dry_run: bool = False,
|
|
628
|
+
) -> dict | None:
|
|
629
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
630
|
+
if issue_id is None:
|
|
631
|
+
console.print("[red]ISSUE_ID_REQUIRED[/] specify pre requires --issue <id>")
|
|
632
|
+
raise typer.Exit(code=1)
|
|
633
|
+
record = resolve_issue_record(issue_id, ledger_path)
|
|
634
|
+
if record is None:
|
|
635
|
+
console.print(f"[red]ISSUE_NOT_FOUND[/] {issue_id}")
|
|
636
|
+
raise typer.Exit(code=1)
|
|
637
|
+
result = _try_claim_issue(
|
|
638
|
+
record,
|
|
639
|
+
repo_root=Path.cwd(),
|
|
640
|
+
ledger_path=ledger_path,
|
|
641
|
+
force=force,
|
|
642
|
+
dry_run=dry_run,
|
|
643
|
+
)
|
|
644
|
+
if result is None:
|
|
645
|
+
console.print(f"[red]CLAIM_FAILED[/] could not claim {issue_id}")
|
|
646
|
+
raise typer.Exit(code=1)
|
|
647
|
+
console.print(f"[green]WORKTREE[/] {result['worktree_path']}")
|
|
648
|
+
return result
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _specify_post(force: bool = False) -> None:
|
|
652
|
+
console.print(
|
|
653
|
+
"[yellow]SETUP_NOOP[/] specify post is not needed — setup is a single pre step"
|
|
654
|
+
)
|
|
655
|
+
raise typer.Exit(code=0)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
# ---------------------------------------------------------------------------
|
|
659
|
+
# Plan — pre / post subcommand behavior
|
|
660
|
+
# ---------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _discover_unclaimed() -> str:
|
|
664
|
+
"""Return the next unblocked BACKLOG issue ID, or halt."""
|
|
665
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
666
|
+
if not ledger_path.exists():
|
|
667
|
+
console.print("[red]NO_LEDGER[/] specs/issues.jsonl not found")
|
|
668
|
+
raise typer.Exit(code=1)
|
|
669
|
+
issue = select_next_unblocked_issue(ledger_path)
|
|
670
|
+
if issue is None:
|
|
671
|
+
console.print("[red]NO_UNBLOCKED_ISSUES[/] no BACKLOG issue available")
|
|
672
|
+
raise typer.Exit(code=1)
|
|
673
|
+
return issue.issue_id
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _claim_and_setup(issue_id: str, force: bool, dry_run: bool) -> Path:
|
|
677
|
+
"""Claim *issue_id* via ``_specify_pre``, advance session to PLAN,
|
|
678
|
+
sync ``.deviate/`` to the new worktree.
|
|
679
|
+
|
|
680
|
+
Returns the worktree path.
|
|
681
|
+
"""
|
|
682
|
+
dot_dir = _resolve_dot_deviate()
|
|
683
|
+
setup_result = _specify_pre(issue_id=issue_id, force=force, dry_run=dry_run)
|
|
684
|
+
if setup_result is None:
|
|
685
|
+
raise typer.Exit(code=1)
|
|
686
|
+
|
|
687
|
+
if not dry_run:
|
|
688
|
+
session_path = dot_dir / "session.json"
|
|
689
|
+
session = SessionState.load(session_path)
|
|
690
|
+
session = session.force_transition_to("PLAN")
|
|
691
|
+
session.active_issue_id = issue_id
|
|
692
|
+
session.save(session_path)
|
|
693
|
+
|
|
694
|
+
wt_path = Path(setup_result["worktree_path"])
|
|
695
|
+
if dot_dir.exists():
|
|
696
|
+
shutil.copytree(str(dot_dir), str(wt_path / ".deviate"), dirs_exist_ok=True)
|
|
697
|
+
|
|
698
|
+
console.print(f"[green]WORKTREE[/] setup at {wt_path}")
|
|
699
|
+
console.print("[green]SESSION[/] advanced to PLAN")
|
|
700
|
+
|
|
701
|
+
return Path(setup_result["worktree_path"])
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
@with_json_quiet
|
|
705
|
+
def _plan_pre(
|
|
706
|
+
issue_id: str | None = None,
|
|
707
|
+
force: bool = False,
|
|
708
|
+
dry_run: bool = False,
|
|
709
|
+
) -> None:
|
|
710
|
+
"""Emit a plan-pre contract.
|
|
711
|
+
|
|
712
|
+
*Not in a linked worktree* — auto-claim + setup:
|
|
713
|
+
- ``issue_id`` given → claim that specific issue.
|
|
714
|
+
- No ``issue_id`` → discover next unblocked BACKLOG issue.
|
|
715
|
+
|
|
716
|
+
*Inside a linked worktree* — emit the JSON contract for the agent.
|
|
717
|
+
"""
|
|
718
|
+
# ── Auto-claim + setup (not in linked worktree) ────────────────────
|
|
719
|
+
if not _is_linked_worktree():
|
|
720
|
+
rid = issue_id if issue_id is not None else _discover_unclaimed()
|
|
721
|
+
_claim_and_setup(rid, force, dry_run)
|
|
722
|
+
raise typer.Exit(code=0)
|
|
723
|
+
|
|
724
|
+
# ── Contract mode (inside worktree or from _meso_run) ──────────────
|
|
725
|
+
session, _ = _load_session_accept("SPECIFY", "PLAN", force=force)
|
|
726
|
+
resolved_issue_id = issue_id or session.active_issue_id or ""
|
|
727
|
+
if not resolved_issue_id:
|
|
728
|
+
console.print(
|
|
729
|
+
"[red]NO_ACTIVE_ISSUE[/] provide --issue or run from a worktree "
|
|
730
|
+
"with active_issue_id in session"
|
|
731
|
+
)
|
|
732
|
+
raise typer.Exit(code=1)
|
|
733
|
+
|
|
734
|
+
repo_root = Path.cwd()
|
|
735
|
+
worktree_full = str(repo_root)
|
|
736
|
+
branch_name = ""
|
|
737
|
+
try:
|
|
738
|
+
r = subprocess.run(
|
|
739
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
740
|
+
cwd=repo_root,
|
|
741
|
+
capture_output=True,
|
|
742
|
+
text=True,
|
|
743
|
+
check=True,
|
|
744
|
+
env=_git_env(),
|
|
745
|
+
)
|
|
746
|
+
branch_name = r.stdout.strip()
|
|
747
|
+
except Exception:
|
|
748
|
+
pass
|
|
749
|
+
console.print(f"[green]WORKTREE[/] {worktree_full} [{branch_name}]")
|
|
750
|
+
|
|
751
|
+
spec_path: str = ""
|
|
752
|
+
status: str = "READY"
|
|
753
|
+
if resolved_issue_id:
|
|
754
|
+
found = _find_issue_file(resolved_issue_id)
|
|
755
|
+
if found is None:
|
|
756
|
+
status = "ISSUE_NOT_FOUND"
|
|
757
|
+
console.print(
|
|
758
|
+
f"[red]ISSUE_NOT_FOUND[/] issue file not found for {resolved_issue_id}"
|
|
759
|
+
)
|
|
760
|
+
else:
|
|
761
|
+
spec_path = str(found)
|
|
762
|
+
console.print(
|
|
763
|
+
f"[green]SPEC_DISCOVERED[/] {spec_path} (issue file IS the spec)"
|
|
764
|
+
)
|
|
765
|
+
else:
|
|
766
|
+
status = "ISSUE_NOT_FOUND"
|
|
767
|
+
console.print("[red]NO_ACTIVE_ISSUE[/]")
|
|
768
|
+
|
|
769
|
+
plan_target: str = ""
|
|
770
|
+
if resolved_issue_id:
|
|
771
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
772
|
+
record = resolve_issue_record(resolved_issue_id, ledger_path)
|
|
773
|
+
if record is not None:
|
|
774
|
+
bucket = _resolve_bucket_dir(record.source_file)
|
|
775
|
+
slug = _source_stem(record.source_file)
|
|
776
|
+
plan_target = str(_resolve_specs_root() / bucket / slug / "plan.md")
|
|
777
|
+
|
|
778
|
+
(
|
|
779
|
+
constitution_path,
|
|
780
|
+
constitution_test_command,
|
|
781
|
+
constitution_lint_command,
|
|
782
|
+
) = _resolve_constitution_commands(repo_root)
|
|
783
|
+
|
|
784
|
+
# ── File structure extraction from spec workstation mapping ──────
|
|
785
|
+
file_structure: dict[str, dict] = {}
|
|
786
|
+
workstation_paths: list[str] = []
|
|
787
|
+
if spec_path:
|
|
788
|
+
try:
|
|
789
|
+
file_structure, workstation_paths = _extract_workstation_file_structures(
|
|
790
|
+
spec_path, repo_root
|
|
791
|
+
)
|
|
792
|
+
except Exception as exc:
|
|
793
|
+
logger.warning("Failed to extract file structure: %s", exc)
|
|
794
|
+
|
|
795
|
+
if dry_run:
|
|
796
|
+
console.print("[yellow]DRY_RUN[/] skipping side effects")
|
|
797
|
+
|
|
798
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
799
|
+
contract: dict[str, object] = {
|
|
800
|
+
"issue_id": resolved_issue_id,
|
|
801
|
+
"spec_path": spec_path,
|
|
802
|
+
"plan_target": plan_target,
|
|
803
|
+
"worktree_full": worktree_full,
|
|
804
|
+
"branch_name": branch_name,
|
|
805
|
+
"constitution_path": constitution_path,
|
|
806
|
+
"constitution_test_command": constitution_test_command,
|
|
807
|
+
"constitution_lint_command": constitution_lint_command,
|
|
808
|
+
"timestamp": timestamp,
|
|
809
|
+
"status": status,
|
|
810
|
+
"phase": "plan_pre",
|
|
811
|
+
"force": force,
|
|
812
|
+
"dry_run": dry_run,
|
|
813
|
+
}
|
|
814
|
+
if workstation_paths:
|
|
815
|
+
contract["file_structure"] = file_structure
|
|
816
|
+
print(json.dumps(contract, indent=2))
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _plan_post(force: bool = False, issue_id: str | None = None) -> None:
|
|
820
|
+
"""Validate plan.md, commit it, and advance session to TASKS."""
|
|
821
|
+
session, session_path = _load_session_accept("PLAN", force=force)
|
|
822
|
+
resolved_issue_id = issue_id or session.active_issue_id
|
|
823
|
+
if not resolved_issue_id:
|
|
824
|
+
console.print("[red]NO_ACTIVE_ISSUE[/] session has no active_issue_id")
|
|
825
|
+
raise typer.Exit(code=1)
|
|
826
|
+
|
|
827
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
828
|
+
record = resolve_issue_record(resolved_issue_id, ledger_path)
|
|
829
|
+
if record is None:
|
|
830
|
+
console.print(f"[red]ISSUE_NOT_FOUND[/] {resolved_issue_id}")
|
|
831
|
+
raise typer.Exit(code=1)
|
|
832
|
+
|
|
833
|
+
bucket = _resolve_bucket_dir(record.source_file)
|
|
834
|
+
slug = _source_stem(record.source_file)
|
|
835
|
+
plan_md = _resolve_specs_root() / bucket / slug / "plan.md"
|
|
836
|
+
if not plan_md.exists():
|
|
837
|
+
console.print(f"[red]PLAN_NOT_FOUND[/] {plan_md}")
|
|
838
|
+
raise typer.Exit(code=1)
|
|
839
|
+
content = plan_md.read_text(encoding="utf-8").strip()
|
|
840
|
+
if not content and not force:
|
|
841
|
+
console.print("[red]PLAN_EMPTY[/] plan.md is empty")
|
|
842
|
+
raise typer.Exit(code=1)
|
|
843
|
+
|
|
844
|
+
epic_num = _extract_epic_num(bucket)
|
|
845
|
+
issue_num = _extract_issue_num(resolved_issue_id)
|
|
846
|
+
try:
|
|
847
|
+
sha = commit_artifact(
|
|
848
|
+
plan_md,
|
|
849
|
+
format_commit_message(
|
|
850
|
+
f"docs({epic_num}-{issue_num}): create plan.md", Path.cwd()
|
|
851
|
+
),
|
|
852
|
+
repo=Path.cwd(),
|
|
853
|
+
no_verify=True,
|
|
854
|
+
)
|
|
855
|
+
if sha is None:
|
|
856
|
+
console.print("[yellow]COMMIT_SKIP[/] plan.md — no changes to stage")
|
|
857
|
+
else:
|
|
858
|
+
console.print(f"[green]COMMITTED[/] plan.md at {sha[:8]}")
|
|
859
|
+
except Exception as e:
|
|
860
|
+
console.print(f"[red]COMMIT_FAILED[/] {e}")
|
|
861
|
+
raise typer.Exit(code=1)
|
|
862
|
+
|
|
863
|
+
session = session.transition_to("TASKS")
|
|
864
|
+
_save_session(session, session_path, "TASKS")
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
# ---------------------------------------------------------------------------
|
|
868
|
+
# Tasks — legacy positional-argument API
|
|
869
|
+
# ---------------------------------------------------------------------------
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _tasks_legacy(issue_id: str) -> None:
|
|
873
|
+
record = _resolve_and_validate_issue(issue_id, "TASKS")
|
|
874
|
+
session_path = _resolve_dot_deviate() / "session.json"
|
|
875
|
+
session = SessionState.load(session_path)
|
|
876
|
+
issue_slug = _resolve_bucket_dir(record.source_file)
|
|
877
|
+
tasks_jsonl = _resolve_specs_root() / issue_slug / "tasks.jsonl"
|
|
878
|
+
if tasks_jsonl.exists():
|
|
879
|
+
console.print(f"[yellow]SKIP[/] tasks already provisioned for {issue_slug}")
|
|
880
|
+
raise typer.Exit(code=0)
|
|
881
|
+
session = session.transition_to("TASKS")
|
|
882
|
+
session.active_issue_id = issue_id
|
|
883
|
+
session.save(session_path)
|
|
884
|
+
|
|
885
|
+
# Generate TSK-NNN-NN: extract issue number, count existing tasks, increment
|
|
886
|
+
issue_num = _extract_issue_num(issue_id)
|
|
887
|
+
existing_ids: list[dict] = []
|
|
888
|
+
if tasks_jsonl.exists():
|
|
889
|
+
for line in tasks_jsonl.read_text(encoding="utf-8").splitlines():
|
|
890
|
+
line = line.strip()
|
|
891
|
+
if line:
|
|
892
|
+
try:
|
|
893
|
+
existing_ids.append(json.loads(line))
|
|
894
|
+
except json.JSONDecodeError:
|
|
895
|
+
continue
|
|
896
|
+
existing_tasks = [r for r in existing_ids if r.get("issue_id") == issue_id]
|
|
897
|
+
existing_max = 0
|
|
898
|
+
for t in existing_tasks:
|
|
899
|
+
m = re.match(r"^TSK-\d{3}-(\d{2})$", t.get("id", ""))
|
|
900
|
+
if m:
|
|
901
|
+
idx = int(m.group(1))
|
|
902
|
+
if idx > existing_max:
|
|
903
|
+
existing_max = idx
|
|
904
|
+
next_index = existing_max + 1
|
|
905
|
+
task_id = f"TSK-{issue_num}-{next_index:02d}"
|
|
906
|
+
|
|
907
|
+
task = TaskRecord(
|
|
908
|
+
id=task_id,
|
|
909
|
+
issue_id=issue_id,
|
|
910
|
+
description=f"Implement {record.title}",
|
|
911
|
+
status="PENDING",
|
|
912
|
+
execution_mode="TDD",
|
|
913
|
+
)
|
|
914
|
+
if not append_task_record(task, tasks_jsonl):
|
|
915
|
+
console.print(f"[red]ERROR[/] Failed to append task record {task.id}")
|
|
916
|
+
raise typer.Exit(code=1)
|
|
917
|
+
|
|
918
|
+
session = session.transition_to("IDLE")
|
|
919
|
+
session.save(session_path)
|
|
920
|
+
console.print(f"[green]TASKS[/] 1 task(s) provisioned for {issue_slug}")
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# ---------------------------------------------------------------------------
|
|
924
|
+
# Tasks — new pre/post subcommand behavior
|
|
925
|
+
# ---------------------------------------------------------------------------
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
@with_json_quiet
|
|
929
|
+
def _tasks_pre(force: bool = False, dry_run: bool = False) -> None:
|
|
930
|
+
session, _ = _load_session_accept("PLAN", "SPECIFY", "TASKS", force=force)
|
|
931
|
+
|
|
932
|
+
issue_id = session.active_issue_id or ""
|
|
933
|
+
|
|
934
|
+
# Resolve issue file (the spec-enriched issue IS the spec)
|
|
935
|
+
spec_path: str = ""
|
|
936
|
+
status: str = "READY"
|
|
937
|
+
if issue_id:
|
|
938
|
+
found = _find_issue_file(issue_id)
|
|
939
|
+
if found is None:
|
|
940
|
+
status = "ISSUE_NOT_FOUND"
|
|
941
|
+
console.print(
|
|
942
|
+
f"[red]ISSUE_NOT_FOUND[/] issue file not found for {issue_id}"
|
|
943
|
+
)
|
|
944
|
+
else:
|
|
945
|
+
spec_path = str(found)
|
|
946
|
+
console.print(f"[green]SPEC_DISCOVERED[/] {spec_path}")
|
|
947
|
+
else:
|
|
948
|
+
status = "SPEC_NOT_FOUND"
|
|
949
|
+
console.print("[red]NO_ACTIVE_ISSUE[/] session has no active_issue_id")
|
|
950
|
+
|
|
951
|
+
# Worktree: we are already inside the worktree when tasks pre runs,
|
|
952
|
+
# so Path.cwd() is the correct answer. Fall back to branch lookup only
|
|
953
|
+
# as a safety net.
|
|
954
|
+
repo_root = Path.cwd()
|
|
955
|
+
worktree_full = str(repo_root)
|
|
956
|
+
branch_name = ""
|
|
957
|
+
try:
|
|
958
|
+
result = subprocess.run(
|
|
959
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
960
|
+
cwd=repo_root,
|
|
961
|
+
capture_output=True,
|
|
962
|
+
text=True,
|
|
963
|
+
check=True,
|
|
964
|
+
)
|
|
965
|
+
branch_name = result.stdout.strip()
|
|
966
|
+
except Exception:
|
|
967
|
+
pass
|
|
968
|
+
console.print(f"[green]WORKTREE[/] {worktree_full} [{branch_name}]")
|
|
969
|
+
|
|
970
|
+
constitution_path, constitution_test_command, constitution_lint_command = (
|
|
971
|
+
_resolve_constitution_commands(repo_root)
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
# Resolve tasks_target and plan_path (per-issue, not per-epic)
|
|
975
|
+
tasks_target: str = ""
|
|
976
|
+
plan_path: str = ""
|
|
977
|
+
if issue_id:
|
|
978
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
979
|
+
record = resolve_issue_record(issue_id, ledger_path)
|
|
980
|
+
if record is not None:
|
|
981
|
+
bucket = _resolve_bucket_dir(record.source_file)
|
|
982
|
+
slug = _source_stem(record.source_file)
|
|
983
|
+
tasks_target = str(_resolve_specs_root() / bucket / slug / "tasks.md")
|
|
984
|
+
plan_path = str(_resolve_specs_root() / bucket / slug / "plan.md")
|
|
985
|
+
|
|
986
|
+
# ── Enforce plan.md prerequisite ─────────────────────────────────
|
|
987
|
+
if plan_path and not Path(plan_path).exists() and not force:
|
|
988
|
+
status = "PLAN_NOT_FOUND"
|
|
989
|
+
console.print(
|
|
990
|
+
f"[red]PLAN_NOT_FOUND[/] {plan_path} — run deviate plan first "
|
|
991
|
+
"(use --force to bypass)"
|
|
992
|
+
)
|
|
993
|
+
if dry_run:
|
|
994
|
+
console.print("[yellow]DRY_RUN[/] skipping side effects")
|
|
995
|
+
|
|
996
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
997
|
+
contract = {
|
|
998
|
+
"issue_id": issue_id,
|
|
999
|
+
"spec_path": spec_path,
|
|
1000
|
+
"tasks_target": tasks_target,
|
|
1001
|
+
"worktree_full": worktree_full,
|
|
1002
|
+
"branch_name": branch_name,
|
|
1003
|
+
"constitution_path": constitution_path,
|
|
1004
|
+
"constitution_test_command": constitution_test_command,
|
|
1005
|
+
"plan_path": plan_path,
|
|
1006
|
+
"constitution_lint_command": constitution_lint_command,
|
|
1007
|
+
"timestamp": timestamp,
|
|
1008
|
+
"status": status,
|
|
1009
|
+
"phase": "tasks_pre",
|
|
1010
|
+
"force": force,
|
|
1011
|
+
"dry_run": dry_run,
|
|
1012
|
+
}
|
|
1013
|
+
print(json.dumps(contract, indent=2))
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _tasks_post(
|
|
1017
|
+
force: bool = False,
|
|
1018
|
+
issue_id: str | None = None,
|
|
1019
|
+
) -> None:
|
|
1020
|
+
session, session_path = _load_session_accept("TASKS", force=force)
|
|
1021
|
+
resolved_issue_id = issue_id or session.active_issue_id
|
|
1022
|
+
if not resolved_issue_id:
|
|
1023
|
+
console.print("[red]NO_ACTIVE_ISSUE[/] session has no active_issue_id")
|
|
1024
|
+
raise typer.Exit(code=1)
|
|
1025
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
1026
|
+
record = resolve_issue_record(resolved_issue_id, ledger_path)
|
|
1027
|
+
if record is None:
|
|
1028
|
+
console.print(f"[red]ISSUE_NOT_FOUND[/] {resolved_issue_id}")
|
|
1029
|
+
raise typer.Exit(code=1)
|
|
1030
|
+
bucket = _resolve_bucket_dir(record.source_file)
|
|
1031
|
+
slug = _source_stem(record.source_file)
|
|
1032
|
+
tasks_md = _resolve_specs_root() / bucket / slug / "tasks.md"
|
|
1033
|
+
if not tasks_md.exists():
|
|
1034
|
+
console.print(f"[red]TASKS_NOT_FOUND[/] {tasks_md}")
|
|
1035
|
+
raise typer.Exit(code=1)
|
|
1036
|
+
content = tasks_md.read_text(encoding="utf-8").strip()
|
|
1037
|
+
if not content and not force:
|
|
1038
|
+
console.print("[red]TASKS_EMPTY[/] tasks.md is empty")
|
|
1039
|
+
raise typer.Exit(code=1)
|
|
1040
|
+
|
|
1041
|
+
epic_num = _extract_epic_num(bucket)
|
|
1042
|
+
issue_num = _extract_issue_num(resolved_issue_id)
|
|
1043
|
+
try:
|
|
1044
|
+
sha = commit_artifact(
|
|
1045
|
+
tasks_md,
|
|
1046
|
+
format_commit_message(
|
|
1047
|
+
f"docs({epic_num}-{issue_num}): create tasks.md", Path.cwd()
|
|
1048
|
+
),
|
|
1049
|
+
repo=Path.cwd(),
|
|
1050
|
+
no_verify=True,
|
|
1051
|
+
)
|
|
1052
|
+
if sha is None:
|
|
1053
|
+
console.print("[yellow]COMMIT_SKIP[/] tasks.md — no changes to stage")
|
|
1054
|
+
else:
|
|
1055
|
+
console.print(f"[green]COMMITTED[/] tasks.md at {sha[:8]}")
|
|
1056
|
+
except Exception as e:
|
|
1057
|
+
console.print(f"[red]COMMIT_FAILED[/] {e}")
|
|
1058
|
+
raise typer.Exit(code=1)
|
|
1059
|
+
session = session.transition_to("IDLE")
|
|
1060
|
+
_save_session(session, session_path, "IDLE")
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
# ---------------------------------------------------------------------------
|
|
1064
|
+
# PR — new pre/run subcommand behavior
|
|
1065
|
+
# ---------------------------------------------------------------------------
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _pr_pre() -> None:
|
|
1069
|
+
session, _ = _load_session_accept("TASKS", "IDLE")
|
|
1070
|
+
repo_root = Path.cwd()
|
|
1071
|
+
issue_id = session.active_issue_id
|
|
1072
|
+
if not issue_id:
|
|
1073
|
+
console.print("[red]NO_ACTIVE_ISSUE[/] session has no active_issue_id")
|
|
1074
|
+
raise typer.Exit(code=1)
|
|
1075
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
1076
|
+
record = resolve_issue_record(issue_id, ledger_path)
|
|
1077
|
+
if record is None:
|
|
1078
|
+
console.print(f"[red]ISSUE_NOT_FOUND[/] {issue_id}")
|
|
1079
|
+
raise typer.Exit(code=1)
|
|
1080
|
+
console.print(f"[green]ISSUE[/] {issue_id}: {record.title}")
|
|
1081
|
+
|
|
1082
|
+
git_state = gather_git_state(repo=repo_root)
|
|
1083
|
+
|
|
1084
|
+
try:
|
|
1085
|
+
result = subprocess.run(
|
|
1086
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
1087
|
+
cwd=repo_root,
|
|
1088
|
+
env=_git_env(),
|
|
1089
|
+
capture_output=True,
|
|
1090
|
+
text=True,
|
|
1091
|
+
check=True,
|
|
1092
|
+
)
|
|
1093
|
+
branch_name = result.stdout.strip()
|
|
1094
|
+
except Exception:
|
|
1095
|
+
branch_name = "detached"
|
|
1096
|
+
|
|
1097
|
+
pr_title, pr_body, base_branch = _derive_pr_metadata(
|
|
1098
|
+
branch_name, issue_id, record.title, record.type
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
# Gather metadata for PR body generation
|
|
1102
|
+
commit_titles = ""
|
|
1103
|
+
changed_files = ""
|
|
1104
|
+
diff_summary = ""
|
|
1105
|
+
try:
|
|
1106
|
+
log_result = subprocess.run(
|
|
1107
|
+
["git", "log", f"{base_branch}..HEAD", "--oneline"],
|
|
1108
|
+
cwd=repo_root,
|
|
1109
|
+
env=_git_env(),
|
|
1110
|
+
capture_output=True,
|
|
1111
|
+
text=True,
|
|
1112
|
+
check=True,
|
|
1113
|
+
)
|
|
1114
|
+
commit_titles = "|".join(
|
|
1115
|
+
line.split(" ", 1)[1] if " " in line else line
|
|
1116
|
+
for line in log_result.stdout.strip().splitlines()
|
|
1117
|
+
if line.strip()
|
|
1118
|
+
)
|
|
1119
|
+
except Exception:
|
|
1120
|
+
pass
|
|
1121
|
+
|
|
1122
|
+
try:
|
|
1123
|
+
stat_result = subprocess.run(
|
|
1124
|
+
["git", "diff", f"{base_branch}...HEAD", "--stat"],
|
|
1125
|
+
cwd=repo_root,
|
|
1126
|
+
env=_git_env(),
|
|
1127
|
+
capture_output=True,
|
|
1128
|
+
text=True,
|
|
1129
|
+
check=True,
|
|
1130
|
+
)
|
|
1131
|
+
diff_summary = stat_result.stdout.strip()
|
|
1132
|
+
files_result = subprocess.run(
|
|
1133
|
+
["git", "diff", f"{base_branch}...HEAD", "--name-only"],
|
|
1134
|
+
cwd=repo_root,
|
|
1135
|
+
env=_git_env(),
|
|
1136
|
+
capture_output=True,
|
|
1137
|
+
text=True,
|
|
1138
|
+
check=True,
|
|
1139
|
+
)
|
|
1140
|
+
changed_files = ",".join(
|
|
1141
|
+
f for f in files_result.stdout.strip().splitlines() if f.strip()
|
|
1142
|
+
)
|
|
1143
|
+
except Exception:
|
|
1144
|
+
pass
|
|
1145
|
+
|
|
1146
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1147
|
+
contract = {
|
|
1148
|
+
"branch_name": branch_name,
|
|
1149
|
+
"base_branch": base_branch,
|
|
1150
|
+
"pr_title": pr_title,
|
|
1151
|
+
"pr_body": pr_body,
|
|
1152
|
+
"git_state": git_state,
|
|
1153
|
+
"issue_title": record.title,
|
|
1154
|
+
"commit_titles": commit_titles,
|
|
1155
|
+
"changed_files": changed_files,
|
|
1156
|
+
"diff_summary": diff_summary,
|
|
1157
|
+
"timestamp": timestamp,
|
|
1158
|
+
"status": "READY",
|
|
1159
|
+
"phase": "pr_pre",
|
|
1160
|
+
}
|
|
1161
|
+
print(json.dumps(contract, indent=2))
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _run_gt_submit(repo_root: Path, title: str, body_file: Path) -> None:
|
|
1165
|
+
try:
|
|
1166
|
+
result = subprocess.run(
|
|
1167
|
+
["gt", "submit", "--stack", "--no-edit"],
|
|
1168
|
+
capture_output=True,
|
|
1169
|
+
text=True,
|
|
1170
|
+
cwd=repo_root,
|
|
1171
|
+
env=_git_env(),
|
|
1172
|
+
)
|
|
1173
|
+
except FileNotFoundError:
|
|
1174
|
+
console.print(
|
|
1175
|
+
"[red]GT_SUBMIT_FAILED[/] Graphite CLI (gt) not found on PATH.\n"
|
|
1176
|
+
"See https://graphite.dev/docs/cli for installation instructions."
|
|
1177
|
+
)
|
|
1178
|
+
raise typer.Exit(code=1)
|
|
1179
|
+
if result.returncode != 0:
|
|
1180
|
+
console.print(
|
|
1181
|
+
f"[red]GT_SUBMIT_FAILED[/] {result.stderr.strip()}\n"
|
|
1182
|
+
"See https://graphite.dev/docs/cli for installation instructions."
|
|
1183
|
+
)
|
|
1184
|
+
raise typer.Exit(code=1)
|
|
1185
|
+
console.print(f"[green]GT_SUBMIT[/] {result.stdout.strip()}")
|
|
1186
|
+
_update_gt_prs(result.stdout, title, body_file, repo_root)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _update_gt_prs(output: str, title: str, body_file: Path, repo_root: Path) -> None:
|
|
1190
|
+
for line in output.strip().splitlines():
|
|
1191
|
+
line = line.strip()
|
|
1192
|
+
if not line:
|
|
1193
|
+
continue
|
|
1194
|
+
pr_url_match = re.search(r"(https://github\.com/\S+/pull/\d+)", line)
|
|
1195
|
+
if not pr_url_match:
|
|
1196
|
+
continue
|
|
1197
|
+
pr_url = pr_url_match.group(1)
|
|
1198
|
+
pr_num_match = re.search(r"/(\d+)$", pr_url)
|
|
1199
|
+
if not pr_num_match:
|
|
1200
|
+
continue
|
|
1201
|
+
pr_number = pr_num_match.group(1)
|
|
1202
|
+
try:
|
|
1203
|
+
subprocess.run(
|
|
1204
|
+
[
|
|
1205
|
+
"gh",
|
|
1206
|
+
"pr",
|
|
1207
|
+
"edit",
|
|
1208
|
+
pr_number,
|
|
1209
|
+
"--title",
|
|
1210
|
+
title,
|
|
1211
|
+
"--body-file",
|
|
1212
|
+
str(body_file),
|
|
1213
|
+
],
|
|
1214
|
+
capture_output=True,
|
|
1215
|
+
text=True,
|
|
1216
|
+
cwd=repo_root,
|
|
1217
|
+
env=_git_env(),
|
|
1218
|
+
check=True,
|
|
1219
|
+
)
|
|
1220
|
+
console.print(f"[green]PR_UPDATED[/] #{pr_number}")
|
|
1221
|
+
except subprocess.CalledProcessError as e:
|
|
1222
|
+
console.print(f"[yellow]PR_EDIT_WARN[/] #{pr_number}: {e.stderr.strip()}")
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _run_gh_pr_create(
|
|
1226
|
+
title: str,
|
|
1227
|
+
body_file: Path,
|
|
1228
|
+
merge: bool = False,
|
|
1229
|
+
auto_merge: bool = False,
|
|
1230
|
+
cwd: Path | None = None,
|
|
1231
|
+
) -> None:
|
|
1232
|
+
cmd = ["gh", "pr", "create", "--title", title, "--body-file", str(body_file)]
|
|
1233
|
+
if merge:
|
|
1234
|
+
cmd.append("--merge")
|
|
1235
|
+
elif auto_merge:
|
|
1236
|
+
cmd.append("--auto-merge")
|
|
1237
|
+
result = subprocess.run(
|
|
1238
|
+
cmd, capture_output=True, text=True, cwd=cwd, env=_git_env()
|
|
1239
|
+
)
|
|
1240
|
+
if result.returncode != 0:
|
|
1241
|
+
console.print(f"[red]PR_CREATE_FAILED[/] {result.stderr.strip()}")
|
|
1242
|
+
raise typer.Exit(code=1)
|
|
1243
|
+
pr_url = result.stdout.strip()
|
|
1244
|
+
console.print(f"[green]PR_CREATED[/] {pr_url}")
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
def _pr_run(
|
|
1248
|
+
body_file: Path,
|
|
1249
|
+
merge: bool = False,
|
|
1250
|
+
auto_merge: bool = False,
|
|
1251
|
+
) -> None:
|
|
1252
|
+
session, session_path = _load_session_accept("TASKS", "IDLE")
|
|
1253
|
+
issue_id = session.active_issue_id
|
|
1254
|
+
if not issue_id:
|
|
1255
|
+
console.print("[red]NO_ACTIVE_ISSUE[/] session has no active_issue_id")
|
|
1256
|
+
raise typer.Exit(code=1)
|
|
1257
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
1258
|
+
record = resolve_issue_record(issue_id, ledger_path)
|
|
1259
|
+
if record is None:
|
|
1260
|
+
console.print(f"[red]ISSUE_NOT_FOUND[/] {issue_id}")
|
|
1261
|
+
raise typer.Exit(code=1)
|
|
1262
|
+
if not body_file.exists():
|
|
1263
|
+
console.print(f"[red]BODY_FILE_NOT_FOUND[/] {body_file}")
|
|
1264
|
+
raise typer.Exit(code=1)
|
|
1265
|
+
|
|
1266
|
+
repo_root = Path.cwd()
|
|
1267
|
+
title = _pr_title(issue_id, record.title, record.type)
|
|
1268
|
+
|
|
1269
|
+
# 1. Record COMPLETED in the ledger before PR creation
|
|
1270
|
+
completed = record.model_copy(
|
|
1271
|
+
update={
|
|
1272
|
+
"status": "COMPLETED",
|
|
1273
|
+
"timestamp": datetime.now(timezone.utc),
|
|
1274
|
+
}
|
|
1275
|
+
)
|
|
1276
|
+
appended = append_issue_transition(completed, ledger_path)
|
|
1277
|
+
if appended:
|
|
1278
|
+
console.print(f"[green]COMPLETED[/] {issue_id} → COMPLETED")
|
|
1279
|
+
else:
|
|
1280
|
+
console.print(
|
|
1281
|
+
f"[yellow]LEDGER_IDEMPOTENT[/] COMPLETED for {issue_id} already recorded"
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
# 2. Stage the ledger and PR body file, then commit together
|
|
1285
|
+
staged = False
|
|
1286
|
+
for path in (str(ledger_path), str(body_file)):
|
|
1287
|
+
try:
|
|
1288
|
+
subprocess.run(
|
|
1289
|
+
["git", "add", path],
|
|
1290
|
+
cwd=repo_root,
|
|
1291
|
+
env=_git_env(),
|
|
1292
|
+
check=True,
|
|
1293
|
+
capture_output=True,
|
|
1294
|
+
text=True,
|
|
1295
|
+
)
|
|
1296
|
+
staged = True
|
|
1297
|
+
except subprocess.CalledProcessError:
|
|
1298
|
+
pass
|
|
1299
|
+
if staged:
|
|
1300
|
+
try:
|
|
1301
|
+
subprocess.run(
|
|
1302
|
+
[
|
|
1303
|
+
"git",
|
|
1304
|
+
"commit",
|
|
1305
|
+
"--no-verify",
|
|
1306
|
+
"-m",
|
|
1307
|
+
f"chore({issue_id}): mark COMPLETED in ledger",
|
|
1308
|
+
],
|
|
1309
|
+
cwd=repo_root,
|
|
1310
|
+
env=_git_env(),
|
|
1311
|
+
check=True,
|
|
1312
|
+
capture_output=True,
|
|
1313
|
+
text=True,
|
|
1314
|
+
)
|
|
1315
|
+
console.print("[green]LEDGER_COMMITTED[/]")
|
|
1316
|
+
except subprocess.CalledProcessError as e:
|
|
1317
|
+
stderr = (e.stderr or "").strip()
|
|
1318
|
+
if "nothing to commit" in stderr:
|
|
1319
|
+
console.print("[yellow]LEDGER_UNCHANGED[/] no ledger changes to commit")
|
|
1320
|
+
else:
|
|
1321
|
+
console.print(f"[yellow]COMMIT_LEDGER_WARN[/] {stderr}")
|
|
1322
|
+
else:
|
|
1323
|
+
console.print("[yellow]LEDGER_UNCHANGED[/] no files staged for commit")
|
|
1324
|
+
|
|
1325
|
+
try:
|
|
1326
|
+
subprocess.run(
|
|
1327
|
+
["git", "push", "-u", "origin", "HEAD"],
|
|
1328
|
+
cwd=repo_root,
|
|
1329
|
+
env=_git_env(),
|
|
1330
|
+
check=True,
|
|
1331
|
+
capture_output=True,
|
|
1332
|
+
text=True,
|
|
1333
|
+
)
|
|
1334
|
+
console.print("[green]BRANCH_PUSHED[/]")
|
|
1335
|
+
except subprocess.CalledProcessError as e:
|
|
1336
|
+
stderr = (e.stderr or "").strip()
|
|
1337
|
+
if "does not exist" in stderr or "not found" in stderr:
|
|
1338
|
+
console.print(
|
|
1339
|
+
"[yellow]BRANCH_DELETED[/] remote branch gone after merge (expected)"
|
|
1340
|
+
)
|
|
1341
|
+
else:
|
|
1342
|
+
console.print(f"[yellow]PUSH_WARN[/] {stderr}")
|
|
1343
|
+
|
|
1344
|
+
# 3. Create (and optionally merge) the PR
|
|
1345
|
+
if resolve_graphite_config(repo_root):
|
|
1346
|
+
if merge or auto_merge:
|
|
1347
|
+
console.print(
|
|
1348
|
+
"[yellow]GRAPHITE_MERGE_FLAGS_IGNORED[/] "
|
|
1349
|
+
"Graphite handles merge flow internally via `gt submit --stack`."
|
|
1350
|
+
)
|
|
1351
|
+
_run_gt_submit(repo_root, title, body_file)
|
|
1352
|
+
else:
|
|
1353
|
+
_run_gh_pr_create(title, body_file, merge, auto_merge, cwd=repo_root)
|
|
1354
|
+
|
|
1355
|
+
_save_session(session, session_path, "TASKS")
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
# ---------------------------------------------------------------------------
|
|
1359
|
+
# Meso automated pipeline
|
|
1360
|
+
# ---------------------------------------------------------------------------
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _invoke_agent_phase(
|
|
1364
|
+
phase: str,
|
|
1365
|
+
contract: dict[str, str],
|
|
1366
|
+
cwd: str | None = None,
|
|
1367
|
+
) -> None:
|
|
1368
|
+
"""Build a slim prompt, invoke the agent, and abort on failure."""
|
|
1369
|
+
prompt = _build_slim_prompt(phase, contract)
|
|
1370
|
+
try:
|
|
1371
|
+
root = Path(cwd) if cwd else Path.cwd()
|
|
1372
|
+
model = resolve_model_for_phase(phase, root)
|
|
1373
|
+
data = _load_deviate_config_toml(root)
|
|
1374
|
+
backend_name = "opencode"
|
|
1375
|
+
if data:
|
|
1376
|
+
agent_data = data.get("agent", {})
|
|
1377
|
+
if isinstance(agent_data, dict) and isinstance(
|
|
1378
|
+
agent_data.get("backend"), str
|
|
1379
|
+
):
|
|
1380
|
+
# Normalise user-facing aliases (``factory``, ``omp``) to
|
|
1381
|
+
# canonical backends before constructing AgentConfig —
|
|
1382
|
+
# the Pydantic ``backend`` Literal only accepts canonical
|
|
1383
|
+
# names (``opencode`` / ``claude`` / ``droid`` / ``pi``).
|
|
1384
|
+
backend_name = resolve_agent_to_backend(agent_data["backend"])
|
|
1385
|
+
agent_cfg = AgentConfig(
|
|
1386
|
+
backend=backend_name,
|
|
1387
|
+
timeout=(data.get("agent", {}).get("timeout", 600) if data else 600),
|
|
1388
|
+
)
|
|
1389
|
+
backend = AgentBackend(config=agent_cfg)
|
|
1390
|
+
model_str = f" --model {model}" if model else ""
|
|
1391
|
+
console.print(
|
|
1392
|
+
f"[green]INVOKE_AGENT[/] running '{backend_name}{model_str}'"
|
|
1393
|
+
f" for [{phase}] phase"
|
|
1394
|
+
)
|
|
1395
|
+
manifest = backend.invoke(prompt, cwd=cwd, model=model)
|
|
1396
|
+
except AgentSubprocessError as e:
|
|
1397
|
+
console.print(f"[red]{phase.upper()}_FAILED[/] {e}")
|
|
1398
|
+
raise SystemExit(1) from e
|
|
1399
|
+
if manifest.status != "PASS":
|
|
1400
|
+
console.print(
|
|
1401
|
+
f"[red]{phase.upper()}_FAILED[/] agent returned status: {manifest.status}"
|
|
1402
|
+
)
|
|
1403
|
+
raise SystemExit(1)
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def _meso_discover_and_sequence() -> str | None:
|
|
1407
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
1408
|
+
issue = select_next_unblocked_issue(ledger_path)
|
|
1409
|
+
if issue is None:
|
|
1410
|
+
return None
|
|
1411
|
+
return issue.issue_id
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def _discover_claimable_issue() -> str | None:
|
|
1415
|
+
"""Return the next BACKLOG issue whose branch does NOT exist on remote.
|
|
1416
|
+
|
|
1417
|
+
Loops through ``select_unblocked_candidates``, checking each candidate's
|
|
1418
|
+
deterministic branch name against the remote. Issues whose branch already
|
|
1419
|
+
exists on remote are treated as claimed-elsewhere and skipped.
|
|
1420
|
+
|
|
1421
|
+
Returns the first claimable ``issue_id``, or ``None`` if none available.
|
|
1422
|
+
"""
|
|
1423
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
1424
|
+
repo_root = Path.cwd()
|
|
1425
|
+
candidates = select_unblocked_candidates(ledger_path)
|
|
1426
|
+
if not candidates:
|
|
1427
|
+
return None
|
|
1428
|
+
|
|
1429
|
+
# Detect the remote once for all candidate checks
|
|
1430
|
+
remote: str | None = None
|
|
1431
|
+
try:
|
|
1432
|
+
r = subprocess.run(
|
|
1433
|
+
["git", "remote", "get-url", "origin"],
|
|
1434
|
+
cwd=repo_root,
|
|
1435
|
+
capture_output=True,
|
|
1436
|
+
text=True,
|
|
1437
|
+
env=_git_env(),
|
|
1438
|
+
)
|
|
1439
|
+
if r.returncode == 0:
|
|
1440
|
+
remote = "origin"
|
|
1441
|
+
except Exception:
|
|
1442
|
+
pass
|
|
1443
|
+
|
|
1444
|
+
for candidate in candidates:
|
|
1445
|
+
if _is_issue_completed(candidate.issue_id, ledger_path):
|
|
1446
|
+
continue
|
|
1447
|
+
if remote:
|
|
1448
|
+
epic_slug = _resolve_bucket_dir(candidate.source_file)
|
|
1449
|
+
issue_slug = _source_stem(candidate.source_file)
|
|
1450
|
+
branch = f"feat/{epic_slug}/{issue_slug}"
|
|
1451
|
+
if branch_exists_on_remote(branch, repo=repo_root, remote=remote):
|
|
1452
|
+
console.print(
|
|
1453
|
+
f"[yellow]SKIP[/] {candidate.issue_id} — "
|
|
1454
|
+
f"branch already on remote (claimed elsewhere)"
|
|
1455
|
+
)
|
|
1456
|
+
continue
|
|
1457
|
+
return candidate.issue_id
|
|
1458
|
+
return None
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
@with_json_quiet
|
|
1462
|
+
def _meso_run(
|
|
1463
|
+
issue_id: str | None = None,
|
|
1464
|
+
dry_run: bool = False,
|
|
1465
|
+
force: bool = False,
|
|
1466
|
+
) -> None:
|
|
1467
|
+
dot_dir = _resolve_dot_deviate()
|
|
1468
|
+
if not dot_dir.exists():
|
|
1469
|
+
_handle_missing_dot_dir("MESO")
|
|
1470
|
+
|
|
1471
|
+
session_path = dot_dir / "session.json"
|
|
1472
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
1473
|
+
|
|
1474
|
+
# ── Discover issue if not specified ──────────────────────────────
|
|
1475
|
+
if issue_id is None:
|
|
1476
|
+
discovered = _discover_claimable_issue()
|
|
1477
|
+
if discovered is None:
|
|
1478
|
+
console.print(
|
|
1479
|
+
"[red]NO_CLAIMABLE_ISSUES[/] no unblocked BACKLOG issue "
|
|
1480
|
+
"available to claim"
|
|
1481
|
+
)
|
|
1482
|
+
raise SystemExit(1)
|
|
1483
|
+
issue_id = discovered
|
|
1484
|
+
console.print(f"[green]DISCOVERED[/] {issue_id}")
|
|
1485
|
+
|
|
1486
|
+
# ── Explicit --issue: validate, resolve record, and claim ───────
|
|
1487
|
+
# ── Check COMPLETED ──────────────────────────────────────────────
|
|
1488
|
+
if _is_issue_completed(issue_id, ledger_path):
|
|
1489
|
+
console.print(f"[red]ISSUE_COMPLETED[/] {issue_id} is already COMPLETED")
|
|
1490
|
+
raise SystemExit(1)
|
|
1491
|
+
|
|
1492
|
+
# ── Check progress → reset ──────────────────────────────────────
|
|
1493
|
+
record = resolve_issue_record(issue_id, ledger_path)
|
|
1494
|
+
if record and record.status not in ("BACKLOG", "DRAFT"):
|
|
1495
|
+
console.print(
|
|
1496
|
+
f"[yellow]PROGRESS_RESET[/] {issue_id} ({record.status})"
|
|
1497
|
+
" — resetting to BACKLOG"
|
|
1498
|
+
)
|
|
1499
|
+
reset = record.model_copy(update={"status": "BACKLOG"})
|
|
1500
|
+
append_issue_transition(reset, ledger_path)
|
|
1501
|
+
|
|
1502
|
+
# Re-resolve after possible reset
|
|
1503
|
+
record = resolve_issue_record(issue_id, ledger_path)
|
|
1504
|
+
if record is None:
|
|
1505
|
+
console.print(f"[red]INVALID_ISSUE_ID[/] {issue_id} not found in ledger")
|
|
1506
|
+
raise SystemExit(1)
|
|
1507
|
+
|
|
1508
|
+
# ── Blocking dependency check (explicit --issue) ─────────────────
|
|
1509
|
+
if record and record.blocked_by and not force:
|
|
1510
|
+
for dep_id in record.blocked_by:
|
|
1511
|
+
if not _is_issue_completed(dep_id, ledger_path):
|
|
1512
|
+
console.print(
|
|
1513
|
+
f"[red]BLOCKED[/] {issue_id} is blocked by {dep_id} "
|
|
1514
|
+
f"(use --force to bypass)"
|
|
1515
|
+
)
|
|
1516
|
+
raise SystemExit(1)
|
|
1517
|
+
|
|
1518
|
+
epic_slug = _resolve_bucket_dir(record.source_file) if record else ""
|
|
1519
|
+
issue_slug = _source_stem(record.source_file) if record else ""
|
|
1520
|
+
issue_title = record.title if record else ""
|
|
1521
|
+
|
|
1522
|
+
# ── Dry-run mode ─────────────────────────────────────────────────
|
|
1523
|
+
if dry_run:
|
|
1524
|
+
contract: dict[str, str] = {
|
|
1525
|
+
"issue_id": issue_id,
|
|
1526
|
+
"issue_title": issue_title,
|
|
1527
|
+
"epic_slug": epic_slug,
|
|
1528
|
+
"issue_slug": issue_slug,
|
|
1529
|
+
}
|
|
1530
|
+
console.print("[bold][yellow]DRY_RUN[/] — no state will be mutated[/]")
|
|
1531
|
+
prompt = _build_slim_prompt("tasks", contract)
|
|
1532
|
+
print(prompt)
|
|
1533
|
+
return
|
|
1534
|
+
|
|
1535
|
+
# ── Setup step: create worktree and claim issue ──────────────────
|
|
1536
|
+
setup_result = _specify_pre(issue_id=issue_id, force=force, dry_run=False)
|
|
1537
|
+
worktree_path = Path(setup_result["worktree_path"])
|
|
1538
|
+
|
|
1539
|
+
# ── PLAN phase — advance session (in original repo) ──────────────
|
|
1540
|
+
dot_dir = _resolve_dot_deviate()
|
|
1541
|
+
session_path = (dot_dir / "session.json").resolve()
|
|
1542
|
+
session = SessionState.load(session_path)
|
|
1543
|
+
if session.current_phase != "PLAN":
|
|
1544
|
+
session = session.force_transition_to("PLAN")
|
|
1545
|
+
session.active_issue_id = issue_id
|
|
1546
|
+
session.save(session_path)
|
|
1547
|
+
|
|
1548
|
+
# Sync .deviate/ to worktree so downstream functions find the session
|
|
1549
|
+
if dot_dir.exists():
|
|
1550
|
+
shutil.copytree(
|
|
1551
|
+
str(dot_dir), str(worktree_path / ".deviate"), dirs_exist_ok=True
|
|
1552
|
+
)
|
|
1553
|
+
|
|
1554
|
+
# Build contract with absolute worktree paths so agent writes files
|
|
1555
|
+
# to the exact worktree location regardless of tool re-rooting.
|
|
1556
|
+
src_file = record.source_file if record else ""
|
|
1557
|
+
spec_path = src_file if src_file.startswith("/") else str(worktree_path / src_file)
|
|
1558
|
+
plan_dir = worktree_path / "specs" / epic_slug / issue_slug
|
|
1559
|
+
tasks_dir = worktree_path / "specs" / epic_slug / issue_slug
|
|
1560
|
+
plan_dir.mkdir(parents=True, exist_ok=True)
|
|
1561
|
+
tasks_dir.mkdir(parents=True, exist_ok=True)
|
|
1562
|
+
contract: dict[str, object] = {
|
|
1563
|
+
"issue_id": issue_id,
|
|
1564
|
+
"issue_title": issue_title,
|
|
1565
|
+
"epic_slug": epic_slug,
|
|
1566
|
+
"issue_slug": issue_slug,
|
|
1567
|
+
"worktree_full": str(worktree_path),
|
|
1568
|
+
"spec_path": spec_path,
|
|
1569
|
+
"plan_path": str(plan_dir / "plan.md"),
|
|
1570
|
+
"tasks_target": str(tasks_dir / "tasks.md"),
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
# ── Inject file structure appendix into plan contract ────────────
|
|
1574
|
+
file_structure_str = ""
|
|
1575
|
+
if spec_path:
|
|
1576
|
+
try:
|
|
1577
|
+
structures, _ = _extract_workstation_file_structures(
|
|
1578
|
+
spec_path, worktree_path
|
|
1579
|
+
)
|
|
1580
|
+
if structures:
|
|
1581
|
+
file_structure_str = _format_file_structure_markdown(structures)
|
|
1582
|
+
except Exception as exc:
|
|
1583
|
+
logger.warning("Failed to extract file structure: %s", exc)
|
|
1584
|
+
if file_structure_str:
|
|
1585
|
+
contract["file_structure"] = file_structure_str
|
|
1586
|
+
|
|
1587
|
+
ctx = chdir(worktree_path)
|
|
1588
|
+
with ctx:
|
|
1589
|
+
_plan_pre(force=force, dry_run=False)
|
|
1590
|
+
|
|
1591
|
+
_invoke_agent_phase("plan", contract, cwd=str(worktree_path))
|
|
1592
|
+
|
|
1593
|
+
_plan_post(force=force, issue_id=issue_id)
|
|
1594
|
+
|
|
1595
|
+
plan_md = Path(contract["plan_path"])
|
|
1596
|
+
contract["plan_content"] = (
|
|
1597
|
+
plan_md.read_text(encoding="utf-8") if plan_md.exists() else ""
|
|
1598
|
+
)
|
|
1599
|
+
|
|
1600
|
+
_tasks_pre(force=force, dry_run=False)
|
|
1601
|
+
|
|
1602
|
+
_invoke_agent_phase("tasks", contract, cwd=str(worktree_path))
|
|
1603
|
+
|
|
1604
|
+
_tasks_post(force=force, issue_id=issue_id)
|
|
1605
|
+
|
|
1606
|
+
# ── Final IDLE guard ─────────────────────────────────────────
|
|
1607
|
+
session = SessionState.load(session_path)
|
|
1608
|
+
if session.current_phase != "IDLE":
|
|
1609
|
+
session = session.force_transition_to("IDLE")
|
|
1610
|
+
session.save(session_path)
|
|
1611
|
+
console.print("[green]MESO[/] pipeline complete — session at IDLE")
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
meso_app = typer.Typer(no_args_is_help=True)
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
@meso_app.command("run")
|
|
1618
|
+
def meso_run_command(
|
|
1619
|
+
issue: str | None = typer.Option(
|
|
1620
|
+
None, "--issue", help="Target issue ID (default: next unblocked BACKLOG)"
|
|
1621
|
+
),
|
|
1622
|
+
dry_run: bool = typer.Option(
|
|
1623
|
+
False,
|
|
1624
|
+
"--dry-run",
|
|
1625
|
+
help="Emit prompts and contracts without side effects",
|
|
1626
|
+
),
|
|
1627
|
+
force: bool = typer.Option(False, "--force", help="Bypass pre-flight guards"),
|
|
1628
|
+
quiet: bool = typer.Option(
|
|
1629
|
+
True, "--quiet/--verbose", help="Suppress non-essential output (default: quiet)"
|
|
1630
|
+
),
|
|
1631
|
+
) -> None:
|
|
1632
|
+
"""Run the meso automated pipeline (setup → plan → tasks)"""
|
|
1633
|
+
_meso_run(issue_id=issue, dry_run=dry_run, force=force, quiet=quiet)
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
# ---------------------------------------------------------------------------
|
|
1637
|
+
# CLI command entry points
|
|
1638
|
+
# ---------------------------------------------------------------------------
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def specify(
|
|
1642
|
+
issue_id: str = typer.Argument(..., help="Issue ID (or 'pre' / 'post')"),
|
|
1643
|
+
force: bool = typer.Option(
|
|
1644
|
+
False, "--force", help="Force operation (bypass push failure)"
|
|
1645
|
+
),
|
|
1646
|
+
dry_run: bool = typer.Option(
|
|
1647
|
+
False,
|
|
1648
|
+
"--dry-run",
|
|
1649
|
+
help="Resolve issue and emit contract without creating worktree or claiming",
|
|
1650
|
+
),
|
|
1651
|
+
issue: str | None = typer.Option(
|
|
1652
|
+
None, "--issue", help="Issue ID for pre subcommand"
|
|
1653
|
+
),
|
|
1654
|
+
) -> None:
|
|
1655
|
+
"""Setup: create worktree and claim issue for the given issue ID"""
|
|
1656
|
+
if issue_id == "pre":
|
|
1657
|
+
_specify_pre(issue_id=issue, force=force, dry_run=dry_run)
|
|
1658
|
+
elif issue_id == "post":
|
|
1659
|
+
_specify_post(force=force)
|
|
1660
|
+
else:
|
|
1661
|
+
_specify_pre(issue_id=issue_id, force=force, dry_run=dry_run)
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
def plan(
|
|
1665
|
+
issue_id: str = typer.Argument(..., help="Issue ID (or 'pre' / 'post')"),
|
|
1666
|
+
force: bool = typer.Option(False, "--force", help="Force operation"),
|
|
1667
|
+
dry_run: bool = typer.Option(
|
|
1668
|
+
False, "--dry-run", help="Preview without side effects"
|
|
1669
|
+
),
|
|
1670
|
+
issue: str | None = typer.Option(
|
|
1671
|
+
None, "--issue", help="Issue ID for pre subcommand"
|
|
1672
|
+
),
|
|
1673
|
+
) -> None:
|
|
1674
|
+
"""Plan phase: pre (research + emit) or post (validate, commit)"""
|
|
1675
|
+
if issue_id == "pre":
|
|
1676
|
+
_plan_pre(issue_id=issue, force=force, dry_run=dry_run)
|
|
1677
|
+
elif issue_id == "post":
|
|
1678
|
+
_plan_post(force=force, issue_id=issue)
|
|
1679
|
+
else:
|
|
1680
|
+
_plan_pre(issue_id=issue_id, force=force, dry_run=dry_run)
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def tasks(
|
|
1684
|
+
issue_id: str = typer.Argument(..., help="Issue ID (or 'pre' / 'post')"),
|
|
1685
|
+
force: bool = typer.Option(False, "--force", help="Force operation"),
|
|
1686
|
+
dry_run: bool = typer.Option(
|
|
1687
|
+
False, "--dry-run", help="Preview without side effects"
|
|
1688
|
+
),
|
|
1689
|
+
issue: str | None = typer.Option(
|
|
1690
|
+
None, "--issue-id", help="Issue ID for post subcommand"
|
|
1691
|
+
),
|
|
1692
|
+
) -> None:
|
|
1693
|
+
"""Tasks phase: pre (detect worktree) or post (validate, commit)"""
|
|
1694
|
+
if issue_id == "pre":
|
|
1695
|
+
_tasks_pre(force=force, dry_run=dry_run)
|
|
1696
|
+
elif issue_id == "post":
|
|
1697
|
+
_tasks_post(force=force, issue_id=issue)
|
|
1698
|
+
else:
|
|
1699
|
+
_tasks_legacy(issue_id)
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def pr(
|
|
1703
|
+
action: str = typer.Argument(..., help="Action: pre (validate) or run (create PR)"),
|
|
1704
|
+
body_file: Path | None = typer.Option(
|
|
1705
|
+
None, "--body-file", help="Path to PR body file"
|
|
1706
|
+
),
|
|
1707
|
+
merge: bool = typer.Option(False, "--merge", help="Merge after PR creation"),
|
|
1708
|
+
auto_merge: bool = typer.Option(False, "--auto-merge", help="Enable auto-merge"),
|
|
1709
|
+
) -> None:
|
|
1710
|
+
"""PR phase: pre (validate) or run (create PR)"""
|
|
1711
|
+
if action == "pre":
|
|
1712
|
+
_pr_pre()
|
|
1713
|
+
elif action == "run":
|
|
1714
|
+
if body_file is None:
|
|
1715
|
+
console.print("[red]MISSING_BODY_FILE[/] --body-file is required for 'run'")
|
|
1716
|
+
raise typer.Exit(code=1)
|
|
1717
|
+
_pr_run(body_file, merge=merge, auto_merge=auto_merge)
|
|
1718
|
+
else:
|
|
1719
|
+
console.print(f"[red]UNKNOWN_ACTION[/] '{action}'. Use 'pre' or 'run'")
|
|
1720
|
+
raise typer.Exit(code=1)
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
# ---------------------------------------------------------------------------
|
|
1724
|
+
# Merge — mark issue COMPLETED after external merge (e.g. squash-merge skill)
|
|
1725
|
+
# ---------------------------------------------------------------------------
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _merge_run(
|
|
1729
|
+
issue_id: str | None = None,
|
|
1730
|
+
delete_branch: bool = False,
|
|
1731
|
+
delete_worktree: bool = False,
|
|
1732
|
+
stage_only: bool = False,
|
|
1733
|
+
message: list[str] | None = None,
|
|
1734
|
+
) -> None:
|
|
1735
|
+
"""Mark an issue COMPLETED in the ledger with a full IssueRecord.
|
|
1736
|
+
|
|
1737
|
+
Intended for use after an external merge (e.g. the /squash-merge skill)
|
|
1738
|
+
that does not write DeviaTDD-compatible ledger entries. Unlike the bare
|
|
1739
|
+
``{issue_id, status, timestamp}`` format, this writes a full record that
|
|
1740
|
+
``resolve_issue_record`` can always validate.
|
|
1741
|
+
|
|
1742
|
+
When *stage_only* is True, the ledger is written and ``git add``-ed but
|
|
1743
|
+
NOT committed — the caller is expected to fold it into a squash-merge
|
|
1744
|
+
commit. Cleanup and session-reset are also skipped so a subsequent
|
|
1745
|
+
``--delete-branch`` pass can handle them.
|
|
1746
|
+
|
|
1747
|
+
When *message* is provided, ``git add -A`` includes pre-staged feature
|
|
1748
|
+
changes, and the first element is routed through ``format_commit_message``
|
|
1749
|
+
(applying the project's emoji convention) with the rest passed verbatim
|
|
1750
|
+
as body paragraphs.
|
|
1751
|
+
"""
|
|
1752
|
+
session, session_path = _load_session_accept("TASKS", "IDLE", force=True)
|
|
1753
|
+
if issue_id is None:
|
|
1754
|
+
issue_id = session.active_issue_id
|
|
1755
|
+
if not issue_id:
|
|
1756
|
+
console.print("[red]NO_ACTIVE_ISSUE[/] session has no active_issue_id")
|
|
1757
|
+
raise typer.Exit(code=1)
|
|
1758
|
+
|
|
1759
|
+
ledger_path = _resolve_specs_root() / "issues.jsonl"
|
|
1760
|
+
record = resolve_issue_record(issue_id, ledger_path)
|
|
1761
|
+
if record is None:
|
|
1762
|
+
console.print(f"[red]ISSUE_NOT_FOUND[/] {issue_id}")
|
|
1763
|
+
raise typer.Exit(code=1)
|
|
1764
|
+
|
|
1765
|
+
if record.status == "COMPLETED":
|
|
1766
|
+
console.print(f"[yellow]ALREADY_COMPLETED[/] {issue_id}")
|
|
1767
|
+
else:
|
|
1768
|
+
completed = record.model_copy(
|
|
1769
|
+
update={
|
|
1770
|
+
"status": "COMPLETED",
|
|
1771
|
+
"timestamp": datetime.now(timezone.utc),
|
|
1772
|
+
}
|
|
1773
|
+
)
|
|
1774
|
+
appended = append_issue_transition(completed, ledger_path)
|
|
1775
|
+
if appended:
|
|
1776
|
+
console.print(f"[green]COMPLETED[/] {issue_id} → COMPLETED")
|
|
1777
|
+
else:
|
|
1778
|
+
console.print(
|
|
1779
|
+
f"[yellow]LEDGER_IDEMPOTENT[/] COMPLETED for {issue_id} already recorded"
|
|
1780
|
+
)
|
|
1781
|
+
|
|
1782
|
+
# Commit the ledger change
|
|
1783
|
+
repo_root = Path.cwd()
|
|
1784
|
+
try:
|
|
1785
|
+
subprocess.run(
|
|
1786
|
+
["git", "add", str(ledger_path)],
|
|
1787
|
+
cwd=repo_root,
|
|
1788
|
+
env=_git_env(),
|
|
1789
|
+
check=True,
|
|
1790
|
+
capture_output=True,
|
|
1791
|
+
text=True,
|
|
1792
|
+
)
|
|
1793
|
+
if not stage_only:
|
|
1794
|
+
if message:
|
|
1795
|
+
# Custom message: combined commit (feature + ledger)
|
|
1796
|
+
subprocess.run(
|
|
1797
|
+
["git", "add", "-A"],
|
|
1798
|
+
cwd=repo_root,
|
|
1799
|
+
env=_git_env(),
|
|
1800
|
+
check=True,
|
|
1801
|
+
capture_output=True,
|
|
1802
|
+
text=True,
|
|
1803
|
+
)
|
|
1804
|
+
subject = format_commit_message(message[0], repo_root)
|
|
1805
|
+
cmd: list[str] = ["git", "commit", "-m", subject]
|
|
1806
|
+
for body in message[1:]:
|
|
1807
|
+
cmd.extend(["-m", body])
|
|
1808
|
+
else:
|
|
1809
|
+
subject = format_commit_message(
|
|
1810
|
+
f"chore({issue_id}): mark COMPLETED in ledger", repo_root
|
|
1811
|
+
)
|
|
1812
|
+
cmd = ["git", "commit", "-m", subject]
|
|
1813
|
+
subprocess.run(
|
|
1814
|
+
cmd,
|
|
1815
|
+
cwd=repo_root,
|
|
1816
|
+
env=_git_env(),
|
|
1817
|
+
check=True,
|
|
1818
|
+
capture_output=True,
|
|
1819
|
+
text=True,
|
|
1820
|
+
)
|
|
1821
|
+
console.print("[green]COMMITTED[/]")
|
|
1822
|
+
else:
|
|
1823
|
+
console.print("[green]LEDGER_STAGED[/]")
|
|
1824
|
+
except subprocess.CalledProcessError as e:
|
|
1825
|
+
stderr = (e.stderr or "").strip()
|
|
1826
|
+
if "nothing to commit" in stderr:
|
|
1827
|
+
console.print("[yellow]LEDGER_UNCHANGED[/]")
|
|
1828
|
+
else:
|
|
1829
|
+
console.print(f"[yellow]COMMIT_WARN[/] {stderr}")
|
|
1830
|
+
# Optional cleanup (skip in stage-only mode)
|
|
1831
|
+
if not stage_only:
|
|
1832
|
+
if delete_worktree:
|
|
1833
|
+
worktree_path = Path.cwd()
|
|
1834
|
+
if _is_linked_worktree(worktree_path):
|
|
1835
|
+
remove_worktree(worktree_path)
|
|
1836
|
+
console.print(f"[green]WORKTREE_REMOVED[/] {worktree_path}")
|
|
1837
|
+
else:
|
|
1838
|
+
console.print("[yellow]SKIP_WORKTREE[/] not in a linked worktree")
|
|
1839
|
+
|
|
1840
|
+
if delete_branch:
|
|
1841
|
+
branch_name = (
|
|
1842
|
+
f"feat/{_resolve_bucket_dir(record.source_file)}"
|
|
1843
|
+
f"/{_source_stem(record.source_file)}"
|
|
1844
|
+
)
|
|
1845
|
+
try:
|
|
1846
|
+
subprocess.run(
|
|
1847
|
+
["git", "branch", "-D", branch_name],
|
|
1848
|
+
cwd=Path.cwd(),
|
|
1849
|
+
env=_git_env(),
|
|
1850
|
+
check=True,
|
|
1851
|
+
capture_output=True,
|
|
1852
|
+
text=True,
|
|
1853
|
+
)
|
|
1854
|
+
console.print(f"[green]BRANCH_DELETED[/] {branch_name}")
|
|
1855
|
+
except subprocess.CalledProcessError:
|
|
1856
|
+
console.print(f"[yellow]BRANCH_SKIP[/] {branch_name} not found locally")
|
|
1857
|
+
|
|
1858
|
+
session.active_issue_id = None
|
|
1859
|
+
session.current_phase = "IDLE"
|
|
1860
|
+
_save_session(session, session_path, "IDLE")
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def merge(
|
|
1864
|
+
issue: str | None = typer.Option(
|
|
1865
|
+
None, "--issue", help="Issue ID to mark completed"
|
|
1866
|
+
),
|
|
1867
|
+
delete_branch: bool = typer.Option(
|
|
1868
|
+
False, "--delete-branch", help="Delete the feature branch"
|
|
1869
|
+
),
|
|
1870
|
+
delete_worktree: bool = typer.Option(
|
|
1871
|
+
False, "--delete-worktree", help="Remove the worktree"
|
|
1872
|
+
),
|
|
1873
|
+
stage_only: bool = typer.Option(
|
|
1874
|
+
False,
|
|
1875
|
+
"--stage-only",
|
|
1876
|
+
help="Append to ledger and stage, but do not commit (for folding into squash-merge commit)",
|
|
1877
|
+
),
|
|
1878
|
+
message: list[str] = typer.Option(
|
|
1879
|
+
[],
|
|
1880
|
+
"-m",
|
|
1881
|
+
"--message",
|
|
1882
|
+
help="Commit message. The first -m is the subject (formatted via "
|
|
1883
|
+
"the project's emoji convention); remaining -m values are passed "
|
|
1884
|
+
"verbatim as body paragraphs. Repeat the flag for each paragraph.",
|
|
1885
|
+
),
|
|
1886
|
+
) -> None:
|
|
1887
|
+
"""Mark an issue COMPLETED after an external merge (e.g. squash-merge)."""
|
|
1888
|
+
_merge_run(
|
|
1889
|
+
issue_id=issue,
|
|
1890
|
+
delete_branch=delete_branch,
|
|
1891
|
+
delete_worktree=delete_worktree,
|
|
1892
|
+
stage_only=stage_only,
|
|
1893
|
+
message=message,
|
|
1894
|
+
)
|