Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/smoke.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down Expand Up @@ -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).
Expand Down
Binary file added docs/assets/quickstart-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/quickstart-run.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion plugin/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
utilizing token allowances that would otherwise go to waste.
"""

__version__ = "0.1.0"
__version__ = "0.2.0"
24 changes: 23 additions & 1 deletion src/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
4 changes: 3 additions & 1 deletion src/report_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os

from .models import NightshiftReport, Finding, FindingSeverity, ProjectReport
from . import __version__


REPORT_TEMPLATE = """
Expand Down Expand Up @@ -384,7 +385,7 @@
{% endif %}

<footer>
<p>Generated by Nightshift v0.1.0</p>
<p>Generated by Nightshift v{{ nightshift_version }}</p>
<p>Models used: {{ report.models_used|join(', ') }}</p>
</footer>
</div>
Expand Down Expand Up @@ -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"
Expand Down
46 changes: 46 additions & 0 deletions src/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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()
5 changes: 3 additions & 2 deletions src/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -22,7 +23,7 @@
app = FastAPI(
title="Nightshift API",
description="Overnight autonomous research agent",
version="0.1.0"
version=__version__
)


Expand Down Expand Up @@ -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")
Expand Down
28 changes: 28 additions & 0 deletions src/task_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions tests/test_nightshift_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()