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.
- {spec_runner-2.3.1/src/spec_runner.egg-info → spec_runner-2.4.1}/PKG-INFO +30 -3
- {spec_runner-2.3.1 → spec_runner-2.4.1}/README.md +29 -2
- {spec_runner-2.3.1 → spec_runner-2.4.1}/pyproject.toml +1 -1
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/cli.py +120 -55
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/cli_plan.py +28 -1
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/config.py +2 -0
- spec_runner-2.4.1/src/spec_runner/doctor.py +419 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/errors.py +1 -3
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/execution.py +26 -15
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/hooks.py +40 -14
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/review.py +37 -29
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/runner.py +142 -17
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/state.py +12 -16
- {spec_runner-2.3.1 → spec_runner-2.4.1/src/spec_runner.egg-info}/PKG-INFO +30 -3
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner.egg-info/SOURCES.txt +2 -0
- spec_runner-2.4.1/tests/test_doctor.py +638 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_errors.py +1 -4
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_execution.py +185 -55
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_hooks.py +99 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_plan_full.py +61 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_runner.py +156 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_stages.py +9 -2
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_state.py +25 -2
- {spec_runner-2.3.1 → spec_runner-2.4.1}/LICENSE +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/setup.cfg +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/__init__.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/audit.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/audit_log.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/cli_info.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/events.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/executor.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/git_ops.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/github_sync.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/init_cmd.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/logging.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/mcp_server.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/notifications.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/obs.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/plugins.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/prompt.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/py.typed +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/report.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/SKILL.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/Makefile.template +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/design.template.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/executor.config.yaml +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/executor.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-design.template.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-requirements.template.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/phase-tasks.template.md +0 -0
- {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
- {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
- {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
- {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
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.claude.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.codex.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.llama.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.ollama.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.opencode.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/prompts/review.pi.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/requirements.template.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/task.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/tasks.template.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/skills/spec-generator-skill/templates/workflow.template.md +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/stages.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/task.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/task_commands.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/tui.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/validate.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner/verify.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner.egg-info/dependency_links.txt +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner.egg-info/entry_points.txt +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner.egg-info/requires.txt +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/src/spec_runner.egg-info/top_level.txt +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_audit.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_audit_log.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_cli_flags.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_cli_info.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_cli_run_reset.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_config.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_costs.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_e2e.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_events.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_gh_sync.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_json_result_contract.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_logging.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_mcp.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_notifications.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_obs.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_obs_contract.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_plugins.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_prompt.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_report.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_spec_prefix.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_subdir_detection.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_task.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_task_diff.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_tui.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_validate.py +0 -0
- {spec_runner-2.3.1 → spec_runner-2.4.1}/tests/test_verify.py +0 -0
- {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
|
+
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} -
|
|
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} -
|
|
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
|
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
163
|
+
from .logging import setup_logging
|
|
164
|
+
from .tui import SpecRunnerApp
|
|
139
165
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
app.run()
|
|
146
|
-
return
|
|
171
|
+
app = SpecRunnerApp(config=config)
|
|
147
172
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
177
|
+
t.start()
|
|
173
178
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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"),
|