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/core/commands.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _resolve_commands_root(commands_root: Path | None = None) -> Path:
|
|
9
|
+
if commands_root is not None:
|
|
10
|
+
return commands_root
|
|
11
|
+
try:
|
|
12
|
+
return Path(importlib.resources.files("deviate.prompts").joinpath("commands"))
|
|
13
|
+
except (ModuleNotFoundError, TypeError, FileNotFoundError):
|
|
14
|
+
fallback = Path("src/deviate/prompts/commands")
|
|
15
|
+
if fallback.exists():
|
|
16
|
+
return fallback
|
|
17
|
+
return Path() / "src" / "deviate" / "prompts" / "commands"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def discover_commands(commands_root: Path | None = None) -> list[str]:
|
|
21
|
+
root = _resolve_commands_root(commands_root)
|
|
22
|
+
if not root.exists():
|
|
23
|
+
return []
|
|
24
|
+
return sorted(p.stem for p in root.glob("*.md") if p.is_file())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_command(name: str, commands_root: Path | None = None) -> Path:
|
|
28
|
+
root = _resolve_commands_root(commands_root)
|
|
29
|
+
command_path = root / f"{name}.md"
|
|
30
|
+
if not command_path.exists():
|
|
31
|
+
raise FileNotFoundError(f"Command '{name}' not found at {command_path}")
|
|
32
|
+
return command_path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Layer prefix for cache-invariant command composition
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
_LAYER_RE = re.compile(r"^layer:\s*(.+)\s*$", re.MULTILINE)
|
|
40
|
+
_YAML_FM_RE = re.compile(r"^(---\n.*?\n---)\n", re.DOTALL)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_text(path: Path) -> str | None:
|
|
44
|
+
return path.read_text(encoding="utf-8") if path.is_file() else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_core_dir() -> Path | None:
|
|
48
|
+
try:
|
|
49
|
+
return Path(importlib.resources.files("deviate.prompts").joinpath("core"))
|
|
50
|
+
except (ModuleNotFoundError, TypeError):
|
|
51
|
+
fallback = Path("src/deviate/prompts/core")
|
|
52
|
+
return fallback if fallback.exists() else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def compose_command_body(raw: str, core_dir: Path) -> str | None:
|
|
56
|
+
"""Compose a command body by prepending core.md and layer-command.md.
|
|
57
|
+
|
|
58
|
+
Returns the full composed text (frontmatter + prefix + original body),
|
|
59
|
+
or *None* if *raw* has no valid YAML frontmatter.
|
|
60
|
+
|
|
61
|
+
The *core_dir* must contain ``core.md`` and ``{layer}-command.md`` files.
|
|
62
|
+
"""
|
|
63
|
+
fm_match = _YAML_FM_RE.match(raw)
|
|
64
|
+
if not fm_match:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
frontmatter = fm_match.group(1)
|
|
68
|
+
body = raw[fm_match.end() :].lstrip()
|
|
69
|
+
|
|
70
|
+
parts: list[str] = []
|
|
71
|
+
core = _read_text(core_dir / "core.md")
|
|
72
|
+
if core:
|
|
73
|
+
parts.append(core)
|
|
74
|
+
|
|
75
|
+
layer_match = _LAYER_RE.search(frontmatter)
|
|
76
|
+
if layer_match:
|
|
77
|
+
layer = layer_match.group(1).strip()
|
|
78
|
+
layer_content = _read_text(core_dir / f"{layer}-command.md")
|
|
79
|
+
if layer_content:
|
|
80
|
+
parts.append(layer_content)
|
|
81
|
+
|
|
82
|
+
prefix = "\n\n".join(parts) if parts else None
|
|
83
|
+
if prefix:
|
|
84
|
+
body = f"{prefix}\n\n{body}"
|
|
85
|
+
|
|
86
|
+
return f"{frontmatter}\n\n{body}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _emit_platform_frontmatter(agent: str, name: str, description: str) -> str:
|
|
90
|
+
"""Build a minimal per-platform YAML frontmatter block.
|
|
91
|
+
|
|
92
|
+
Emits only fields the platform actually recognizes — keeps the
|
|
93
|
+
on-disk command free of DeviaTDD-internal keys (`category`,
|
|
94
|
+
`version`, `aliases`, `layer`) that would clutter slash-command
|
|
95
|
+
autocomplete across heterogeneous backends.
|
|
96
|
+
"""
|
|
97
|
+
name_line = f"name: {name}\n" if name else ""
|
|
98
|
+
description_line = f"description: {description}\n" if description else ""
|
|
99
|
+
body = f"---\n{name_line}{description_line}---\n"
|
|
100
|
+
return body
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _strip_deviate_frontmatter(frontmatter: str, name: str) -> tuple[str, str]:
|
|
104
|
+
"""Pull `description` out of the source frontmatter and drop internal keys.
|
|
105
|
+
|
|
106
|
+
Returns ``(emitted_frontmatter_block, description)``. The emitted
|
|
107
|
+
block is passed to :func:`_emit_platform_frontmatter` to assemble
|
|
108
|
+
the on-disk frontmatter; the description drives ``description:`` in
|
|
109
|
+
every platform's slash-command UI.
|
|
110
|
+
"""
|
|
111
|
+
description = ""
|
|
112
|
+
for line in frontmatter.splitlines():
|
|
113
|
+
stripped = line.strip()
|
|
114
|
+
if stripped.startswith("description:"):
|
|
115
|
+
description = stripped.split(":", 1)[1].strip().strip("'\"")
|
|
116
|
+
break
|
|
117
|
+
return "", description
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _read_graphite_routing() -> str | None:
|
|
121
|
+
"""Read the conditional `## Graphite Routing` block for deviate-pr."""
|
|
122
|
+
try:
|
|
123
|
+
path = Path(
|
|
124
|
+
importlib.resources.files("deviate.prompts").joinpath(
|
|
125
|
+
"extras/deviate-pr-graphite-routing.md"
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return path.read_text(encoding="utf-8")
|
|
129
|
+
except (ModuleNotFoundError, FileNotFoundError, OSError):
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _graphite_enabled(workdir: Path) -> bool:
|
|
134
|
+
"""Late-bound import to keep `core` free of `state` deps at module load."""
|
|
135
|
+
from deviate.state.config import resolve_graphite_config
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
return resolve_graphite_config(workdir)
|
|
139
|
+
except Exception:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def install_command(
|
|
144
|
+
name: str,
|
|
145
|
+
target_dir: Path,
|
|
146
|
+
commands_root: Path | None = None,
|
|
147
|
+
workdir: Path | None = None,
|
|
148
|
+
agent: str = "claude",
|
|
149
|
+
) -> bool:
|
|
150
|
+
"""Install a command as a flat .md file at ``target_dir/<name>.md``.
|
|
151
|
+
|
|
152
|
+
Returns ``True`` when the file was created or rewritten, ``False``
|
|
153
|
+
when the on-disk copy already matches the composed output.
|
|
154
|
+
"""
|
|
155
|
+
command_path = resolve_command(name, commands_root)
|
|
156
|
+
target_path = target_dir / f"{name}.md"
|
|
157
|
+
|
|
158
|
+
raw = _read_text(command_path)
|
|
159
|
+
if raw is None:
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
core_dir = _resolve_core_dir()
|
|
163
|
+
if core_dir is None:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
composed = compose_command_body(raw, core_dir)
|
|
167
|
+
if composed is None:
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
if name == "deviate-pr" and workdir is not None and _graphite_enabled(workdir):
|
|
171
|
+
routing = _read_graphite_routing()
|
|
172
|
+
if routing:
|
|
173
|
+
composed = f"{composed}\n\n{routing.rstrip()}"
|
|
174
|
+
|
|
175
|
+
fm_match = _YAML_FM_RE.match(composed)
|
|
176
|
+
if fm_match:
|
|
177
|
+
_, description = _strip_deviate_frontmatter(fm_match.group(1), name)
|
|
178
|
+
emitted_fm = _emit_platform_frontmatter(agent, name, description)
|
|
179
|
+
composed = f"{emitted_fm}\n{composed[fm_match.end() :]}"
|
|
180
|
+
|
|
181
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
|
|
183
|
+
if target_path.exists() and target_path.read_text(encoding="utf-8") == composed:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
target_path.write_text(composed, encoding="utf-8")
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def detect_agents(workdir: Path | None = None) -> list[str]:
|
|
191
|
+
"""Detect agent platforms from cwd directories.
|
|
192
|
+
|
|
193
|
+
Scans *workdir* for ``.claude/``, ``.opencode/``, ``.factory/``,
|
|
194
|
+
``.pi/``, and ``.omp/`` subdirectories and returns the matching
|
|
195
|
+
agent names.
|
|
196
|
+
"""
|
|
197
|
+
workdir = workdir or Path.cwd()
|
|
198
|
+
agents: list[str] = []
|
|
199
|
+
for name in ("claude", "opencode", "factory", "pi", "omp"):
|
|
200
|
+
if (workdir / f".{name}").is_dir():
|
|
201
|
+
agents.append(name)
|
|
202
|
+
return sorted(agents)
|
deviate/core/commit.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from deviate.core._shared import git_env as _git_env
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _has_changes_to_stage(files: list[Path], repo: Path) -> bool:
|
|
10
|
+
"""Check if any of the given files have unstaged changes or are untracked."""
|
|
11
|
+
for f in files:
|
|
12
|
+
result = subprocess.run(
|
|
13
|
+
["git", "status", "--porcelain", "--", str(f)],
|
|
14
|
+
cwd=repo,
|
|
15
|
+
env=_git_env(),
|
|
16
|
+
capture_output=True,
|
|
17
|
+
text=True,
|
|
18
|
+
)
|
|
19
|
+
if result.returncode != 0:
|
|
20
|
+
raise RuntimeError(f"git status failed for {f}: {result.stderr.strip()}")
|
|
21
|
+
if result.stdout.strip():
|
|
22
|
+
return True
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def stage_and_commit(
|
|
27
|
+
message: str,
|
|
28
|
+
files: list[Path],
|
|
29
|
+
repo: Path | None = None,
|
|
30
|
+
no_verify: bool = False,
|
|
31
|
+
) -> str | None:
|
|
32
|
+
repo = repo or Path.cwd()
|
|
33
|
+
|
|
34
|
+
if not _has_changes_to_stage(files, repo):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
commit_cmd = ["git", "commit", "-m", message]
|
|
38
|
+
if no_verify:
|
|
39
|
+
commit_cmd.append("--no-verify")
|
|
40
|
+
|
|
41
|
+
subprocess.run(
|
|
42
|
+
["git", "add", "--"] + [str(f) for f in files],
|
|
43
|
+
cwd=repo,
|
|
44
|
+
env=_git_env(),
|
|
45
|
+
check=True,
|
|
46
|
+
)
|
|
47
|
+
subprocess.run(
|
|
48
|
+
commit_cmd,
|
|
49
|
+
cwd=repo,
|
|
50
|
+
env=_git_env(),
|
|
51
|
+
check=True,
|
|
52
|
+
)
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
["git", "rev-parse", "HEAD"],
|
|
55
|
+
cwd=repo,
|
|
56
|
+
env=_git_env(),
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
check=True,
|
|
60
|
+
)
|
|
61
|
+
return result.stdout.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def stage_files(files: list[Path], repo: Path | None = None) -> None:
|
|
65
|
+
"""Stage specific files without committing."""
|
|
66
|
+
repo = repo or Path.cwd()
|
|
67
|
+
if not _has_changes_to_stage(files, repo):
|
|
68
|
+
return
|
|
69
|
+
subprocess.run(
|
|
70
|
+
["git", "add", "--"] + [str(f) for f in files],
|
|
71
|
+
cwd=repo,
|
|
72
|
+
env=_git_env(),
|
|
73
|
+
check=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def commit_artifact(
|
|
78
|
+
path: Path,
|
|
79
|
+
message: str,
|
|
80
|
+
repo: Path | None = None,
|
|
81
|
+
no_verify: bool = False,
|
|
82
|
+
) -> str | None:
|
|
83
|
+
repo = repo or Path.cwd()
|
|
84
|
+
return stage_and_commit(
|
|
85
|
+
message=message, files=[path], repo=repo, no_verify=no_verify
|
|
86
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ClassificationResult",
|
|
8
|
+
"ComplexityGate",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ClassificationResult:
|
|
14
|
+
level: Literal["LOW", "MEDIUM", "HIGH"]
|
|
15
|
+
execution_mode: Literal["DIRECT", "TDD"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ComplexityGate:
|
|
19
|
+
_CLASSIFICATION_TABLE = {
|
|
20
|
+
"LOW": ClassificationResult(level="LOW", execution_mode="DIRECT"),
|
|
21
|
+
"MEDIUM": ClassificationResult(level="MEDIUM", execution_mode="DIRECT"),
|
|
22
|
+
"HIGH": ClassificationResult(level="HIGH", execution_mode="TDD"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def classify(
|
|
27
|
+
cls,
|
|
28
|
+
description: str,
|
|
29
|
+
_stub: str | None = None,
|
|
30
|
+
) -> ClassificationResult:
|
|
31
|
+
if _stub is not None:
|
|
32
|
+
if _stub not in cls._CLASSIFICATION_TABLE:
|
|
33
|
+
raise ValueError(f"Unknown stub value: {_stub}")
|
|
34
|
+
return cls._CLASSIFICATION_TABLE[_stub]
|
|
35
|
+
|
|
36
|
+
return cls._CLASSIFICATION_TABLE["LOW"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_REQUIRED_PLACEHOLDERS: frozenset[str] = frozenset(
|
|
9
|
+
{
|
|
10
|
+
"PROJECT_NAME",
|
|
11
|
+
"REPO_ROOT",
|
|
12
|
+
"TARGET_BACKEND_FRAMEWORK",
|
|
13
|
+
"TARGET_PACKAGE_MANAGER",
|
|
14
|
+
"TARGET_TEST_RUNNER",
|
|
15
|
+
"TARGET_COVERAGE_MINIMUM",
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_PLACEHOLDER_PATTERN = re.compile(r"\$\{(\w+)\}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class PlaceholderAuditResult:
|
|
24
|
+
all_present: bool
|
|
25
|
+
variables: list[str] = field(default_factory=list)
|
|
26
|
+
missing: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_placeholders(seed_path: Path) -> PlaceholderAuditResult:
|
|
30
|
+
content = seed_path.read_text()
|
|
31
|
+
found = set(_PLACEHOLDER_PATTERN.findall(content))
|
|
32
|
+
variables = sorted(found & _REQUIRED_PLACEHOLDERS)
|
|
33
|
+
missing = sorted(_REQUIRED_PLACEHOLDERS - found)
|
|
34
|
+
return PlaceholderAuditResult(
|
|
35
|
+
all_present=len(missing) == 0,
|
|
36
|
+
variables=variables,
|
|
37
|
+
missing=missing,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_constitution(repo_root: Path) -> Path:
|
|
42
|
+
path = repo_root / "specs" / "constitution.md"
|
|
43
|
+
if not path.exists():
|
|
44
|
+
raise FileNotFoundError(f"constitution.md not found at {path}")
|
|
45
|
+
return path
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_constitution(path: Path) -> bool:
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return False
|
|
51
|
+
content = path.read_text()
|
|
52
|
+
return len(content.strip()) > 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_COMMAND_KEYS: dict[str, str] = {
|
|
56
|
+
"TEST_COMMAND": "test_command",
|
|
57
|
+
"LINT_COMMAND": "lint_command",
|
|
58
|
+
"TYPE_CHECK_COMMAND": "type_check_command",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_value(line: str) -> str | None:
|
|
63
|
+
_, _, tail = line.partition(":")
|
|
64
|
+
value = tail.strip().strip("`").strip()
|
|
65
|
+
return value or None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_sections(path: Path, sections: list[str]) -> list[str]:
|
|
69
|
+
headings = set(re.findall(r"^(#{1,6}\s+.+)$", path.read_text(), re.MULTILINE))
|
|
70
|
+
return [s for s in sections if s not in headings]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def extract_commands(path: Path) -> dict[str, str]:
|
|
74
|
+
content = path.read_text()
|
|
75
|
+
commands: dict[str, str] = {}
|
|
76
|
+
for line in content.splitlines():
|
|
77
|
+
stripped = line.strip()
|
|
78
|
+
if ":" not in stripped:
|
|
79
|
+
continue
|
|
80
|
+
for marker, key in _COMMAND_KEYS.items():
|
|
81
|
+
if marker in stripped:
|
|
82
|
+
value = _extract_value(stripped)
|
|
83
|
+
if value:
|
|
84
|
+
commands[key] = value
|
|
85
|
+
return commands
|
deviate/core/contract.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def emit_contract(data: dict, output_dir: Path) -> Path:
|
|
9
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
11
|
+
path = output_dir / f"contract_{timestamp}.json"
|
|
12
|
+
path.write_text(json.dumps(data, indent=2, default=str))
|
|
13
|
+
return path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_contract(path: Path) -> dict:
|
|
17
|
+
return json.loads(path.read_text())
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Commit convention detection.
|
|
2
|
+
|
|
3
|
+
Detects whether a repository uses emoji-prefixed conventional commits
|
|
4
|
+
(by checking CONTRIBUTING.md / .commit-convention.md for emoji content,
|
|
5
|
+
then falling back to sampling recent git history) and provides a
|
|
6
|
+
formatter that prepends the correct emoji to commit messages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from deviate.core._shared import git_env as _git_env
|
|
16
|
+
|
|
17
|
+
# Default type → emoji mapping (gitmoji conventional-commit standard).
|
|
18
|
+
TYPE_EMOJI_MAP: dict[str, str] = {
|
|
19
|
+
"feat": "✨",
|
|
20
|
+
"fix": "🐛",
|
|
21
|
+
"docs": "📚",
|
|
22
|
+
"style": "🎨",
|
|
23
|
+
"refactor": "♻️",
|
|
24
|
+
"perf": "🚀",
|
|
25
|
+
"test": "✅",
|
|
26
|
+
"build": "📦",
|
|
27
|
+
"ci": "👷",
|
|
28
|
+
"chore": "🔧",
|
|
29
|
+
"revert": "⏪",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Per-phase emoji override for `test:` commits during the red-green TDD cycle.
|
|
33
|
+
# RED commits a failing test (🚨); GREEN commits a passing test (✅). All other
|
|
34
|
+
# types keep their `TYPE_EMOJI_MAP` entry. An unknown phase falls back to the
|
|
35
|
+
# default for the type.
|
|
36
|
+
PHASE_TEST_EMOJI: dict[str, str] = {
|
|
37
|
+
"red": "\U0001f6a8", # 🚨 — failing test
|
|
38
|
+
"green": "\u2705", # ✅ — passing test
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Conventional-commit type pattern at the start of a message.
|
|
42
|
+
_TYPE_RE = re.compile(r"^(\w+)")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_convention_file(repo: Path) -> str | None:
|
|
46
|
+
"""Return the content of a project commit-convention file, or None."""
|
|
47
|
+
for name in ("CONTRIBUTING.md", ".commit-convention.md"):
|
|
48
|
+
path = repo / name
|
|
49
|
+
if path.exists():
|
|
50
|
+
return path.read_text(encoding="utf-8")
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Emoji Unicode ranges (covers the standard gitmoji set and common emoji).
|
|
55
|
+
_EMOJI_RANGE_RE = re.compile(
|
|
56
|
+
"["
|
|
57
|
+
"\U0001f600-\U0001f64f" # emoticons
|
|
58
|
+
"\U0001f300-\U0001f5ff" # symbols & pictographs
|
|
59
|
+
"\U0001f680-\U0001f6ff" # transport & map symbols
|
|
60
|
+
"\U0001f1e0-\U0001f1ff" # flags
|
|
61
|
+
"\U0001f900-\U0001f9ff" # supplemental symbols
|
|
62
|
+
"\U0001fa00-\U0001fa6f" # chess symbols
|
|
63
|
+
"\U0001fa70-\U0001faff" # symbols extended-A
|
|
64
|
+
"\U00002702-\U000027b0" # dingbats
|
|
65
|
+
"\U00002600-\U000026ff" # misc symbols
|
|
66
|
+
"]+",
|
|
67
|
+
re.UNICODE,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _file_has_emojis(content: str) -> bool:
|
|
72
|
+
"""Check whether a text contains Unicode emoji characters."""
|
|
73
|
+
return bool(_EMOJI_RANGE_RE.search(content))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _git_log_has_emojis(repo: Path, n: int = 10) -> bool:
|
|
77
|
+
"""Sample the last *n* commit subjects for emoji presence."""
|
|
78
|
+
try:
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
["git", "log", f"-{n}", "--pretty=format:%s"],
|
|
81
|
+
cwd=repo,
|
|
82
|
+
env=_git_env(),
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=5,
|
|
86
|
+
)
|
|
87
|
+
if result.returncode != 0:
|
|
88
|
+
return False
|
|
89
|
+
return _file_has_emojis(result.stdout)
|
|
90
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def detect_uses_emojis(repo: Path) -> bool:
|
|
95
|
+
"""Determine whether a repository uses emoji-prefixed commits.
|
|
96
|
+
|
|
97
|
+
Detection order:
|
|
98
|
+
1. CONTRIBUTING.md / .commit-convention.md — if the file contains
|
|
99
|
+
Unicode emoji characters, the project uses them.
|
|
100
|
+
2. Git history — sample the last 10 commit subjects.
|
|
101
|
+
3. Default: False (no emoji prefix).
|
|
102
|
+
"""
|
|
103
|
+
convention_content = _read_convention_file(repo)
|
|
104
|
+
if convention_content is not None and _file_has_emojis(convention_content):
|
|
105
|
+
return True
|
|
106
|
+
return _git_log_has_emojis(repo)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _extract_type(message: str) -> str | None:
|
|
110
|
+
"""Extract the conventional-commit type from a message string."""
|
|
111
|
+
m = _TYPE_RE.match(message)
|
|
112
|
+
return m.group(1) if m else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def format_commit_message(message: str, repo: Path, phase: str | None = None) -> str:
|
|
116
|
+
"""Prepend the appropriate emoji to a conventional-commit message.
|
|
117
|
+
|
|
118
|
+
If the repository uses emoji prefixes (detected via CONTRIBUTING.md
|
|
119
|
+
or git history) and the message starts with a known type, the
|
|
120
|
+
corresponding emoji is prepended. Otherwise the message is returned
|
|
121
|
+
unchanged.
|
|
122
|
+
|
|
123
|
+
The optional ``phase`` argument selects a per-phase emoji override for
|
|
124
|
+
``test:`` commits during the red-green TDD cycle:
|
|
125
|
+
|
|
126
|
+
- ``phase="red"`` → 🚨 (failing test, RED phase commit)
|
|
127
|
+
- ``phase="green"`` → ✅ (passing test, GREEN phase commit)
|
|
128
|
+
|
|
129
|
+
For any other commit type the ``phase`` argument is ignored, and the
|
|
130
|
+
emoji falls back to ``TYPE_EMOJI_MAP``. An unknown ``phase`` value
|
|
131
|
+
also falls back to the type's default emoji.
|
|
132
|
+
"""
|
|
133
|
+
if not detect_uses_emojis(repo):
|
|
134
|
+
return message
|
|
135
|
+
|
|
136
|
+
commit_type = _extract_type(message)
|
|
137
|
+
if commit_type is None:
|
|
138
|
+
return message
|
|
139
|
+
|
|
140
|
+
if commit_type == "test" and phase in PHASE_TEST_EMOJI:
|
|
141
|
+
emoji = PHASE_TEST_EMOJI[phase]
|
|
142
|
+
elif commit_type in TYPE_EMOJI_MAP:
|
|
143
|
+
emoji = TYPE_EMOJI_MAP[commit_type]
|
|
144
|
+
else:
|
|
145
|
+
return message
|
|
146
|
+
|
|
147
|
+
return f"{emoji} {message}"
|
deviate/core/epic.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _resolve_specs_root(specs_root: Path | None = None) -> Path:
|
|
7
|
+
return specs_root or Path("specs")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _discover_all(specs_root: Path | None = None) -> list[str]:
|
|
11
|
+
root = _resolve_specs_root(specs_root)
|
|
12
|
+
if not root.exists():
|
|
13
|
+
return []
|
|
14
|
+
return sorted(
|
|
15
|
+
d.name
|
|
16
|
+
for d in root.iterdir()
|
|
17
|
+
if d.is_dir()
|
|
18
|
+
and not d.name.startswith(".")
|
|
19
|
+
and (
|
|
20
|
+
(d / "explore.md").exists() # Old format: specs/<slug>/explore.md
|
|
21
|
+
or _extract_prefix_num(d.name)
|
|
22
|
+
> 0 # New format: specs/NNN-slug/ (numbered bucket)
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def discover_epic(specs_root: Path | None = None) -> str:
|
|
28
|
+
slug_dirs = _discover_all(specs_root)
|
|
29
|
+
return slug_dirs[0] if slug_dirs else ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def discover_latest_epic(specs_root: Path | None = None) -> str:
|
|
33
|
+
slug_dirs = _discover_all(specs_root)
|
|
34
|
+
if not slug_dirs:
|
|
35
|
+
return ""
|
|
36
|
+
return max(slug_dirs, key=lambda s: _extract_prefix_num(s))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _extract_prefix_num(slug: str) -> int:
|
|
40
|
+
try:
|
|
41
|
+
return int(slug.split("-")[0])
|
|
42
|
+
except (ValueError, IndexError):
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _find_next_epic_num(root: Path) -> int:
|
|
47
|
+
if not root.is_dir():
|
|
48
|
+
return 1
|
|
49
|
+
nums = [
|
|
50
|
+
_extract_prefix_num(d.name)
|
|
51
|
+
for d in root.iterdir()
|
|
52
|
+
if d.is_dir() and _extract_prefix_num(d.name) > 0
|
|
53
|
+
]
|
|
54
|
+
return max(nums, default=0) + 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def allocate_feature_bucket(slug: str, specs_root: Path | None = None) -> Path:
|
|
58
|
+
root = _resolve_specs_root(specs_root)
|
|
59
|
+
|
|
60
|
+
if _extract_prefix_num(slug) > 0:
|
|
61
|
+
bucket = root / slug
|
|
62
|
+
bucket.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
return bucket
|
|
64
|
+
|
|
65
|
+
next_num = _find_next_epic_num(root)
|
|
66
|
+
numbered_slug = f"{next_num:03d}-{slug}"
|
|
67
|
+
bucket = root / numbered_slug
|
|
68
|
+
bucket.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
return bucket
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_active_feature(specs_root: Path | None = None) -> str:
|
|
73
|
+
return discover_latest_epic(specs_root)
|
deviate/core/issues.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from deviate.state.ledger import IssueRecord, resolve_issue_record
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _resolve_ledger(path: Path | None = None) -> Path:
|
|
10
|
+
return path or Path("specs/issues.jsonl")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_issue(issue_id: str, ledger_path: Path | None = None) -> IssueRecord | None:
|
|
14
|
+
return resolve_issue_record(issue_id, _resolve_ledger(ledger_path))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def claim_issue(issue_id: str, ledger_path: Path | None = None) -> bool:
|
|
18
|
+
ledger_path = _resolve_ledger(ledger_path)
|
|
19
|
+
record = resolve_issue(issue_id, ledger_path)
|
|
20
|
+
if record is None:
|
|
21
|
+
return False
|
|
22
|
+
claimed = record.model_copy(
|
|
23
|
+
update={
|
|
24
|
+
"status": "SPECIFIED",
|
|
25
|
+
"timestamp": datetime.now(timezone.utc),
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
with ledger_path.open("a", encoding="utf-8") as f:
|
|
30
|
+
f.write(claimed.model_dump_json() + "\n")
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_issue_body(issue_id: str, ledger_path: Path | None = None) -> str:
|
|
35
|
+
ledger_path = _resolve_ledger(ledger_path)
|
|
36
|
+
record = resolve_issue(issue_id, ledger_path)
|
|
37
|
+
if record is None:
|
|
38
|
+
return ""
|
|
39
|
+
return record.model_dump_json(indent=2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_issue_completed(issue_id: str, ledger_path: Path | None = None) -> bool:
|
|
43
|
+
record = resolve_issue(issue_id, _resolve_ledger(ledger_path))
|
|
44
|
+
if record is None:
|
|
45
|
+
return False
|
|
46
|
+
return record.status == "COMPLETED"
|