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/init.py ADDED
@@ -0,0 +1,485 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import NoReturn
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ init_app = typer.Typer(no_args_is_help=True)
12
+ console = Console()
13
+
14
+
15
+ def _fail_with(reason: str) -> NoReturn:
16
+ print(json.dumps({"status": "FAILURE", "reason": reason}))
17
+ raise typer.Exit(code=1)
18
+
19
+
20
+ def _detect_project_type(repo_root: Path) -> str:
21
+ if (repo_root / "mix.exs").exists():
22
+ return "elixir_phoenix"
23
+ if (repo_root / "pyproject.toml").exists():
24
+ return "python"
25
+ if (repo_root / "package.json").exists():
26
+ return "node"
27
+ if (repo_root / "Cargo.toml").exists():
28
+ return "rust"
29
+ if (repo_root / "go.mod").exists():
30
+ return "go"
31
+ return "unknown"
32
+
33
+
34
+ def _get_test_command(project_type: str) -> str:
35
+ commands = {
36
+ "elixir_phoenix": "mix test",
37
+ "python": "uv run pytest",
38
+ "node": "npm test",
39
+ "rust": "cargo test",
40
+ "go": "go test ./...",
41
+ }
42
+ return commands.get(project_type, "true")
43
+
44
+
45
+ def _get_zero_test_pass_command(project_type: str) -> str:
46
+ commands = {
47
+ "elixir_phoenix": "mix test || true",
48
+ "python": "uv run pytest || true",
49
+ "node": "npm test || true",
50
+ "rust": "cargo test || true",
51
+ "go": "go test ./... || true",
52
+ }
53
+ return commands.get(project_type, "echo 'No test framework' || true")
54
+
55
+
56
+ def _get_lint_command(project_type: str) -> str:
57
+ commands = {
58
+ "elixir_phoenix": "mix credo --strict",
59
+ "python": "uv run ruff check",
60
+ "node": "npm run lint 2>/dev/null || echo 'No lint configured'",
61
+ "rust": "cargo clippy -- -D warnings",
62
+ "go": "golangci-lint run 2>/dev/null || echo 'No linter configured'",
63
+ }
64
+ return commands.get(project_type, "echo 'No linter configured'")
65
+
66
+
67
+ def _get_format_command(project_type: str) -> str:
68
+ commands = {
69
+ "elixir_phoenix": "mix format",
70
+ "python": "uv run ruff format",
71
+ "node": "npm run format 2>/dev/null || echo 'No formatter configured'",
72
+ "rust": "cargo fmt",
73
+ "go": "gofmt -w .",
74
+ }
75
+ return commands.get(project_type, "echo 'No formatter configured'")
76
+
77
+
78
+ def _get_format_check_command(project_type: str) -> str:
79
+ commands = {
80
+ "elixir_phoenix": "mix format --check-formatted",
81
+ "python": "uv run ruff format --check",
82
+ "node": "npm run format:check 2>/dev/null || echo 'No format check configured'",
83
+ "rust": "cargo fmt --check",
84
+ "go": "gofmt -l .",
85
+ }
86
+ return commands.get(project_type, "echo 'No format check configured'")
87
+
88
+
89
+ def _get_setup_command(project_type: str) -> str:
90
+ commands = {
91
+ "elixir_phoenix": "mix deps.get && mix deps.compile",
92
+ "python": "uv sync --extra dev",
93
+ "node": "npm install",
94
+ "rust": "cargo fetch",
95
+ "go": "go mod download",
96
+ }
97
+ return commands.get(project_type, "echo 'No setup required'")
98
+
99
+
100
+ def _get_dev_command(project_type: str, repo_root: Path) -> str:
101
+ if project_type == "elixir_phoenix":
102
+ return "mix phx.server"
103
+ if project_type == "python":
104
+ pyproject = repo_root / "pyproject.toml"
105
+ if pyproject.exists():
106
+ content = pyproject.read_text()
107
+ if "uvicorn" in content.lower() or "fastapi" in content.lower():
108
+ name = repo_root.name.replace("-", "_")
109
+ return f"uv run uvicorn {name}:app --reload"
110
+ if "django" in content.lower():
111
+ return "uv run python manage.py runserver"
112
+ if "flask" in content.lower():
113
+ return "uv run flask --reload run"
114
+ return f"uv run python -m {repo_root.name}"
115
+ if project_type == "node":
116
+ return "npm run dev 2>/dev/null || npm run start"
117
+ if project_type == "rust":
118
+ return "cargo run"
119
+ if project_type == "go":
120
+ return "go run ."
121
+ return "echo 'No dev server configured'"
122
+
123
+
124
+ def _generate_mise_toml(project_type: str, repo_root: Path) -> str:
125
+ if project_type == "elixir_phoenix":
126
+ return """# Mise configuration for Elixir/Phoenix project
127
+ # Scaffolded by /deviate-init — DeviaTDD scaffolding
128
+ # ZERO-TEST-PASS: test task uses || true to pass when no tests exist
129
+
130
+ [tools]
131
+ elixir = "latest"
132
+ erlang = "latest"
133
+
134
+ [tasks]
135
+ # DeviaTDD zero-test-pass invariant: passes even with zero tests written
136
+ test = "mix test || true"
137
+ test-raw = "mix test"
138
+ setup = { depends = ["hooks"], run = "mix deps.get && mix deps.compile" }
139
+ lint = "mix credo --strict"
140
+ format = "mix format"
141
+ format-check = "mix format --check-formatted"
142
+ fix = { depends = ["format", "lint"] }
143
+ check = { depends = ["format-check", "lint", "test"] }
144
+ pre-commit = { depends = ["format-check", "lint"] }
145
+ pre-push = { depends = ["test"] }
146
+ hooks = "mise generate git-pre-commit --write && mise generate git-pre-commit --hook pre-push --write"
147
+ dev = "mix phx.server"
148
+ clean = "mix clean && rm -rf _build deps .fetch"
149
+ """
150
+ if project_type == "python":
151
+ dev_cmd = _get_dev_command(project_type, repo_root)
152
+ return f"""# Mise configuration for Python project
153
+ # Scaffolded by /deviate-init — DeviaTDD scaffolding
154
+ # ZERO-TEST-PASS: test task uses || true to pass when no tests exist
155
+
156
+ [tools]
157
+ python = "3.12"
158
+ uv = "latest"
159
+
160
+ [tasks]
161
+ # DeviaTDD zero-test-pass invariant: passes even with zero tests written
162
+ test = "uv run pytest || true"
163
+ test-raw = "uv run pytest"
164
+ setup = {{ depends = ["hooks"], run = "uv sync --extra dev" }}
165
+ lint = "uv run ruff check"
166
+ format = "uv run ruff format"
167
+ format-check = "uv run ruff format --check"
168
+ fix = {{ depends = ["format", "lint"] }}
169
+ check = {{ depends = ["format-check", "lint", "test"] }}
170
+ pre-commit = {{ depends = ["format-check", "lint"] }}
171
+ pre-push = {{ depends = ["test"] }}
172
+ hooks = "mise generate git-pre-commit --write && mise generate git-pre-commit --hook pre-push --write"
173
+ dev = "{dev_cmd}"
174
+ clean = "rm -rf .venv dist build __pycache__"
175
+ """
176
+ if project_type == "node":
177
+ pkg_manager = "npm"
178
+ if (repo_root / "pnpm-lock.yaml").exists():
179
+ pkg_manager = "pnpm"
180
+ elif (repo_root / "yarn.lock").exists():
181
+ pkg_manager = "yarn"
182
+ return f"""# Mise configuration for Node.js project
183
+ # Scaffolded by /deviate-init — DeviaTDD scaffolding
184
+ # ZERO-TEST-PASS: test task uses || true to pass when no tests exist
185
+
186
+ [tools]
187
+ node = "lts"
188
+ {pkg_manager} = "latest"
189
+
190
+ [tasks]
191
+ # DeviaTDD zero-test-pass invariant: passes even with zero tests written
192
+ test = "{pkg_manager} test || true"
193
+ test-raw = "{pkg_manager} test"
194
+ setup = {{ depends = ["hooks"], run = "{pkg_manager} install" }}
195
+ lint = "{pkg_manager} run lint 2>/dev/null || echo 'No lint configured'"
196
+ format = "{pkg_manager} run format 2>/dev/null || echo 'No formatter configured'"
197
+ format-check = "{pkg_manager} run format:check 2>/dev/null || echo 'No format check configured'"
198
+ fix = {{ depends = ["format", "lint"] }}
199
+ check = {{ depends = ["format-check", "lint", "test"] }}
200
+ pre-commit = {{ depends = ["format-check", "lint"] }}
201
+ pre-push = {{ depends = ["test"] }}
202
+ hooks = "mise generate git-pre-commit --write && mise generate git-pre-commit --hook pre-push --write"
203
+ dev = "{pkg_manager} run dev 2>/dev/null || {pkg_manager} run start"
204
+ clean = "rm -rf node_modules dist build"
205
+ """
206
+ if project_type == "rust":
207
+ return """# Mise configuration for Rust project
208
+ # Scaffolded by /deviate-init — DeviaTDD scaffolding
209
+ # ZERO-TEST-PASS: test task uses || true to pass when no tests exist
210
+
211
+ [tools]
212
+ rust = "stable"
213
+
214
+ [tasks]
215
+ # DeviaTDD zero-test-pass invariant: passes even with zero tests written
216
+ test = "cargo test || true"
217
+ test-raw = "cargo test"
218
+ setup = { depends = ["hooks"], run = "cargo fetch" }
219
+ lint = "cargo clippy -- -D warnings"
220
+ format = "cargo fmt"
221
+ format-check = "cargo fmt --check"
222
+ fix = { depends = ["format", "lint"] }
223
+ check = { depends = ["format-check", "lint", "test"] }
224
+ pre-commit = { depends = ["format-check", "lint"] }
225
+ pre-push = { depends = ["test"] }
226
+ hooks = "mise generate git-pre-commit --write && mise generate git-pre-commit --hook pre-push --write"
227
+ dev = "cargo run"
228
+ clean = "cargo clean"
229
+ """
230
+ if project_type == "go":
231
+ return """# Mise configuration for Go project
232
+ # Scaffolded by /deviate-init — DeviaTDD scaffolding
233
+ # ZERO-TEST-PASS: test task uses || true to pass when no tests exist
234
+
235
+ [tools]
236
+ go = "latest"
237
+
238
+ [tasks]
239
+ # DeviaTDD zero-test-pass invariant: passes even with zero tests written
240
+ test = "go test ./... || true"
241
+ test-raw = "go test ./..."
242
+ setup = { depends = ["hooks"], run = "go mod download" }
243
+ lint = "golangci-lint run 2>/dev/null || echo 'No linter configured'"
244
+ format = "gofmt -w ."
245
+ format-check = "gofmt -l ."
246
+ fix = { depends = ["format", "lint"] }
247
+ check = { depends = ["format-check", "lint", "test"] }
248
+ pre-commit = { depends = ["format-check", "lint"] }
249
+ pre-push = { depends = ["test"] }
250
+ hooks = "mise generate git-pre-commit --write && mise generate git-pre-commit --hook pre-push --write"
251
+ dev = "go run ."
252
+ clean = "go clean"
253
+ """
254
+ _fail_with(f"Unknown project type: {project_type}")
255
+
256
+
257
+ def _scaffold_constitution(project_type: str, repo_root: Path) -> None:
258
+ test_cmd = _get_test_command(project_type)
259
+ lint_cmd = _get_lint_command(project_type)
260
+
261
+ constitution_path = repo_root / "specs" / "constitution.md"
262
+ constitution_path.write_text(
263
+ f"""# Project Constitution
264
+
265
+ Version: 0.1.0
266
+
267
+ ---
268
+
269
+ ## 1. Architectural Principles
270
+ > TBD — populated by `/research` from codebase analysis.
271
+
272
+ ## 2. Tech Stack Standards
273
+
274
+ ### Backend
275
+ > TBD
276
+
277
+ ### Frontend
278
+ > TBD
279
+
280
+ ### Database
281
+ > TBD
282
+
283
+ ### Infrastructure
284
+ > TBD
285
+
286
+ ### Tooling
287
+ > TBD
288
+
289
+ ## 3. Testing Protocols
290
+
291
+ ### Framework
292
+ - `TEST_COMMAND`: `{test_cmd}`
293
+ - `LINT_COMMAND`: `{lint_cmd}`
294
+
295
+ ### Coverage
296
+ > TBD
297
+
298
+ ## 4. Development Workflow
299
+ > TBD — populated by `/research`.
300
+
301
+ ## 5. Definition of Done
302
+ - [ ] Code implemented
303
+ - [ ] Tests passing
304
+ - [ ] Lint passing
305
+ - [ ] Documentation updated
306
+ - [ ] No governance violations
307
+
308
+ ## 6. Version History
309
+
310
+ - 0.1.0 — Initial constitution scaffolded by `deviate init`
311
+ """,
312
+ encoding="utf-8",
313
+ )
314
+
315
+
316
+ def _check_tool(name: str) -> bool:
317
+ try:
318
+ subprocess.run(
319
+ ["which", name],
320
+ capture_output=True,
321
+ check=False,
322
+ )
323
+ return True
324
+ except Exception:
325
+ return False
326
+
327
+
328
+ @init_app.command()
329
+ def pre() -> None:
330
+ """Detect project type, scaffold DeviaTDD structure, emit JSON contract."""
331
+ try:
332
+ repo_root = Path(
333
+ subprocess.check_output(
334
+ ["git", "rev-parse", "--show-toplevel"],
335
+ text=True,
336
+ stderr=subprocess.DEVNULL,
337
+ ).strip()
338
+ )
339
+ except subprocess.CalledProcessError:
340
+ _fail_with("Not a git repository")
341
+
342
+ try:
343
+ branch = subprocess.check_output(
344
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
345
+ text=True,
346
+ stderr=subprocess.DEVNULL,
347
+ ).strip()
348
+ except subprocess.CalledProcessError:
349
+ branch = "unknown"
350
+
351
+ project_type = _detect_project_type(repo_root)
352
+
353
+ has_mise_toml = (repo_root / "mise.toml").exists()
354
+ has_specs_dir = (repo_root / "specs").exists()
355
+ has_constitution = (repo_root / "specs" / "constitution.md").exists()
356
+ has_issues_ledger = (repo_root / "specs" / "issues.jsonl").exists()
357
+ has_claude_md = (repo_root / "CLAUDE.md").exists()
358
+
359
+ mise_available = _check_tool("mise")
360
+ tooling = {
361
+ "mise": mise_available,
362
+ "jq": _check_tool("jq"),
363
+ "gh": _check_tool("gh"),
364
+ "uv": _check_tool("uv"),
365
+ "ruff": _check_tool("ruff"),
366
+ }
367
+
368
+ artifacts_created = []
369
+
370
+ if not has_mise_toml and project_type != "unknown":
371
+ mise_content = _generate_mise_toml(project_type, repo_root)
372
+ (repo_root / "mise.toml").write_text(mise_content, encoding="utf-8")
373
+ has_mise_toml = True
374
+ artifacts_created.append("mise.toml")
375
+
376
+ if not has_specs_dir:
377
+ (repo_root / "specs").mkdir(exist_ok=True)
378
+ has_specs_dir = True
379
+ artifacts_created.append("specs/")
380
+ else:
381
+ artifacts_created.append("specs/")
382
+
383
+ if not has_issues_ledger:
384
+ (repo_root / "specs" / "issues.jsonl").touch()
385
+ has_issues_ledger = True
386
+ artifacts_created.append("specs/issues.jsonl")
387
+
388
+ if not has_constitution:
389
+ _scaffold_constitution(project_type, repo_root)
390
+ has_constitution = True
391
+ artifacts_created.append("specs/constitution.md")
392
+
393
+ from deviate.cli import _linkify_governance_files
394
+
395
+ _linkify_governance_files(repo_root)
396
+ if (repo_root / "AGENTS.md").is_symlink():
397
+ artifacts_created.append("AGENTS.md")
398
+
399
+ # Provision union-merge rules for append-only JSONL ledgers.
400
+ # Idempotent: never duplicates entries, never overwrites user content.
401
+ from deviate.cli import _ensure_root_gitattributes
402
+
403
+ _ensure_root_gitattributes(repo_root)
404
+ if (repo_root / ".gitattributes").exists():
405
+ artifacts_created.append(".gitattributes")
406
+
407
+ try:
408
+ top_level_entries = [
409
+ p.name for p in repo_root.iterdir() if p.name not in (".git",)
410
+ ]
411
+ except Exception:
412
+ top_level_entries = []
413
+
414
+ contract = {
415
+ "phase": "deviate-init",
416
+ "status": "READY",
417
+ "branch": branch,
418
+ "repo_root": str(repo_root),
419
+ "project_type": project_type,
420
+ "tooling": tooling,
421
+ "mise_available": mise_available,
422
+ "gh_available": _check_tool("gh"),
423
+ "existing_artifacts": {
424
+ "mise_toml": has_mise_toml,
425
+ "specs_dir": has_specs_dir,
426
+ "constitution": has_constitution,
427
+ "issues_ledger": has_issues_ledger,
428
+ "claude_md": has_claude_md,
429
+ },
430
+ "artifacts_created": artifacts_created,
431
+ "top_level_entries": top_level_entries,
432
+ "timestamp": subprocess.check_output(
433
+ ["date", "-u", "+%Y-%m-%dT%H:%M:%SZ"],
434
+ text=True,
435
+ ).strip(),
436
+ }
437
+
438
+ print(json.dumps(contract, indent=2))
439
+
440
+
441
+ @init_app.command()
442
+ def post() -> None:
443
+ """Validate artifacts, stage for commit, emit status JSON."""
444
+ try:
445
+ repo_root = Path(
446
+ subprocess.check_output(
447
+ ["git", "rev-parse", "--show-toplevel"],
448
+ text=True,
449
+ stderr=subprocess.DEVNULL,
450
+ ).strip()
451
+ )
452
+ except subprocess.CalledProcessError:
453
+ _fail_with("Not a git repository")
454
+
455
+ artifacts = []
456
+ if (repo_root / "mise.toml").exists():
457
+ artifacts.append("mise.toml")
458
+ if (repo_root / "specs").is_dir():
459
+ artifacts.append("specs/")
460
+ if (repo_root / "specs" / "constitution.md").exists():
461
+ artifacts.append("specs/constitution.md")
462
+ if (repo_root / "specs" / "issues.jsonl").exists():
463
+ artifacts.append("specs/issues.jsonl")
464
+ if (repo_root / "AGENTS.md").is_symlink():
465
+ artifacts.append("AGENTS.md")
466
+ if (repo_root / ".gitattributes").exists():
467
+ artifacts.append(".gitattributes")
468
+
469
+ if artifacts:
470
+ subprocess.run(
471
+ ["git", "add"] + artifacts,
472
+ cwd=repo_root,
473
+ check=False,
474
+ )
475
+
476
+ print(
477
+ json.dumps(
478
+ {
479
+ "status": "SUCCESS",
480
+ "artifacts_created": artifacts,
481
+ "artifact_count": len(artifacts),
482
+ },
483
+ indent=2,
484
+ )
485
+ )
deviate/cli/inspect.py ADDED
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from deviate.cli.meso import _resolve_bucket_dir, _source_stem
12
+ from deviate.core._shared import git_env as _git_env
13
+ from deviate.core.worktree import detect_remote
14
+ from deviate.state.ledger import (
15
+ IssueRecord,
16
+ LedgerFilter,
17
+ _read_ledger_strict,
18
+ filter_tasks,
19
+ )
20
+
21
+ inspect_app = typer.Typer(no_args_is_help=True)
22
+ issues_app = typer.Typer(no_args_is_help=True)
23
+ tasks_app = typer.Typer(no_args_is_help=True)
24
+ inspect_app.add_typer(issues_app, name="issues")
25
+ inspect_app.add_typer(tasks_app, name="tasks")
26
+
27
+ console = Console()
28
+
29
+
30
+ def _derive_issue_branch(source_file: str) -> str:
31
+ bucket = _resolve_bucket_dir(source_file)
32
+ slug = _source_stem(source_file)
33
+ return f"feat/{bucket}/{slug}"
34
+
35
+
36
+ def _check_orphan_claim(issue: IssueRecord, repo: Path) -> bool | None:
37
+ if not issue.source_file:
38
+ return None
39
+ branch = _derive_issue_branch(issue.source_file)
40
+ try:
41
+ remote = detect_remote(repo)
42
+ except RuntimeError:
43
+ return None
44
+ try:
45
+ result = subprocess.run(
46
+ ["git", "ls-remote", "--heads", remote, branch],
47
+ cwd=repo,
48
+ env=_git_env(),
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=30,
52
+ )
53
+ except (subprocess.TimeoutExpired, OSError):
54
+ return None
55
+ if result.returncode != 0:
56
+ return None
57
+ return not bool(result.stdout.strip())
58
+
59
+
60
+ def _deduplicate_issues(records: list[dict]) -> list[dict]:
61
+ """Deduplicate issue records by ``issue_id``.
62
+
63
+ ``COMPLETED`` is a terminal status and always takes precedence: once any
64
+ ``COMPLETED`` entry is captured for an issue, subsequent non-``COMPLETED``
65
+ transitions (e.g. a ``SPECIFIED`` entry appended after the ``COMPLETED``
66
+ write during a merge flow) do not override it. Among non-``COMPLETED``
67
+ entries, the last entry by file position wins (the prior behaviour).
68
+ """
69
+ seen: dict[str, dict] = {}
70
+ for rec in records:
71
+ issue_id = rec.get("issue_id")
72
+ if not issue_id:
73
+ continue
74
+ current = seen.get(issue_id)
75
+ # Preserve any COMPLETED entry already captured — COMPLETED is terminal.
76
+ if current is not None and current.get("status") == "COMPLETED":
77
+ continue
78
+ seen[issue_id] = rec
79
+ return list(seen.values())
80
+
81
+
82
+ def _issues_list(
83
+ type_filter: str | None = None,
84
+ status_filter: str | None = None,
85
+ ) -> list[dict]:
86
+ ledger_path = Path.cwd() / "specs" / "issues.jsonl"
87
+ records = _read_ledger_strict(ledger_path)
88
+ issues = _deduplicate_issues(records)
89
+ if type_filter:
90
+ issues = [i for i in issues if i.get("type") == type_filter]
91
+ if status_filter:
92
+ issues = [i for i in issues if i.get("status") == status_filter]
93
+ result: list[dict] = []
94
+ for raw in issues:
95
+ entry: dict = {
96
+ "issue_id": raw.get("issue_id", ""),
97
+ "type": raw.get("type", ""),
98
+ "title": raw.get("title", ""),
99
+ "status": raw.get("status", ""),
100
+ "source_file": raw.get("source_file", ""),
101
+ "blocked_by": raw.get("blocked_by", []),
102
+ "coordinates_with": raw.get("coordinates_with", []),
103
+ }
104
+ if raw.get("status") == "SPECIFIED":
105
+ try:
106
+ issue_record = IssueRecord.model_validate(raw)
107
+ orphan = _check_orphan_claim(issue_record, Path.cwd())
108
+ entry["orphan_claim"] = orphan
109
+ except Exception:
110
+ entry["orphan_claim"] = None
111
+ else:
112
+ entry["orphan_claim"] = None
113
+ result.append(entry)
114
+ return result
115
+
116
+
117
+ @issues_app.command("list")
118
+ def issues_list_command(
119
+ type_filter: str | None = typer.Option(None, "--type", help="Filter by issue type"),
120
+ status_filter: str | None = typer.Option(
121
+ None, "--status", help="Filter by issue status"
122
+ ),
123
+ json_flag: bool = typer.Option(False, "--json", help="Output as JSON array"),
124
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress non-JSON output"),
125
+ ) -> None:
126
+ issues = _issues_list(
127
+ type_filter=type_filter,
128
+ status_filter=status_filter,
129
+ )
130
+ if json_flag:
131
+ typer.echo(json.dumps(issues))
132
+ elif quiet:
133
+ pass
134
+ else:
135
+ table = Table(title="Issues")
136
+ table.add_column("ID", style="cyan")
137
+ table.add_column("Type", style="magenta")
138
+ table.add_column("Title")
139
+ table.add_column("Status", style="green")
140
+ table.add_column("Orphan")
141
+ for issue in issues:
142
+ orphan_str = ""
143
+ if issue.get("orphan_claim") is True:
144
+ orphan_str = "\U0001f7e1 ORPHAN_CLAIM"
145
+ elif issue.get("orphan_claim") is False:
146
+ orphan_str = ""
147
+ table.add_row(
148
+ issue.get("issue_id", ""),
149
+ issue.get("type", ""),
150
+ issue.get("title", ""),
151
+ issue.get("status", ""),
152
+ orphan_str,
153
+ )
154
+ console.print(table)
155
+
156
+
157
+ def _tasks_list(
158
+ status_filter: str | None = None,
159
+ ) -> list[dict]:
160
+ tasks_ledger = Path.cwd() / "tasks.jsonl"
161
+ filter_obj = LedgerFilter(
162
+ entity_type="task",
163
+ status_filter=status_filter or None,
164
+ )
165
+ records = filter_tasks(tasks_ledger, filter_obj)
166
+ return [
167
+ {
168
+ "id": t.id,
169
+ "issue_id": t.issue_id,
170
+ "description": t.description,
171
+ "status": t.status,
172
+ "execution_mode": t.execution_mode,
173
+ }
174
+ for t in records
175
+ ]
176
+
177
+
178
+ @tasks_app.command("list")
179
+ def tasks_list_command(
180
+ status_filter: str | None = typer.Option(
181
+ None, "--status", help="Filter by task status"
182
+ ),
183
+ json_flag: bool = typer.Option(False, "--json", help="Output as JSON array"),
184
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress non-JSON output"),
185
+ ) -> None:
186
+ tasks = _tasks_list(
187
+ status_filter=status_filter,
188
+ )
189
+ if json_flag:
190
+ typer.echo(json.dumps(tasks))
191
+ elif quiet:
192
+ pass
193
+ else:
194
+ table = Table(title="Tasks")
195
+ table.add_column("ID", style="cyan")
196
+ table.add_column("Issue ID")
197
+ table.add_column("Description")
198
+ table.add_column("Status", style="green")
199
+ table.add_column("Mode", style="magenta")
200
+ for task in tasks:
201
+ table.add_row(
202
+ task.get("id", ""),
203
+ task.get("issue_id", ""),
204
+ task.get("description", ""),
205
+ task.get("status", ""),
206
+ task.get("execution_mode", ""),
207
+ )
208
+ console.print(table)