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.
Files changed (124) hide show
  1. deviatdd-2.5.1.dist-info/METADATA +386 -0
  2. deviatdd-2.5.1.dist-info/RECORD +124 -0
  3. deviatdd-2.5.1.dist-info/WHEEL +4 -0
  4. deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
  5. deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
  6. deviate/__init__.py +3 -0
  7. deviate/cli/__init__.py +824 -0
  8. deviate/cli/_common.py +155 -0
  9. deviate/cli/adhoc.py +183 -0
  10. deviate/cli/constitution.py +113 -0
  11. deviate/cli/feature.py +94 -0
  12. deviate/cli/init.py +485 -0
  13. deviate/cli/inspect.py +208 -0
  14. deviate/cli/macro.py +937 -0
  15. deviate/cli/meso.py +1894 -0
  16. deviate/cli/micro.py +3249 -0
  17. deviate/cli/review.py +441 -0
  18. deviate/core/__init__.py +0 -0
  19. deviate/core/_shared.py +20 -0
  20. deviate/core/agent.py +505 -0
  21. deviate/core/cache_discipline.py +65 -0
  22. deviate/core/commands.py +202 -0
  23. deviate/core/commit.py +86 -0
  24. deviate/core/complexity.py +36 -0
  25. deviate/core/constitution.py +85 -0
  26. deviate/core/contract.py +17 -0
  27. deviate/core/convention.py +147 -0
  28. deviate/core/epic.py +73 -0
  29. deviate/core/issues.py +46 -0
  30. deviate/core/prd.py +18 -0
  31. deviate/core/profile.py +33 -0
  32. deviate/core/repo.py +47 -0
  33. deviate/core/run_logger.py +50 -0
  34. deviate/core/tasks_ledger.py +69 -0
  35. deviate/core/treesitter/__init__.py +31 -0
  36. deviate/core/treesitter/analysis.py +457 -0
  37. deviate/core/treesitter/models.py +35 -0
  38. deviate/core/treesitter/parser.py +184 -0
  39. deviate/core/treesitter/queries/bash.scm +7 -0
  40. deviate/core/treesitter/queries/c_sharp.scm +11 -0
  41. deviate/core/treesitter/queries/cpp.scm +9 -0
  42. deviate/core/treesitter/queries/css.scm +4 -0
  43. deviate/core/treesitter/queries/dockerfile.scm +8 -0
  44. deviate/core/treesitter/queries/elixir.scm +6 -0
  45. deviate/core/treesitter/queries/go.scm +9 -0
  46. deviate/core/treesitter/queries/hcl.scm +3 -0
  47. deviate/core/treesitter/queries/html.scm +3 -0
  48. deviate/core/treesitter/queries/javascript.scm +12 -0
  49. deviate/core/treesitter/queries/json.scm +4 -0
  50. deviate/core/treesitter/queries/kotlin.scm +8 -0
  51. deviate/core/treesitter/queries/markdown.scm +4 -0
  52. deviate/core/treesitter/queries/python.scm +10 -0
  53. deviate/core/treesitter/queries/rust.scm +12 -0
  54. deviate/core/treesitter/queries/sql.scm +7 -0
  55. deviate/core/treesitter/queries/swift.scm +10 -0
  56. deviate/core/treesitter/queries/toml.scm +3 -0
  57. deviate/core/treesitter/queries/tsx.scm +14 -0
  58. deviate/core/treesitter/queries/typescript.scm +14 -0
  59. deviate/core/treesitter/queries/yaml.scm +4 -0
  60. deviate/core/validation.py +153 -0
  61. deviate/core/worktree.py +141 -0
  62. deviate/main.py +4 -0
  63. deviate/prompts/__init__.py +0 -0
  64. deviate/prompts/assembly.py +122 -0
  65. deviate/prompts/auto/__init__.py +1 -0
  66. deviate/prompts/auto/execute.md +62 -0
  67. deviate/prompts/auto/explore.md +103 -0
  68. deviate/prompts/auto/green.md +137 -0
  69. deviate/prompts/auto/judge.md +260 -0
  70. deviate/prompts/auto/plan.md +127 -0
  71. deviate/prompts/auto/prd.md +108 -0
  72. deviate/prompts/auto/red.md +156 -0
  73. deviate/prompts/auto/refactor.md +166 -0
  74. deviate/prompts/auto/research.md +111 -0
  75. deviate/prompts/auto/shard.md +90 -0
  76. deviate/prompts/auto/specify.md +77 -0
  77. deviate/prompts/auto/tasks.md +191 -0
  78. deviate/prompts/commands/deviate-adhoc.md +216 -0
  79. deviate/prompts/commands/deviate-architecture.md +147 -0
  80. deviate/prompts/commands/deviate-constitution.md +215 -0
  81. deviate/prompts/commands/deviate-e2e.md +264 -0
  82. deviate/prompts/commands/deviate-execute.md +223 -0
  83. deviate/prompts/commands/deviate-explore.md +253 -0
  84. deviate/prompts/commands/deviate-flows.md +226 -0
  85. deviate/prompts/commands/deviate-green.md +217 -0
  86. deviate/prompts/commands/deviate-hotfix.md +188 -0
  87. deviate/prompts/commands/deviate-init.md +170 -0
  88. deviate/prompts/commands/deviate-judge.md +193 -0
  89. deviate/prompts/commands/deviate-merge.md +221 -0
  90. deviate/prompts/commands/deviate-plan.md +158 -0
  91. deviate/prompts/commands/deviate-pr.md +144 -0
  92. deviate/prompts/commands/deviate-prd.md +216 -0
  93. deviate/prompts/commands/deviate-prune.md +260 -0
  94. deviate/prompts/commands/deviate-red.md +231 -0
  95. deviate/prompts/commands/deviate-refactor.md +211 -0
  96. deviate/prompts/commands/deviate-release.md +123 -0
  97. deviate/prompts/commands/deviate-research.md +359 -0
  98. deviate/prompts/commands/deviate-review.md +289 -0
  99. deviate/prompts/commands/deviate-shard.md +226 -0
  100. deviate/prompts/commands/deviate-tasks.md +281 -0
  101. deviate/prompts/commands/deviate-triage.md +141 -0
  102. deviate/prompts/constitution_seed.md +51 -0
  103. deviate/prompts/core/core.md +21 -0
  104. deviate/prompts/core/macro-auto.md +59 -0
  105. deviate/prompts/core/macro-command.md +54 -0
  106. deviate/prompts/core/macro-skill.md +54 -0
  107. deviate/prompts/core/meso-auto.md +63 -0
  108. deviate/prompts/core/meso-command.md +58 -0
  109. deviate/prompts/core/meso-skill.md +58 -0
  110. deviate/prompts/core/micro-auto.md +76 -0
  111. deviate/prompts/core/micro-command.md +67 -0
  112. deviate/prompts/core/micro-skill.md +67 -0
  113. deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
  114. deviate/prompts/governance/__init__.py +0 -0
  115. deviate/prompts/governance/agents_seed.md +1 -0
  116. deviate/prompts/governance/claudemd_seed.md +1 -0
  117. deviate/prompts/governance/graphite_seed.md +18 -0
  118. deviate/prompts/governance/libref_seed.md +3 -0
  119. deviate/state/__init__.py +0 -0
  120. deviate/state/config.py +257 -0
  121. deviate/state/ledger.py +407 -0
  122. deviate/ui/__init__.py +5 -0
  123. deviate/ui/monitor.py +187 -0
  124. 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
+ )