diff --git a/README.md b/README.md index 69df58ee..dd9ace65 100644 --- a/README.md +++ b/README.md @@ -250,9 +250,11 @@ nerve (single Python process) ## Configuration -Two config files: -- `config.yaml` — Template settings (committed) -- `config.local.yaml` — Secrets and overrides (gitignored) +Three layers, lowest precedence first: +- `/config/settings.yaml` — shareable settings, git-tracked with the + workspace (this is the layer you review in a PR and sync between machines) +- `config.yaml` — machine-local settings (gitignored) +- `config.local.yaml` — secrets and personal overrides (gitignored) See [docs/config.md](docs/config.md) for all options. diff --git a/config.example.yaml b/config.example.yaml index 51020d81..75980c75 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -122,13 +122,16 @@ sync: # Memory (memU) memory: - chat_model: claude-sonnet-4-6 + recall_model: claude-sonnet-4-6 # embed_model: text-embedding-3-small # Only needed with openai_api_key -# Cron -cron: - system_file: ~/.nerve/cron/system.yaml # Managed by 'nerve init' — system crons - jobs_file: ~/.nerve/cron/jobs.yaml # Your custom crons — never touched by Nerve +# Cron — no keys needed. Cron config lives in /config/cron/ +# (system.yaml, jobs.yaml, gates/) and is resolved from the workspace, with a +# fallback to the legacy ~/.nerve/cron for un-migrated installs. +# +# Do NOT pin system_file/jobs_file here unless you really mean it: an explicit +# value wins outright, so it disables both the workspace location and the +# fallback, and `nerve init` will keep regenerating a system.yaml nothing reads. # Workflow runs — budget-capped multi-agent jobs (Claude Workflow tool or # Codex Ultracode) in dedicated tracked sessions. Nerve meters real dollar diff --git a/docs/codex-sync.md b/docs/codex-sync.md index 810061f4..04007df5 100644 --- a/docs/codex-sync.md +++ b/docs/codex-sync.md @@ -31,7 +31,7 @@ client metadata. ## Enabling ```yaml -# config.yaml +# /config/settings.yaml sync: codex: enabled: true diff --git a/docs/config.md b/docs/config.md index 5dca3d59..a43711e9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -494,8 +494,9 @@ Manual commands (run regardless of `enabled`): | Key | Type | Default | Description | |-----|------|---------|-------------| -| `cron.system_file` | path | `~/.nerve/cron/system.yaml` | System cron jobs (managed by `nerve init`) | -| `cron.jobs_file` | path | `~/.nerve/cron/jobs.yaml` | User-defined custom cron jobs | +| `cron.system_file` | path | `/config/cron/system.yaml` (falls back to `~/.nerve/cron/system.yaml` for un-migrated installs) | System cron jobs (managed by `nerve init`) | +| `cron.jobs_file` | path | `/config/cron/jobs.yaml` (falls back to `~/.nerve/cron/jobs.yaml`) | User-defined custom cron jobs | +| `cron.gate_plugins_dir` | path | `/config/cron/gates` (falls back to `~/.nerve/cron/gates`) | Drop-in custom gate plugin directory | ## Workflow Runs diff --git a/docs/cron.md b/docs/cron.md index c5784264..0872889c 100644 --- a/docs/cron.md +++ b/docs/cron.md @@ -6,13 +6,20 @@ Nerve uses APScheduler for in-process async job scheduling. Jobs can run in isol ## Two-File Layout -Cron jobs live in two YAML files under `~/.nerve/cron/`: +Cron jobs live in two YAML files under `/config/cron/` (the +git-syncable workspace subtree): | File | Purpose | Managed by | |------|---------|------------| | `system.yaml` | Built-in crons (core + productivity) | `nerve init` — safe to regenerate | | `jobs.yaml` | Your custom crons | You — Nerve never touches this file | +> **Location & migration.** New installs write cron config to +> `/config/cron/`. Installs that still have the legacy +> `~/.nerve/cron/` keep working: if the workspace location doesn't exist yet, +> Nerve reads from `~/.nerve/cron/` automatically. You can also pin the paths +> explicitly with `cron.jobs_file` / `cron.system_file` / `cron.gate_plugins_dir`. + Both files use the same format. On startup, CronService loads and merges both: - If a job ID appears in both files, the **user version wins** (with a warning in the log). - Old installs with everything in `jobs.yaml` still work — if `system.yaml` doesn't exist, all jobs load from `jobs.yaml`. @@ -22,7 +29,7 @@ Running `nerve init` on an existing install regenerates `system.yaml` (e.g., to ## Job Definition ```yaml -# ~/.nerve/cron/jobs.yaml (or system.yaml — same format) +# /config/cron/jobs.yaml (or system.yaml — same format) jobs: - id: morning-briefing schedule: "30 11 * * *" # 11:30 AM daily @@ -65,7 +72,7 @@ prompt definition. - Relative paths resolve against the directory of the YAML file the job was loaded from (e.g. `prompts/repo-watch.md` next to `jobs.yaml` → - `~/.nerve/cron/prompts/repo-watch.md`). Absolute paths and `~` work too. + `/config/cron/prompts/repo-watch.md`). Absolute paths and `~` work too. - The file is read fresh on **every run** — edits take effect on the next trigger without a restart. - If both `prompt` and `prompt_file` are set, the file wins; the inline @@ -183,7 +190,7 @@ immediately. This is the right path for gates that ship with Nerve. ### Custom gate plugins (drop-in) To add your **own** gate without editing core source, drop a `.py` file into -the gate-plugins directory — `~/.nerve/cron/gates/` by default (overridable via +the gate-plugins directory — `/config/cron/gates/` by default (overridable via the `cron.gate_plugins_dir` config key). On daemon startup Nerve imports each file and registers every `CronGate` subclass it defines with a non-empty `type`. After that, `run_if` can reference your gate by `type` exactly like a @@ -191,7 +198,7 @@ built-in. Because this never touches `nerve/cron/gates.py`, your custom gates don't conflict when you pull Nerve upstream. ```python -# ~/.nerve/cron/gates/stale_tasks.py +# /config/cron/gates/stale_tasks.py from nerve.cron.gates import CronGate, GateContext @@ -214,7 +221,7 @@ class StaleTasksGate(CronGate): ``` ```yaml -# ~/.nerve/cron/jobs.yaml — reference it like any built-in gate +# /config/cron/jobs.yaml — reference it like any built-in gate run_if: - type: stale_tasks min_age_minutes: 60 @@ -302,13 +309,16 @@ nerve cron morning-briefing # Check cron status nerve doctor -# [OK] System crons: ~/.nerve/cron/system.yaml (3/5 enabled) -# [OK] User crons: ~/.nerve/cron/jobs.yaml (1 jobs) +# [OK] Cron jobs: 3/5 enabled, 1 overridden in jobs.yaml +# +# system.yaml and jobs.yaml are merged first (a user job overrides a system +# job with the same id), so the counts are for the merged set. If either file +# can't be parsed, doctor falls back to just listing the paths it found. ``` ## Built-in System Crons -These ship in `~/.nerve/cron/system.yaml` and are managed by `nerve init`. Running `nerve init` regenerates this file (e.g., to pick up updated prompts from a Nerve update) without touching your custom `jobs.yaml`. +These ship in `/config/cron/system.yaml` and are managed by `nerve init`. Running `nerve init` regenerates this file (e.g., to pick up updated prompts from a Nerve update) without touching your custom `jobs.yaml`. | Job | Schedule | Session Mode | Description | Personal | Worker | |-----|----------|-------------|-------------|:--------:|:------:| diff --git a/docs/memory.md b/docs/memory.md index 14c02cbf..75c28fc5 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -45,7 +45,9 @@ memU provides semantic search over conversations and workspace files. It runs em ### Categories -Memory items are organized into categories defined in `config.yaml`. Categories are seeded on startup — only missing ones are created, so adding new entries is safe. +Memory items are organized into categories defined in `/config/settings.yaml` +(the shareable layer — `config.yaml` can still override them per machine). +Categories are seeded on startup — only missing ones are created, so adding new entries is safe. ```yaml memory: @@ -85,15 +87,21 @@ memU uses two or three LLM profiles depending on configuration: When no OpenAI key is configured, memU uses **LLM-based recall** instead of vector search — the Chat profile ranks memories directly, requiring no embeddings. This uses more Anthropic API tokens per recall but removes the OpenAI dependency entirely. -Config in `config.yaml`: +Config in `/config/settings.yaml`: ```yaml memory: - chat_model: claude-sonnet-4-6 # recall routing - fast_model: claude-haiku-4-5-20251001 # extraction & categorization - # embed_model: text-embedding-3-small # only needed with openai_api_key + recall_model: claude-sonnet-4-6 # recall routing + memorize_model: claude-sonnet-4-6 # writing memories back + fast_model: claude-haiku-4-5-20251001 # extraction & categorization + # embed_model: text-embedding-3-small # only needed with openai_api_key categories: [...] # see Categories section above ``` +> On a **Bedrock** install the three model keys are machine-local instead: +> `nerve init` writes region-scoped inference-profile IDs into `config.yaml`, +> which shadows `settings.yaml`. The prefix must match the configured region +> or every call 400s, so they can't be shared. `categories` stays portable. + ### Event Date Resolution After a conversation is indexed, Nerve resolves temporal context for extracted event items. Event items get their `happened_at` field set via an LLM call (using `fast_model` / Haiku) that parses dates from the content. Non-event items (profiles, knowledge, behavior) stay timeless. diff --git a/docs/migration.md b/docs/migration.md index d2dc50bb..5639438b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -33,10 +33,18 @@ cp -r $OPENCLAW_WS/memory/* $NERVE_WS/memory/ Convert OpenClaw's `~/.openclaw/cron/jobs.json` to Nerve's YAML format: ```bash -mkdir -p ~/.nerve/cron +mkdir -p $NERVE_WS/config/cron ``` -Create `~/.nerve/cron/jobs.yaml` from your existing jobs. The format changes from: +Create `$NERVE_WS/config/cron/jobs.yaml` from your existing jobs — cron config +lives in the workspace so it can be reviewed and synced like the rest of it. + +> Nerve still reads the legacy `~/.nerve/cron/` when the workspace location has +> no job files, so an older guide that put them there is not broken. But once +> you (or `nerve init`) create `$NERVE_WS/config/cron/jobs.yaml`, the fallback +> stops firing — don't split jobs across both. + +The format changes from: ```json { diff --git a/docs/plans.md b/docs/plans.md index cd003a45..46497ded 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -104,7 +104,7 @@ User reviews (via /plans UI or chat tools) ## Cron Job -Defined in `~/.nerve/cron/jobs.yaml` as `task-planner`: +Defined in `/config/cron/jobs.yaml` as `task-planner`: - **Schedule:** Every 4 hours (`0 */4 * * *`) - **Session mode:** Persistent (keeps context for revisions) diff --git a/docs/setup.md b/docs/setup.md index 8d766794..f4abbf11 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -104,11 +104,12 @@ You can re-run `nerve init` at any time — it's safe on existing installations. **What gets overwritten:** - `config.yaml` and `config.local.yaml` — regenerated from your choices -- `~/.nerve/cron/system.yaml` — regenerated (picks up new built-in cron prompts from Nerve updates) +- `/config/cron/system.yaml` — regenerated (picks up new built-in cron prompts from Nerve updates) **What's preserved:** - All workspace files (`SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md`, skills, tasks, etc.) -- `~/.nerve/cron/jobs.yaml` — your custom crons are never touched +- `/config/cron/jobs.yaml` — your custom crons are never touched +- `/config/settings.yaml` — only the keys `nerve init` generates are rewritten; anything else you added stays - `~/.nerve/nerve.db` and `~/.nerve/memu.sqlite` — databases are preserved When you run `nerve init` on an existing install, it prompts: *"Nerve is already configured. Re-run setup?"* The `--if-needed` flag skips setup entirely if already configured (useful in Docker entrypoints). diff --git a/docs/sources.md b/docs/sources.md index f24a0749..11ae2f9d 100644 --- a/docs/sources.md +++ b/docs/sources.md @@ -420,7 +420,7 @@ if ms.enabled: )) ``` -4. **Add config** to `config.yaml`: +4. **Add config** to `/config/settings.yaml`: ```yaml sync: mysource: diff --git a/docs/worker-guide.md b/docs/worker-guide.md index 56b99573..45d218ed 100644 --- a/docs/worker-guide.md +++ b/docs/worker-guide.md @@ -39,7 +39,7 @@ On first boot, Nerve detects that `TASK.md` contains a raw description (no `## M - `## Approval` — what needs human approval vs autonomous action - `## References` — links to docs, APIs, tools 3. **Create skills** — writes domain-specific procedures as reusable skills (`skill_create`) -4. **Configure cron jobs** — adds monitoring and task-specific crons to `~/.nerve/cron/jobs.yaml` +4. **Configure cron jobs** — adds monitoring and task-specific crons to `/config/cron/jobs.yaml` 5. **Create initial tasks** — files setup tasks that need manual work (credentials, access tokens, etc.) 6. **Notify** — sends a notification that onboarding is complete with a summary of what was configured @@ -165,8 +165,7 @@ Run `nerve doctor` to verify the worker is healthy: nerve doctor # [OK] Config loaded # [OK] Database schema: v14 -# [OK] System crons: ~/.nerve/cron/system.yaml (3/4 enabled) -# [OK] User crons: ~/.nerve/cron/jobs.yaml (2 jobs) +# [OK] Cron jobs: 3/4 enabled, 1 overridden in jobs.yaml # [OK] Proxy: running on port 8317 ``` @@ -185,7 +184,7 @@ Workers communicate through the notification system: - **`notify`** — status updates, completion alerts, issues found - **`ask_user`** — questions that need decisions (rendered as buttons in web UI and Telegram) -Priority levels: `urgent`, `high`, `normal`, `low`. Configure quiet hours in `config.yaml` to avoid late-night pings. +Priority levels: `urgent`, `high`, `normal`, `low`. Configure quiet hours in `/config/settings.yaml` to avoid late-night pings. ### Logs diff --git a/docs/workflow-runs.md b/docs/workflow-runs.md index 0f50a3b8..0462d33c 100644 --- a/docs/workflow-runs.md +++ b/docs/workflow-runs.md @@ -205,7 +205,7 @@ A cron job may declare a `workflow` block **instead of** `prompt` — each trigger starts a workflow run: ```yaml -# ~/.nerve/cron/jobs.yaml +# /config/cron/jobs.yaml jobs: - id: nightly-sample-audit schedule: "0 3 * * *" diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 576e1318..010079f0 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -560,7 +560,8 @@ async def _run_worker_onboarding(self) -> None: "Each skill should have clear step-by-step instructions for a procedure\n" "(e.g., 'how to query the monitoring API', 'how to debug a deployment failure').\n\n" "## Step 4: Configure Cron Jobs\n\n" - "Set up monitoring cron jobs by editing `~/.nerve/cron/jobs.yaml`.\n" + "Set up monitoring cron jobs by editing `/config/cron/jobs.yaml`\n" + "(legacy installs may still use `~/.nerve/cron/jobs.yaml`).\n" "This is the Nerve cron system — NOT the Anthropic SDK or system crontab.\n\n" "The YAML format is:\n" "```yaml\n" diff --git a/nerve/bootstrap.py b/nerve/bootstrap.py index dee5cf50..5ac587b5 100644 --- a/nerve/bootstrap.py +++ b/nerve/bootstrap.py @@ -1705,11 +1705,11 @@ def _apply(self) -> None: self._write_config_local_yaml() click.secho(" ✓", fg="green") - # 9. Create the machine-local state directory + # 9. Create the machine-local state directory. Cron config no longer + # lives here — it's in workspace/config/cron. click.echo(f" Setting up {paths.home_label()}/...", nl=False) nerve_dir = paths.nerve_home() nerve_dir.mkdir(parents=True, exist_ok=True) - (nerve_dir / "cron").mkdir(parents=True, exist_ok=True) click.secho(" ✓", fg="green") # 10. Write cron jobs @@ -2060,9 +2060,9 @@ def _build_config_layers( else _WORKER_MEMORY_CATEGORIES ), }, - # No "cron" block: CronConfig already defaults to paths.cron_dir(). - # Pinning literal ~/.nerve paths here overrode NERVE_HOME and sent - # the daemon looking somewhere `nerve init` had not written. + # Cron config lives in workspace/config/cron (git-syncable); the + # loader resolves it from the workspace automatically, so no explicit + # cron paths are written here. "sessions": { "sticky_period_minutes": 120, "archive_after_days": 30, @@ -2358,13 +2358,18 @@ def _write_cron_jobs(self) -> None: job["run_if"] = cron["run_if"] jobs.append(job) - # Write system crons (managed by nerve init, safe to regenerate). - # Must go through paths.cron_dir() -- a literal ~/.nerve here ignores - # NERVE_HOME, so a relocated instance writes its jobs into the default - # state dir while the daemon reads them from the relocated one. - cron_dir = paths.cron_dir() + # Cron config lives in the git-syncable workspace/config/cron subtree. + # Via _workspace_dir for the reason spelled out there: expanding only + # `~` here sent a `workspace: ${VAR}` install's jobs to a literal + # "./${VAR}/config/cron" beside the process CWD, where nothing loads + # them, while settings.yaml landed correctly and the wizard reported + # success. + cron_dir = self._workspace_dir() / "config" / "cron" + cron_dir.mkdir(parents=True, exist_ok=True) + (cron_dir / "gates").mkdir(parents=True, exist_ok=True) + + # Write system crons (managed by nerve init, safe to regenerate) system_file = cron_dir / "system.yaml" - system_file.parent.mkdir(parents=True, exist_ok=True) with open(system_file, "w", encoding="utf-8") as f: f.write("# Nerve — System Cron Jobs\n") @@ -2372,14 +2377,24 @@ def _write_cron_jobs(self) -> None: f.write("# To add custom crons, use jobs.yaml instead.\n\n") yaml.safe_dump({"jobs": jobs}, f, default_flow_style=False, sort_keys=False) - # Create empty jobs.yaml scaffold if it doesn't exist + # Create jobs.yaml scaffold if it doesn't exist. If this is an upgrade + # from a legacy install, preserve the user's existing custom crons by + # copying the legacy jobs.yaml rather than writing a blank placeholder + # (a blank one here would shadow the legacy jobs — see _resolve_cron_dir). jobs_file = cron_dir / "jobs.yaml" if not jobs_file.exists(): - with open(jobs_file, "w", encoding="utf-8") as f: - f.write("# Nerve — Custom Cron Jobs\n") - f.write("# Add your own cron jobs here. Nerve will never overwrite this file.\n") - f.write("# Format is the same as system.yaml — see it for examples.\n\n") - f.write("jobs: []\n") + legacy_jobs = paths.cron_dir() / "jobs.yaml" + if legacy_jobs.exists(): + shutil.copy2(legacy_jobs, jobs_file) + click.echo( + f"\n Migrated custom crons from {legacy_jobs} to {jobs_file}", + ) + else: + with open(jobs_file, "w", encoding="utf-8") as f: + f.write("# Nerve — Custom Cron Jobs\n") + f.write("# Add your own cron jobs here. Nerve will never overwrite this file.\n") + f.write("# Format is the same as system.yaml — see it for examples.\n\n") + f.write("jobs: []\n") # --- Preflight --- @@ -2894,8 +2909,9 @@ def _build_docker_compose( | Path | Contents | Writable | Notes | |------|----------|----------|-------| | `/nerve` | Nerve source code **and config** | ✓ (bind mount) | `pyproject.toml`, `nerve/` package, `web/` — the full repo. The config directory resolves to the working directory, so `config.yaml` and `config.local.yaml` live here. | -| `{_DOCKER_NERVE_HOME}` | Machine-local state (`$NERVE_HOME`) | ✓ (bind mount) | Databases, logs, PID, caches, `cron/` jobs. Never synced. | -| `{_DOCKER_WORKSPACE}` | Your workspace (`$NERVE_WORKSPACE`) | ✓ (bind mount) | AGENTS.md, SOUL.md, MEMORY.md, skills — where you live. | +| `{_DOCKER_NERVE_HOME}` | Machine-local state (`$NERVE_HOME`) | ✓ (bind mount) | Databases, logs, PID, caches. Never synced. | +| `{_DOCKER_WORKSPACE}` | Your workspace (`$NERVE_WORKSPACE`) | ✓ (bind mount) | AGENTS.md, SOUL.md, MEMORY.md, skills, and `config/` — where you live. | +| `{_DOCKER_WORKSPACE}/config` | Shareable config | ✓ (bind mount) | `settings.yaml` and `cron/` — the git-syncable layer. | ### Working with Nerve source @@ -2904,7 +2920,8 @@ def _build_docker_compose( - If you modify the web UI (`/nerve/web/`), rebuild with: `cd /nerve/web && npm run build` - Config files live in `/nerve/`, NOT in `{_DOCKER_NERVE_HOME}/` — the config directory is the working directory the daemon was started from. -- Cron jobs live in `{_DOCKER_NERVE_HOME}/cron/` (`jobs.yaml`, `system.yaml`, `gates/`). +- Cron jobs live in `{_DOCKER_WORKSPACE}/config/cron/` (`jobs.yaml`, `system.yaml`, + `gates/`), not under `$NERVE_HOME` — they are part of the syncable config. ### Credentials diff --git a/nerve/config.py b/nerve/config.py index 8ae1fa6b..a79f6b1d 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -913,8 +913,39 @@ def from_dict(cls, d: dict) -> MemoryConfig: ) +def _cron_dir_has_jobs(d: Path) -> bool: + """True if a cron dir actually holds job definitions (jobs.yaml or system.yaml).""" + return (d / "jobs.yaml").exists() or (d / "system.yaml").exists() + + +def _resolve_cron_dir(workspace: Path | None) -> Path: + """Effective directory holding cron config (jobs/system/gates). + + Prefers the git-syncable ``workspace/config/cron`` so cron definitions live + in the shared workspace. Resolution is **file-aware**: an empty + ``workspace/config/cron`` (e.g. a git checkout with only a ``gates/`` + placeholder, or a partially-initialized workspace) must NOT silently shadow + a legacy ``~/.nerve/cron`` that still holds real jobs. So: + + * If the workspace location has job files → use it. + * Else, if the legacy location has job files → use it (un-migrated install). + * Else → the workspace location (new install; may be about to be populated). + """ + legacy = paths.cron_dir() + if workspace is None: + return legacy + ws_cron = workspace_config_dir(workspace) / "cron" + if _cron_dir_has_jobs(ws_cron): + return ws_cron + if _cron_dir_has_jobs(legacy): + return legacy + return ws_cron + + @dataclass class CronConfig: + # Bare defaults (no workspace context) point at the legacy machine-local + # location; from_dict resolves the workspace-aware location when it can. jobs_file: Path = field(default_factory=lambda: paths.cron_dir() / "jobs.yaml") system_file: Path = field(default_factory=lambda: paths.cron_dir() / "system.yaml") # Directory scanned at startup for drop-in custom gate plugins (.py files @@ -923,11 +954,12 @@ class CronConfig: @classmethod @_coerced - def from_dict(cls, d: dict) -> CronConfig: + def from_dict(cls, d: dict, workspace: Path | None = None) -> CronConfig: + base = _resolve_cron_dir(workspace) return cls( - jobs_file=_expand_path(d.get("jobs_file")) or paths.cron_dir() / "jobs.yaml", - system_file=_expand_path(d.get("system_file")) or paths.cron_dir() / "system.yaml", - gate_plugins_dir=_expand_path(d.get("gate_plugins_dir")) or paths.cron_dir() / "gates", + jobs_file=_expand_path(d.get("jobs_file")) or base / "jobs.yaml", + system_file=_expand_path(d.get("system_file")) or base / "system.yaml", + gate_plugins_dir=_expand_path(d.get("gate_plugins_dir")) or base / "gates", ) @@ -2043,8 +2075,11 @@ def from_dict(cls, d: dict) -> NerveConfig: @classmethod def _build_from_dict(cls, d: dict) -> NerveConfig: + # Resolve the workspace once so workspace-aware sub-configs (e.g. cron, + # which now lives in workspace/config/cron) can be located relative to it. + workspace = _expand_path(d.get("workspace")) or paths.default_workspace() return cls( - workspace=_expand_path(d.get("workspace")) or paths.default_workspace(), + workspace=workspace, timezone=d.get("timezone", "America/New_York"), deployment=d.get("deployment", "server"), quiet_start=d.get("quiet_start", "02:00"), @@ -2055,7 +2090,7 @@ def _build_from_dict(cls, d: dict) -> NerveConfig: telegram=TelegramConfig.from_dict(d.get("telegram", {})), sync=SyncConfig.from_dict(d.get("sync", {})), memory=MemoryConfig.from_dict(d.get("memory", {})), - cron=CronConfig.from_dict(d.get("cron", {})), + cron=CronConfig.from_dict(d.get("cron", {}), workspace=workspace), backup=BackupConfig.from_dict(d.get("backup", {})), sessions=SessionsConfig.from_dict(d.get("sessions", {})), retention=RetentionConfig.from_dict(d.get("retention", {})), diff --git a/nerve/cron/gate_plugins.py b/nerve/cron/gate_plugins.py index fb55ce73..c67b7ca2 100644 --- a/nerve/cron/gate_plugins.py +++ b/nerve/cron/gate_plugins.py @@ -3,8 +3,9 @@ Built-in gates live in :mod:`nerve.cron.gates` and are registered directly in :data:`nerve.cron.gates.GATE_REGISTRY`. To add a *custom* gate **without editing core source**, drop a ``.py`` file into the gate-plugins directory -(``~/.nerve/cron/gates/`` by default, overridable via the ``cron.gate_plugins_dir`` -config key). On daemon startup each file is imported and every +(``/config/cron/gates/`` by default — falling back to the legacy +``~/.nerve/cron/gates/`` for un-migrated installs — overridable via the +``cron.gate_plugins_dir`` config key). On daemon startup each file is imported and every :class:`~nerve.cron.gates.CronGate` subclass it defines with a non-empty ``type`` is registered into ``GATE_REGISTRY`` — after which ``jobs.yaml`` can reference it via ``run_if: [{type: , ...}]`` exactly like a built-in. @@ -14,7 +15,7 @@ A plugin file looks like any other module defining a gate:: - # ~/.nerve/cron/gates/stale_tasks.py + # /config/cron/gates/stale_tasks.py from nerve.cron.gates import CronGate, GateContext class StaleTasksGate(CronGate): diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index cc237eaf..34e28dfd 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -125,13 +125,10 @@ def test_creates_all_files(self, tmp_path: Path) -> None: assert (ws / "SOUL.md").exists() assert (ws / "AGENTS.md").exists() - # Cron jobs land under NERVE_HOME (the conftest tmpdir), not the real - # ~/.nerve — until this was fixed, running the suite rewrote the - # developer's own system.yaml. - from nerve import paths - - assert (paths.cron_dir() / "jobs.yaml").exists() - assert (paths.cron_dir() / "system.yaml").exists() + # Cron config now lives in the git-syncable workspace/config/cron subtree + assert (ws / "config" / "cron" / "system.yaml").exists() + assert (ws / "config" / "cron" / "jobs.yaml").exists() + assert (ws / "config" / "cron" / "gates").is_dir() def test_worker_mode(self, tmp_path: Path) -> None: """Worker mode should create minimal workspace.""" @@ -432,12 +429,17 @@ def test_agent_docs_locate_config_where_it_is_actually_written(self) -> None: to claim the opposite, which sends the agent to edit a file nothing reads (and on a different bind mount than the one it meant). """ - from nerve.bootstrap import _DOCKER_NERVE_HOME, _DOCKER_TOOLS_SECTION + from nerve.bootstrap import ( + _DOCKER_NERVE_HOME, + _DOCKER_TOOLS_SECTION, + _DOCKER_WORKSPACE, + ) - assert f"Config files live in `/nerve/`" in _DOCKER_TOOLS_SECTION + assert "Config files live in `/nerve/`" in _DOCKER_TOOLS_SECTION assert f"`{_DOCKER_NERVE_HOME}/config.local.yaml`" not in _DOCKER_TOOLS_SECTION - # Cron still lives under the machine-local state dir on this branch. - assert f"{_DOCKER_NERVE_HOME}/cron/" in _DOCKER_TOOLS_SECTION + # Cron now lives in the workspace's syncable config subtree. + assert f"{_DOCKER_WORKSPACE}/config/cron/" in _DOCKER_TOOLS_SECTION + assert f"{_DOCKER_NERVE_HOME}/cron" not in _DOCKER_TOOLS_SECTION def test_entrypoint_executable(self, tmp_path: Path) -> None: """docker-entrypoint.sh should be executable.""" @@ -1346,6 +1348,18 @@ def test_workspace_path_with_env_ref_lands_where_the_loader_looks( assert (real_ws / "config" / "settings.yaml").exists() assert load_config(tmp_path).timezone == "Asia/Tokyo" + # Every writer has to agree on where the workspace is, not just the one + # this test was originally written for. The cron writer expanded only + # `~`, so it created a literal "${MY_WS}" directory next to the process + # CWD — which is how one got committed to this repo. + cron_dir = real_ws / "config" / "cron" + assert (cron_dir / "system.yaml").exists() + assert (cron_dir / "jobs.yaml").exists() + assert not (Path.cwd() / "${MY_WS}").exists() + cfg = load_config(tmp_path) + assert cfg.cron.system_file == cron_dir / "system.yaml" + assert cfg.cron.jobs_file == cron_dir / "jobs.yaml" + def test_reinit_backs_up_settings(self, tmp_path: Path) -> None: wizard = self._wizard(tmp_path) wizard._apply() diff --git a/tests/test_cron_paths.py b/tests/test_cron_paths.py new file mode 100644 index 00000000..2a1f9fc6 --- /dev/null +++ b/tests/test_cron_paths.py @@ -0,0 +1,158 @@ +"""Tests for workspace-aware cron config path resolution. + +Cron config (jobs/system/gates) lives in workspace/config/cron, with a +fallback to the legacy ~/.nerve/cron for un-migrated installs, and honors an +explicit override in config. + +Note: the autouse conftest fixture sets NERVE_HOME to a tmp dir, so +paths.cron_dir() (the legacy location) is already isolated per-test. +""" + +from pathlib import Path + +from nerve import paths +from nerve.config import CronConfig, _resolve_cron_dir + + +class TestResolveCronDir: + def test_new_install_prefers_workspace(self, tmp_path): + workspace = tmp_path / "ws" + # Neither location exists yet → new install lands on the workspace. + assert _resolve_cron_dir(workspace) == workspace / "config" / "cron" + + def test_prefers_workspace_when_it_has_jobs(self, tmp_path): + workspace = tmp_path / "ws" + ws_cron = workspace / "config" / "cron" + ws_cron.mkdir(parents=True) + (ws_cron / "system.yaml").write_text("jobs: []\n", encoding="utf-8") + legacy = paths.cron_dir() + legacy.mkdir(parents=True, exist_ok=True) + (legacy / "jobs.yaml").write_text("jobs: []\n", encoding="utf-8") + assert _resolve_cron_dir(workspace) == ws_cron + + def test_falls_back_to_legacy_when_only_legacy_has_jobs(self, tmp_path): + workspace = tmp_path / "ws" # no workspace/config/cron + legacy = paths.cron_dir() + legacy.mkdir(parents=True, exist_ok=True) + (legacy / "jobs.yaml").write_text("jobs: []\n", encoding="utf-8") + assert _resolve_cron_dir(workspace) == legacy + + def test_empty_ws_cron_does_not_shadow_legacy_jobs(self, tmp_path): + """Regression: an empty workspace/config/cron (e.g. from a git checkout + with only a gates/ placeholder) must NOT shadow real legacy jobs.""" + workspace = tmp_path / "ws" + ws_cron = workspace / "config" / "cron" + (ws_cron / "gates").mkdir(parents=True) # exists, but no job files + legacy = paths.cron_dir() + legacy.mkdir(parents=True, exist_ok=True) + (legacy / "jobs.yaml").write_text( + "jobs:\n - id: real\n schedule: 1h\n prompt: hi\n", encoding="utf-8" + ) + assert _resolve_cron_dir(workspace) == legacy + + def test_none_workspace_uses_legacy(self): + assert _resolve_cron_dir(None) == paths.cron_dir() + + +class TestCronConfigFromDict: + def test_defaults_to_workspace_cron(self, tmp_path): + workspace = tmp_path / "ws" + cfg = CronConfig.from_dict({}, workspace=workspace) + assert cfg.jobs_file == workspace / "config" / "cron" / "jobs.yaml" + assert cfg.system_file == workspace / "config" / "cron" / "system.yaml" + assert cfg.gate_plugins_dir == workspace / "config" / "cron" / "gates" + + def test_legacy_fallback(self, tmp_path): + workspace = tmp_path / "ws" + legacy = paths.cron_dir() + (legacy).mkdir(parents=True, exist_ok=True) + (legacy / "jobs.yaml").write_text("jobs: []\n", encoding="utf-8") + cfg = CronConfig.from_dict({}, workspace=workspace) + assert cfg.jobs_file == legacy / "jobs.yaml" + + def test_explicit_override_wins(self, tmp_path): + workspace = tmp_path / "ws" + ws_cron = workspace / "config" / "cron" + ws_cron.mkdir(parents=True) + (ws_cron / "system.yaml").write_text("jobs: []\n", encoding="utf-8") + custom = tmp_path / "custom" / "myjobs.yaml" + cfg = CronConfig.from_dict({"jobs_file": str(custom)}, workspace=workspace) + assert cfg.jobs_file == custom + # unspecified files still resolve to the workspace default + assert cfg.system_file == ws_cron / "system.yaml" + + def test_gate_plugins_dir_override(self, tmp_path): + workspace = tmp_path / "ws" + custom_gates = tmp_path / "mygates" + cfg = CronConfig.from_dict( + {"gate_plugins_dir": str(custom_gates)}, workspace=workspace + ) + assert cfg.gate_plugins_dir == custom_gates + + +class TestLoadConfigCron: + def test_cron_resolved_relative_to_configured_workspace(self, tmp_path): + from nerve.config import load_config + + config_dir = tmp_path / "cfg" + workspace = tmp_path / "ws" + config_dir.mkdir(parents=True) + ws_cron = workspace / "config" / "cron" + ws_cron.mkdir(parents=True) + (ws_cron / "system.yaml").write_text("jobs: []\n", encoding="utf-8") + (config_dir / "config.yaml").write_text( + f"workspace: {workspace}\n", encoding="utf-8" + ) + cfg = load_config(config_dir) + assert cfg.cron.jobs_file == ws_cron / "jobs.yaml" + + def test_legacy_fallback_end_to_end(self, tmp_path): + from nerve.config import load_config + + config_dir = tmp_path / "cfg" + workspace = tmp_path / "ws" # no workspace/config/cron + config_dir.mkdir(parents=True) + legacy = paths.cron_dir() + legacy.mkdir(parents=True, exist_ok=True) + (legacy / "jobs.yaml").write_text("jobs: []\n", encoding="utf-8") + (config_dir / "config.yaml").write_text( + f"workspace: {workspace}\n", encoding="utf-8" + ) + cfg = load_config(config_dir) + assert cfg.cron.jobs_file == legacy / "jobs.yaml" + + +class TestInitPreservesLegacyJobs: + def test_reinit_migrates_legacy_custom_crons(self, tmp_path, monkeypatch): + """Re-running init on an upgraded install must not drop custom crons.""" + import os + + from nerve.bootstrap import run_non_interactive + + # Legacy install with a real custom cron under the isolated NERVE_HOME. + legacy = paths.cron_dir() + legacy.mkdir(parents=True, exist_ok=True) + legacy_jobs = ( + "jobs:\n" + " - id: my-custom\n" + " schedule: 1h\n" + " prompt: do the thing\n" + ) + (legacy / "jobs.yaml").write_text(legacy_jobs, encoding="utf-8") + + config_dir = tmp_path / "cfg" + config_dir.mkdir(parents=True, exist_ok=True) + workspace = tmp_path / "ws" + env = { + "ANTHROPIC_API_KEY": "sk-ant-api03-testkey123", + "NERVE_MODE": "personal", + "NERVE_WORKSPACE": str(workspace), + } + with monkeypatch.context() as m: + for k, v in env.items(): + m.setenv(k, v) + run_non_interactive(config_dir) + + migrated = workspace / "config" / "cron" / "jobs.yaml" + assert migrated.exists() + assert "my-custom" in migrated.read_text(encoding="utf-8") diff --git a/tests/test_docs_cron_paths.py b/tests/test_docs_cron_paths.py new file mode 100644 index 00000000..8892a828 --- /dev/null +++ b/tests/test_docs_cron_paths.py @@ -0,0 +1,219 @@ +"""Guard against docs drifting back to the pre-move cron location. + +Cron config moved from the machine-local state dir into the workspace's +git-syncable ``config/cron/`` subtree. The legacy path is still *read* when the +workspace one is absent, so a stale doc doesn't fail loudly — it quietly tells +the reader to author a file that nothing will load. Worse, an explicit +``cron.jobs_file`` in a config example wins outright over the resolver, so a +copied example disables both the workspace location *and* the fallback. + +A mention of the legacy path is fine when it is explicitly labelled as the old +location. This test only rejects unqualified ones. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from textwrap import dedent + +import pytest + +REPO = Path(__file__).resolve().parent.parent + +# Matches the legacy cron dir however it is spelled: `~/.nerve/cron`, +# `$HOME/.nerve/cron`, `/root/.nerve/cron`, and the f-string interpolations +# used in generated agent docs (`{_DOCKER_NERVE_HOME}/cron`, +# `${NERVE_HOME}/cron`) — those render to the same place, and missing them is +# how the Docker TOOLS.md section stayed wrong through the first sweep. +LEGACY = re.compile( + r"(?:\.nerve/cron" + r"|\$\{?NERVE_HOME\}?/cron" + r"|\{_?DOCKER_NERVE_HOME\}/cron)" +) + +# Words that mark a mention as "this is the old location" rather than an +# instruction to the reader. The arrow forms cover migration mappings, where +# naming the legacy path *is* the point. +_QUALIFIERS = ( + "legacy", "falls back", "fallback", "un-migrated", "no job files", + "→ workspace/config/cron", "-> workspace/config/cron", + "→ `workspace/config/cron", "→ /config/cron", +) + + +# Start of a table row or list item — a scope boundary, because each row or +# item is an independent claim and must carry its own qualifier. Ordered markers +# are matched by *shape* (``1.``, ``2.``, ``10.``, ``1)``) rather than by the +# literal ``"1. "``: a numbered migration list whose legacy path sits under item +# ``2.`` would otherwise be scoped to the whole list and inherit the "legacy" +# from item ``1.`` — a silent free pass, the one direction a guard must not fail +# in. Leading whitespace is stripped before matching, so nested items count too. +# A marker must be followed by space or end-of-line, which is what keeps `---` +# rules and `**bold**` lead-ins from reading as items and cutting a paragraph in +# half. +_ITEM_START = re.compile(r"(?:\||(?:[-*+]|\d+[.)])(?=[ \t]|$))") + + +def _scope(lines: list[str], i: int) -> str: + """The text a qualifier has to appear in to excuse line ``i``. + + A fixed ±N-line window is the obvious choice and it's wrong in both + directions: too small and it misses a qualifier three lines up in wrapped + prose, too large and the fallback documentation — exactly where someone + would add a new cron path — hands out a free pass to its neighbours. + + So: the enclosing paragraph (contiguous non-blank lines), clipped at table + rows and list items. The clip runs both ways, which is what keeps a wrapped + item honest: a continuation line is scoped to *its own* item (back to the + marker, forward to the next one) instead of to the whole list, so it can use + the qualifier its item states on another line but not a sibling's. + """ + start = i + while ( + not _ITEM_START.match(lines[start].lstrip()) + and start > 0 + and lines[start - 1].strip() + ): + start -= 1 + end = i + while ( + end + 1 < len(lines) + and lines[end + 1].strip() + and not _ITEM_START.match(lines[end + 1].lstrip()) + ): + end += 1 + return " ".join(lines[start:end + 1]) + + +def _label(path: Path) -> str: + """Repo-relative so failures are copy-pasteable, absolute otherwise — the + scope tests below feed in tmp files, which have no repo-relative form.""" + return str(path.relative_to(REPO) if path.is_relative_to(REPO) else path) + + +def _offenders(paths: list[Path]) -> list[str]: + out = [] + for path in paths: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + continue + for i, line in enumerate(lines): + if not LEGACY.search(line): + continue + if any(q in _scope(lines, i).lower() for q in _QUALIFIERS): + continue + out.append(f"{_label(path)}:{i + 1}: {line.strip()}") + return out + + +def _doc_files() -> list[Path]: + return [ + *sorted((REPO / "docs").rglob("*.md")), + REPO / "README.md", + REPO / "config.example.yaml", + ] + + +def test_docs_do_not_send_readers_to_the_legacy_cron_dir(): + docs = [p for p in _doc_files() if p.exists()] + assert len(docs) > 5, "doc set looks wrong — did the layout change?" + offenders = _offenders(docs) + assert not offenders, ( + "docs point at the legacy cron location without saying it's the " + "old one:\n" + "\n".join(offenders) + ) + + +def test_docs_name_the_current_cron_location(): + """Negative-only checks pass when a doc drops the path entirely, or names + a third wrong one. Assert the real location is present where it matters.""" + for rel in ("docs/cron.md", "docs/config.md", "docs/migration.md"): + body = (REPO / rel).read_text(encoding="utf-8") + assert "config/cron/" in body, f"{rel} never names /config/cron/" + + +def test_python_strings_do_not_send_the_agent_to_the_legacy_cron_dir(): + """Same rule for text the agent reads as fact — prompts, generated docs, + docstrings. Scans the whole package rather than a hand-picked list, since + the file that stayed wrong through the first sweep wasn't on that list.""" + sources = [ + p for p in (REPO / "nerve").rglob("*.py") + # paths.py *defines* the legacy accessor and migrate.py exists to read + # it; both name the old location as their subject, not as advice. + if p.name not in {"paths.py", "migrate.py"} + ] + sources += sorted((REPO / "nerve" / "templates").rglob("*.md")) + offenders = _offenders(sources) + assert not offenders, "\n".join(offenders) + + +def _scan(tmp_path: Path, body: str) -> list[str]: + doc = tmp_path / "sample.md" + doc.write_text(dedent(body).strip("\n") + "\n", encoding="utf-8") + return _offenders([doc]) + + +# Every marker shape that appears in — or could plausibly land in — these docs. +# Only `1. ` used to count, so `2.`, `10.` and friends fell back to paragraph +# scope and borrowed the previous item's qualifier. +@pytest.mark.parametrize( + "marker", ["2.", "10.", "2)", "-", "*", "+", " 2.", " -"] +) +def test_a_qualifier_does_not_leak_from_one_item_to_the_next(tmp_path, marker): + offenders = _scan(tmp_path, f""" + Steps: + + 1. Old installs kept their jobs in the legacy `~/.nerve/cron/`. + {marker} Author new jobs in `~/.nerve/cron/jobs.yaml`. + """) + assert len(offenders) == 1, f"expected only the {marker!r} item: {offenders}" + assert "jobs.yaml" in offenders[0] + + +def test_a_qualifier_does_not_leak_between_table_rows(tmp_path): + offenders = _scan(tmp_path, """ + | Key | Default | + |-----|---------| + | `cron.system_file` | `/config/cron/system.yaml` (falls back to `~/.nerve/cron/system.yaml`) | + | `cron.jobs_file` | `~/.nerve/cron/jobs.yaml` | + """) + assert len(offenders) == 1 + assert "jobs_file" in offenders[0] + + +def test_a_wrapped_item_keeps_the_qualifier_it_states_on_another_line(tmp_path): + """The clip is not "one line per item": an item that wraps is still one + claim, so its own qualifier must count wherever inside it it lands.""" + assert not _scan(tmp_path, """ + 1. Jobs used to live in `~/.nerve/cron/jobs.yaml`, which Nerve now reads + only as a fallback. + 2. Move them into `/config/cron/`. + """) + assert not _scan(tmp_path, """ + 1. Nerve falls back to + `~/.nerve/cron/jobs.yaml` when the workspace copy has no jobs. + 2. Move them into `/config/cron/`. + """) + + +def test_a_wrapped_item_cannot_borrow_a_siblings_qualifier(tmp_path): + offenders = _scan(tmp_path, """ + 1. Old installs kept their jobs in the legacy location. + 2. Author new jobs in + `~/.nerve/cron/jobs.yaml`. + """) + assert len(offenders) == 1 + assert "jobs.yaml" in offenders[0] + + +def test_migration_guidance_still_passes(tmp_path): + """The docs are *supposed* to name the old location when explaining the + fallback. Over-eagerness would push authors into dropping that guidance.""" + assert not _scan(tmp_path, """ + > Nerve still reads the legacy `~/.nerve/cron/` when the workspace + > location has no job files, so an older guide that put them there is + > not broken. But once you create `$NERVE_WS/config/cron/jobs.yaml`, + > the fallback stops firing — don't split jobs across both. + """)