deviatdd 2.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deviatdd-2.5.1.dist-info/METADATA +386 -0
- deviatdd-2.5.1.dist-info/RECORD +124 -0
- deviatdd-2.5.1.dist-info/WHEEL +4 -0
- deviatdd-2.5.1.dist-info/entry_points.txt +2 -0
- deviatdd-2.5.1.dist-info/licenses/LICENSE +21 -0
- deviate/__init__.py +3 -0
- deviate/cli/__init__.py +824 -0
- deviate/cli/_common.py +155 -0
- deviate/cli/adhoc.py +183 -0
- deviate/cli/constitution.py +113 -0
- deviate/cli/feature.py +94 -0
- deviate/cli/init.py +485 -0
- deviate/cli/inspect.py +208 -0
- deviate/cli/macro.py +937 -0
- deviate/cli/meso.py +1894 -0
- deviate/cli/micro.py +3249 -0
- deviate/cli/review.py +441 -0
- deviate/core/__init__.py +0 -0
- deviate/core/_shared.py +20 -0
- deviate/core/agent.py +505 -0
- deviate/core/cache_discipline.py +65 -0
- deviate/core/commands.py +202 -0
- deviate/core/commit.py +86 -0
- deviate/core/complexity.py +36 -0
- deviate/core/constitution.py +85 -0
- deviate/core/contract.py +17 -0
- deviate/core/convention.py +147 -0
- deviate/core/epic.py +73 -0
- deviate/core/issues.py +46 -0
- deviate/core/prd.py +18 -0
- deviate/core/profile.py +33 -0
- deviate/core/repo.py +47 -0
- deviate/core/run_logger.py +50 -0
- deviate/core/tasks_ledger.py +69 -0
- deviate/core/treesitter/__init__.py +31 -0
- deviate/core/treesitter/analysis.py +457 -0
- deviate/core/treesitter/models.py +35 -0
- deviate/core/treesitter/parser.py +184 -0
- deviate/core/treesitter/queries/bash.scm +7 -0
- deviate/core/treesitter/queries/c_sharp.scm +11 -0
- deviate/core/treesitter/queries/cpp.scm +9 -0
- deviate/core/treesitter/queries/css.scm +4 -0
- deviate/core/treesitter/queries/dockerfile.scm +8 -0
- deviate/core/treesitter/queries/elixir.scm +6 -0
- deviate/core/treesitter/queries/go.scm +9 -0
- deviate/core/treesitter/queries/hcl.scm +3 -0
- deviate/core/treesitter/queries/html.scm +3 -0
- deviate/core/treesitter/queries/javascript.scm +12 -0
- deviate/core/treesitter/queries/json.scm +4 -0
- deviate/core/treesitter/queries/kotlin.scm +8 -0
- deviate/core/treesitter/queries/markdown.scm +4 -0
- deviate/core/treesitter/queries/python.scm +10 -0
- deviate/core/treesitter/queries/rust.scm +12 -0
- deviate/core/treesitter/queries/sql.scm +7 -0
- deviate/core/treesitter/queries/swift.scm +10 -0
- deviate/core/treesitter/queries/toml.scm +3 -0
- deviate/core/treesitter/queries/tsx.scm +14 -0
- deviate/core/treesitter/queries/typescript.scm +14 -0
- deviate/core/treesitter/queries/yaml.scm +4 -0
- deviate/core/validation.py +153 -0
- deviate/core/worktree.py +141 -0
- deviate/main.py +4 -0
- deviate/prompts/__init__.py +0 -0
- deviate/prompts/assembly.py +122 -0
- deviate/prompts/auto/__init__.py +1 -0
- deviate/prompts/auto/execute.md +62 -0
- deviate/prompts/auto/explore.md +103 -0
- deviate/prompts/auto/green.md +137 -0
- deviate/prompts/auto/judge.md +260 -0
- deviate/prompts/auto/plan.md +127 -0
- deviate/prompts/auto/prd.md +108 -0
- deviate/prompts/auto/red.md +156 -0
- deviate/prompts/auto/refactor.md +166 -0
- deviate/prompts/auto/research.md +111 -0
- deviate/prompts/auto/shard.md +90 -0
- deviate/prompts/auto/specify.md +77 -0
- deviate/prompts/auto/tasks.md +191 -0
- deviate/prompts/commands/deviate-adhoc.md +216 -0
- deviate/prompts/commands/deviate-architecture.md +147 -0
- deviate/prompts/commands/deviate-constitution.md +215 -0
- deviate/prompts/commands/deviate-e2e.md +264 -0
- deviate/prompts/commands/deviate-execute.md +223 -0
- deviate/prompts/commands/deviate-explore.md +253 -0
- deviate/prompts/commands/deviate-flows.md +226 -0
- deviate/prompts/commands/deviate-green.md +217 -0
- deviate/prompts/commands/deviate-hotfix.md +188 -0
- deviate/prompts/commands/deviate-init.md +170 -0
- deviate/prompts/commands/deviate-judge.md +193 -0
- deviate/prompts/commands/deviate-merge.md +221 -0
- deviate/prompts/commands/deviate-plan.md +158 -0
- deviate/prompts/commands/deviate-pr.md +144 -0
- deviate/prompts/commands/deviate-prd.md +216 -0
- deviate/prompts/commands/deviate-prune.md +260 -0
- deviate/prompts/commands/deviate-red.md +231 -0
- deviate/prompts/commands/deviate-refactor.md +211 -0
- deviate/prompts/commands/deviate-release.md +123 -0
- deviate/prompts/commands/deviate-research.md +359 -0
- deviate/prompts/commands/deviate-review.md +289 -0
- deviate/prompts/commands/deviate-shard.md +226 -0
- deviate/prompts/commands/deviate-tasks.md +281 -0
- deviate/prompts/commands/deviate-triage.md +141 -0
- deviate/prompts/constitution_seed.md +51 -0
- deviate/prompts/core/core.md +21 -0
- deviate/prompts/core/macro-auto.md +59 -0
- deviate/prompts/core/macro-command.md +54 -0
- deviate/prompts/core/macro-skill.md +54 -0
- deviate/prompts/core/meso-auto.md +63 -0
- deviate/prompts/core/meso-command.md +58 -0
- deviate/prompts/core/meso-skill.md +58 -0
- deviate/prompts/core/micro-auto.md +76 -0
- deviate/prompts/core/micro-command.md +67 -0
- deviate/prompts/core/micro-skill.md +67 -0
- deviate/prompts/extras/deviate-pr-graphite-routing.md +20 -0
- deviate/prompts/governance/__init__.py +0 -0
- deviate/prompts/governance/agents_seed.md +1 -0
- deviate/prompts/governance/claudemd_seed.md +1 -0
- deviate/prompts/governance/graphite_seed.md +18 -0
- deviate/prompts/governance/libref_seed.md +3 -0
- deviate/state/__init__.py +0 -0
- deviate/state/config.py +257 -0
- deviate/state/ledger.py +407 -0
- deviate/ui/__init__.py +5 -0
- deviate/ui/monitor.py +187 -0
- deviate/ui/render.py +26 -0
deviate/cli/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)
|