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/_common.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from contextlib import redirect_stdout
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from io import StringIO
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from deviate.core._shared import git_env as _git_env
|
|
16
|
+
from deviate.core.constitution import resolve_constitution, validate_constitution
|
|
17
|
+
from deviate.prompts.assembly import assemble_prompt
|
|
18
|
+
from deviate.state.config import TransitionViolationError
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def with_json_quiet(func):
|
|
24
|
+
"""Decorator that injects ``--json`` and ``--quiet`` options into a Typer command.
|
|
25
|
+
|
|
26
|
+
``--json``: capture stdout, serialize the command's return value as JSON,
|
|
27
|
+
print only JSON to stdout.
|
|
28
|
+
|
|
29
|
+
``--quiet``: suppress stdout while preserving stderr output.
|
|
30
|
+
|
|
31
|
+
Flags are orthogonal: ``--json --quiet`` emits JSON on stdout and errors
|
|
32
|
+
on stderr.
|
|
33
|
+
"""
|
|
34
|
+
sig = inspect.signature(func)
|
|
35
|
+
orig_params = list(sig.parameters.values())
|
|
36
|
+
|
|
37
|
+
new_params = list(orig_params)
|
|
38
|
+
new_params.extend(
|
|
39
|
+
[
|
|
40
|
+
inspect.Parameter(
|
|
41
|
+
"json",
|
|
42
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
43
|
+
default=typer.Option(
|
|
44
|
+
False, "--json", help="Output result as JSON contract"
|
|
45
|
+
),
|
|
46
|
+
annotation=bool,
|
|
47
|
+
),
|
|
48
|
+
inspect.Parameter(
|
|
49
|
+
"quiet",
|
|
50
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
51
|
+
default=typer.Option(False, "--quiet", help="Suppress non-JSON output"),
|
|
52
|
+
annotation=bool,
|
|
53
|
+
),
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@wraps(func)
|
|
58
|
+
def wrapper(**kwargs: object) -> object:
|
|
59
|
+
json_flag = kwargs.pop("json", False)
|
|
60
|
+
quiet_flag = kwargs.pop("quiet", False)
|
|
61
|
+
|
|
62
|
+
def _captured() -> object:
|
|
63
|
+
buf = StringIO()
|
|
64
|
+
with redirect_stdout(buf):
|
|
65
|
+
return func(**kwargs)
|
|
66
|
+
|
|
67
|
+
if json_flag:
|
|
68
|
+
result = _captured()
|
|
69
|
+
typer.echo(json.dumps(result))
|
|
70
|
+
elif quiet_flag:
|
|
71
|
+
result = _captured()
|
|
72
|
+
else:
|
|
73
|
+
result = func(**kwargs)
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
wrapper.__signature__ = sig.replace(parameters=new_params)
|
|
77
|
+
return wrapper
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _extract_epic_num(slug: str) -> str:
|
|
81
|
+
"""Extract the leading numeric prefix from an epic slug.
|
|
82
|
+
|
|
83
|
+
``001-deviate-cli-bootstrapping`` → ``001``
|
|
84
|
+
"""
|
|
85
|
+
m = re.match(r"(\d+)", slug)
|
|
86
|
+
return m.group(1) if m else slug
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_issue_num(issue_id: str) -> str:
|
|
90
|
+
"""Extract the numeric suffix from an issue ID.
|
|
91
|
+
|
|
92
|
+
``ISS-001-006`` → ``006``, ``TSK-042`` → ``042``
|
|
93
|
+
"""
|
|
94
|
+
m = re.search(r"-(\d+)$", issue_id)
|
|
95
|
+
return m.group(1) if m else issue_id
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _halt(phase: str, message: str) -> None:
|
|
99
|
+
console.print(f"[red]{phase}_HALTED: {message}[/]")
|
|
100
|
+
raise SystemExit(1)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _handle_missing_dot_dir(phase: str) -> None:
|
|
104
|
+
_halt(phase, ".deviate/ not found, run 'deviate init' first")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _handle_transition_error(phase: str, error: TransitionViolationError) -> None:
|
|
108
|
+
_halt(phase, str(error))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_constitution(phase: str = "CONSTITUTION") -> Path:
|
|
112
|
+
try:
|
|
113
|
+
const_path = resolve_constitution(Path.cwd())
|
|
114
|
+
if not validate_constitution(const_path):
|
|
115
|
+
_halt(phase, "constitution validation failed")
|
|
116
|
+
return const_path
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
_halt(phase, "constitution.md not found")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _load_manifest(path: Path, phase: str) -> dict:
|
|
122
|
+
try:
|
|
123
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
124
|
+
except (json.JSONDecodeError, FileNotFoundError) as e:
|
|
125
|
+
_halt(phase, f"invalid manifest at {path} — {e}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_pre_commit_hooks(worktree_path: Path | None = None) -> None:
|
|
129
|
+
"""Ensure .githooks/ is configured as hooks path if the directory exists."""
|
|
130
|
+
repo = worktree_path or Path.cwd()
|
|
131
|
+
githooks_dir = repo / ".githooks"
|
|
132
|
+
if not githooks_dir.is_dir():
|
|
133
|
+
console.print("[dim]PRE_COMMIT_SKIP[/] no .githooks/ directory")
|
|
134
|
+
return
|
|
135
|
+
try:
|
|
136
|
+
subprocess.run(
|
|
137
|
+
["git", "config", "core.hooksPath", str(githooks_dir)],
|
|
138
|
+
cwd=repo,
|
|
139
|
+
env=_git_env(),
|
|
140
|
+
check=True,
|
|
141
|
+
capture_output=True,
|
|
142
|
+
)
|
|
143
|
+
console.print("[green]PRE_COMMIT_HOOKS[/] hooks path set to .githooks/")
|
|
144
|
+
except subprocess.CalledProcessError:
|
|
145
|
+
console.print("[yellow]PRE_COMMIT_WARN[/] could not set hooks path")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_slim_prompt(phase: str, contract: dict[str, str]) -> str:
|
|
149
|
+
repo_root = Path.cwd()
|
|
150
|
+
constitution_path = repo_root / "specs" / "constitution.md"
|
|
151
|
+
return assemble_prompt(
|
|
152
|
+
template_name=phase,
|
|
153
|
+
context=contract,
|
|
154
|
+
constitution_path=constitution_path,
|
|
155
|
+
)
|
deviate/cli/adhoc.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from deviate.cli._common import console
|
|
12
|
+
from deviate.core._shared import git_env as _git_env
|
|
13
|
+
from deviate.core.complexity import ClassificationResult, ComplexityGate
|
|
14
|
+
from deviate.core.convention import format_commit_message
|
|
15
|
+
from deviate.state.ledger import AdhocRecord
|
|
16
|
+
|
|
17
|
+
adhoc_app = typer.Typer(no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
_FLOW_REF_PATTERN = re.compile(r"^FLOW-\d{2,}$")
|
|
20
|
+
_FLOW_REF_FORMAT_HINT = "expected format: FLOW-XX with at least two digits"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _adhoc_ledger_path() -> Path:
|
|
24
|
+
return Path.cwd() / "specs" / "adhoc.jsonl"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _exit_with_error(message: str, code: int = 1) -> None:
|
|
28
|
+
console.print(f"[red]{message}[/]")
|
|
29
|
+
raise typer.Exit(code=code)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_adhoc_ledger(path: Path) -> dict[str, dict]:
|
|
33
|
+
if not path.exists():
|
|
34
|
+
return {}
|
|
35
|
+
records: dict[str, dict] = {}
|
|
36
|
+
with path.open("r", encoding="utf-8") as f:
|
|
37
|
+
for line in f:
|
|
38
|
+
line = line.strip()
|
|
39
|
+
if not line:
|
|
40
|
+
continue
|
|
41
|
+
try:
|
|
42
|
+
rec = json.loads(line)
|
|
43
|
+
if isinstance(rec, dict):
|
|
44
|
+
issue_id = rec.get("issue_id")
|
|
45
|
+
if issue_id:
|
|
46
|
+
records[issue_id] = rec
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
continue
|
|
49
|
+
return records
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _emit_contract(status: str, **fields: object) -> None:
|
|
53
|
+
contract: dict[str, object] = {"status": status, **fields}
|
|
54
|
+
print(json.dumps(contract, indent=2))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_flow_refs(raw: str | None) -> list[str]:
|
|
58
|
+
if raw is None:
|
|
59
|
+
return []
|
|
60
|
+
tokens = [token.strip() for token in raw.split(",") if token.strip()]
|
|
61
|
+
for token in tokens:
|
|
62
|
+
if not _FLOW_REF_PATTERN.match(token):
|
|
63
|
+
_exit_with_error(
|
|
64
|
+
f"INVALID_FLOW_REF {token!r} is not a valid flow ID "
|
|
65
|
+
f"({_FLOW_REF_FORMAT_HINT})"
|
|
66
|
+
)
|
|
67
|
+
return tokens
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@adhoc_app.command()
|
|
71
|
+
def pre(
|
|
72
|
+
description: str = typer.Argument(..., help="Task description to classify"),
|
|
73
|
+
skip_gates: bool = typer.Option(
|
|
74
|
+
False,
|
|
75
|
+
"--skip-gates",
|
|
76
|
+
help="Skip complexity gate rejection for HIGH complexity tasks",
|
|
77
|
+
),
|
|
78
|
+
flow_ref: str | None = typer.Option(
|
|
79
|
+
None,
|
|
80
|
+
"--flow-ref",
|
|
81
|
+
help="Comma-separated FLOW-XX IDs (e.g. FLOW-01,FLOW-02)",
|
|
82
|
+
),
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Classify an ad-hoc task description and record it for execution."""
|
|
85
|
+
flow_refs = _parse_flow_refs(flow_ref)
|
|
86
|
+
result: ClassificationResult = ComplexityGate.classify(description)
|
|
87
|
+
|
|
88
|
+
if result.level == "HIGH" and not skip_gates:
|
|
89
|
+
_exit_with_error(
|
|
90
|
+
"COMPLEXITY_GATE_REJECTION HIGH complexity tasks require "
|
|
91
|
+
"--skip-gates to proceed"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
record = AdhocRecord(
|
|
95
|
+
issue_id=f"adhoc-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}",
|
|
96
|
+
description=description,
|
|
97
|
+
execution_mode=result.execution_mode,
|
|
98
|
+
status="PENDING",
|
|
99
|
+
flow_refs=flow_refs,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
ledger_path = _adhoc_ledger_path()
|
|
103
|
+
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
with ledger_path.open("a", encoding="utf-8") as f:
|
|
105
|
+
f.write(record.model_dump_json() + "\n")
|
|
106
|
+
|
|
107
|
+
console.print(
|
|
108
|
+
f"[green]{result.execution_mode}[/] execution_mode={result.execution_mode}"
|
|
109
|
+
)
|
|
110
|
+
_emit_contract(
|
|
111
|
+
status="READY",
|
|
112
|
+
execution_mode=result.execution_mode,
|
|
113
|
+
description=description,
|
|
114
|
+
issue_id=record.issue_id,
|
|
115
|
+
flow_refs=flow_refs,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@adhoc_app.command()
|
|
120
|
+
def post(
|
|
121
|
+
issue_id: str = typer.Argument(..., help="Issue/manifest ID"),
|
|
122
|
+
title: str = typer.Option(
|
|
123
|
+
"", "--title", "-t", help="Issue title for commit message"
|
|
124
|
+
),
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Stage, commit ad-hoc issue artifacts, and mark record as completed."""
|
|
127
|
+
root = Path.cwd()
|
|
128
|
+
|
|
129
|
+
# --- Commit step (skip if not a git repo) ---
|
|
130
|
+
if (root / ".git").is_dir():
|
|
131
|
+
subject = f"docs(adhoc): add issue {issue_id}"
|
|
132
|
+
if title:
|
|
133
|
+
subject += f" - {title}"
|
|
134
|
+
message = format_commit_message(subject, root)
|
|
135
|
+
|
|
136
|
+
staged = subprocess.run(
|
|
137
|
+
["git", "diff", "--cached", "--quiet"], cwd=root, env=_git_env()
|
|
138
|
+
)
|
|
139
|
+
unstaged = subprocess.run(["git", "diff", "--quiet"], cwd=root, env=_git_env())
|
|
140
|
+
untracked = subprocess.run(
|
|
141
|
+
["git", "status", "--porcelain"],
|
|
142
|
+
cwd=root,
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
env=_git_env(),
|
|
146
|
+
)
|
|
147
|
+
has_untracked = bool(untracked.stdout.strip())
|
|
148
|
+
|
|
149
|
+
if staged.returncode != 0 or unstaged.returncode != 0 or has_untracked:
|
|
150
|
+
subprocess.run(["git", "add", "-A"], cwd=root, env=_git_env(), check=False)
|
|
151
|
+
result = subprocess.run(
|
|
152
|
+
["git", "commit", "-m", message, "--no-verify"],
|
|
153
|
+
cwd=root,
|
|
154
|
+
env=_git_env(),
|
|
155
|
+
capture_output=True,
|
|
156
|
+
text=True,
|
|
157
|
+
)
|
|
158
|
+
if result.returncode == 0:
|
|
159
|
+
console.print(f"[green]COMMITTED[/] adhoc issue {issue_id}")
|
|
160
|
+
else:
|
|
161
|
+
console.print(f"[red]COMMIT_FAILED[/] {result.stderr.strip()}")
|
|
162
|
+
raise typer.Exit(code=1)
|
|
163
|
+
else:
|
|
164
|
+
console.print("[yellow]COMMIT_SKIP[/] no changes to commit")
|
|
165
|
+
else:
|
|
166
|
+
console.print("[dim]COMMIT_SKIP[/] not a git repository")
|
|
167
|
+
|
|
168
|
+
# --- Mark adhoc record as COMPLETED ---
|
|
169
|
+
ledger_path = _adhoc_ledger_path()
|
|
170
|
+
records = _read_adhoc_ledger(ledger_path)
|
|
171
|
+
found = records.get(issue_id)
|
|
172
|
+
|
|
173
|
+
if found is None:
|
|
174
|
+
_exit_with_error(f"MANIFEST_NOT_FOUND No record found with issue_id={issue_id}")
|
|
175
|
+
|
|
176
|
+
completed = found.copy()
|
|
177
|
+
completed["status"] = "COMPLETED"
|
|
178
|
+
completed["timestamp"] = datetime.now(timezone.utc).isoformat()
|
|
179
|
+
with ledger_path.open("a", encoding="utf-8") as f:
|
|
180
|
+
f.write(json.dumps(completed) + "\n")
|
|
181
|
+
console.print(f"[green]COMPLETED[/] {issue_id}")
|
|
182
|
+
|
|
183
|
+
_emit_contract(status="COMPLETED", issue_id=issue_id)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import NoReturn
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from deviate.core.commit import commit_artifact
|
|
12
|
+
from deviate.core.convention import format_commit_message
|
|
13
|
+
from deviate.core.constitution import (
|
|
14
|
+
extract_commands,
|
|
15
|
+
validate_constitution,
|
|
16
|
+
validate_sections,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
constitution_app = typer.Typer(no_args_is_help=True)
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fail_with(reason: str) -> NoReturn:
|
|
25
|
+
print(json.dumps({"status": "FAILURE", "reason": reason}))
|
|
26
|
+
raise typer.Exit(code=1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_seed(filename: str) -> str | None:
|
|
30
|
+
try:
|
|
31
|
+
seed = importlib.resources.files("deviate.prompts").joinpath(filename)
|
|
32
|
+
return seed.read_text(encoding="utf-8")
|
|
33
|
+
except (ModuleNotFoundError, FileNotFoundError):
|
|
34
|
+
console.print(f" [red]ERROR[/] {filename} not found in package")
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@constitution_app.command()
|
|
39
|
+
def generate(
|
|
40
|
+
force: bool = typer.Option(
|
|
41
|
+
False, "--force", help="Overwrite existing constitution.md"
|
|
42
|
+
),
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Scaffold a placeholder specs/constitution.md.
|
|
45
|
+
|
|
46
|
+
The placeholder is populated by ``/research`` during the macro layer.
|
|
47
|
+
"""
|
|
48
|
+
specs_dir = Path.cwd() / "specs"
|
|
49
|
+
const_path = specs_dir / "constitution.md"
|
|
50
|
+
|
|
51
|
+
if const_path.exists() and not force:
|
|
52
|
+
console.print(
|
|
53
|
+
" [yellow]SKIP[/] specs/constitution.md already exists."
|
|
54
|
+
" Use --force to overwrite."
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
seed = _read_seed("constitution_seed.md")
|
|
59
|
+
if seed is None:
|
|
60
|
+
raise typer.Exit(code=1)
|
|
61
|
+
|
|
62
|
+
specs_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
const_path.write_text(seed, encoding="utf-8")
|
|
64
|
+
console.print(f" [green]CREATE[/] {const_path.relative_to(Path.cwd())}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@constitution_app.command()
|
|
68
|
+
def pre() -> None:
|
|
69
|
+
"""Validate constitution and extract commands."""
|
|
70
|
+
repo_root = Path.cwd()
|
|
71
|
+
const_path = repo_root / "specs" / "constitution.md"
|
|
72
|
+
|
|
73
|
+
if not const_path.exists():
|
|
74
|
+
_fail_with(f"constitution.md not found at {const_path}")
|
|
75
|
+
|
|
76
|
+
if not validate_constitution(const_path):
|
|
77
|
+
_fail_with("constitution validation failed")
|
|
78
|
+
|
|
79
|
+
missing = validate_sections(const_path, ["## TESTING_PROTOCOLS"])
|
|
80
|
+
if missing:
|
|
81
|
+
_fail_with(f"Missing required section: {missing[0]}")
|
|
82
|
+
|
|
83
|
+
commands = extract_commands(const_path)
|
|
84
|
+
print(json.dumps(commands))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@constitution_app.command()
|
|
88
|
+
def post(
|
|
89
|
+
manifest: str = typer.Argument(..., help="Path to manifest JSON"),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Validate constitution sections and commit."""
|
|
92
|
+
manifest_path = Path(manifest)
|
|
93
|
+
|
|
94
|
+
if not manifest_path.exists():
|
|
95
|
+
_fail_with(f"manifest not found at {manifest_path}")
|
|
96
|
+
|
|
97
|
+
manifest_data = json.loads(manifest_path.read_text())
|
|
98
|
+
sections = manifest_data.get("sections", [])
|
|
99
|
+
const_rel_path = manifest_data.get("constitution_path", "specs/constitution.md")
|
|
100
|
+
|
|
101
|
+
const_path = Path.cwd() / const_rel_path
|
|
102
|
+
|
|
103
|
+
missing = validate_sections(const_path, sections)
|
|
104
|
+
if missing:
|
|
105
|
+
_fail_with(f"Missing sections: {', '.join(missing)}")
|
|
106
|
+
|
|
107
|
+
commit_artifact(
|
|
108
|
+
path=const_path,
|
|
109
|
+
message=format_commit_message(
|
|
110
|
+
"chore(constitution): update constitution", Path.cwd()
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
print(json.dumps({"status": "SUCCESS"}))
|
deviate/cli/feature.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from deviate.cli._common import console
|
|
9
|
+
from deviate.core._shared import git_env
|
|
10
|
+
from deviate.state.config import SessionState, resolve_graphite_config
|
|
11
|
+
|
|
12
|
+
feature_app = typer.Typer(no_args_is_help=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _derive_slug(title: str) -> str:
|
|
16
|
+
slug = title.lower()
|
|
17
|
+
slug = re.sub(r"[^a-z0-9]+", "-", slug)
|
|
18
|
+
slug = slug.strip("-")
|
|
19
|
+
return slug
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _create_feature_directory(slug: str, repo_path: Path) -> Path:
|
|
23
|
+
spec_dir = repo_path / "specs" / slug
|
|
24
|
+
spec_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
return spec_dir
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _create_feature_branch(slug: str, repo_path: Path) -> None:
|
|
29
|
+
"""Create `feat/<slug>` via Graphite (`gt create -am`) or git.
|
|
30
|
+
|
|
31
|
+
Git Isolation: never `git checkout -b` — agents running TDD cycles
|
|
32
|
+
must not mutate branch state. This CLI command is the only
|
|
33
|
+
sanctioned entry point for branch creation in a worktree.
|
|
34
|
+
"""
|
|
35
|
+
branch_name = f"feat/{slug}"
|
|
36
|
+
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["git", "rev-parse", "--verify", "--quiet", branch_name],
|
|
39
|
+
cwd=repo_path,
|
|
40
|
+
env=git_env(),
|
|
41
|
+
capture_output=True,
|
|
42
|
+
)
|
|
43
|
+
if result.returncode == 0:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if resolve_graphite_config(repo_path):
|
|
47
|
+
try:
|
|
48
|
+
subprocess.run(
|
|
49
|
+
["gt", "create", "-am", branch_name],
|
|
50
|
+
cwd=repo_path,
|
|
51
|
+
env=git_env(),
|
|
52
|
+
check=True,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
)
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
console.print(
|
|
58
|
+
"[red]GRAPHITE_NOT_FOUND[/] Graphite CLI (gt) not found on PATH.\n"
|
|
59
|
+
"See https://graphite.dev/docs/cli for installation instructions."
|
|
60
|
+
)
|
|
61
|
+
raise typer.Exit(code=1)
|
|
62
|
+
except subprocess.CalledProcessError as e:
|
|
63
|
+
detail = e.stderr.strip() if e.stderr else "Graphite CLI (gt) failed."
|
|
64
|
+
console.print(
|
|
65
|
+
f"[red]GRAPHITE_FAILED[/] {detail} "
|
|
66
|
+
"See https://graphite.dev/docs/cli for installation instructions."
|
|
67
|
+
)
|
|
68
|
+
raise typer.Exit(code=1)
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
subprocess.run(
|
|
72
|
+
["git", "branch", branch_name],
|
|
73
|
+
cwd=repo_path,
|
|
74
|
+
env=git_env(),
|
|
75
|
+
check=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@feature_app.command()
|
|
80
|
+
def create(
|
|
81
|
+
title: str = typer.Argument(..., help="Feature title"),
|
|
82
|
+
slug: str | None = typer.Option(None, "--slug", help="Override derived slug"),
|
|
83
|
+
) -> None:
|
|
84
|
+
repo_path = Path.cwd()
|
|
85
|
+
final_slug = slug or _derive_slug(title)
|
|
86
|
+
|
|
87
|
+
_create_feature_directory(final_slug, repo_path)
|
|
88
|
+
_create_feature_branch(final_slug, repo_path)
|
|
89
|
+
|
|
90
|
+
session_dir = repo_path / ".deviate"
|
|
91
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
session = SessionState()
|
|
93
|
+
session_path = session_dir / "session.json"
|
|
94
|
+
session.save(session_path)
|