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/__init__.py
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.resources
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.prompt import Prompt
|
|
11
|
+
|
|
12
|
+
from deviate.state.config import DeviateConfig, SessionState
|
|
13
|
+
from deviate.state.config import resolve_graphite_config as resolve_graphite_config # noqa: F401
|
|
14
|
+
from deviate.cli.macro import explore_app, macro_app, research_app, prd_app, shard_app # noqa: F401
|
|
15
|
+
from deviate.cli.meso import merge, meso_app, plan, pr, specify, tasks
|
|
16
|
+
from deviate.cli.micro import (
|
|
17
|
+
e2e_app,
|
|
18
|
+
execute_app,
|
|
19
|
+
green_app,
|
|
20
|
+
hotfix_app,
|
|
21
|
+
judge_app,
|
|
22
|
+
red_app,
|
|
23
|
+
refactor_app,
|
|
24
|
+
run_command,
|
|
25
|
+
)
|
|
26
|
+
from deviate.cli.adhoc import adhoc_app
|
|
27
|
+
from deviate.cli.constitution import constitution_app
|
|
28
|
+
from deviate.cli.feature import feature_app
|
|
29
|
+
from deviate.cli.inspect import inspect_app
|
|
30
|
+
from deviate.cli.init import init_app
|
|
31
|
+
from deviate.cli.review import review_app
|
|
32
|
+
from deviate.core.agent import AGENT_TO_BACKEND as AGENT_TO_BACKEND # noqa: F401
|
|
33
|
+
from deviate.core.agent import resolve_agent_to_backend as _resolve_agent_to_backend # noqa: F401
|
|
34
|
+
from deviate.core.commands import discover_commands, install_command
|
|
35
|
+
from deviate.ui.render import is_interactive
|
|
36
|
+
|
|
37
|
+
cli = typer.Typer(no_args_is_help=True)
|
|
38
|
+
console = Console()
|
|
39
|
+
|
|
40
|
+
_GOVERNANCE_MODULE = "deviate.prompts.governance"
|
|
41
|
+
|
|
42
|
+
# User-facing agent platform choices (selectable via --agent and the
|
|
43
|
+
# interactive init prompt). Order is intentional: factory/droid (Droid
|
|
44
|
+
# ecosystem) come first, then the third-party CLIs.
|
|
45
|
+
AGENT_CHOICES: tuple[str, ...] = ("factory", "droid", "claude", "opencode", "pi", "omp")
|
|
46
|
+
|
|
47
|
+
# Map a user-facing agent name to the underlying backend that meso/micro
|
|
48
|
+
# layers invoke. ``factory`` is the Factory Droid IDE — the meso/micro
|
|
49
|
+
# commands still drive the ``droid`` binary under the hood. ``pi`` is
|
|
50
|
+
# the @earendil-works/pi-coding-agent CLI binary. ``omp`` is the
|
|
51
|
+
# Oh-My-Pi CLI (``omp -p``) — a distinct backend from ``pi``, even
|
|
52
|
+
# though OMP internally wraps Pi. ``deviate`` spawns the ``omp`` binary
|
|
53
|
+
# directly, not ``pi``, when the user selects ``omp``.
|
|
54
|
+
# Canonical source is :data:`deviate.core.agent.AGENT_TO_BACKEND`; the
|
|
55
|
+
# top-of-module re-export keeps the existing
|
|
56
|
+
# ``from deviate.cli import AGENT_TO_BACKEND`` import path working and
|
|
57
|
+
# keeps the cli init flow reading the same values.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _version_callback(value: bool) -> None:
|
|
61
|
+
if value:
|
|
62
|
+
from importlib.metadata import version
|
|
63
|
+
|
|
64
|
+
typer.echo(f"deviate {version('deviate')}")
|
|
65
|
+
raise typer.Exit()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@cli.callback()
|
|
69
|
+
def main(
|
|
70
|
+
version: bool = typer.Option(
|
|
71
|
+
False,
|
|
72
|
+
"--version",
|
|
73
|
+
callback=_version_callback,
|
|
74
|
+
is_eager=True,
|
|
75
|
+
help="Show the version and exit",
|
|
76
|
+
),
|
|
77
|
+
) -> None:
|
|
78
|
+
"""DeviaTDD CLI — agent orchestration framework"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# TOML comment annotations for DeviateConfig fields — emitted as `#` lines
|
|
82
|
+
# before their corresponding key in .deviate/config.toml. These are the
|
|
83
|
+
# primary documentation surface for end users editing config by hand.
|
|
84
|
+
_CONFIG_TOML_COMMENTS: dict[str, str] = {
|
|
85
|
+
"profile": 'Preset config group: "default", "full", "fast", or "secure"',
|
|
86
|
+
"timeout_seconds": "CLI inactivity timeout in seconds (must be > 0)",
|
|
87
|
+
"agent_export_mode": 'Agent export mode: "local" (project) or "global" (~/.claude/)',
|
|
88
|
+
"agent": "Agent backend configuration",
|
|
89
|
+
"models": "Per-phase model overrides; key = phase name, value = model ID",
|
|
90
|
+
"use_libref": "Enable the libref CLI for offline documentation lookups",
|
|
91
|
+
"graphite": "Enable Graphite CLI integration for stacked changes",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _ensure_dir(path: Path) -> None:
|
|
96
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _write_if_missing(path: Path, content: str) -> bool:
|
|
100
|
+
if path.exists():
|
|
101
|
+
console.print(f" [yellow]SKIP[/] {path.name} already exists")
|
|
102
|
+
return False
|
|
103
|
+
path.write_text(content, encoding="utf-8")
|
|
104
|
+
console.print(f" [green]CREATE[/] {path.name}")
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _serialize_value(key: str, value: object) -> str:
|
|
109
|
+
if value is None:
|
|
110
|
+
return ""
|
|
111
|
+
if isinstance(value, bool):
|
|
112
|
+
return f"{key} = {'true' if value else 'false'}"
|
|
113
|
+
if isinstance(value, (int, float)):
|
|
114
|
+
return f"{key} = {value}"
|
|
115
|
+
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
116
|
+
return f'{key} = "{escaped}"'
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _dict_to_toml(data: dict, comments: dict[str, str] | None = None) -> str:
|
|
120
|
+
lines: list[str] = []
|
|
121
|
+
# Emit all scalar top-level keys FIRST, then all tables. TOML has no
|
|
122
|
+
# "back to root" syntax — once a [table] header is written, any subsequent
|
|
123
|
+
# bare keys are absorbed into that table. By ordering scalars before
|
|
124
|
+
# dicts, top-level scalars stay at root scope.
|
|
125
|
+
scalars: list[tuple[str, object]] = []
|
|
126
|
+
tables: list[tuple[str, dict]] = []
|
|
127
|
+
for key, value in data.items():
|
|
128
|
+
if value is None:
|
|
129
|
+
continue
|
|
130
|
+
if isinstance(value, dict):
|
|
131
|
+
tables.append((key, value))
|
|
132
|
+
else:
|
|
133
|
+
scalars.append((key, value))
|
|
134
|
+
|
|
135
|
+
for key, value in scalars:
|
|
136
|
+
if comments and key in comments:
|
|
137
|
+
lines.append(f"# {comments[key]}")
|
|
138
|
+
line = _serialize_value(key, value)
|
|
139
|
+
if line:
|
|
140
|
+
lines.append(line)
|
|
141
|
+
|
|
142
|
+
for key, value in tables:
|
|
143
|
+
if not value:
|
|
144
|
+
continue
|
|
145
|
+
if comments and key in comments:
|
|
146
|
+
lines.append(f"\n# {comments[key]}")
|
|
147
|
+
lines.append(f"\n[{key}]")
|
|
148
|
+
for k, v in value.items():
|
|
149
|
+
line = _serialize_value(k, v)
|
|
150
|
+
if line:
|
|
151
|
+
lines.append(line)
|
|
152
|
+
lines.append("")
|
|
153
|
+
toml_str = "\n".join(lines)
|
|
154
|
+
try:
|
|
155
|
+
import tomllib
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
tomllib.loads(toml_str)
|
|
159
|
+
except tomllib.TOMLDecodeError:
|
|
160
|
+
console.print(" [red]ERROR[/] Generated TOML failed round-trip validation")
|
|
161
|
+
except ImportError:
|
|
162
|
+
pass
|
|
163
|
+
return toml_str
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _extract_section_heading(content: str) -> str | None:
|
|
167
|
+
match = re.search(r"^## (.+)$", content, re.MULTILINE)
|
|
168
|
+
if match:
|
|
169
|
+
return f"## {match.group(1)}"
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _read_seed(module: str, filename: str) -> str | None:
|
|
174
|
+
try:
|
|
175
|
+
seed = importlib.resources.files(module).joinpath(filename)
|
|
176
|
+
return seed.read_text(encoding="utf-8")
|
|
177
|
+
except (ModuleNotFoundError, FileNotFoundError):
|
|
178
|
+
console.print(f" [red]ERROR[/] {filename} not found in package")
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _split_governance_sections(content: str) -> list[str]:
|
|
183
|
+
"""Split multi-section governance content into individual ``##`` sections."""
|
|
184
|
+
parts = re.split(r"^(?=## )", content, flags=re.MULTILINE)
|
|
185
|
+
return [p.strip() for p in parts if p.strip() and p.startswith("## ")]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _normalize_heading(text: str) -> str:
|
|
189
|
+
"""Lowercase heading text with emojis, punctuation, and parentheticals stripped."""
|
|
190
|
+
h = re.sub(r"^##\s*", "", text)
|
|
191
|
+
h = re.sub(r"\([^)]*\)", "", h)
|
|
192
|
+
h = re.sub(r"[^\w\s-]", "", h)
|
|
193
|
+
return " ".join(h.lower().split())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _find_section_heading(content: str, seed_header: str) -> str | None:
|
|
197
|
+
"""Return the heading line in *content* that matches *seed_header*.
|
|
198
|
+
|
|
199
|
+
Tries exact match first (line-boundary aware), then normalized
|
|
200
|
+
(ignore emoji/parentheticals). Returns ``None`` when no match is found.
|
|
201
|
+
"""
|
|
202
|
+
for line in content.split("\n"):
|
|
203
|
+
if line.strip().startswith(seed_header):
|
|
204
|
+
return seed_header
|
|
205
|
+
|
|
206
|
+
seed_norm = _normalize_heading(seed_header)
|
|
207
|
+
if not seed_norm:
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
for heading in re.findall(r"^## .+$", content, re.MULTILINE):
|
|
211
|
+
if _normalize_heading(heading) == seed_norm:
|
|
212
|
+
return heading
|
|
213
|
+
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _upsert_section(target_path: Path, section_content: str) -> None:
|
|
218
|
+
section_header = _extract_section_heading(section_content)
|
|
219
|
+
if section_header is None:
|
|
220
|
+
console.print(" [red]ERROR[/] Could not extract section heading from seed")
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
if not target_path.exists():
|
|
224
|
+
target_path.write_text(section_content + "\n", encoding="utf-8")
|
|
225
|
+
console.print(f" [green]CREATE[/] {target_path.name}")
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
existing = target_path.read_text(encoding="utf-8")
|
|
229
|
+
|
|
230
|
+
if not existing.strip():
|
|
231
|
+
target_path.write_text(section_content + "\n", encoding="utf-8")
|
|
232
|
+
console.print(f" [green]CREATE[/] {target_path.name}")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
target_heading = _find_section_heading(existing, section_header)
|
|
236
|
+
if target_heading is None:
|
|
237
|
+
target_path.write_text(
|
|
238
|
+
existing.rstrip("\n") + "\n\n" + section_content + "\n", encoding="utf-8"
|
|
239
|
+
)
|
|
240
|
+
console.print(f" [green]APPEND[/] {target_path.name}")
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
pattern = re.compile(
|
|
244
|
+
rf"^{re.escape(target_heading)}.*?(?=^## |\Z)",
|
|
245
|
+
re.MULTILINE | re.DOTALL,
|
|
246
|
+
)
|
|
247
|
+
existing = pattern.sub(lambda _: section_content.strip() + "\n", existing)
|
|
248
|
+
target_path.write_text(existing, encoding="utf-8")
|
|
249
|
+
console.print(f" [green]UPDATE[/] {target_path.name} block replaced")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _upsert_governance_block(target_path: Path, seed_content: str) -> None:
|
|
253
|
+
sections = _split_governance_sections(seed_content)
|
|
254
|
+
if not sections:
|
|
255
|
+
console.print(" [red]ERROR[/] No valid governance sections found in seed")
|
|
256
|
+
return
|
|
257
|
+
for section in sections:
|
|
258
|
+
_upsert_section(target_path, section)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _detect_libref() -> bool:
|
|
262
|
+
return shutil.which("libref") is not None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# Agent selection
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _read_agent_backend_from_config(config_path: Path) -> str | None:
|
|
271
|
+
"""Return the ``[agent].backend`` value stored in *config_path*.
|
|
272
|
+
|
|
273
|
+
Used by both init (to detect a previously persisted choice) and the
|
|
274
|
+
interactive prompt (to pre-select the current value as the default).
|
|
275
|
+
"""
|
|
276
|
+
if not config_path.exists():
|
|
277
|
+
return None
|
|
278
|
+
try:
|
|
279
|
+
import tomllib
|
|
280
|
+
|
|
281
|
+
with open(config_path, "rb") as f:
|
|
282
|
+
data = tomllib.load(f)
|
|
283
|
+
except Exception:
|
|
284
|
+
return None
|
|
285
|
+
backend = data.get("agent", {}).get("backend")
|
|
286
|
+
return backend if isinstance(backend, str) and backend else None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _write_agent_block_to_config(config_path: Path, backend: str) -> bool:
|
|
290
|
+
"""Surgically upsert ``[agent]\nbackend = "<value>"`` in *config_path*.
|
|
291
|
+
|
|
292
|
+
Preserves every other key/table in the file (similar in spirit to
|
|
293
|
+
:func:`_merge_flag_keys` for the boolean ``graphite`` / ``use_libref``
|
|
294
|
+
keys, but for the nested ``[agent]`` table).
|
|
295
|
+
|
|
296
|
+
Returns ``True`` when the file was modified, ``False`` when the
|
|
297
|
+
existing ``[agent].backend`` already matches the requested value.
|
|
298
|
+
"""
|
|
299
|
+
content = config_path.read_text(encoding="utf-8")
|
|
300
|
+
new_line = f'backend = "{backend}"'
|
|
301
|
+
|
|
302
|
+
block_pattern = re.compile(
|
|
303
|
+
r"^\[agent\]\s*\n(?:backend\s*=\s*.*\n?)+",
|
|
304
|
+
re.MULTILINE,
|
|
305
|
+
)
|
|
306
|
+
match = block_pattern.search(content)
|
|
307
|
+
if match:
|
|
308
|
+
if f'backend = "{backend}"' in match.group(0):
|
|
309
|
+
return False
|
|
310
|
+
content = block_pattern.sub(f"[agent]\n{new_line}\n", content)
|
|
311
|
+
else:
|
|
312
|
+
table_match = re.search(r"^\[.*\]\s*$", content, re.MULTILINE)
|
|
313
|
+
new_block = f"\n[agent]\n{new_line}\n"
|
|
314
|
+
if table_match:
|
|
315
|
+
idx = table_match.start()
|
|
316
|
+
content = content[:idx] + new_block.lstrip("\n") + "\n" + content[idx:]
|
|
317
|
+
else:
|
|
318
|
+
if content and not content.endswith("\n"):
|
|
319
|
+
content += "\n"
|
|
320
|
+
content += new_block
|
|
321
|
+
config_path.write_text(content, encoding="utf-8")
|
|
322
|
+
return True
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _prompt_agent_selection(
|
|
326
|
+
workdir: Path,
|
|
327
|
+
config_path: Path,
|
|
328
|
+
) -> str | None:
|
|
329
|
+
"""Interactively prompt the user to pick an agent platform.
|
|
330
|
+
|
|
331
|
+
Returns the selected agent name, or ``None`` when the session is not
|
|
332
|
+
interactive (e.g. CI) — the caller is then expected to abort the
|
|
333
|
+
command with a clear error message.
|
|
334
|
+
"""
|
|
335
|
+
if not is_interactive():
|
|
336
|
+
return None
|
|
337
|
+
existing = _read_agent_backend_from_config(config_path)
|
|
338
|
+
default = existing if existing in AGENT_CHOICES else None
|
|
339
|
+
try:
|
|
340
|
+
selected = Prompt.ask(
|
|
341
|
+
"Select agent platform",
|
|
342
|
+
choices=list(AGENT_CHOICES),
|
|
343
|
+
default=default,
|
|
344
|
+
console=console,
|
|
345
|
+
)
|
|
346
|
+
except (EOFError, KeyboardInterrupt):
|
|
347
|
+
return None
|
|
348
|
+
if not selected or selected not in AGENT_CHOICES:
|
|
349
|
+
return None
|
|
350
|
+
return selected
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _validate_agent_choice(value: str | None) -> str | None:
|
|
354
|
+
"""Typer callback: validate ``--agent`` value and emit Typer error.
|
|
355
|
+
|
|
356
|
+
``None`` is allowed — that means the user did not pass ``--agent`` and
|
|
357
|
+
the init command should fall through to config lookup / interactive
|
|
358
|
+
prompt.
|
|
359
|
+
"""
|
|
360
|
+
if value is None:
|
|
361
|
+
return None
|
|
362
|
+
if value not in AGENT_CHOICES:
|
|
363
|
+
raise typer.BadParameter(
|
|
364
|
+
f"Invalid agent '{value}'. Must be one of: {', '.join(AGENT_CHOICES)}"
|
|
365
|
+
)
|
|
366
|
+
return value
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _merge_flag_keys(config_path: Path, *, graphite: bool, use_libref: bool) -> None:
|
|
370
|
+
"""Surgically update ``graphite`` and ``use_libref`` keys in an existing TOML.
|
|
371
|
+
|
|
372
|
+
Preserves every other key/table (e.g. user-customised ``[models]``).
|
|
373
|
+
Used when ``init --graphite`` or ``init --libref`` is re-run on a workspace
|
|
374
|
+
whose ``.deviate/config.toml`` already exists — the idempotency guard in
|
|
375
|
+
``_write_if_missing`` would otherwise silently drop the new flag values.
|
|
376
|
+
"""
|
|
377
|
+
content = config_path.read_text(encoding="utf-8")
|
|
378
|
+
for key, value in (("graphite", graphite), ("use_libref", use_libref)):
|
|
379
|
+
new_line = f"{key} = {'true' if value else 'false'}"
|
|
380
|
+
pattern = re.compile(rf"^{re.escape(key)}\s*=\s*.*$", re.MULTILINE)
|
|
381
|
+
if pattern.search(content):
|
|
382
|
+
content = pattern.sub(new_line, content)
|
|
383
|
+
else:
|
|
384
|
+
# Insert before the first [table] header if any, else append.
|
|
385
|
+
table_match = re.search(r"^\[.*\]\s*$", content, re.MULTILINE)
|
|
386
|
+
if table_match:
|
|
387
|
+
idx = table_match.start()
|
|
388
|
+
content = content[:idx] + f"{new_line}\n" + content[idx:]
|
|
389
|
+
else:
|
|
390
|
+
if content and not content.endswith("\n"):
|
|
391
|
+
content += "\n"
|
|
392
|
+
content += f"{new_line}\n"
|
|
393
|
+
config_path.write_text(content, encoding="utf-8")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _scaffold_dotfiles(
|
|
397
|
+
workdir: Path,
|
|
398
|
+
agent_export_mode: str,
|
|
399
|
+
use_libref: bool = False,
|
|
400
|
+
graphite: bool = False,
|
|
401
|
+
force_update_flags: bool = False,
|
|
402
|
+
agent_backend: str | None = None,
|
|
403
|
+
) -> None:
|
|
404
|
+
dot_dir = workdir / ".deviate"
|
|
405
|
+
_ensure_dir(dot_dir)
|
|
406
|
+
_ensure_dir(dot_dir / "artifacts")
|
|
407
|
+
|
|
408
|
+
config_path = dot_dir / "config.toml"
|
|
409
|
+
if config_path.exists() and not force_update_flags and agent_backend is None:
|
|
410
|
+
console.print(f" [yellow]SKIP[/] {config_path.name} already exists")
|
|
411
|
+
elif config_path.exists():
|
|
412
|
+
# Existing config: only touch the keys the caller asked us to touch.
|
|
413
|
+
# `use_libref` and `graphite` are only ever upserted when the
|
|
414
|
+
# corresponding flag was passed (force_update_flags). `agent_backend`
|
|
415
|
+
# is always upserted when provided so `--agent factory` can overwrite
|
|
416
|
+
# a previously persisted backend.
|
|
417
|
+
changed = False
|
|
418
|
+
if force_update_flags:
|
|
419
|
+
_merge_flag_keys(config_path, graphite=graphite, use_libref=use_libref)
|
|
420
|
+
changed = True
|
|
421
|
+
if agent_backend is not None:
|
|
422
|
+
changed = (
|
|
423
|
+
_write_agent_block_to_config(config_path, agent_backend) or changed
|
|
424
|
+
)
|
|
425
|
+
if changed:
|
|
426
|
+
console.print(f" [green]UPDATE[/] {config_path.name} flags merged")
|
|
427
|
+
else:
|
|
428
|
+
console.print(f" [yellow]SKIP[/] {config_path.name} already exists")
|
|
429
|
+
else:
|
|
430
|
+
config = DeviateConfig(
|
|
431
|
+
agent_export_mode=agent_export_mode,
|
|
432
|
+
use_libref=use_libref,
|
|
433
|
+
graphite=graphite,
|
|
434
|
+
)
|
|
435
|
+
if agent_backend is not None:
|
|
436
|
+
config = config.model_copy(
|
|
437
|
+
update={
|
|
438
|
+
"agent": config.agent.model_copy(update={"backend": agent_backend})
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
_write_if_missing(
|
|
442
|
+
config_path,
|
|
443
|
+
_dict_to_toml(config.model_dump(), comments=_CONFIG_TOML_COMMENTS),
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
session = SessionState()
|
|
447
|
+
session_path = dot_dir / "session.json"
|
|
448
|
+
_write_if_missing(session_path, session.model_dump_json(indent=2))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _linkify_governance_files(workdir: Path) -> None:
|
|
452
|
+
"""Ensure CLAUDE.md ↔ AGENTS.md symlink relationship.
|
|
453
|
+
|
|
454
|
+
If neither file exists, create an empty CLAUDE.md and symlink
|
|
455
|
+
AGENTS.md → CLAUDE.md. If exactly one exists, symlink the other
|
|
456
|
+
to it. If both exist (as regular files), leave them alone.
|
|
457
|
+
Idempotent — an existing symlink is never replaced.
|
|
458
|
+
"""
|
|
459
|
+
claude = workdir / "CLAUDE.md"
|
|
460
|
+
agents = workdir / "AGENTS.md"
|
|
461
|
+
claude_exists = claude.exists() or claude.is_symlink()
|
|
462
|
+
agents_exists = agents.exists() or agents.is_symlink()
|
|
463
|
+
|
|
464
|
+
if claude_exists and agents_exists:
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
if not claude_exists and not agents_exists:
|
|
468
|
+
claude.write_text("", encoding="utf-8")
|
|
469
|
+
agents.symlink_to("CLAUDE.md")
|
|
470
|
+
console.print(" [green]CREATE[/] CLAUDE.md")
|
|
471
|
+
console.print(" [green]LINK[/] AGENTS.md -> CLAUDE.md")
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
if claude_exists and not agents_exists:
|
|
475
|
+
agents.symlink_to("CLAUDE.md")
|
|
476
|
+
console.print(" [green]LINK[/] AGENTS.md -> CLAUDE.md")
|
|
477
|
+
return
|
|
478
|
+
|
|
479
|
+
# agents_exists and not claude_exists
|
|
480
|
+
claude.symlink_to("AGENTS.md")
|
|
481
|
+
console.print(" [green]LINK[/] CLAUDE.md -> AGENTS.md")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _apply_governance(workdir: Path, graphite: bool = False) -> None:
|
|
485
|
+
# NOTE: claudemd_seed.md and agents_seed.md are intentionally empty — the
|
|
486
|
+
# former ``## 🛠 DeviaTDD Phase Architecture`` block was project-internal
|
|
487
|
+
# guidance that did not help consuming projects. An empty seed (read
|
|
488
|
+
# successfully but with no content) is skipped silently so the remaining
|
|
489
|
+
# blocks below still run. A missing seed is treated as a packaging error.
|
|
490
|
+
|
|
491
|
+
# Ensure CLAUDE.md ↔ AGENTS.md symlink before any seed writes.
|
|
492
|
+
# After linking, determine which paths are canonical (not symlinks)
|
|
493
|
+
# so upserts only write to the real file — never double-write through
|
|
494
|
+
# a symlink to the same target.
|
|
495
|
+
_linkify_governance_files(workdir)
|
|
496
|
+
|
|
497
|
+
claude_path = workdir / "CLAUDE.md"
|
|
498
|
+
agents_path = workdir / "AGENTS.md"
|
|
499
|
+
targets: list[Path] = [p for p in (claude_path, agents_path) if not p.is_symlink()]
|
|
500
|
+
|
|
501
|
+
claude_content = _read_seed(_GOVERNANCE_MODULE, "claudemd_seed.md")
|
|
502
|
+
if claude_content is None:
|
|
503
|
+
return
|
|
504
|
+
if "## " in claude_content:
|
|
505
|
+
for t in targets:
|
|
506
|
+
_upsert_governance_block(t, claude_content)
|
|
507
|
+
|
|
508
|
+
agents_content = _read_seed(_GOVERNANCE_MODULE, "agents_seed.md")
|
|
509
|
+
if agents_content is None:
|
|
510
|
+
return
|
|
511
|
+
if "## " in agents_content:
|
|
512
|
+
for t in targets:
|
|
513
|
+
_upsert_governance_block(t, agents_content)
|
|
514
|
+
|
|
515
|
+
libref_content = _read_seed(_GOVERNANCE_MODULE, "libref_seed.md")
|
|
516
|
+
if libref_content:
|
|
517
|
+
for t in targets:
|
|
518
|
+
_upsert_governance_block(t, libref_content)
|
|
519
|
+
|
|
520
|
+
if graphite:
|
|
521
|
+
content = _read_seed(_GOVERNANCE_MODULE, "graphite_seed.md")
|
|
522
|
+
if content:
|
|
523
|
+
for t in targets:
|
|
524
|
+
_upsert_governance_block(t, content)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
_CONSTITUTION_SEED_MODULE = "deviate.prompts"
|
|
528
|
+
_CONSTITUTION_SEED_FILE = "constitution_seed.md"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _scaffold_constitution(workdir: Path) -> None:
|
|
532
|
+
"""Write a placeholder specs/constitution.md if it doesn't exist.
|
|
533
|
+
|
|
534
|
+
The placeholder is populated by ``/research`` during the macro layer.
|
|
535
|
+
"""
|
|
536
|
+
specs_dir = workdir / "specs"
|
|
537
|
+
const_path = specs_dir / "constitution.md"
|
|
538
|
+
|
|
539
|
+
if const_path.exists():
|
|
540
|
+
console.print(" [yellow]SKIP[/] specs/constitution.md already exists")
|
|
541
|
+
return
|
|
542
|
+
|
|
543
|
+
seed = _read_seed(_CONSTITUTION_SEED_MODULE, _CONSTITUTION_SEED_FILE)
|
|
544
|
+
if seed is None:
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
specs_dir.mkdir(parents=True, exist_ok=True)
|
|
548
|
+
const_path.write_text(seed, encoding="utf-8")
|
|
549
|
+
console.print(" [green]CREATE[/] specs/constitution.md")
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _get_agent_command_dir(agent_name: str, workdir: Path) -> Path | None:
|
|
553
|
+
"""Resolve the slash-command directory for a given agent platform.
|
|
554
|
+
|
|
555
|
+
Factory, Claude, OpenCode discover slash commands from
|
|
556
|
+
``<workdir>/.{agent}/commands/`` (flat top-level only). Pi and OMP use
|
|
557
|
+
``<workdir>/.{agent}/prompts/`` per their platform conventions.
|
|
558
|
+
"""
|
|
559
|
+
if agent_name in ("claude", "opencode", "factory"):
|
|
560
|
+
return workdir / f".{agent_name}" / "commands"
|
|
561
|
+
if agent_name == "pi":
|
|
562
|
+
return workdir / ".pi" / "prompts"
|
|
563
|
+
if agent_name == "omp":
|
|
564
|
+
return workdir / ".omp" / "prompts"
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _install_commands_to_agents(workdir: Path, agents: list[str]) -> None:
|
|
569
|
+
"""Install the command library into every supported agent directory.
|
|
570
|
+
|
|
571
|
+
Output is aggregated per-agent — one summary line per agent instead of
|
|
572
|
+
one line per (command × agent) — to keep ``deviate setup`` output
|
|
573
|
+
readable when 32 commands are written to four agent directories
|
|
574
|
+
(128 lines per invocation under the legacy per-command format).
|
|
575
|
+
"""
|
|
576
|
+
commands = discover_commands()
|
|
577
|
+
if not commands:
|
|
578
|
+
return
|
|
579
|
+
for agent in agents:
|
|
580
|
+
target_dir = _get_agent_command_dir(agent, workdir)
|
|
581
|
+
if target_dir is None:
|
|
582
|
+
console.print(f" [yellow]SKIP[/] Unknown agent: {agent}")
|
|
583
|
+
continue
|
|
584
|
+
installed = 0
|
|
585
|
+
skipped = 0
|
|
586
|
+
for command_name in commands:
|
|
587
|
+
if install_command(command_name, target_dir, workdir=workdir, agent=agent):
|
|
588
|
+
installed += 1
|
|
589
|
+
else:
|
|
590
|
+
skipped += 1
|
|
591
|
+
if installed and not skipped:
|
|
592
|
+
console.print(f" [green]INSTALL[/] {installed} commands → {agent}")
|
|
593
|
+
elif skipped and not installed:
|
|
594
|
+
console.print(f" [yellow]SKIP[/] {skipped} commands → {agent}")
|
|
595
|
+
else:
|
|
596
|
+
console.print(
|
|
597
|
+
f" [green]INSTALL[/] {installed}, [yellow]SKIP[/] {skipped} → {agent}"
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _ensure_gitignore(workdir: Path) -> None:
|
|
602
|
+
dot_dir = workdir / ".deviate"
|
|
603
|
+
dot_dir.mkdir(parents=True, exist_ok=True)
|
|
604
|
+
gitignore = dot_dir / ".gitignore"
|
|
605
|
+
entries = [
|
|
606
|
+
"session.json",
|
|
607
|
+
"artifacts/",
|
|
608
|
+
"prompts.log",
|
|
609
|
+
"reports/",
|
|
610
|
+
"rollback.jsonl",
|
|
611
|
+
"logs/",
|
|
612
|
+
]
|
|
613
|
+
if gitignore.exists():
|
|
614
|
+
content = gitignore.read_text(encoding="utf-8")
|
|
615
|
+
for entry in entries:
|
|
616
|
+
if entry not in content:
|
|
617
|
+
content = content.rstrip("\n") + f"\n{entry}\n"
|
|
618
|
+
gitignore.write_text(content, encoding="utf-8")
|
|
619
|
+
else:
|
|
620
|
+
gitignore.write_text("\n".join(entries) + "\n", encoding="utf-8")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@cli.command(name="setup")
|
|
624
|
+
def setup(
|
|
625
|
+
agent_export_mode: str = typer.Option(
|
|
626
|
+
"local", "--agent-export-mode", help="Export mode for agent commands"
|
|
627
|
+
),
|
|
628
|
+
graphite: bool = typer.Option(
|
|
629
|
+
False, "--graphite", help="Enable Graphite CLI integration for stacked changes"
|
|
630
|
+
),
|
|
631
|
+
libref: bool = typer.Option(
|
|
632
|
+
False,
|
|
633
|
+
"--libref",
|
|
634
|
+
help="Force-enable offline libref CLI integration (overrides PATH detection)",
|
|
635
|
+
),
|
|
636
|
+
agent: str | None = typer.Option(
|
|
637
|
+
None,
|
|
638
|
+
"--agent",
|
|
639
|
+
help="Override auto-detected agent platform",
|
|
640
|
+
callback=_validate_agent_choice,
|
|
641
|
+
),
|
|
642
|
+
) -> None:
|
|
643
|
+
workdir = Path.cwd()
|
|
644
|
+
config_path = workdir / ".deviate" / "config.toml"
|
|
645
|
+
|
|
646
|
+
console.print("[bold]Initializing deviate workspace...[/bold]")
|
|
647
|
+
|
|
648
|
+
selected_agent: str | None = agent
|
|
649
|
+
if selected_agent is None:
|
|
650
|
+
existing_backend = _read_agent_backend_from_config(config_path)
|
|
651
|
+
if existing_backend is not None:
|
|
652
|
+
selected_agent = existing_backend
|
|
653
|
+
else:
|
|
654
|
+
selected_agent = _prompt_agent_selection(workdir, config_path)
|
|
655
|
+
if selected_agent is None:
|
|
656
|
+
console.print(
|
|
657
|
+
"[red]NO_AGENT_SELECTED[/] No agent platform chosen."
|
|
658
|
+
" Re-run `deviate setup --agent <name>` with one of:"
|
|
659
|
+
f" {', '.join(AGENT_CHOICES)}."
|
|
660
|
+
)
|
|
661
|
+
raise typer.Exit(code=1)
|
|
662
|
+
|
|
663
|
+
backend = _resolve_agent_to_backend(selected_agent)
|
|
664
|
+
|
|
665
|
+
use_libref_val = True if libref else _detect_libref()
|
|
666
|
+
_scaffold_dotfiles(
|
|
667
|
+
workdir,
|
|
668
|
+
agent_export_mode,
|
|
669
|
+
use_libref=use_libref_val,
|
|
670
|
+
graphite=graphite,
|
|
671
|
+
force_update_flags=graphite or libref,
|
|
672
|
+
agent_backend=backend,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
_apply_governance(workdir, graphite=graphite)
|
|
676
|
+
|
|
677
|
+
_scaffold_constitution(workdir)
|
|
678
|
+
# DeviaTDD commands are installed into ALL agent directories regardless
|
|
679
|
+
# of ``--agent``. ``--agent`` only drives the ``[agent].backend`` value
|
|
680
|
+
# written to ``.deviate/config.toml`` — that value is consumed by the
|
|
681
|
+
# meso/micro layers to dispatch agent invocations, never to gate which
|
|
682
|
+
# agents receive commands. ``droid`` is normalised to ``factory`` —
|
|
683
|
+
# both names map to the Factory Droid IDE commands directory
|
|
684
|
+
# (``.factory/commands/``); ``droid`` is the underlying backend binary.
|
|
685
|
+
# ``pi`` uses ``.pi/prompts/`` per the platform's documented convention;
|
|
686
|
+
# ``omp`` uses ``.omp/prompts/`` per the OMP platform convention; the
|
|
687
|
+
# other three use ``commands/``. No global ``~/.pi/agent/`` writes,
|
|
688
|
+
# no ``settings.json`` generation — the operator's Pi config is out of
|
|
689
|
+
# scope.
|
|
690
|
+
active_agents = ("claude", "opencode", "factory", "pi", "omp")
|
|
691
|
+
_install_commands_to_agents(workdir, list(active_agents))
|
|
692
|
+
|
|
693
|
+
_ensure_gitignore(workdir)
|
|
694
|
+
_ensure_root_gitignore(workdir)
|
|
695
|
+
_ensure_root_gitattributes(workdir)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
# Canonical ``.gitattributes`` content provisioned by ``deviate setup``.
|
|
699
|
+
# Marked as a module constant so the deviatdd repo's own ``.gitattributes``
|
|
700
|
+
# file and every downstream scaffolded project stay in sync — single source
|
|
701
|
+
# of truth for the union-merge rules over append-only JSONL ledgers.
|
|
702
|
+
DEVIATE_GITATTRIBUTES_SEED = (
|
|
703
|
+
"# DeviaTDD append-only JSONL ledgers: union-merge so concurrent\n"
|
|
704
|
+
"# appends never conflict at branch-merge time.\n"
|
|
705
|
+
"# See specs/constitution.md §1 Append-Only Ledger Protocol.\n"
|
|
706
|
+
"specs/issues.jsonl merge=union\n"
|
|
707
|
+
"specs/**/tasks.jsonl merge=union\n"
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _ensure_root_gitattributes(workdir: Path) -> None:
|
|
712
|
+
"""Provision a project-root ``.gitattributes`` declaring
|
|
713
|
+
``merge=union`` for the append-only JSONL ledgers.
|
|
714
|
+
|
|
715
|
+
Mirrors the idempotent-merge contract of :func:`_ensure_root_gitignore`:
|
|
716
|
+
user-authored entries are preserved, and re-running setup never
|
|
717
|
+
duplicates the union-merge rules.
|
|
718
|
+
|
|
719
|
+
Without this, concurrent ``deviate shard`` runs on feature branches
|
|
720
|
+
produce line-level conflicts in ``specs/issues.jsonl`` at merge time
|
|
721
|
+
that require manual resolution. ``merge=union`` is git's built-in
|
|
722
|
+
line-wise union driver — it keeps every unique line across all
|
|
723
|
+
branches and emits no conflict markers.
|
|
724
|
+
|
|
725
|
+
Rationale, semantic-dup behaviour, and diamond-merge verification
|
|
726
|
+
are documented in ``specs/DeviaTDD-api.md`` under ``deviate init``
|
|
727
|
+
and ``deviate setup``.
|
|
728
|
+
"""
|
|
729
|
+
attr_path = workdir / ".gitattributes"
|
|
730
|
+
if attr_path.exists():
|
|
731
|
+
content = attr_path.read_text(encoding="utf-8")
|
|
732
|
+
existing_lines = content.splitlines()
|
|
733
|
+
union_lines = [
|
|
734
|
+
line
|
|
735
|
+
for line in DEVIATE_GITATTRIBUTES_SEED.splitlines()
|
|
736
|
+
if line and not line.startswith("#")
|
|
737
|
+
]
|
|
738
|
+
missing = [line for line in union_lines if line not in existing_lines]
|
|
739
|
+
if not missing:
|
|
740
|
+
return
|
|
741
|
+
merged = list(existing_lines)
|
|
742
|
+
if merged and merged[-1].strip():
|
|
743
|
+
merged.append("")
|
|
744
|
+
merged.extend(missing)
|
|
745
|
+
attr_path.write_text("\n".join(merged) + "\n", encoding="utf-8")
|
|
746
|
+
console.print(
|
|
747
|
+
f" [green]UPDATE[/] .gitattributes added {len(missing)} union-merge rules"
|
|
748
|
+
)
|
|
749
|
+
else:
|
|
750
|
+
attr_path.write_text(DEVIATE_GITATTRIBUTES_SEED, encoding="utf-8")
|
|
751
|
+
console.print(" [green]CREATE[/] .gitattributes with union-merge rules")
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _ensure_root_gitignore(workdir: Path) -> None:
|
|
755
|
+
"""Update the project-root ``.gitignore`` to exclude DeviaTDD-installed
|
|
756
|
+
commands across all agent platforms.
|
|
757
|
+
Two command families are installed and must not be committed:
|
|
758
|
+
|
|
759
|
+
- ``deviate-*`` — the core DeviaTDD command library
|
|
760
|
+
|
|
761
|
+
The patterns are scoped with ``*/commands/`` and ``*/prompts/`` so they
|
|
762
|
+
only match a SINGLE directory level before the agent subdir — this is
|
|
763
|
+
deliberately tight because the project itself stores command sources
|
|
764
|
+
three levels deep at ``src/deviate/prompts/commands/deviate-*.md``
|
|
765
|
+
(plus spec files like ``specs/plans/deviate-content.md``). A broader
|
|
766
|
+
``**/deviate-*.md`` pattern would silently ignore those source-of-truth
|
|
767
|
+
files and break ``deviate setup`` in the deviatdd repo itself. The
|
|
768
|
+
patterns cover every supported agent (``.claude/commands/``,
|
|
769
|
+
``.opencode/commands/``, ``.factory/commands/``, ``.pi/prompts/``,
|
|
770
|
+
``.omp/prompts/``) and any future agent that follows the same
|
|
771
|
+
``<dir>/commands/`` or ``<dir>/prompts/`` flat-file convention.
|
|
772
|
+
"""
|
|
773
|
+
gitignore_path = workdir / ".gitignore"
|
|
774
|
+
entries = (
|
|
775
|
+
"*/commands/deviate-*.md",
|
|
776
|
+
"*/prompts/deviate-*.md",
|
|
777
|
+
)
|
|
778
|
+
if gitignore_path.exists():
|
|
779
|
+
content = gitignore_path.read_text(encoding="utf-8")
|
|
780
|
+
existing_lines = content.splitlines()
|
|
781
|
+
missing = [entry for entry in entries if entry not in existing_lines]
|
|
782
|
+
if not missing:
|
|
783
|
+
return
|
|
784
|
+
merged = list(existing_lines)
|
|
785
|
+
if merged and merged[-1].strip():
|
|
786
|
+
merged.append("")
|
|
787
|
+
merged.extend(missing)
|
|
788
|
+
gitignore_path.write_text("\n".join(merged) + "\n", encoding="utf-8")
|
|
789
|
+
console.print(
|
|
790
|
+
f" [green]UPDATE[/] .gitignore added {len(missing)} agent entries"
|
|
791
|
+
)
|
|
792
|
+
else:
|
|
793
|
+
gitignore_path.write_text("\n".join(entries) + "\n", encoding="utf-8")
|
|
794
|
+
console.print(
|
|
795
|
+
f" [green]CREATE[/] .gitignore with {len(entries)} agent entries"
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
cli.add_typer(explore_app, name="explore")
|
|
800
|
+
cli.add_typer(research_app, name="research")
|
|
801
|
+
cli.add_typer(prd_app, name="prd")
|
|
802
|
+
cli.add_typer(shard_app, name="shard")
|
|
803
|
+
|
|
804
|
+
cli.add_typer(meso_app, name="meso")
|
|
805
|
+
cli.add_typer(macro_app, name="macro")
|
|
806
|
+
cli.add_typer(red_app, name="red")
|
|
807
|
+
cli.add_typer(green_app, name="green")
|
|
808
|
+
cli.add_typer(judge_app, name="judge")
|
|
809
|
+
cli.add_typer(refactor_app, name="refactor")
|
|
810
|
+
cli.add_typer(execute_app, name="execute")
|
|
811
|
+
cli.add_typer(e2e_app, name="e2e")
|
|
812
|
+
cli.add_typer(hotfix_app, name="hotfix")
|
|
813
|
+
cli.add_typer(adhoc_app, name="adhoc")
|
|
814
|
+
cli.add_typer(constitution_app, name="constitution")
|
|
815
|
+
cli.add_typer(init_app, name="init")
|
|
816
|
+
cli.add_typer(feature_app, name="feature")
|
|
817
|
+
cli.add_typer(inspect_app, name="inspect")
|
|
818
|
+
cli.command(name="specify")(specify)
|
|
819
|
+
cli.command(name="plan")(plan)
|
|
820
|
+
cli.add_typer(review_app, name="review")
|
|
821
|
+
cli.command(name="tasks")(tasks)
|
|
822
|
+
cli.command(name="pr")(pr)
|
|
823
|
+
cli.command(name="merge")(merge)
|
|
824
|
+
cli.command(name="run")(run_command)
|