spec-runner 2.3.1__tar.gz → 2.4.1__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 (102) hide show
  1. {spec_runner-2.3.1/src/spec_runner.egg-info → spec_runner-2.4.1}/PKG-INFO +30 -3
  2. {spec_runner-2.3.1 → spec_runner-2.4.1}/README.md +29 -2
  3. {spec_runner-2.3.1 → spec_runner-2.4.1}/pyproject.toml +1 -1
  4. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/cli.py +120 -55
  5. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/cli_plan.py +28 -1
  6. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/config.py +2 -0
  7. spec_runner-2.4.1/src/spec_runner/doctor.py +419 -0
  8. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/errors.py +1 -3
  9. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/execution.py +26 -15
  10. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/hooks.py +40 -14
  11. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/review.py +37 -29
  12. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/runner.py +142 -17
  13. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/state.py +12 -16
  14. {spec_runner-2.3.1 → spec_runner-2.4.1/src/spec_runner.egg-info}/PKG-INFO +30 -3
  15. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner.egg-info/SOURCES.txt +2 -0
  16. spec_runner-2.4.1/tests/test_doctor.py +638 -0
  17. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_errors.py +1 -4
  18. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_execution.py +185 -55
  19. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_hooks.py +99 -0
  20. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_plan_full.py +61 -0
  21. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_runner.py +156 -0
  22. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_stages.py +9 -2
  23. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_state.py +25 -2
  24. {spec_runner-2.3.1 → spec_runner-2.4.1}/LICENSE +0 -0
  25. {spec_runner-2.3.1 → spec_runner-2.4.1}/setup.cfg +0 -0
  26. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/__init__.py +0 -0
  27. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/audit.py +0 -0
  28. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/audit_log.py +0 -0
  29. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/cli_info.py +0 -0
  30. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/events.py +0 -0
  31. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/executor.py +0 -0
  32. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/git_ops.py +0 -0
  33. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/github_sync.py +0 -0
  34. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/init_cmd.py +0 -0
  35. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/logging.py +0 -0
  36. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/mcp_server.py +0 -0
  37. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/notifications.py +0 -0
  38. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/obs.py +0 -0
  39. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/plugins.py +0 -0
  40. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/prompt.py +0 -0
  41. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/py.typed +0 -0
  42. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/report.py +0 -0
  43. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/SKILL.md +0 -0
  44. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/Makefile.template +0 -0
  45. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/design.template.md +0 -0
  46. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/executor.config.yaml +0 -0
  47. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/executor.py +0 -0
  48. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-design.template.md +0 -0
  49. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-requirements.template.md +0 -0
  50. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-tasks.template.md +0 -0
  51. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/pi/skills/pi-implementer/SKILL.md +0 -0
  52. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/pi/skills/pi-reviewer/SKILL.md +0 -0
  53. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/pi/skills/pi-tester/SKILL.md +0 -0
  54. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/pi/spec-runner.pi.config.yaml +0 -0
  55. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.claude.md +0 -0
  56. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.codex.md +0 -0
  57. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.llama.md +0 -0
  58. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.md +0 -0
  59. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.ollama.md +0 -0
  60. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.opencode.md +0 -0
  61. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.pi.md +0 -0
  62. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/requirements.template.md +0 -0
  63. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/task.py +0 -0
  64. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/tasks.template.md +0 -0
  65. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/workflow.template.md +0 -0
  66. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/stages.py +0 -0
  67. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/task.py +0 -0
  68. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/task_commands.py +0 -0
  69. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/tui.py +0 -0
  70. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/validate.py +0 -0
  71. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/verify.py +0 -0
  72. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner.egg-info/dependency_links.txt +0 -0
  73. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner.egg-info/entry_points.txt +0 -0
  74. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner.egg-info/requires.txt +0 -0
  75. {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner.egg-info/top_level.txt +0 -0
  76. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_audit.py +0 -0
  77. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_audit_log.py +0 -0
  78. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_cli_flags.py +0 -0
  79. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_cli_info.py +0 -0
  80. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_cli_run_reset.py +0 -0
  81. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_config.py +0 -0
  82. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_costs.py +0 -0
  83. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_e2e.py +0 -0
  84. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_events.py +0 -0
  85. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_gh_sync.py +0 -0
  86. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_json_result_contract.py +0 -0
  87. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_logging.py +0 -0
  88. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_mcp.py +0 -0
  89. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_notifications.py +0 -0
  90. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_obs.py +0 -0
  91. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_obs_contract.py +0 -0
  92. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_plugins.py +0 -0
  93. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_prompt.py +0 -0
  94. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_report.py +0 -0
  95. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_spec_prefix.py +0 -0
  96. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_subdir_detection.py +0 -0
  97. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_task.py +0 -0
  98. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_task_diff.py +0 -0
  99. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_tui.py +0 -0
  100. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_validate.py +0 -0
  101. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_verify.py +0 -0
  102. {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spec-runner
3
- Version: 2.3.1
3
+ Version: 2.4.1
4
4
  Summary: Task automation from markdown specs via Claude CLI
5
5
  Author: Andrei
6
6
  License-Expression: MIT
@@ -178,6 +178,14 @@ spec-runner report --json # JSON matrix output
178
178
  # Planning
179
179
  spec-runner plan "description" # Interactive task planning
180
180
  spec-runner plan --full "description" # Generate full spec (requirements + design + tasks)
181
+ spec-runner plan --full --from-file spec.md # Read the description from a file instead of an arg
182
+
183
+ # Diagnostics
184
+ spec-runner doctor # Probe the configured CLI/model (real mini-task)
185
+ spec-runner doctor --cli=codex --model=gpt-5.4 # Probe an ad-hoc CLI+model
186
+ spec-runner doctor --with-review # Also probe the review stage
187
+ spec-runner doctor --json --yes # Machine-readable, no confirmation (CI)
188
+ spec-runner doctor --strict # Exit non-zero on DEGRADED too
181
189
 
182
190
  # Integration
183
191
  spec-runner mcp # Launch MCP server (stdio)
@@ -369,7 +377,7 @@ paths:
369
377
  | CLI | Auto-detected | Example template |
370
378
  |-----|--------------|------------------|
371
379
  | Claude | Yes | `{cmd} -p {prompt} --model {model}` |
372
- | Codex | Yes | `{cmd} -p {prompt} --model {model}` |
380
+ | Codex | Yes | `{cmd} exec -m {model} {prompt}` (codex's `-p` is `--profile`, not the prompt) |
373
381
  | OpenCode ([sst/opencode](https://opencode.ai)) | Yes | `{cmd} run --model {model} {prompt}` |
374
382
  | Pi Agent ([pi.dev](https://pi.dev)) | Yes (basename match) | `{cmd} -p --model {model} {prompt}` |
375
383
  | Ollama | Yes | `{cmd} run {model} {prompt}` |
@@ -381,6 +389,22 @@ paths:
381
389
  > script — no core code. See [docs/pi-workflow.md](docs/pi-workflow.md) and the runnable
382
390
  > [examples/pi-loop/](examples/pi-loop/).
383
391
 
392
+ ### Checking CLI/model compatibility
393
+
394
+ `spec-runner doctor` runs a real one-task probe through the actual execution
395
+ path and reports, per capability, whether your CLI/model works:
396
+
397
+ - **invocation** — the command runs and authenticates
398
+ - **completion_marker** — the model prints `TASK_COMPLETE` (not all models do)
399
+ - **task_action** — the model actually performs the work
400
+ - **cost_tracking** — token/cost parsing works (needed for `costs`/`--budget`)
401
+ - **error_classification** — failures are classified (diagnostic)
402
+ - **review** *(with `--with-review`)* — the reviewer prints `REVIEW_PASSED`/`FAILED`
403
+
404
+ Verdict: **READY** / **DEGRADED** (works, but something like cost tracking is
405
+ unavailable) / **BROKEN**. It makes real, billable model calls (capped by
406
+ `--budget`, default $0.50) and asks for confirmation unless `--yes`.
407
+
384
408
  ## Project Structure
385
409
 
386
410
  ```
@@ -397,6 +421,8 @@ project/
397
421
  │ ├── cli_info.py # Status, costs, logs, validate, verify, report, TUI, MCP
398
422
  │ ├── cli_plan.py # Interactive planning command
399
423
  │ ├── execution.py # Task execution + retry logic
424
+ │ ├── errors.py # CLI stderr → human-readable failure reasons
425
+ │ ├── stages.py # Per-task sub-stage tracking (StageReporter)
400
426
  │ ├── config.py # ExecutorConfig + YAML loading
401
427
  │ ├── state.py # SQLite state persistence + degraded-mode fallback
402
428
  │ ├── prompt.py # Prompt building + templates
@@ -413,7 +439,8 @@ project/
413
439
  │ ├── report.py # Traceability matrix generation
414
440
  │ ├── validate.py # Config + task validation
415
441
  │ ├── plugins.py # Plugin discovery + hooks
416
- │ ├── logging.py # Structured logging (structlog)
442
+ │ ├── logging.py # Structured logging (structlog back-compat shim)
443
+ │ ├── obs.py # OTel JSONL observability emitter (shared contract)
417
444
  │ ├── events.py # EventBus for streaming to TUI
418
445
  │ ├── notifications.py # Telegram + webhook notifications
419
446
  │ ├── tui.py # Textual TUI dashboard
@@ -143,6 +143,14 @@ spec-runner report --json # JSON matrix output
143
143
  # Planning
144
144
  spec-runner plan "description" # Interactive task planning
145
145
  spec-runner plan --full "description" # Generate full spec (requirements + design + tasks)
146
+ spec-runner plan --full --from-file spec.md # Read the description from a file instead of an arg
147
+
148
+ # Diagnostics
149
+ spec-runner doctor # Probe the configured CLI/model (real mini-task)
150
+ spec-runner doctor --cli=codex --model=gpt-5.4 # Probe an ad-hoc CLI+model
151
+ spec-runner doctor --with-review # Also probe the review stage
152
+ spec-runner doctor --json --yes # Machine-readable, no confirmation (CI)
153
+ spec-runner doctor --strict # Exit non-zero on DEGRADED too
146
154
 
147
155
  # Integration
148
156
  spec-runner mcp # Launch MCP server (stdio)
@@ -334,7 +342,7 @@ paths:
334
342
  | CLI | Auto-detected | Example template |
335
343
  |-----|--------------|------------------|
336
344
  | Claude | Yes | `{cmd} -p {prompt} --model {model}` |
337
- | Codex | Yes | `{cmd} -p {prompt} --model {model}` |
345
+ | Codex | Yes | `{cmd} exec -m {model} {prompt}` (codex's `-p` is `--profile`, not the prompt) |
338
346
  | OpenCode ([sst/opencode](https://opencode.ai)) | Yes | `{cmd} run --model {model} {prompt}` |
339
347
  | Pi Agent ([pi.dev](https://pi.dev)) | Yes (basename match) | `{cmd} -p --model {model} {prompt}` |
340
348
  | Ollama | Yes | `{cmd} run {model} {prompt}` |
@@ -346,6 +354,22 @@ paths:
346
354
  > script — no core code. See [docs/pi-workflow.md](docs/pi-workflow.md) and the runnable
347
355
  > [examples/pi-loop/](examples/pi-loop/).
348
356
 
357
+ ### Checking CLI/model compatibility
358
+
359
+ `spec-runner doctor` runs a real one-task probe through the actual execution
360
+ path and reports, per capability, whether your CLI/model works:
361
+
362
+ - **invocation** — the command runs and authenticates
363
+ - **completion_marker** — the model prints `TASK_COMPLETE` (not all models do)
364
+ - **task_action** — the model actually performs the work
365
+ - **cost_tracking** — token/cost parsing works (needed for `costs`/`--budget`)
366
+ - **error_classification** — failures are classified (diagnostic)
367
+ - **review** *(with `--with-review`)* — the reviewer prints `REVIEW_PASSED`/`FAILED`
368
+
369
+ Verdict: **READY** / **DEGRADED** (works, but something like cost tracking is
370
+ unavailable) / **BROKEN**. It makes real, billable model calls (capped by
371
+ `--budget`, default $0.50) and asks for confirmation unless `--yes`.
372
+
349
373
  ## Project Structure
350
374
 
351
375
  ```
@@ -362,6 +386,8 @@ project/
362
386
  │ ├── cli_info.py # Status, costs, logs, validate, verify, report, TUI, MCP
363
387
  │ ├── cli_plan.py # Interactive planning command
364
388
  │ ├── execution.py # Task execution + retry logic
389
+ │ ├── errors.py # CLI stderr → human-readable failure reasons
390
+ │ ├── stages.py # Per-task sub-stage tracking (StageReporter)
365
391
  │ ├── config.py # ExecutorConfig + YAML loading
366
392
  │ ├── state.py # SQLite state persistence + degraded-mode fallback
367
393
  │ ├── prompt.py # Prompt building + templates
@@ -378,7 +404,8 @@ project/
378
404
  │ ├── report.py # Traceability matrix generation
379
405
  │ ├── validate.py # Config + task validation
380
406
  │ ├── plugins.py # Plugin discovery + hooks
381
- │ ├── logging.py # Structured logging (structlog)
407
+ │ ├── logging.py # Structured logging (structlog back-compat shim)
408
+ │ ├── obs.py # OTel JSONL observability emitter (shared contract)
382
409
  │ ├── events.py # EventBus for streaming to TUI
383
410
  │ ├── notifications.py # Telegram + webhook notifications
384
411
  │ ├── tui.py # Textual TUI dashboard
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "spec-runner"
7
- version = "2.3.1"
7
+ version = "2.4.1"
8
8
  description = "Task automation from markdown specs via Claude CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -117,6 +117,27 @@ def _print_dry_run(tasks_to_run: list[Task], config: ExecutorConfig, state: Exec
117
117
  print(json.dumps({"dry_run": True, "tasks": data}, indent=2))
118
118
 
119
119
 
120
+ def _acquire_run_lock(config: ExecutorConfig) -> ExecutorLock:
121
+ """Acquire the exclusive executor lock, or exit(1) if another run holds it."""
122
+ lock = ExecutorLock(config.state_file.with_suffix(".lock"))
123
+ if not lock.acquire():
124
+ held_by = getattr(lock, "_held_by", {})
125
+ alive = held_by.get("alive", "true")
126
+ logger.error(
127
+ "Another executor is already running",
128
+ lock_file=str(config.state_file.with_suffix(".lock")),
129
+ held_by_pid=held_by.get("pid", "unknown"),
130
+ started=held_by.get("started", "unknown"),
131
+ process_alive=alive,
132
+ )
133
+ if alive == "false":
134
+ logger.error(
135
+ "Lock holder is dead. Use --force to override, or delete the lock file manually."
136
+ )
137
+ sys.exit(1)
138
+ return lock
139
+
140
+
120
141
  def cmd_run(args: argparse.Namespace, config: ExecutorConfig) -> None:
121
142
  """Execute tasks."""
122
143
  # HITL review incompatible with TUI mode
@@ -124,61 +145,52 @@ def cmd_run(args: argparse.Namespace, config: ExecutorConfig) -> None:
124
145
  logger.warning("--hitl-review ignored in TUI mode (TUI owns the screen)")
125
146
  config.hitl_review = False
126
147
 
127
- if getattr(args, "tui", False):
128
- import threading
129
-
130
- from .logging import setup_logging
131
- from .tui import SpecRunnerApp
148
+ # Acquire the exclusive lock unless --force. TUI mode also holds it (one
149
+ # executor per project) — when held, stale-task recovery can safely reset all
150
+ # orphaned 'running' tasks; with --force a concurrent runner may exist, so we
151
+ # fall back to the age-based heuristic.
152
+ if getattr(args, "force", False):
153
+ logger.warning("Skipping lock check (--force)")
154
+ lock = None
155
+ else:
156
+ lock = _acquire_run_lock(config)
157
+ lock_held = lock is not None
132
158
 
133
- # TUI mode: log to file, TUI owns screen
134
- log_file = config.logs_dir / f"run-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
135
- config.logs_dir.mkdir(parents=True, exist_ok=True)
136
- setup_logging(level=config.log_level, tui_mode=True, log_file=log_file)
159
+ try:
160
+ if getattr(args, "tui", False):
161
+ import threading
137
162
 
138
- app = SpecRunnerApp(config=config)
163
+ from .logging import setup_logging
164
+ from .tui import SpecRunnerApp
139
165
 
140
- def _start_execution() -> None:
141
- t = threading.Thread(target=lambda: _run_tasks(args, config), daemon=True)
142
- t.start()
166
+ # TUI mode: log to file, TUI owns screen
167
+ log_file = config.logs_dir / f"run-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log"
168
+ config.logs_dir.mkdir(parents=True, exist_ok=True)
169
+ setup_logging(level=config.log_level, tui_mode=True, log_file=log_file)
143
170
 
144
- app.call_later(_start_execution)
145
- app.run()
146
- return
171
+ app = SpecRunnerApp(config=config)
147
172
 
148
- if getattr(args, "force", False):
149
- logger.warning("Skipping lock check (--force)")
150
- _run_tasks(args, config)
151
- else:
152
- # Acquire lock to prevent concurrent runs
153
- lock = ExecutorLock(config.state_file.with_suffix(".lock"))
154
- if not lock.acquire():
155
- held_by = getattr(lock, "_held_by", {})
156
- pid = held_by.get("pid", "unknown")
157
- started = held_by.get("started", "unknown")
158
- alive = held_by.get("alive", "true")
159
-
160
- logger.error(
161
- "Another executor is already running",
162
- lock_file=str(config.state_file.with_suffix(".lock")),
163
- held_by_pid=pid,
164
- started=started,
165
- process_alive=alive,
166
- )
167
- if alive == "false":
168
- logger.error(
169
- "Lock holder is dead. Use --force to override, "
170
- "or delete the lock file manually."
173
+ def _start_execution() -> None:
174
+ t = threading.Thread(
175
+ target=lambda: _run_tasks(args, config, lock_held=lock_held), daemon=True
171
176
  )
172
- sys.exit(1)
177
+ t.start()
173
178
 
174
- try:
175
- _run_tasks(args, config)
176
- finally:
179
+ app.call_later(_start_execution)
180
+ app.run()
181
+ else:
182
+ _run_tasks(args, config, lock_held=lock_held)
183
+ finally:
184
+ if lock is not None:
177
185
  lock.release()
178
186
 
179
187
 
180
- def _run_tasks(args, config: ExecutorConfig):
181
- """Internal task execution logic."""
188
+ def _run_tasks(args, config: ExecutorConfig, *, lock_held: bool = False):
189
+ """Internal task execution logic.
190
+
191
+ lock_held: True when the caller holds the exclusive executor lock, so any
192
+ orphaned 'running' task can be safely reset regardless of age.
193
+ """
182
194
  # Clear any leftover stop file from previous runs
183
195
  clear_stop_file(config)
184
196
 
@@ -194,17 +206,23 @@ def _run_tasks(args, config: ExecutorConfig):
194
206
  task_filter=getattr(args, "task", None),
195
207
  )
196
208
 
197
- # Recover tasks stuck in 'running' from previous crash
209
+ # Recover tasks stuck in 'running' from a previous crashed/interrupted run.
210
+ # When we hold the exclusive lock (lock_held), no other runner exists — any
211
+ # 'running' task is orphaned and is reset regardless of age (otherwise a
212
+ # session interruption, e.g. a dropped remote shell, leaves a half-done
213
+ # task that the next run re-picks first and hangs re-doing it). Without the
214
+ # lock (--force), a concurrent runner may be active, so fall back to the
215
+ # age-based heuristic (2x the task timeout).
198
216
  stale_timeout = config.task_timeout_minutes * 2
199
- recovered = recover_stale_tasks(state, stale_timeout, config.tasks_file)
217
+ recovered = recover_stale_tasks(
218
+ state, stale_timeout, config.tasks_file, recover_all=lock_held
219
+ )
200
220
  if recovered:
201
221
  logger.warning("Recovered stale tasks", task_ids=recovered)
202
222
  tasks = parse_tasks(config.tasks_file)
203
223
 
204
224
  # v2.3.0: reset failed-task state on `run --all` unless opted out.
205
- reset_enabled = getattr(args, "all", False) and not getattr(
206
- args, "no_reset_failed", False
207
- )
225
+ reset_enabled = getattr(args, "all", False) and not getattr(args, "no_reset_failed", False)
208
226
  previously_failed: set[str] = set() # used by T17 second-pass detection
209
227
  if reset_enabled:
210
228
  previously_failed = state.reset_failed_to_pending()
@@ -416,8 +434,7 @@ def _run_tasks(args, config: ExecutorConfig):
416
434
  else:
417
435
  stop_reason = "max_consecutive_failures"
418
436
  stop_detail = (
419
- f"{state.consecutive_failures}/"
420
- f"{config.max_consecutive_failures}"
437
+ f"{state.consecutive_failures}/{config.max_consecutive_failures}"
421
438
  )
422
439
  logger.warning("Stopping: too many consecutive failures")
423
440
  break
@@ -460,8 +477,7 @@ def _run_tasks(args, config: ExecutorConfig):
460
477
  else:
461
478
  stop_reason = "max_consecutive_failures"
462
479
  stop_detail = (
463
- f"{state.consecutive_failures}/"
464
- f"{config.max_consecutive_failures}"
480
+ f"{state.consecutive_failures}/{config.max_consecutive_failures}"
465
481
  )
466
482
  logger.warning("Stopping: too many consecutive failures")
467
483
  break
@@ -663,6 +679,25 @@ def cmd_watch(args: argparse.Namespace, config: ExecutorConfig) -> None:
663
679
  time.sleep(1)
664
680
 
665
681
 
682
+ def cmd_doctor(args: argparse.Namespace, config: ExecutorConfig) -> None:
683
+ """Run the CLI/model compatibility probe and exit with its status code."""
684
+ from .doctor import run_doctor
685
+
686
+ code = run_doctor(
687
+ config,
688
+ cli=args.cli,
689
+ model=args.model,
690
+ with_review=args.with_review,
691
+ budget=args.budget,
692
+ timeout_min=getattr(args, "timeout", None),
693
+ assume_yes=args.yes,
694
+ strict=args.strict,
695
+ as_json=args.json,
696
+ keep=args.keep,
697
+ )
698
+ raise SystemExit(code)
699
+
700
+
666
701
  # === Main ===
667
702
 
668
703
 
@@ -853,7 +888,14 @@ def _build_parser() -> argparse.ArgumentParser:
853
888
 
854
889
  # plan
855
890
  plan_parser = subparsers.add_parser("plan", parents=[common], help="Interactive task planning")
856
- plan_parser.add_argument("description", help="Feature description")
891
+ plan_parser.add_argument(
892
+ "description", nargs="?", default=None, help="Feature description (or use --from-file)"
893
+ )
894
+ plan_parser.add_argument(
895
+ "--from-file",
896
+ metavar="PATH",
897
+ help="Read the feature description from a file instead of the positional argument",
898
+ )
857
899
  plan_parser.add_argument(
858
900
  "--full",
859
901
  action="store_true",
@@ -943,6 +985,28 @@ def _build_parser() -> argparse.ArgumentParser:
943
985
  # mcp
944
986
  subparsers.add_parser("mcp", parents=[common], help="Launch read-only MCP server")
945
987
 
988
+ # doctor
989
+ doctor_parser = subparsers.add_parser(
990
+ "doctor", parents=[common], help="Probe CLI/model compatibility (real mini-task)"
991
+ )
992
+ doctor_parser.add_argument("--cli", help="Override the CLI command (claude/codex/pi/...)")
993
+ doctor_parser.add_argument("--model", help="Override the model (executor + review)")
994
+ doctor_parser.add_argument(
995
+ "--with-review",
996
+ action="store_true",
997
+ help="Also probe the review stage (2nd model call)",
998
+ )
999
+ doctor_parser.add_argument(
1000
+ "--yes", "-y", action="store_true", help="Skip the cost-gate confirmation"
1001
+ )
1002
+ doctor_parser.add_argument(
1003
+ "--strict", action="store_true", help="Exit non-zero on DEGRADED too"
1004
+ )
1005
+ doctor_parser.add_argument("--json", action="store_true", help="Machine-readable output")
1006
+ doctor_parser.add_argument("--keep", action="store_true", help="Keep the scratch workspace")
1007
+ # --budget is inherited from common (default None); override default to 0.50 for doctor
1008
+ doctor_parser.set_defaults(budget=0.5)
1009
+
946
1010
  # task (unified: replaces spec-task binary)
947
1011
  task_parser = subparsers.add_parser(
948
1012
  "task", help="Task management (list, show, start, done, graph, sync)"
@@ -1038,6 +1102,7 @@ def main():
1038
1102
  "tui": cmd_tui,
1039
1103
  "watch": cmd_watch,
1040
1104
  "mcp": cmd_mcp,
1105
+ "doctor": cmd_doctor,
1041
1106
  }
1042
1107
 
1043
1108
  # Handle unified task subcommand
@@ -4,6 +4,7 @@ import re
4
4
  import subprocess
5
5
  import sys
6
6
  from datetime import datetime
7
+ from pathlib import Path
7
8
 
8
9
  from .config import ExecutorConfig
9
10
  from .logging import get_logger
@@ -23,6 +24,32 @@ from .task import (
23
24
  logger = get_logger("cli")
24
25
 
25
26
 
27
+ def resolve_plan_description(description: str | None, from_file: str | None) -> str:
28
+ """Resolve the plan description from --from-file (preferred) or the positional
29
+ argument. Exits with an error if neither is usable.
30
+
31
+ Args:
32
+ description: the positional description (may be None when --from-file is used).
33
+ from_file: path to a file whose contents are the description.
34
+ """
35
+ if from_file:
36
+ path = Path(from_file)
37
+ if not path.is_file():
38
+ raise SystemExit(f"plan --from-file: not a readable file: {from_file}")
39
+ try:
40
+ text = path.read_text(encoding="utf-8").strip()
41
+ except UnicodeDecodeError as e:
42
+ raise SystemExit(f"plan --from-file: not valid UTF-8 text: {from_file}") from e
43
+ except OSError as e:
44
+ raise SystemExit(f"plan --from-file: cannot read {from_file}: {e}") from e
45
+ if not text:
46
+ raise SystemExit(f"plan --from-file: file is empty: {from_file}")
47
+ return text
48
+ if description and description.strip():
49
+ return description
50
+ raise SystemExit("plan: provide a description argument or --from-file PATH")
51
+
52
+
26
53
  def cmd_plan(args, config: ExecutorConfig):
27
54
  """Interactive task planning via Claude.
28
55
 
@@ -30,7 +57,7 @@ def cmd_plan(args, config: ExecutorConfig):
30
57
  requirements, design, and tasks files from a description.
31
58
  """
32
59
 
33
- description = args.description
60
+ description = resolve_plan_description(args.description, getattr(args, "from_file", None))
34
61
 
35
62
  if getattr(args, "full", False):
36
63
  from .prompt import build_generation_prompt, parse_spec_marker
@@ -153,6 +153,7 @@ class ExecutorConfig:
153
153
  create_git_branch: bool = True # Create branch on start
154
154
  auto_commit: bool = True # Auto-commit on success
155
155
  main_branch: str = "" # Main branch name (empty = auto-detect: main/master)
156
+ sync_deps: bool = True # Run `uv sync` in pre_start_hook (doctor disables this)
156
157
 
157
158
  # Code review
158
159
  run_review: bool = True # Run code review after task completion
@@ -377,6 +378,7 @@ def load_config_from_yaml(config_path: Path | None = None) -> dict:
377
378
  "claude_model": executor_config.get("claude_model"),
378
379
  "skip_permissions": executor_config.get("skip_permissions"),
379
380
  "create_git_branch": pre_start.get("create_git_branch"),
381
+ "sync_deps": pre_start.get("sync_deps"),
380
382
  "main_branch": executor_config.get("main_branch"),
381
383
  "run_tests_on_done": post_done.get("run_tests"),
382
384
  "run_lint_on_done": post_done.get("run_lint"),