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,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)