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/macro.py ADDED
@@ -0,0 +1,937 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from deviate.cli._common import (
12
+ _build_slim_prompt,
13
+ _extract_epic_num,
14
+ _halt,
15
+ _handle_missing_dot_dir,
16
+ _load_manifest,
17
+ _run_pre_commit_hooks,
18
+ _validate_constitution,
19
+ console,
20
+ with_json_quiet,
21
+ )
22
+ from deviate.core._shared import git_env as _git_env
23
+ from deviate.core.agent import AgentBackend, AgentSubprocessError
24
+ from deviate.core.commit import commit_artifact, stage_and_commit
25
+ from deviate.core.convention import format_commit_message
26
+ from deviate.core.constitution import extract_commands, resolve_constitution
27
+ from deviate.cli.feature import _derive_slug
28
+ from deviate.core.epic import (
29
+ allocate_feature_bucket,
30
+ discover_latest_epic,
31
+ resolve_active_feature,
32
+ )
33
+ from deviate.core.prd import extract_prd_requirements
34
+ from deviate.core.repo import find_repo_root
35
+ from deviate.core.validation import (
36
+ ARTIFACT_VALIDATORS,
37
+ extract_section_body,
38
+ validate_artifact,
39
+ validate_gherkin_syntax,
40
+ validate_sections,
41
+ validate_source_file,
42
+ validate_yaml_frontmatter,
43
+ )
44
+ from deviate.state.config import SessionState, resolve_model_for_phase
45
+ from deviate.state.ledger import IssueRecord, _read_ledger, append_issue_record
46
+
47
+
48
+ def _load_or_create_session(phase: str) -> tuple[SessionState, Path]:
49
+ dot_dir = Path(".deviate")
50
+ if not dot_dir.exists():
51
+ _handle_missing_dot_dir(phase)
52
+ session_path = dot_dir / "session.json"
53
+ if not session_path.exists():
54
+ session = SessionState.reconstruct_from_worktree(Path.cwd())
55
+ session.save(session_path)
56
+ else:
57
+ session = SessionState.load(session_path)
58
+ return session, session_path
59
+
60
+
61
+ def _load_and_transition(phase: str) -> tuple[SessionState, Path]:
62
+ session, session_path = _load_or_create_session(phase)
63
+ session = session.transition_to(phase)
64
+ return session, session_path
65
+
66
+
67
+ def _load_session_accept(
68
+ *phases: str, force: bool = False
69
+ ) -> tuple[SessionState, Path]:
70
+ """Load session, accepting any phase — state is purely descriptive."""
71
+ phase_tag = phases[0] if phases else "?"
72
+ session, session_path = _load_or_create_session(phase_tag)
73
+ return session, session_path
74
+
75
+
76
+ def _load_session(phase: str) -> tuple[SessionState, Path]:
77
+ session, session_path = _load_or_create_session(phase)
78
+ return session, session_path
79
+
80
+
81
+ def _save_session(session: SessionState, session_path: Path, phase: str) -> None:
82
+ session.save(session_path)
83
+ console.print(f"[green]{phase}[/] session advanced to {phase} phase")
84
+
85
+
86
+ def _load_session_for_phase(
87
+ phase: str, dry_run: bool = False
88
+ ) -> tuple[SessionState, Path]:
89
+ if dry_run:
90
+ console.print("[yellow]DRY_RUN[/] skipping phase transition")
91
+ dot_dir = Path(".deviate")
92
+ session_path = dot_dir / "session.json"
93
+ session = SessionState.load(session_path)
94
+ return session, session_path
95
+ session, session_path = _load_or_create_session(phase)
96
+ session = session.transition_to(phase)
97
+ return session, session_path
98
+
99
+
100
+ def _resolve_specs_root() -> Path:
101
+ return Path("specs")
102
+
103
+
104
+ def _resolve_repo_context() -> dict:
105
+ try:
106
+ repo_root = find_repo_root()
107
+ except ValueError:
108
+ repo_root = Path.cwd()
109
+ try:
110
+ result = subprocess.run(
111
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
112
+ cwd=repo_root,
113
+ env=_git_env(),
114
+ capture_output=True,
115
+ text=True,
116
+ check=True,
117
+ )
118
+ branch = result.stdout.strip()
119
+ except subprocess.CalledProcessError:
120
+ branch = "detached"
121
+ return {
122
+ "repo_root": str(repo_root.resolve()),
123
+ "git_branch": branch,
124
+ }
125
+
126
+
127
+ def _resolve_constitution_commands() -> dict:
128
+ try:
129
+ repo_root = find_repo_root()
130
+ const_path = resolve_constitution(repo_root)
131
+ commands = extract_commands(const_path)
132
+ test_cmd = commands.get("test_command", "")
133
+ lint_cmd = commands.get("lint_command", "")
134
+ type_check_cmd = commands.get("type_check_command", "")
135
+ const_path_str = str(const_path)
136
+ except (FileNotFoundError, ValueError):
137
+ const_path_str = ""
138
+ test_cmd = ""
139
+ lint_cmd = ""
140
+ type_check_cmd = ""
141
+ return {
142
+ "constitution_path": const_path_str,
143
+ "test_cmd": test_cmd,
144
+ "lint_cmd": lint_cmd,
145
+ "type_check_cmd": type_check_cmd,
146
+ "test_command": test_cmd,
147
+ "lint_command": lint_cmd,
148
+ "type_check_command": type_check_cmd,
149
+ "constitution_test_command": test_cmd,
150
+ "constitution_lint_command": lint_cmd,
151
+ }
152
+
153
+
154
+ def _emit_contract(
155
+ phase: str,
156
+ session: SessionState,
157
+ session_path: Path,
158
+ dry_run: bool = False,
159
+ **extra: str | int | bool | None,
160
+ ) -> dict[str, object]:
161
+ repo_ctx = _resolve_repo_context()
162
+ const_cmds = _resolve_constitution_commands()
163
+ contract = {
164
+ **repo_ctx,
165
+ **const_cmds,
166
+ "status": "DRY_RUN" if dry_run else "READY",
167
+ "phase": phase,
168
+ "timestamp": datetime.now(timezone.utc).isoformat(),
169
+ "dry_run": dry_run,
170
+ **extra,
171
+ }
172
+ print(json.dumps(contract, indent=2))
173
+ if not dry_run:
174
+ _save_session(session, session_path, phase)
175
+ return contract
176
+
177
+
178
+ def _compute_next_issue_id(ledger_path: Path) -> str:
179
+ records = _read_ledger(ledger_path)
180
+ numbers: list[int] = []
181
+ for data in records:
182
+ iid = data.get("issue_id", "")
183
+ if isinstance(iid, str) and iid.startswith("ISS-"):
184
+ try:
185
+ numbers.append(int(iid.split("-")[1]))
186
+ except (ValueError, IndexError):
187
+ continue
188
+ next_num = (max(numbers) + 1) if numbers else 1
189
+ return f"ISS-{next_num:03d}"
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Explore
194
+ # ---------------------------------------------------------------------------
195
+
196
+ explore_app = typer.Typer(no_args_is_help=True, help="Explore phase commands")
197
+
198
+
199
+ @explore_app.command("pre")
200
+ @with_json_quiet
201
+ def explore_pre(
202
+ problem: str = typer.Argument(..., help="Problem description"),
203
+ slug: str | None = typer.Option(None, "--slug", help="Explore slug"),
204
+ ) -> None:
205
+ """Allocate explore directory and register scratch entry"""
206
+ _validate_constitution("EXPLORE")
207
+
208
+ session, session_path = _load_and_transition("EXPLORE")
209
+
210
+ specs_root = _resolve_specs_root()
211
+ final_slug = slug or _derive_slug(problem)
212
+ explore_dir = specs_root / "explore"
213
+ explore_dir.mkdir(parents=True, exist_ok=True)
214
+
215
+ spec_target_rel = f"specs/explore/{final_slug}.md"
216
+ spec_target_abs = str((explore_dir / f"{final_slug}.md").resolve())
217
+
218
+ console.print(f"[green]EXPLORE_DIR_CREATED[/] {explore_dir}")
219
+
220
+ _emit_contract(
221
+ "EXPLORE",
222
+ session,
223
+ session_path,
224
+ epic_id=final_slug or "",
225
+ is_greenfield=False,
226
+ feature_slug=final_slug,
227
+ feature_dir=str(explore_dir),
228
+ specs_directory=str(specs_root),
229
+ spec_target=spec_target_rel,
230
+ spec_target_abs=spec_target_abs,
231
+ feature_bucket=final_slug,
232
+ explore_path=spec_target_abs,
233
+ problem=problem,
234
+ slug=final_slug,
235
+ bucket_path=str(explore_dir),
236
+ issue_id="",
237
+ )
238
+
239
+
240
+ @explore_app.command("post")
241
+ def explore_post(
242
+ slug: str | None = typer.Option(None, "--slug", help="Explore slug"),
243
+ force: bool = typer.Option(False, "--force", help="Bypass phase validation"),
244
+ ) -> None:
245
+ """Validate explore.md and commit"""
246
+ session, session_path = _load_session_accept("EXPLORE", force=force)
247
+
248
+ specs_root = _resolve_specs_root()
249
+ explore_dir = specs_root / "explore"
250
+
251
+ if slug:
252
+ explore_path = explore_dir / f"{slug}.md"
253
+ else:
254
+ explores = sorted(explore_dir.glob("*.md"))
255
+ if not explores:
256
+ _halt("EXPLORE", "no explore.md found in specs/explore/")
257
+ explore_path = explores[-1]
258
+
259
+ if not explore_path.exists():
260
+ _halt("EXPLORE", f"explore.md not found at {explore_path}")
261
+
262
+ content = explore_path.read_text(encoding="utf-8")
263
+ if not content.strip():
264
+ _halt("EXPLORE", "explore.md is empty")
265
+
266
+ result = validate_artifact(content, "explore")
267
+ if not result.passed:
268
+ _halt("EXPLORE", f"missing required sections: {', '.join(result.errors)}")
269
+
270
+ _run_pre_commit_hooks()
271
+
272
+ sha = commit_artifact(
273
+ explore_path,
274
+ format_commit_message(f"docs(explore): scan {explore_path.stem}", Path.cwd()),
275
+ repo=Path.cwd(),
276
+ )
277
+ if sha is None:
278
+ console.print(f"[yellow]COMMIT_SKIP[/] {explore_path} — no changes to stage")
279
+ else:
280
+ console.print(f"[green]COMMITTED[/] {explore_path}")
281
+
282
+ _save_session(session, session_path, "EXPLORE")
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Research
287
+ # ---------------------------------------------------------------------------
288
+
289
+ research_app = typer.Typer(no_args_is_help=True, help="Research phase commands")
290
+
291
+
292
+ @research_app.command("pre")
293
+ @with_json_quiet
294
+ def research_pre(
295
+ slug: str | None = typer.Option(
296
+ None,
297
+ "--slug",
298
+ help="Explore slug (e.g. offline-context-docs). Auto-discovers latest from specs/explore/ if omitted.",
299
+ ),
300
+ ) -> None:
301
+ """Gate on explore.md, allocate numbered epic bucket, validate constitution"""
302
+ specs_root = _resolve_specs_root()
303
+
304
+ # Discover slug if not provided — scan specs/explore/ for latest
305
+ resolved_slug = slug
306
+ if not resolved_slug:
307
+ explore_dir = specs_root / "explore"
308
+ if explore_dir.exists():
309
+ md_files = sorted(explore_dir.glob("*.md"))
310
+ if md_files:
311
+ resolved_slug = md_files[-1].stem
312
+
313
+ if not resolved_slug:
314
+ _halt(
315
+ "RESEARCH",
316
+ "no explore slug provided and no explore.md found in specs/explore/",
317
+ )
318
+
319
+ # Resolve explore.md — try specs/explore/<slug>.md first, fall back to old format
320
+ explore_path = specs_root / "explore" / f"{resolved_slug}.md"
321
+ if not explore_path.exists():
322
+ # Fallback: old format specs/<slug>/explore.md
323
+ alt = specs_root / resolved_slug / "explore.md"
324
+ if alt.exists():
325
+ explore_path = alt
326
+
327
+ if not explore_path.exists():
328
+ _halt("RESEARCH", f"explore.md not found at specs/explore/{resolved_slug}.md")
329
+
330
+ _validate_constitution("RESEARCH")
331
+
332
+ session, session_path = _load_and_transition("RESEARCH")
333
+
334
+ # Allocate numbered epic bucket for downstream phases
335
+ bucket = allocate_feature_bucket(resolved_slug)
336
+ epic_slug = bucket.name
337
+ feature_dir = specs_root / epic_slug
338
+
339
+ issues_ledger = str(specs_root / "issues.jsonl")
340
+ explore_md_abs = str(explore_path.resolve())
341
+ explore_md_rel = str(explore_path)
342
+ design_target_abs = str((feature_dir / "design.md").resolve())
343
+ data_model_target_abs = str((feature_dir / "data-model.md").resolve())
344
+
345
+ console.print(f"[green]EPIC_CREATED[/] {bucket}")
346
+
347
+ _emit_contract(
348
+ "RESEARCH",
349
+ session,
350
+ session_path,
351
+ is_greenfield=False,
352
+ epic_id=epic_slug,
353
+ feature_slug=epic_slug,
354
+ feature_dir=str(feature_dir),
355
+ specs_directory=str(specs_root),
356
+ explore_md_path=explore_md_abs,
357
+ explore_md_rel=explore_md_rel,
358
+ design_target=str(feature_dir / "design.md"),
359
+ design_target_abs=design_target_abs,
360
+ data_model_target=str(feature_dir / "data-model.md"),
361
+ data_model_target_abs=data_model_target_abs,
362
+ issues_ledger=issues_ledger,
363
+ issue_id="",
364
+ feature_bucket=epic_slug,
365
+ explore_path=str(explore_path),
366
+ epic_slug=epic_slug,
367
+ )
368
+
369
+
370
+ @research_app.command("post")
371
+ def research_post(
372
+ epic: str | None = typer.Option(
373
+ None, "--epic", help="Epic slug (e.g. 003-prompt-optimization)"
374
+ ),
375
+ force: bool = typer.Option(False, "--force", help="Bypass phase validation"),
376
+ ) -> None:
377
+ """Validate research artifacts and create a single commit for all"""
378
+ session, session_path = _load_session_accept("RESEARCH", force=force)
379
+
380
+ _validate_constitution("RESEARCH")
381
+
382
+ specs_root = _resolve_specs_root()
383
+ epic_slug = epic or resolve_active_feature(specs_root)
384
+ if not epic_slug:
385
+ _halt("RESEARCH", "no active feature bucket found")
386
+
387
+ artifacts: list[Path] = []
388
+ for artifact, atype in (
389
+ ("design.md", "design"),
390
+ ("data-model.md", "data_model"),
391
+ ):
392
+ path = specs_root / epic_slug / artifact
393
+ if not path.exists():
394
+ _halt("RESEARCH", f"{artifact} not found in {epic_slug}")
395
+ content = path.read_text(encoding="utf-8")
396
+ if not content.strip():
397
+ _halt("RESEARCH", f"{artifact} is empty")
398
+ result = validate_artifact(content, atype)
399
+ if not result.passed:
400
+ _halt(
401
+ "RESEARCH", f"{artifact} missing sections: {', '.join(result.errors)}"
402
+ )
403
+ artifacts.append(path)
404
+
405
+ # Include constitution.md if it was created/modified (greenfield bootstrap)
406
+ const_path = resolve_constitution(Path.cwd())
407
+ if const_path and const_path.exists():
408
+ artifacts.append(const_path)
409
+
410
+ _run_pre_commit_hooks()
411
+
412
+ epic_num = _extract_epic_num(epic_slug)
413
+ message = format_commit_message(
414
+ f"docs({epic_num}): add research artifacts (design.md, data-model.md)",
415
+ Path.cwd(),
416
+ )
417
+ sha = stage_and_commit(message=message, files=artifacts, repo=Path.cwd())
418
+ if sha is None:
419
+ console.print("[yellow]COMMIT_SKIP[/] research artifacts — no changes to stage")
420
+ else:
421
+ console.print(f"[green]COMMITTED[/] research artifacts at {sha[:8]}")
422
+
423
+ _save_session(session, session_path, "RESEARCH")
424
+
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # PRD
428
+ # ---------------------------------------------------------------------------
429
+
430
+
431
+ def _check_pending_hitl_decisions(design_path: Path) -> list[str]:
432
+ """Check design.md for unresolved HITL decisions.
433
+
434
+ Returns a list of pending decision descriptions. If empty, Gate 1 is clear.
435
+ """
436
+ if not design_path.exists():
437
+ # Let the existence check above handle this
438
+ return []
439
+
440
+ content = design_path.read_text(encoding="utf-8")
441
+ section_body = extract_section_body(content, "Pending HITL Decisions")
442
+ if not section_body:
443
+ # Section missing — the ARTIFACT_VALIDATORS check will catch this
444
+ # during research_post; treat as clear for PRD to proceed
445
+ return []
446
+
447
+ import re
448
+
449
+ pending_rows: list[str] = []
450
+ for line in section_body.splitlines():
451
+ line = line.strip()
452
+ # Match table data rows: starts with | and ends with | PENDING |
453
+ if line.startswith("|") and re.search(r"\|\s*PENDING\s*\|", line):
454
+ # Extract the Decision ID column (first cell after opening pipe)
455
+ parts = [p.strip() for p in line.split("|")]
456
+ decision_id = parts[1] if len(parts) > 1 else "?"
457
+ pending_rows.append(decision_id)
458
+ return pending_rows
459
+
460
+
461
+ prd_app = typer.Typer(no_args_is_help=True, help="PRD phase commands")
462
+
463
+
464
+ @prd_app.command("pre")
465
+ @with_json_quiet
466
+ def prd_pre(
467
+ epic: str | None = typer.Option(
468
+ None, "--epic", help="Epic slug (e.g. 003-prompt-optimization)"
469
+ ),
470
+ dry_run: bool = typer.Option(
471
+ False, "--dry-run", help="Preview contract without side effects"
472
+ ),
473
+ ) -> None:
474
+ """Discover epic slug, resolve upstream artifacts"""
475
+ specs_root = _resolve_specs_root()
476
+ epic_slug = epic or discover_latest_epic(specs_root)
477
+ if not epic_slug:
478
+ _halt("PRD", "no epic discovered")
479
+ epic_dir = specs_root / epic_slug
480
+
481
+ required = ["design.md", "data-model.md"]
482
+ missing = [a for a in required if not (epic_dir / a).exists()]
483
+ if missing:
484
+ paths = "\n - ".join(str(specs_root / epic_slug / a) for a in missing)
485
+ _halt("PRD", f"missing upstream artifacts\n - {paths}")
486
+
487
+ design_path = epic_dir / "design.md"
488
+ pending = _check_pending_hitl_decisions(design_path)
489
+ if pending:
490
+ console.print("[red]HITL GATE 1 — UNRESOLVED DECISIONS[/]")
491
+ console.print(
492
+ "The following items in `## Pending HITL Decisions` require human resolution:"
493
+ )
494
+ for item in pending:
495
+ console.print(f" [yellow]•[/] {item}")
496
+ console.print()
497
+ console.print(
498
+ "Edit design.md to resolve each item (set Status to `RESOLVED`), then re-run."
499
+ )
500
+ _halt("PRD", "HITL Gate 1 not passed — pending decisions exist")
501
+
502
+ session, session_path = _load_session_for_phase("PRD", dry_run=dry_run)
503
+
504
+ artifacts_dir = Path(".deviate") / "artifacts"
505
+ artifacts_dir.mkdir(parents=True, exist_ok=True)
506
+
507
+ return _emit_contract(
508
+ "PRD",
509
+ session,
510
+ session_path,
511
+ dry_run=dry_run,
512
+ epic_slug=epic_slug,
513
+ feature_bucket=epic_slug,
514
+ design_path=str(epic_dir / "design.md"),
515
+ data_model_path=str(epic_dir / "data-model.md"),
516
+ plan_target=str(artifacts_dir / "manifest_prd.json"),
517
+ issue_id=session.active_issue_id or "",
518
+ )
519
+
520
+
521
+ @prd_app.command("post")
522
+ def prd_post(
523
+ manifest: Path = typer.Argument(..., help="Path to manifest JSON file"),
524
+ force: bool = typer.Option(False, "--force", help="Bypass phase validation"),
525
+ ) -> None:
526
+ """Read manifest, validate PRD, commit"""
527
+ session, session_path = _load_session_accept("PRD", force=force)
528
+
529
+ manifest_data = _load_manifest(manifest, "PRD")
530
+
531
+ epic_slug = manifest_data.get("epic_slug", "")
532
+ if not epic_slug:
533
+ _halt("PRD", "manifest missing 'epic_slug'")
534
+
535
+ prd_path = _resolve_specs_root() / epic_slug / "prd.md"
536
+ if not prd_path.exists():
537
+ _halt("PRD", f"prd.md not found at {prd_path}")
538
+
539
+ prd_content = prd_path.read_text(encoding="utf-8")
540
+ prd_required = ARTIFACT_VALIDATORS.get("prd", [])
541
+ missing_sections = validate_sections(prd_content, prd_required)
542
+ if missing_sections:
543
+ console.print(
544
+ f"[yellow]PRD_WARNING[/] missing required sections: {', '.join(missing_sections)}"
545
+ )
546
+
547
+ gherkin_errors = validate_gherkin_syntax(prd_content)
548
+ if gherkin_errors:
549
+ _halt("PRD", f"invalid Gherkin syntax: {'; '.join(gherkin_errors)}")
550
+
551
+ reqs = extract_prd_requirements(prd_path)
552
+ manifest_reqs = manifest_data.get("prd_requirements", [])
553
+ missing = [r for r in manifest_reqs if r not in reqs]
554
+ if missing:
555
+ console.print(
556
+ f"[yellow]PRD_WARNING[/] missing requirements in prd.md: {missing}"
557
+ )
558
+
559
+ _run_pre_commit_hooks()
560
+
561
+ try:
562
+ epic_num = _extract_epic_num(epic_slug)
563
+ sha = commit_artifact(
564
+ prd_path,
565
+ format_commit_message(f"docs({epic_num}): create prd.md", Path.cwd()),
566
+ repo=Path.cwd(),
567
+ )
568
+ if sha is None:
569
+ console.print("[yellow]COMMIT_SKIP[/] prd.md — no changes to stage")
570
+ else:
571
+ console.print(f"[green]COMMITTED[/] prd.md at {sha[:8]}")
572
+ except Exception as e:
573
+ _halt("PRD", f"commit failed - {e}")
574
+
575
+ _save_session(session, session_path, "PRD")
576
+
577
+
578
+ # ---------------------------------------------------------------------------
579
+ # Shard
580
+ # ---------------------------------------------------------------------------
581
+
582
+ shard_app = typer.Typer(no_args_is_help=True, help="Shard phase commands")
583
+
584
+
585
+ @shard_app.command("pre")
586
+ @with_json_quiet
587
+ def shard_pre(
588
+ epic: str | None = typer.Option(
589
+ None, "--epic", help="Epic slug (e.g. 002-deviatdd-gap-analysis)"
590
+ ),
591
+ dry_run: bool = typer.Option(
592
+ False, "--dry-run", help="Preview contract without side effects"
593
+ ),
594
+ ) -> None:
595
+ """Discover epic, resolve PRD, compute next_issue_id"""
596
+ specs_root = _resolve_specs_root()
597
+ epic_slug = epic or discover_latest_epic(specs_root)
598
+ if not epic_slug:
599
+ _halt("SHARD", "no epic discovered")
600
+
601
+ prd_path = specs_root / epic_slug / "prd.md"
602
+ if not prd_path.exists():
603
+ _halt("SHARD", f"prd.md not found at {prd_path}")
604
+
605
+ ledger_path = _resolve_specs_root() / "issues.jsonl"
606
+ ledger_path.parent.mkdir(parents=True, exist_ok=True)
607
+
608
+ next_issue_id = _compute_next_issue_id(ledger_path)
609
+
610
+ session, session_path = _load_session_for_phase("SHARD", dry_run=dry_run)
611
+
612
+ epic_path = specs_root / epic_slug
613
+ issues_dir = epic_path / "issues"
614
+ shard_count = len(list(issues_dir.glob("*.md"))) if issues_dir.exists() else 0
615
+
616
+ artifacts_dir = Path(".deviate") / "artifacts"
617
+ artifacts_dir.mkdir(parents=True, exist_ok=True)
618
+
619
+ _emit_contract(
620
+ "SHARD",
621
+ session,
622
+ session_path,
623
+ dry_run=dry_run,
624
+ epic_slug=epic_slug,
625
+ prd_path=str(prd_path),
626
+ next_issue_id=next_issue_id,
627
+ issues_dir=str(issues_dir),
628
+ plan_target=str(artifacts_dir / "manifest_shard.json"),
629
+ issue_id=session.active_issue_id or "",
630
+ shard_count=shard_count,
631
+ )
632
+
633
+
634
+ @shard_app.command("post")
635
+ def shard_post(
636
+ manifest: Path = typer.Argument(..., help="Path to shard manifest JSON file"),
637
+ epic: str | None = typer.Option(
638
+ None, "--epic", help="Override epic slug from manifest"
639
+ ),
640
+ force: bool = typer.Option(False, "--force", help="Bypass phase validation"),
641
+ ) -> None:
642
+ """Validate shard output, register issues as BACKLOG, reset session to IDLE"""
643
+ session, session_path = _load_session_accept("SHARD", force=force)
644
+
645
+ manifest_data = _load_manifest(manifest, "SHARD")
646
+
647
+ issues = manifest_data.get("issues", [])
648
+ if not issues:
649
+ _halt(
650
+ "SHARD",
651
+ "manifest missing 'issues' array — shard must declare at least one "
652
+ "IssueRecord-shaped object (issue_id, type, title, source_file, "
653
+ "blocked_by, coordinates_with)",
654
+ )
655
+ ledger_path = _resolve_specs_root() / "issues.jsonl"
656
+
657
+ epic_slug = epic or manifest_data.get("epic_slug", "")
658
+ if epic_slug:
659
+ issues_dir = _resolve_specs_root() / epic_slug / "issues"
660
+ if issues_dir.exists():
661
+ for shard_file in sorted(issues_dir.glob("*-*.md")):
662
+ shard_content = shard_file.read_text(encoding="utf-8")
663
+ if not shard_content.strip():
664
+ console.print(
665
+ f"[yellow]SHARD_WARNING[/] {shard_file.name} is empty"
666
+ )
667
+ elif not validate_yaml_frontmatter(shard_content):
668
+ console.print(
669
+ f"[yellow]SHARD_WARNING[/] invalid YAML frontmatter in {shard_file.name}"
670
+ )
671
+
672
+ for issue_data in issues:
673
+ source_file = issue_data.get("source_file", "")
674
+ inferred_epic = ""
675
+ if source_file:
676
+ parts = source_file.split("/")
677
+ if len(parts) >= 2 and parts[0] == "specs":
678
+ inferred_epic = parts[1]
679
+ validation_epic = epic_slug or inferred_epic
680
+ if not validate_source_file(source_file, validation_epic):
681
+ issue_id = issue_data.get("issue_id", "<missing>")
682
+ _halt(
683
+ "SHARD",
684
+ f"issue {issue_id!r} source_file {source_file!r} does not match "
685
+ f"required pattern specs/{validation_epic or '<epic>'}/issues/<file>.md "
686
+ "— downstream 'deviate meso run' relies on this shape to "
687
+ "derive epic bucket and issue slug",
688
+ )
689
+ record = IssueRecord(
690
+ issue_id=issue_data.get("issue_id", str(uuid.uuid4())),
691
+ type=issue_data.get("type", "feature"),
692
+ title=issue_data.get("title", ""),
693
+ status="BACKLOG",
694
+ source_file=source_file,
695
+ blocked_by=issue_data.get("blocked_by", []),
696
+ coordinates_with=issue_data.get("coordinates_with", []),
697
+ timestamp=datetime.now(timezone.utc),
698
+ flow_refs=issue_data.get("flow_refs", []),
699
+ )
700
+ appended = append_issue_record(record, ledger_path)
701
+ if appended:
702
+ console.print(
703
+ f"[green]LEDGER_APPENDED[/] {record.issue_id} ({record.title})"
704
+ )
705
+ else:
706
+ console.print(
707
+ f"[yellow]LEDGER_IDEMPOTENT[/] {record.issue_id} already exists"
708
+ )
709
+
710
+ _run_pre_commit_hooks()
711
+
712
+ artifacts: list[Path] = [ledger_path]
713
+ if epic_slug:
714
+ issues_dir = _resolve_specs_root() / epic_slug / "issues"
715
+ if issues_dir.exists():
716
+ artifacts.extend(sorted(issues_dir.glob("*-*.md")))
717
+ if (Path.cwd() / ".git").exists():
718
+ epic_num = _extract_epic_num(epic_slug)
719
+ sha = stage_and_commit(
720
+ message=format_commit_message(
721
+ f"docs({epic_num}): shard issue files and ledger", Path.cwd()
722
+ ),
723
+ files=artifacts,
724
+ repo=Path.cwd(),
725
+ )
726
+ if sha is None:
727
+ console.print(
728
+ "[yellow]COMMIT_SKIP[/] shard artifacts — no changes to stage"
729
+ )
730
+ else:
731
+ console.print(f"[green]COMMITTED[/] shard artifacts at {sha[:8]}")
732
+ else:
733
+ console.print("[yellow]COMMIT_SKIP[/] not a git repository")
734
+
735
+ session = session.transition_to("IDLE")
736
+ session.save(session_path)
737
+ console.print("[green]SHARD_POST[/] session reset to IDLE")
738
+
739
+
740
+ # ---------------------------------------------------------------------------
741
+ # Macro automated pipeline helpers
742
+ # ---------------------------------------------------------------------------
743
+
744
+
745
+ def _invoke_agent_phase(phase: str, contract: dict[str, str]) -> None:
746
+ prompt = _build_slim_prompt(phase, contract)
747
+ backend = AgentBackend()
748
+ try:
749
+ root = Path.cwd()
750
+ model = resolve_model_for_phase(phase, root)
751
+ manifest = backend.invoke(prompt, model=model)
752
+ except AgentSubprocessError as e:
753
+ _halt(phase.upper(), str(e))
754
+ if manifest.status != "PASS":
755
+ _halt(phase.upper(), f"agent returned status: {manifest.status}")
756
+
757
+
758
+ _PHASE_ORDER = ["explore", "research", "prd", "shard"]
759
+
760
+
761
+ def _macro_discover_bucket(target: str | None) -> str:
762
+ specs_root = _resolve_specs_root()
763
+ if target:
764
+ bucket_path = specs_root / target
765
+ if not bucket_path.exists():
766
+ _halt("MACRO", f"BUCKET_NOT_FOUND: '{target}' not found in specs/")
767
+ return target
768
+ return discover_latest_epic(specs_root) or ""
769
+
770
+
771
+ def _dry_run_phases(phases: list[str], resolved: str) -> None:
772
+ """Emit contracts and prompts for each phase without side effects."""
773
+ console.print("[bold][yellow]DRY_RUN[/] — no state will be mutated[/]")
774
+ for phase in phases:
775
+ contract: dict[str, str] = {"phase": phase, "target": resolved}
776
+ print(json.dumps(contract, indent=2))
777
+ prompt = _build_slim_prompt(phase, contract)
778
+ print(prompt)
779
+
780
+
781
+ def _cycle_phase(
782
+ phase: str, resolved: str, specs_root: Path, force: bool = False
783
+ ) -> None:
784
+ """Execute a single macro phase: upstream check, pre, agent, post."""
785
+ if phase == "research" and not (specs_root / resolved / "explore.md").exists():
786
+ _halt("RESEARCH", "UPSTREAM_MISSING: explore.md not found")
787
+
788
+ if phase == "explore":
789
+ _explore_pre(problem=f"Automated explore for {resolved}", slug=resolved)
790
+ elif phase == "research":
791
+ _research_pre(slug=resolved)
792
+ elif phase == "prd":
793
+ _prd_pre()
794
+ elif phase == "shard":
795
+ _shard_pre(epic=resolved)
796
+
797
+ agent_contract: dict[str, str] = {"phase": phase, "target": resolved}
798
+ _invoke_agent_phase(phase, agent_contract)
799
+
800
+ if phase == "explore":
801
+ _explore_post(force=force)
802
+ elif phase == "research":
803
+ _research_post(force=force)
804
+ elif phase == "prd":
805
+ manifest_path = Path(".deviate") / "artifacts" / "manifest_prd.json"
806
+ if not manifest_path.exists():
807
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
808
+ manifest_path.write_text(
809
+ json.dumps({"epic_slug": resolved, "phase": "prd", "status": "PASS"}),
810
+ )
811
+ _prd_post(manifest=manifest_path, force=force)
812
+ elif phase == "shard":
813
+ manifest_path = Path(".deviate") / "artifacts" / "manifest_shard.json"
814
+ if not manifest_path.exists():
815
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
816
+ manifest_path.write_text(
817
+ json.dumps(
818
+ {
819
+ "epic_slug": resolved,
820
+ "phase": "shard",
821
+ "status": "PASS",
822
+ "issues": [],
823
+ },
824
+ ),
825
+ )
826
+ _shard_post(manifest=manifest_path, epic=resolved, force=force)
827
+
828
+
829
+ def _macro_run(
830
+ target: str | None = None,
831
+ from_phase: str | None = None,
832
+ dry_run: bool = False,
833
+ force: bool = False,
834
+ ) -> None:
835
+ if from_phase and from_phase not in _PHASE_ORDER:
836
+ valid = ", ".join(_PHASE_ORDER)
837
+ _halt("MACRO", f"INVALID_PHASE: '{from_phase}'. Valid phases: {valid}")
838
+
839
+ resolved = _macro_discover_bucket(target)
840
+ if not resolved:
841
+ _halt("MACRO", "no epic discovered")
842
+
843
+ start_idx = _PHASE_ORDER.index(from_phase) if from_phase else 0
844
+ phases = _PHASE_ORDER[start_idx:]
845
+
846
+ if from_phase:
847
+ session_path = Path(".deviate") / "session.json"
848
+ session = SessionState.load(session_path)
849
+ preceding_states = {
850
+ "explore": "IDLE",
851
+ "research": "EXPLORE",
852
+ "prd": "RESEARCH",
853
+ "shard": "PRD",
854
+ }
855
+ required_preceding = preceding_states[from_phase]
856
+ if session.current_phase != required_preceding:
857
+ session = session.force_transition_to(required_preceding)
858
+ session.save(session_path)
859
+
860
+ if dry_run:
861
+ _dry_run_phases(phases, resolved)
862
+ return
863
+
864
+ specs_root = _resolve_specs_root()
865
+ for phase in phases:
866
+ _cycle_phase(phase, resolved, specs_root, force=force)
867
+
868
+ session_path = Path(".deviate") / "session.json"
869
+ session = SessionState.load(session_path)
870
+ if session.current_phase != "IDLE":
871
+ session = session.force_transition_to("IDLE")
872
+ session.save(session_path)
873
+ console.print("[green]MACRO[/] pipeline complete — session at IDLE")
874
+
875
+
876
+ # ---------------------------------------------------------------------------
877
+ # Macro pre/post wrappers (called by _macro_run)
878
+ # ---------------------------------------------------------------------------
879
+
880
+
881
+ def _explore_pre(problem: str, slug: str | None = None) -> None:
882
+ explore_pre(problem=problem, slug=slug)
883
+
884
+
885
+ def _explore_post(force: bool = False) -> None:
886
+ explore_post(epic=None, force=force)
887
+
888
+
889
+ def _research_pre(slug: str | None = None) -> None:
890
+ research_pre(slug=slug)
891
+
892
+
893
+ def _research_post(force: bool = False) -> None:
894
+ research_post(epic=None, force=force)
895
+
896
+
897
+ def _prd_pre() -> None:
898
+ prd_pre()
899
+
900
+
901
+ def _prd_post(manifest: Path, force: bool = False) -> None:
902
+ prd_post(manifest, force=force)
903
+
904
+
905
+ def _shard_pre(epic: str | None = None) -> None:
906
+ shard_pre(epic=epic, dry_run=False)
907
+
908
+
909
+ def _shard_post(manifest: Path, epic: str | None = None, force: bool = False) -> None:
910
+ shard_post(manifest, epic=epic, force=force)
911
+
912
+
913
+ # ---------------------------------------------------------------------------
914
+ # Macro CLI command
915
+ # ---------------------------------------------------------------------------
916
+
917
+
918
+ macro_app = typer.Typer(no_args_is_help=True)
919
+
920
+
921
+ @macro_app.command("run")
922
+ def macro_run_command(
923
+ target: str | None = typer.Option(
924
+ None, "--target", help="Target feature bucket slug"
925
+ ),
926
+ from_phase: str | None = typer.Option(
927
+ None,
928
+ "--from",
929
+ help="Resume from a specific phase (explore|research|prd|shard)",
930
+ ),
931
+ dry_run: bool = typer.Option(
932
+ False, "--dry-run", help="Emit prompts and contracts without side effects"
933
+ ),
934
+ force: bool = typer.Option(False, "--force", help="Bypass pre-flight guards"),
935
+ ) -> None:
936
+ """Run the macro automated pipeline (explore→research→prd→shard)"""
937
+ _macro_run(target=target, from_phase=from_phase, dry_run=dry_run, force=force)