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