spec-runner 2.1.0__tar.gz → 2.2.0__tar.gz

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 (87) hide show
  1. {spec_runner-2.1.0/src/spec_runner.egg-info → spec_runner-2.2.0}/PKG-INFO +3 -1
  2. {spec_runner-2.1.0 → spec_runner-2.2.0}/README.md +2 -0
  3. {spec_runner-2.1.0 → spec_runner-2.2.0}/pyproject.toml +1 -1
  4. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/audit.py +4 -12
  5. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/cli.py +2 -1
  6. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/config.py +4 -1
  7. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/execution.py +5 -16
  8. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/github_sync.py +1 -1
  9. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/obs.py +3 -2
  10. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/report.py +3 -11
  11. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/runner.py +27 -2
  12. spec_runner-2.2.0/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.opencode.md +38 -0
  13. spec_runner-2.2.0/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.pi.md +38 -0
  14. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/state.py +6 -0
  15. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/tui.py +4 -3
  16. {spec_runner-2.1.0 → spec_runner-2.2.0/src/spec_runner.egg-info}/PKG-INFO +3 -1
  17. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner.egg-info/SOURCES.txt +2 -0
  18. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_audit.py +1 -3
  19. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_audit_log.py +2 -6
  20. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_runner.py +36 -0
  21. {spec_runner-2.1.0 → spec_runner-2.2.0}/LICENSE +0 -0
  22. {spec_runner-2.1.0 → spec_runner-2.2.0}/setup.cfg +0 -0
  23. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/__init__.py +0 -0
  24. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/audit_log.py +0 -0
  25. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/cli_info.py +0 -0
  26. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/cli_plan.py +0 -0
  27. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/events.py +0 -0
  28. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/executor.py +0 -0
  29. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/git_ops.py +0 -0
  30. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/hooks.py +0 -0
  31. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/init_cmd.py +0 -0
  32. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/logging.py +0 -0
  33. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/mcp_server.py +0 -0
  34. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/notifications.py +0 -0
  35. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/plugins.py +0 -0
  36. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/prompt.py +0 -0
  37. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/py.typed +0 -0
  38. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/review.py +0 -0
  39. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/SKILL.md +0 -0
  40. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/Makefile.template +0 -0
  41. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/design.template.md +0 -0
  42. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/executor.config.yaml +0 -0
  43. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/executor.py +0 -0
  44. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/phase-design.template.md +0 -0
  45. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/phase-requirements.template.md +0 -0
  46. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/phase-tasks.template.md +0 -0
  47. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.claude.md +0 -0
  48. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.codex.md +0 -0
  49. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.llama.md +0 -0
  50. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.md +0 -0
  51. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.ollama.md +0 -0
  52. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/requirements.template.md +0 -0
  53. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/task.py +0 -0
  54. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/tasks.template.md +0 -0
  55. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/skills/spec-generator-skill/templates/workflow.template.md +0 -0
  56. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/task.py +0 -0
  57. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/task_commands.py +0 -0
  58. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/validate.py +0 -0
  59. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner/verify.py +0 -0
  60. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner.egg-info/dependency_links.txt +0 -0
  61. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner.egg-info/entry_points.txt +0 -0
  62. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner.egg-info/requires.txt +0 -0
  63. {spec_runner-2.1.0 → spec_runner-2.2.0}/src/spec_runner.egg-info/top_level.txt +0 -0
  64. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_config.py +0 -0
  65. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_costs.py +0 -0
  66. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_e2e.py +0 -0
  67. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_events.py +0 -0
  68. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_execution.py +0 -0
  69. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_gh_sync.py +0 -0
  70. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_hooks.py +0 -0
  71. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_json_result_contract.py +0 -0
  72. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_logging.py +0 -0
  73. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_mcp.py +0 -0
  74. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_notifications.py +0 -0
  75. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_obs.py +0 -0
  76. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_obs_contract.py +0 -0
  77. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_plan_full.py +0 -0
  78. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_plugins.py +0 -0
  79. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_prompt.py +0 -0
  80. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_report.py +0 -0
  81. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_spec_prefix.py +0 -0
  82. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_state.py +0 -0
  83. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_task_diff.py +0 -0
  84. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_tui.py +0 -0
  85. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_validate.py +0 -0
  86. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_verify.py +0 -0
  87. {spec_runner-2.1.0 → spec_runner-2.2.0}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spec-runner
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Task automation from markdown specs via Claude CLI
5
5
  Author: Andrei
