diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..3f96521 --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,49 @@ +name: Smoke + +on: + pull_request: + +jobs: + smoke: + runs-on: ubuntu-latest + env: + NIGHTSHIFT_DATA_DIR: ${{ runner.temp }}/nightshift-data + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e '.[dev]' + + - name: Stub OpenCode models command + run: | + mkdir -p "${RUNNER_TEMP}/bin" + cat > "${RUNNER_TEMP}/bin/opencode" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + if [[ "${1:-}" == "models" ]]; then + printf "openai/gpt-5.2\n" + printf "google/antigravity-gemini-3-pro-high\n" + exit 0 + fi + echo "opencode stub only supports: opencode models" >&2 + exit 1 + EOF + chmod +x "${RUNNER_TEMP}/bin/opencode" + echo "${RUNNER_TEMP}/bin" >> "${GITHUB_PATH}" + + - name: Initialize config + run: nightshift init --force --no-add-current-project + + - name: Run doctor + run: nightshift doctor + + - name: Run dry-run smoke + run: nightshift start . --duration 0.1 --priority-mode quick_scan --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..86b2f8a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [0.2.0] - 2026-02-17 + +### Added +- `nightshift start --dry-run` to validate configuration and preview task plans without executing OpenCode agents. +- CI smoke workflow for pull requests: + - `nightshift init --force --no-add-current-project` + - `nightshift doctor` + - `nightshift start . --duration 0.1 --priority-mode quick_scan --dry-run` +- Onboarding visuals and a guided "First 10 Minutes" section in the README. + +### Changed +- Improved onboarding documentation to use portable project references and explicit setup validation steps. + +## [0.1.0] - 2026-01-06 + +### Added +- Initial Nightshift release with OpenCode-integrated autonomous codebase research, dashboard, and plugin. diff --git a/README.md b/README.md index 49dc878..d3b19d6 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,43 @@ bun install To load the plugin in OpenCode, point your plugin configuration to the `plugin/index.ts` file or the compiled output. +## First 10 Minutes + +Use this path if you want to validate your setup quickly before running a full overnight session. + +1. **Create starter config** (in your local Nightshift data directory): + ```bash + nightshift init --no-add-current-project + ``` + +2. **Verify your environment**: + ```bash + nightshift doctor + ``` + +3. **Preview tasks without executing agents**: + ```bash + nightshift start . --duration 0.5 --priority-mode quick_scan --dry-run + ``` + +4. **Start dashboard**: + ```bash + nightshift serve + ``` + Open: http://127.0.0.1:7890/ + +5. **Run a small real pass**: + ```bash + nightshift start . --duration 1 --priority-mode quick_scan + nightshift report + ``` + +Replace `.` with a project alias from `config.toml` if you prefer named targets. + +![Nightshift dashboard after a starter run](docs/assets/quickstart-dashboard.png) + +![Nightshift quickstart run flow](docs/assets/quickstart-run.gif) + ## Quick Start 1. **Initialize local config**: @@ -71,6 +108,7 @@ To load the plugin in OpenCode, point your plugin configuration to the `plugin/i The `nightshift` command provides several subcommands: - `start [PROJECTS]...`: Start a research run on specified projects or paths. +- `start [PROJECTS]... --dry-run`: Validate config and preview the generated task plan without running agents. - `init`: Create a starter `config.toml` in your Nightshift data directory. - `doctor`: Validate OpenCode/GitHub/config/dependency setup and show fix hints. - `serve`: Start the HTTP API server (default port: 7890). diff --git a/docs/assets/quickstart-dashboard.png b/docs/assets/quickstart-dashboard.png new file mode 100644 index 0000000..fd424b8 Binary files /dev/null and b/docs/assets/quickstart-dashboard.png differ diff --git a/docs/assets/quickstart-run.gif b/docs/assets/quickstart-run.gif new file mode 100644 index 0000000..9bbb55e Binary files /dev/null and b/docs/assets/quickstart-run.gif differ diff --git a/plugin/package.json b/plugin/package.json index d695d64..7091c5a 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -1,6 +1,6 @@ { "name": "opencode-nightshift", - "version": "0.1.0", + "version": "0.2.0", "description": "Overnight autonomous research agent for OpenCode - analyzes codebases while you sleep", "type": "module", "main": "index.ts", diff --git a/pyproject.toml b/pyproject.toml index 395ef5c..3c2933a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nightshift" -version = "0.1.0" +version = "0.2.0" description = "Overnight autonomous research agent for OpenCode" requires-python = ">=3.11" dependencies = [ diff --git a/src/__init__.py b/src/__init__.py index 46bf543..ce59487 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -5,4 +5,4 @@ utilizing token allowances that would otherwise go to waste. """ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/cli.py b/src/cli.py index d794f5f..e13d7f2 100644 --- a/src/cli.py +++ b/src/cli.py @@ -36,17 +36,39 @@ def start( "--priority-mode", "-m", help="Task prioritization mode (defaults to config file value if set)", ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Validate config and show planned tasks without running OpenCode agents", + ), ): """Start a nightshift research run.""" - from .runner import run_nightshift + from .runner import run_nightshift, run_nightshift_dry console.print(f"[bold green]Starting Nightshift[/bold green]") console.print(f"Projects: {', '.join(projects)}") console.print(f"Max duration: {duration if duration is not None else 'config default'}") console.print(f"Priority mode: {priority_mode if priority_mode is not None else 'config default'}") + if dry_run: + console.print("Mode: [yellow]dry run[/yellow]") console.print() try: + if dry_run: + summary = run_nightshift_dry(projects, duration, priority_mode=priority_mode) + table = Table(title="Nightshift Dry Run") + table.add_column("Field", style="cyan") + table.add_column("Value") + table.add_row("Projects", ", ".join(summary.projects)) + table.add_row("Planned tasks", str(summary.total_tasks)) + table.add_row("Task types", ", ".join(summary.task_types)) + table.add_row("Duration (hours)", str(summary.duration_hours)) + table.add_row("Priority mode", summary.priority_mode) + table.add_row("Model chain", ", ".join(summary.model_chain) if summary.model_chain else "None discovered") + console.print(table) + console.print("[green]Dry run complete.[/green] No tasks were executed.") + return + report = run_nightshift(projects, duration, priority_mode=priority_mode) console.print(f"\n[bold green]Nightshift completed![/bold green]") console.print(f"Tasks completed: {report.completed_tasks}") diff --git a/src/report_generator.py b/src/report_generator.py index 8704292..cd05c44 100644 --- a/src/report_generator.py +++ b/src/report_generator.py @@ -6,6 +6,7 @@ import os from .models import NightshiftReport, Finding, FindingSeverity, ProjectReport +from . import __version__ REPORT_TEMPLATE = """ @@ -384,7 +385,7 @@ {% endif %} @@ -423,6 +424,7 @@ def generate( high_count=high_count, executive_summary=executive_summary, failed_tasks=failed_tasks or [], + nightshift_version=__version__, ) filename = f"nightshift_{report.started_at.strftime('%Y%m%d_%H%M%S')}.html" diff --git a/src/runner.py b/src/runner.py index 97fbace..edd3d82 100644 --- a/src/runner.py +++ b/src/runner.py @@ -188,6 +188,27 @@ def setup_tasks(self): prioritized_tasks = prioritizer.prioritize_tasks(tasks) self.task_queue.update_task_priorities(prioritized_tasks) + def preview(self) -> "NightshiftDryRun": + if not self.run_id: + self.setup_tasks() + + tasks = self.task_queue.get_tasks_for_run(self.run_id) + task_types = sorted({task.task_type.value for task in tasks}) + model_chain = [f"{model.provider}/{model.model_id}" for model in self.model_manager.models] + + summary = NightshiftDryRun( + projects=[project.name for project in self.config.projects], + total_tasks=len(tasks), + task_types=task_types, + model_chain=model_chain, + duration_hours=self.config.max_duration_hours, + priority_mode=self.config.priority_mode, + ) + + self.task_queue.delete_run_data(self.run_id) + self.run_id = "" + return summary + def run(self) -> NightshiftReport: self.start_time = time.time() if not self.run_id: @@ -371,6 +392,16 @@ class RateLimitError(Exception): pass +@dataclass +class NightshiftDryRun: + projects: list[str] + total_tasks: int + task_types: list[str] + model_chain: list[str] + duration_hours: float + priority_mode: str + + def run_nightshift( projects: list[str], duration_hours: Optional[float] = None, @@ -381,3 +412,18 @@ def run_nightshift( config = get_config(projects, duration_hours, priority_mode=priority_mode) runner = NightshiftRunner(config) return runner.run() + + +def run_nightshift_dry( + projects: list[str], + duration_hours: Optional[float] = None, + priority_mode: Optional[str] = None, +) -> NightshiftDryRun: + from .config import get_config + + config = get_config(projects, duration_hours, priority_mode=priority_mode) + runner = NightshiftRunner(config) + try: + return runner.preview() + finally: + runner.task_queue.close() diff --git a/src/server.py b/src/server.py index 1f3f455..1f6fd1d 100644 --- a/src/server.py +++ b/src/server.py @@ -14,6 +14,7 @@ from .github_issues import GitHubIssueCreator from .notifications import get_notification_manager from .scheduler import ScheduleManager +from . import __version__ DASHBOARD_HTML = Path(__file__).parent / "dashboard.html" @@ -22,7 +23,7 @@ app = FastAPI( title="Nightshift API", description="Overnight autonomous research agent", - version="0.1.0" + version=__version__ ) @@ -258,7 +259,7 @@ async def get_model_status(): @app.get("/health") async def health(): - return {"status": "healthy", "version": "0.1.0"} + return {"status": "healthy", "version": __version__} @app.get("/schedules") diff --git a/src/task_queue.py b/src/task_queue.py index 5d4f6e8..7f930cf 100644 --- a/src/task_queue.py +++ b/src/task_queue.py @@ -202,6 +202,17 @@ def get_next_pending_task(self, run_id: Optional[str] = None) -> Optional[Resear return self._row_to_task(row) return None + def get_tasks_for_run(self, run_id: str) -> list[ResearchTask]: + rows = self._conn.execute( + """ + SELECT * FROM tasks + WHERE run_id = ? + ORDER BY priority ASC + """, + (run_id,), + ).fetchall() + return [self._row_to_task(row) for row in rows] + def get_pending_count(self, run_id: Optional[str] = None) -> int: active_run_id = self._resolve_run_id(run_id) if active_run_id: @@ -434,6 +445,23 @@ def get_failed_tasks(self, run_id: Optional[str] = None, limit: int = 50) -> lis for row in rows ] + def delete_run_data(self, run_id: str): + self._conn.execute( + """ + DELETE FROM findings + WHERE task_id IN ( + SELECT id FROM tasks WHERE run_id = ? + ) + """, + (run_id,), + ) + self._conn.execute("DELETE FROM tasks WHERE run_id = ?", (run_id,)) + self._conn.execute("DELETE FROM runs WHERE id = ?", (run_id,)) + self._conn.commit() + + if self.current_run_id == run_id: + self.current_run_id = None + def close(self): if self._conn: self._conn.close() diff --git a/tests/test_nightshift_core.py b/tests/test_nightshift_core.py index 5b64c2b..26d513a 100644 --- a/tests/test_nightshift_core.py +++ b/tests/test_nightshift_core.py @@ -17,6 +17,7 @@ from src.model_manager import ModelConfig from src.models import Finding, FindingSeverity, TaskType from src.runner import NightshiftRunner +from src.runner import run_nightshift_dry from src.task_queue import TaskQueue import src.model_manager as model_manager @@ -244,3 +245,27 @@ def fake_run(*args, **kwargs): keys = [f"{m.provider}/{m.model_id}" for m in manager.models] assert keys assert keys[0] == "openai/gpt-5.2" + + +def test_dry_run_creates_plan_without_persisting_tasks(tmp_path, monkeypatch): + data_dir = tmp_path / "nightshift-data" + project_path = tmp_path / "project" + project_path.mkdir() + + monkeypatch.setenv("NIGHTSHIFT_DATA_DIR", str(data_dir)) + + summary = run_nightshift_dry( + [str(project_path)], + duration_hours=0.5, + priority_mode="quick_scan", + ) + + assert summary.projects == ["project"] + assert summary.total_tasks == 11 + assert "security_review" in summary.task_types + assert summary.priority_mode == "quick_scan" + + queue = TaskQueue(NightshiftConfig(projects=[])) + assert queue.get_latest_run_id() is None + assert queue.get_pending_count() == 0 + queue.close()