deviatdd 2.5.1__py3-none-any.whl

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