6
6
  License-Expression: MIT
@@ -369,6 +369,8 @@ paths:
369
369
  |-----|--------------|------------------|
370
370
  | Claude | Yes | `{cmd} -p {prompt} --model {model}` |
371
371
  | Codex | Yes | `{cmd} -p {prompt} --model {model}` |
372
+ | OpenCode ([sst/opencode](https://opencode.ai)) | Yes | `{cmd} run --model {model} {prompt}` |
373
+ | Pi Agent ([pi.dev](https://pi.dev)) | Yes (basename match) | `{cmd} -p --model {model} {prompt}` |
372
374
  | Ollama | Yes | `{cmd} run {model} {prompt}` |
373
375
  | llama-cli | Yes | `{cmd} -m {model} -p {prompt} --no-display-prompt` |
374
376
  | Custom | Use template | `{cmd} --prompt {prompt}` |
@@ -334,6 +334,8 @@ paths:
334
334
  |-----|--------------|------------------|
335
335
  | Claude | Yes | `{cmd} -p {prompt} --model {model}` |
336
336
  | Codex | Yes | `{cmd} -p {prompt} --model {model}` |
337
+ | OpenCode ([sst/opencode](https://opencode.ai)) | Yes | `{cmd} run --model {model} {prompt}` |
338
+ | Pi Agent ([pi.dev](https://pi.dev)) | Yes (basename match) | `{cmd} -p --model {model} {prompt}` |
337
339
  | Ollama | Yes | `{cmd} run {model} {prompt}` |
338
340
  | llama-cli | Yes | `{cmd} -m {model} -p {prompt} --no-display-prompt` |
339
341
  | Custom | Use template | `{cmd} --prompt {prompt}` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spec-runner"
7
- version = "2.1.0"
7
+ version = "2.2.0"
8
8
  description = "Task automation from markdown specs via Claude CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -116,9 +116,7 @@ def audit_all(config: ExecutorConfig, *, strict: bool = False) -> AuditReport:
116
116
  """
117
117
  report = AuditReport(strict=strict)
118
118
 
119
- tasks: list[Task] = (
120
- parse_tasks(config.tasks_file) if config.tasks_file.exists() else []
121
- )
119
+ tasks: list[Task] = parse_tasks(config.tasks_file) if config.tasks_file.exists() else []
122
120
 
123
121
  req_ids: set[str] = set()
124
122
  if config.requirements_file.exists():
@@ -168,8 +166,7 @@ def audit_all(config: ExecutorConfig, *, strict: bool = False) -> AuditReport:
168
166
  category=CAT_DANGLING_DESIGN_REF,
169
167
  subject=ref,
170
168
  message=(
171
- f"{task.id} references {ref} but it is not "
172
- "defined in design.md"
169
+ f"{task.id} references {ref} but it is not defined in design.md"
173
170
  ),
174
171
  location=task.id,
175
172
  )
@@ -177,9 +174,7 @@ def audit_all(config: ExecutorConfig, *, strict: bool = False) -> AuditReport:
177
174
 
178
175
  # 4. Uncovered requirements — REQ defined but no task references it
179
176
  if req_ids:
180
- covered_reqs = {
181
- ref for task in tasks for ref in task.traces_to if ref.startswith("REQ-")
182
- }
177
+ covered_reqs = {ref for task in tasks for ref in task.traces_to if ref.startswith("REQ-")}
183
178
  for req in sorted(req_ids - covered_reqs):
184
179
  report.findings.append(
185
180
  AuditFinding(
@@ -194,10 +189,7 @@ def audit_all(config: ExecutorConfig, *, strict: bool = False) -> AuditReport:
194
189
  # 5. Uncovered designs — DESIGN defined but no task references it
195
190
  if design_ids:
196
191
  covered_designs = {
197
- ref
198
- for task in tasks
199
- for ref in task.traces_to
200
- if ref.startswith("DESIGN-")
192
+ ref for task in tasks for ref in task.traces_to if ref.startswith("DESIGN-")
201
193
  }
202
194
  for design in sorted(design_ids - covered_designs):
203
195
  report.findings.append(
@@ -5,6 +5,7 @@ import json
5
5
  import signal
6
6
  import sys
7
7
  import time
8
+ from collections.abc import Callable
8
9
  from datetime import datetime
9
10
  from pathlib import Path
10
11
  from uuid import uuid4
@@ -622,7 +623,7 @@ def _dispatch_task_command(args: argparse.Namespace) -> None:
622
623
  tasks_file = Path(f"spec/{prefix}tasks.md") if prefix else TASKS_FILE
623
624
  tasks = parse_tasks(tasks_file)
624
625
 
625
- write_commands = {
626
+ write_commands: dict[str, Callable[..., object]] = {
626
627
  "start": cmd_start,
627
628
  "done": cmd_done,
628
629
  "block": cmd_block,
@@ -138,10 +138,13 @@ class ExecutorConfig:
138
138
  # Examples:
139
139
  # claude: "{cmd} -p {prompt}" or "{cmd} -p {prompt} --model {model}"
140
140
  # codex: "{cmd} -p {prompt}"
141
+ # opencode: "{cmd} run --model {model} {prompt}"
142
+ # pi: "{cmd} -p --model {model} {prompt}"
141
143
  # ollama: "{cmd} run {model} {prompt}"
142
144
  # llama-cli: "{cmd} -m {model} -p {prompt} --no-display-prompt"
143
145
  # llama-server: "curl -s http://localhost:8080/completion -d '{{\"prompt\": {prompt}}}'"
144
- # If empty, auto-detects based on command name
146
+ # If empty, auto-detects based on command name (claude, codex, opencode, pi,
147
+ # ollama, llama-cli, llama-server)
145
148
  command_template: str = ""
146
149
 
147
150
  # Hooks
@@ -375,7 +375,7 @@ def compute_retry_delay(error_code: ErrorCode | str, attempt: int, base_delay: i
375
375
  if strategy == "fatal":
376
376
  return 0.0
377
377
  if strategy == "backoff_exponential":
378
- return min(30.0 * (2**attempt), 300.0)
378
+ return float(min(30.0 * (2**attempt), 300.0))
379
379
  return float(base_delay * (attempt + 1))
380
380
 
381
381
 
@@ -396,24 +396,13 @@ def _check_task_budget(
396
396
  """
397
397
  spent = state.task_cost(task_id)
398
398
  if config.task_budget_usd is not None and spent >= config.task_budget_usd:
399
- return (
400
- f"Task budget exceeded "
401
- f"(${spent:.2f} >= ${config.task_budget_usd:.2f})"
402
- )
399
+ return f"Task budget exceeded (${spent:.2f} >= ${config.task_budget_usd:.2f})"
403
400
 
404
- if (
405
- config.max_retry_cost_usd is not None
406
- and attempt_index > 0
407
- ):
401
+ if config.max_retry_cost_usd is not None and attempt_index > 0:
408
402
  ts = state.get_task_state(task_id)
409
- retry_spent = (
410
- sum(a.cost_usd or 0.0 for a in ts.attempts[1:]) if ts else 0.0
411
- )
403
+ retry_spent = sum(a.cost_usd or 0.0 for a in ts.attempts[1:]) if ts else 0.0
412
404
  if retry_spent >= config.max_retry_cost_usd:
413
- return (
414
- f"Retry budget exceeded "
415
- f"(${retry_spent:.2f} >= ${config.max_retry_cost_usd:.2f})"
416
- )
405
+ return f"Retry budget exceeded (${retry_spent:.2f} >= ${config.max_retry_cost_usd:.2f})"
417
406
  return None
418
407
 
419
408
 
@@ -117,7 +117,7 @@ def _status_from_issue(issue: dict) -> str:
117
117
  if issue["state"] == "CLOSED":
118
118
  return "done"
119
119
  for label in issue.get("labels", []):
120
- name = label["name"] if isinstance(label, dict) else label
120
+ name = str(label["name"]) if isinstance(label, dict) else str(label)
121
121
  if name.startswith("status:"):
122
122
  status = name.split(":", 1)[1]
123
123
  if status in STATUS_EMOJI:
@@ -17,7 +17,7 @@ from collections.abc import Iterator
17
17
  from contextlib import contextmanager
18
18
  from datetime import UTC, datetime
19
19
  from pathlib import Path
20
- from typing import Any
20
+ from typing import Any, cast
21
21
 
22
22
  import structlog
23
23
  import ulid
@@ -190,7 +190,8 @@ def init_logging(
190
190
 
191
191
 
192
192
  def get_logger(module: str | None = None) -> structlog.BoundLogger:
193
- return structlog.get_logger(module=module) if module else structlog.get_logger()
193
+ logger = structlog.get_logger(module=module) if module else structlog.get_logger()
194
+ return cast("structlog.BoundLogger", logger)
194
195
 
195
196
 
196
197
  class Span:
@@ -50,11 +50,7 @@ class TraceabilityReport:
50
50
  @property
51
51
  def has_gaps(self) -> bool:
52
52
  """True when CI should flag this report as incomplete."""
53
- return bool(
54
- self.orphan_tasks
55
- or self.uncovered_requirements
56
- or self.unreferenced_designs
57
- )
53
+ return bool(self.orphan_tasks or self.uncovered_requirements or self.unreferenced_designs)
58
54
 
59
55
 
60
56
  def _extract_section_ids(text: str, prefix: str) -> list[str]:
@@ -135,16 +131,12 @@ def build_report(
135
131
  # Gap warnings (LABS-42): identifiers defined in spec files that no
136
132
  # task references. Useful for CI integration — CI can fail if the
137
133
  # report has gaps, surfacing drift between specs and implementation.
138
- report.uncovered_requirements = sorted(
139
- req for req in all_reqs if req not in req_to_tasks
140
- )
134
+ report.uncovered_requirements = sorted(req for req in all_reqs if req not in req_to_tasks)
141
135
  if design_to_req:
142
136
  referenced_designs = {
143
137
  ref for task in tasks for ref in task.traces_to if ref.startswith("DESIGN-")
144
138
  }
145
- report.unreferenced_designs = sorted(
146
- set(design_to_req) - referenced_designs
147
- )
139
+ report.unreferenced_designs = sorted(set(design_to_req) - referenced_designs)
148
140
 
149
141
  # Build rows
150
142
  with ExecutorState(config) as state:
@@ -149,7 +149,8 @@ def build_cli_command(
149
149
  """Build CLI command from template or auto-detect based on command name.
150
150
 
151
151
  Args:
152
- cmd: CLI command name (e.g., "claude", "codex", "llama-cli")
152
+ cmd: CLI command name (e.g., "claude", "codex", "opencode", "pi",
153
+ "ollama", "llama-cli")
153
154
  prompt: The prompt text
154
155
  model: Model name (optional)
155
156
  template: Command template with placeholders (optional)
@@ -182,6 +183,9 @@ def build_cli_command(
182
183
 
183
184
  # Auto-detect based on command name
184
185
  cmd_lower = cmd.lower()
186
+ # "pi" is too short for substring matching — match on basename only to
187
+ # avoid false positives like "/usr/local/bin/anti-pi" or "opencode-pi-cli".
188
+ cmd_basename = Path(cmd).name.lower()
185
189
 
186
190
  if "llama-cli" in cmd_lower or "llama.cpp" in cmd_lower:
187
191
  # llama.cpp CLI
@@ -199,6 +203,16 @@ def build_cli_command(
199
203
  # Ollama CLI
200
204
  return [cmd, "run", model or "llama3", prompt]
201
205
 
206
+ elif "opencode" in cmd_lower:
207
+ # sst/opencode: `opencode run [--model provider/id] <prompt>`
208
+ # Prompt is positional, model accepts "provider/model" form
209
+ # (e.g. "anthropic/claude-3-5-sonnet").
210
+ result = [cmd, "run"]
211
+ if model:
212
+ result.extend(["--model", model])
213
+ result.append(prompt)
214
+ return result
215
+
202
216
  elif "codex" in cmd_lower:
203
217
  # Codex CLI
204
218
  result = [cmd, "-p", prompt]
@@ -206,6 +220,17 @@ def build_cli_command(
206
220
  result.extend(["--model", model])
207
221
  return result
208
222
 
223
+ elif cmd_basename == "pi" or cmd_basename.startswith("pi."):
224
+ # earendil-works/pi: `pi -p [--model X] <prompt>` (non-interactive mode)
225
+ # Model accepts "provider/id" or bare model name; defaults driven by
226
+ # `~/.config/pi/config.yaml`. Match on basename to avoid short-name
227
+ # collisions (see cmd_basename comment above).
228
+ result = [cmd, "-p"]
229
+ if model:
230
+ result.extend(["--model", model])
231
+ result.append(prompt)
232
+ return result
233
+
209
234
  else:
210
235
  # Claude CLI (default)
211
236
  result = [cmd, "-p", prompt]
@@ -297,4 +322,4 @@ async def run_claude_async(
297
322
  proc.kill()
298
323
  await proc.wait()
299
324
  raise
300
- return stdout_bytes.decode(), stderr_bytes.decode(), proc.returncode
325
+ return stdout_bytes.decode(), stderr_bytes.decode(), proc.returncode or 0
@@ -0,0 +1,38 @@
1
+ # Code Review
2
+
3
+ Task: ${TASK_ID} — ${TASK_NAME}
4
+
5
+ Files changed:
6
+ ${CHANGED_FILES}
7
+
8
+ Diff:
9
+ ${GIT_DIFF}
10
+
11
+ ## Instructions
12
+
13
+ Review the code for:
14
+ 1. Bugs and errors
15
+ 2. Security issues
16
+ 3. Missing error handling
17
+ 4. Test coverage
18
+
19
+ ## Required Response Format
20
+
21
+ You MUST end your response with exactly one of these status codes on a new line:
22
+
23
+ ```
24
+ REVIEW_PASSED
25
+ ```
26
+ Use this if the code looks good and has no issues.
27
+
28
+ ```
29
+ REVIEW_FIXED
30
+ ```
31
+ Use this if you found and fixed issues.
32
+
33
+ ```
34
+ REVIEW_FAILED
35
+ ```
36
+ Use this if there are issues that need manual attention.
37
+
38
+ Do not add any text after the status code.
@@ -0,0 +1,38 @@
1
+ # Code Review
2
+
3
+ Task: ${TASK_ID} — ${TASK_NAME}
4
+
5
+ Files changed:
6
+ ${CHANGED_FILES}
7
+
8
+ Diff:
9
+ ${GIT_DIFF}
10
+
11
+ ## Instructions
12
+
13
+ Review the code for:
14
+ 1. Bugs and errors
15
+ 2. Security issues
16
+ 3. Missing error handling
17
+ 4. Test coverage
18
+
19
+ ## Required Response Format
20
+
21
+ You MUST end your response with exactly one of these status codes on a new line:
22
+
23
+ ```
24
+ REVIEW_PASSED
25
+ ```
26
+ Use this if the code looks good and has no issues.
27
+
28
+ ```
29
+ REVIEW_FIXED
30
+ ```
31
+ Use this if you found and fixed issues.
32
+
33
+ ```
34
+ REVIEW_FAILED
35
+ ```
36
+ Use this if there are issues that need manual attention.
37
+
38
+ Do not add any text after the status code.
@@ -213,6 +213,7 @@ class ExecutorState:
213
213
  # Init DB first so tables exist
214
214
  self._init_db()
215
215
 
216
+ assert self._conn is not None
216
217
  with self._conn:
217
218
  # Migrate tasks and attempts
218
219
  for task_id, task_data in data.get("tasks", {}).items():
@@ -262,6 +263,7 @@ class ExecutorState:
262
263
 
263
264
  def _load(self) -> None:
264
265
  """Load state from SQLite into in-memory dicts."""
266
+ assert self._conn is not None
265
267
  # Load tasks
266
268
  cursor = self._conn.execute("SELECT task_id, status, started_at, completed_at FROM tasks")
267
269
  for row in cursor.fetchall():
@@ -323,6 +325,7 @@ class ExecutorState:
323
325
 
324
326
  def _save_meta(self) -> None:
325
327
  """Persist meta counters to SQLite."""
328
+ assert self._conn is not None
326
329
  for key, value in [
327
330
  ("consecutive_failures", str(self.consecutive_failures)),
328
331
  ("total_completed", str(self.total_completed)),
@@ -340,6 +343,7 @@ class ExecutorState:
340
343
  Called by external code (e.g. executor.py) when direct
341
344
  mutations are made to in-memory state outside record_attempt/mark_running.
342
345
  """
346
+ assert self._conn is not None
343
347
  with self._conn:
344
348
  # Upsert all tasks
345
349
  for task_id, ts in self.tasks.items():
@@ -415,6 +419,7 @@ class ExecutorState:
415
419
  review_findings=review_findings,
416
420
  )
417
421
  state.attempts.append(attempt)
422
+ assert self._conn is not None
418
423
 
419
424
  if success:
420
425
  state.status = "success"
@@ -519,6 +524,7 @@ class ExecutorState:
519
524
  state = self.get_task_state(task_id)
520
525
  state.status = "running"
521
526
  state.started_at = datetime.now().isoformat()
527
+ assert self._conn is not None
522
528
 
523
529
  try:
524
530
  with self._conn:
@@ -10,6 +10,7 @@ import contextlib
10
10
  import sqlite3
11
11
  from datetime import datetime
12
12
  from pathlib import Path
13
+ from typing import Any
13
14
 
14
15
  from textual.app import App, ComposeResult
15
16
  from textual.binding import Binding
@@ -159,7 +160,7 @@ class StatsBar(Static):
159
160
  class KanbanColumn(Vertical):
160
161
  """A single column in the Kanban board."""
161
162
 
162
- def __init__(self, title: str, **kwargs: object) -> None:
163
+ def __init__(self, title: str, **kwargs: Any) -> None:
163
164
  super().__init__(**kwargs)
164
165
  self.border_title = title
165
166
 
@@ -167,7 +168,7 @@ class KanbanColumn(Vertical):
167
168
  class LogPanel(Static):
168
169
  """Panel showing execution progress log, tailing a progress file."""
169
170
 
170
- def __init__(self, **kwargs: object) -> None:
171
+ def __init__(self, **kwargs: Any) -> None:
171
172
  super().__init__(**kwargs)
172
173
  self._file_pos: int = 0
173
174
  self._lines: list[str] = []
@@ -553,6 +554,6 @@ class SpecRunnerApp(App[None]):
553
554
  else:
554
555
  log_panel.add_line(f"[bold cyan]While paused: {summary}[/bold cyan]")
555
556
 
556
- def action_quit(self) -> None:
557
+ async def action_quit(self) -> None:
557
558
  """Quit the TUI."""
558
559
  self.exit()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spec-runner
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Task automation from markdown specs via Claude CLI
5
5
  Author: Andrei
6
6
  License-Expression: MIT
@@ -369,6 +369,8 @@ paths:
369
369
  |-----|--------------|------------------|
370
370
  | Claude | Yes | `{cmd} -p {prompt} --model {model}` |
371
371
  | Codex | Yes | `{cmd} -p {prompt} --model {model}` |
372
+ | OpenCode ([sst/opencode](https://opencode.ai)) | Yes | `{cmd} run --model {model} {prompt}` |
373
+ | Pi Agent ([pi.dev](https://pi.dev)) | Yes (basename match) | `{cmd} -p --model {model} {prompt}` |
372
374
  | Ollama | Yes | `{cmd} run {model} {prompt}` |
373
375
  | llama-cli | Yes | `{cmd} -m {model} -p {prompt} --no-display-prompt` |
374
376
  | Custom | Use template | `{cmd} --prompt {prompt}` |
@@ -54,6 +54,8 @@ src/spec_runner/skills/spec-generator-skill/templates/prompts/review.codex.md
54
54
  src/spec_runner/skills/spec-generator-skill/templates/prompts/review.llama.md
55
55
  src/spec_runner/skills/spec-generator-skill/templates/prompts/review.md
56
56
  src/spec_runner/skills/spec-generator-skill/templates/prompts/review.ollama.md
57
+ src/spec_runner/skills/spec-generator-skill/templates/prompts/review.opencode.md
58
+ src/spec_runner/skills/spec-generator-skill/templates/prompts/review.pi.md
57
59
  tests/test_audit.py
58
60
  tests/test_audit_log.py
59
61
  tests/test_config.py
@@ -177,9 +177,7 @@ class TestUncoveredSpec:
177
177
  config = _write_specs(tmp_path, tasks, REQS_CLEAN, DESIGN_CLEAN)
178
178
  report = audit_all(config)
179
179
 
180
- uncovered_designs = [
181
- f for f in report.findings if f.category == CAT_UNCOVERED_DESIGN
182
- ]
180
+ uncovered_designs = [f for f in report.findings if f.category == CAT_UNCOVERED_DESIGN]
183
181
  assert {f.subject for f in uncovered_designs} == {"DESIGN-002"}
184
182
 
185
183
 
@@ -155,9 +155,7 @@ class TestBuildAuditLogger:
155
155
  assert logger.operator == "maestro"
156
156
 
157
157
  def test_spec_prefix_flows_through(self, tmp_path: Path) -> None:
158
- config = _make_config(
159
- tmp_path, audit_log_path="audit.jsonl", spec_prefix="phase5-"
160
- )
158
+ config = _make_config(tmp_path, audit_log_path="audit.jsonl", spec_prefix="phase5-")
161
159
  logger = build_audit_logger(config)
162
160
  logger.record(EVENT_RUN_STARTED)
163
161
  entry = _read_audit(logger.path)[0]
@@ -236,9 +234,7 @@ class TestExecutorStateAuditIntegration:
236
234
  assert failed["details"]["last_error"] == "boom"
237
235
  assert failed["details"]["error_code"] == "TASK_FAILED"
238
236
 
239
- def test_degraded_mode_emits_state_degraded_event(
240
- self, tmp_path: Path, monkeypatch
241
- ) -> None:
237
+ def test_degraded_mode_emits_state_degraded_event(self, tmp_path: Path, monkeypatch) -> None:
242
238
  import sqlite3
243
239
  from unittest.mock import MagicMock
244
240
 
@@ -50,6 +50,42 @@ class TestBuildCliCommand:
50
50
  assert "--model" in result
51
51
  assert "gpt-4" in result
52
52
 
53
+ def test_opencode_auto_detect(self):
54
+ result = build_cli_command("opencode", "hello")
55
+ assert result == ["opencode", "run", "hello"]
56
+
57
+ def test_opencode_with_model(self):
58
+ result = build_cli_command("opencode", "hello", model="anthropic/claude-sonnet-4-6")
59
+ assert result == [
60
+ "opencode",
61
+ "run",
62
+ "--model",
63
+ "anthropic/claude-sonnet-4-6",
64
+ "hello",
65
+ ]
66
+
67
+ def test_pi_auto_detect(self):
68
+ result = build_cli_command("pi", "hello")
69
+ assert result == ["pi", "-p", "hello"]
70
+
71
+ def test_pi_with_model(self):
72
+ result = build_cli_command("pi", "hello", model="openai/gpt-4o")
73
+ assert result == ["pi", "-p", "--model", "openai/gpt-4o", "hello"]
74
+
75
+ def test_pi_path_basename_match(self):
76
+ # Absolute path with pi as the basename should still auto-detect.
77
+ result = build_cli_command("/usr/local/bin/pi", "hello")
78
+ assert result == ["/usr/local/bin/pi", "-p", "hello"]
79
+
80
+ def test_pi_no_false_positive_substring(self):
81
+ # "pipe-cli" or anything containing "pi" should NOT be treated as Pi —
82
+ # it must fall through to the Claude default.
83
+ result = build_cli_command("pipe-cli", "hello")
84
+ assert result[0] == "pipe-cli"
85
+ # Claude default uses -p too, but key signal: prompt is the third
86
+ # arg ("-p hello"), not the fourth ("-p" + appended prompt).
87
+ assert result == ["pipe-cli", "-p", "hello"]
88
+
53
89
  def test_ollama_auto_detect(self):
54
90
  result = build_cli_command("ollama", "hello", model="llama3")
55
91
  assert result == ["ollama", "run", "llama3", "hello"]
File without changes
File without changes