From c4ba9d0b99b829b098c898f8ec1a9a9a1c9b3c13 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:13:54 +0300 Subject: [PATCH 1/3] feat: add generic scheduling command and reposition OpenClaw cron as optional The primary automation story is now agent-agnostic. `selftune schedule` generates ready-to-use snippets for system cron, macOS launchd, and Linux systemd timers. `selftune cron` is repositioned as an optional OpenClaw integration rather than the main automation path. Co-Authored-By: Claude Opus 4.6 --- cli/selftune/cron/setup.ts | 8 +- cli/selftune/index.ts | 11 +- cli/selftune/schedule.ts | 217 ++++++++++++++++++++++++++++++++ docs/integration-guide.md | 146 ++++++++++++--------- skill/Workflows/Cron.md | 16 ++- skill/Workflows/Schedule.md | 65 ++++++++++ tests/schedule/schedule.test.ts | 155 +++++++++++++++++++++++ 7 files changed, 547 insertions(+), 71 deletions(-) create mode 100644 cli/selftune/schedule.ts create mode 100644 skill/Workflows/Schedule.md create mode 100644 tests/schedule/schedule.test.ts diff --git a/cli/selftune/cron/setup.ts b/cli/selftune/cron/setup.ts index 5bbb403..ef9a4fb 100644 --- a/cli/selftune/cron/setup.ts +++ b/cli/selftune/cron/setup.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * selftune cron — Manage OpenClaw cron jobs for selftune automation. + * selftune cron — OpenClaw cron integration for selftune automation. * * Subcommands: * setup Register default selftune cron jobs with OpenClaw @@ -249,7 +249,11 @@ export async function cliMain(): Promise { await removeCronJobs(values["dry-run"] ?? false); break; default: - console.log(`selftune cron — Manage OpenClaw cron jobs + console.log(`selftune cron — OpenClaw cron integration + +Registers selftune automation jobs with OpenClaw's Gateway Scheduler. +This is an optional convenience for OpenClaw users. For generic scheduling +with system cron, launchd, or systemd, see: selftune schedule Usage: selftune cron setup [--dry-run] [--tz ] diff --git a/cli/selftune/index.ts b/cli/selftune/index.ts index b2bfc61..af2f1e9 100644 --- a/cli/selftune/index.ts +++ b/cli/selftune/index.ts @@ -22,7 +22,8 @@ * selftune status — Show skill health summary * selftune last — Show last session details * selftune dashboard [options] — Open visual data dashboard - * selftune cron [options] — Manage OpenClaw cron jobs (setup, list, remove) + * selftune schedule [options] — Generate scheduling examples (cron, launchd, systemd) + * selftune cron [options] — OpenClaw cron integration (setup, list, remove) * selftune baseline [options] — Measure skill value vs. no-skill baseline * selftune composability [options] — Analyze skill co-occurrence conflicts * selftune workflows [options] — Discover multi-skill workflow patterns @@ -62,7 +63,8 @@ Commands: status Show skill health summary last Show last session details dashboard Open visual data dashboard - cron Manage OpenClaw cron jobs (setup, list, remove) + schedule Generate scheduling examples (cron, launchd, systemd) + cron OpenClaw cron integration (setup, list, remove) badge Generate skill health badges for READMEs baseline Measure skill value vs. no-skill baseline composability Analyze skill co-occurrence conflicts @@ -187,6 +189,11 @@ switch (command) { await cliMain(); break; } + case "schedule": { + const { cliMain } = await import("./schedule.js"); + cliMain(); + break; + } case "cron": { const { cliMain } = await import("./cron/setup.js"); await cliMain(); diff --git a/cli/selftune/schedule.ts b/cli/selftune/schedule.ts new file mode 100644 index 0000000..f625f5d --- /dev/null +++ b/cli/selftune/schedule.ts @@ -0,0 +1,217 @@ +#!/usr/bin/env bun +/** + * selftune schedule — Generate scheduling examples for automated selftune runs. + * + * Outputs ready-to-use snippets for system cron, macOS launchd, and Linux systemd. + * This is the generic, agent-agnostic way to automate selftune. + * + * For OpenClaw-specific scheduling, see `selftune cron`. + * + * Usage: + * selftune schedule [--format cron|launchd|systemd] + */ + +import { parseArgs } from "node:util"; + +// --------------------------------------------------------------------------- +// Schedule definitions (matches the selftune automation loop) +// --------------------------------------------------------------------------- + +export interface ScheduleEntry { + name: string; + schedule: string; + command: string; + description: string; +} + +export const SCHEDULE_ENTRIES: ScheduleEntry[] = [ + { + name: "selftune-sync", + schedule: "*/30 * * * *", + command: "selftune sync", + description: "Sync source-truth telemetry every 30 minutes", + }, + { + name: "selftune-status", + schedule: "0 8 * * *", + command: "selftune sync && selftune status", + description: "Daily health check at 8am (syncs first)", + }, + { + name: "selftune-evolve", + schedule: "0 3 * * 0", + command: "selftune evolve --sync-first --skill --skill-path ", + description: "Weekly evolution at 3am Sunday", + }, + { + name: "selftune-watch", + schedule: "0 */6 * * *", + command: "selftune watch --sync-first --skill --skill-path ", + description: "Monitor regressions every 6 hours", + }, +]; + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +export function generateCrontab(): string { + const lines = [ + "# selftune automation — add to your crontab with: crontab -e", + "#", + "# The core loop: sync → status → evolve → watch", + "# Adjust paths and skill names for your setup.", + "#", + ]; + for (const entry of SCHEDULE_ENTRIES) { + lines.push(`# ${entry.description}`); + lines.push(`${entry.schedule} ${entry.command}`); + lines.push(""); + } + return lines.join("\n"); +} + +export function generateLaunchd(): string { + const plist = ` + + + + + Label + com.selftune.sync + ProgramArguments + + selftune + sync + + StartInterval + 1800 + StandardOutPath + /tmp/selftune-sync.log + StandardErrorPath + /tmp/selftune-sync.err + +`; + return plist; +} + +export function generateSystemd(): string { + const timer = `# selftune automation — systemd timer + service +# +# Install: +# cp selftune-sync.service selftune-sync.timer ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now selftune-sync.timer +# +# Create similar pairs for status, evolve, and watch, +# or combine them into a single wrapper script. + +# --- selftune-sync.timer --- +[Unit] +Description=selftune sync timer + +[Timer] +OnCalendar=*:0/30 +Persistent=true + +[Install] +WantedBy=timers.target + +# --- selftune-sync.service --- +[Unit] +Description=selftune sync + +[Service] +Type=oneshot +ExecStart=selftune sync`; + return timer; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +const VALID_FORMATS = ["cron", "launchd", "systemd"] as const; +export type ScheduleFormat = (typeof VALID_FORMATS)[number]; + +function isValidFormat(value: string): value is ScheduleFormat { + return (VALID_FORMATS as readonly string[]).includes(value); +} + +export function formatOutput(format?: string): { ok: true; data: string } | { ok: false; error: string } { + if (format && !isValidFormat(format)) { + return { ok: false, error: `Unknown format "${format}". Valid formats: ${VALID_FORMATS.join(", ")}` }; + } + + const sections: string[] = []; + + if (!format || format === "cron") { + sections.push("## System cron\n"); + sections.push(generateCrontab()); + } + + if (!format || format === "launchd") { + sections.push("## macOS launchd\n"); + sections.push(generateLaunchd()); + } + + if (!format || format === "systemd") { + sections.push("## Linux systemd\n"); + sections.push(generateSystemd()); + } + + return { ok: true, data: sections.join("\n\n") }; +} + +export function cliMain(): void { + const { values } = parseArgs({ + options: { + format: { type: "string", short: "f" }, + help: { type: "boolean", default: false }, + }, + strict: false, + allowPositionals: true, + }); + + if (values.help) { + console.log(`selftune schedule — Generate scheduling examples for automation + +Usage: + selftune schedule [--format cron|launchd|systemd] + +Flags: + --format, -f Output only one format (cron, launchd, or systemd) + --help Show this help message + +The selftune automation loop is: + sync → status → evolve --sync-first → watch --sync-first + +This command generates ready-to-use snippets for running that loop +with standard system scheduling tools. No agent runtime required. + +For OpenClaw-specific scheduling, see: selftune cron`); + process.exit(0); + } + + const result = formatOutput(values.format); + if (!result.ok) { + console.error(result.error); + process.exit(1); + } + console.log(result.data); +} + +if (import.meta.main) { + cliMain(); +} diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 495ccd0..05225c7 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -279,67 +279,11 @@ against known skill names from OpenClaw's skill directories. **Multi-agent support:** If you run multiple OpenClaw agents, selftune scans all directories under `~/.openclaw/agents/` automatically. -**Setup (autonomous cron loop):** +**OpenClaw autonomous cron loop (optional):** -This is the unique OpenClaw feature — skills that improve while you sleep. -OpenClaw's built-in Gateway Scheduler runs selftune autonomously on a schedule. - -1. Ensure OpenClaw is installed (`which openclaw`). -2. Register default cron jobs: - -```bash -selftune cron setup -``` - -This registers 4 jobs with OpenClaw: - -| Job | Schedule | Purpose | -|-----|----------|---------| -| `selftune-sync` | Every 30 min | Sync source-truth telemetry and rebuild repaired overlay | -| `selftune-status` | Daily 8am | Sync first, then flag skills below 80% | -| `selftune-evolve` | Weekly Sunday 3am | Full evolution pipeline on undertriggering skills | -| `selftune-watch` | Every 6 hours | Sync first, then monitor recently evolved skills | - -1. Customize timezone: `selftune cron setup --tz America/New_York` -2. Preview without registering: `selftune cron setup --dry-run` -3. View registered jobs: `selftune cron list` -4. Remove all jobs: `selftune cron remove` - -**How the autonomous loop works:** - -```text -Cron fires (isolated session) - ↓ -OpenClaw agent reads selftune skill instructions - ↓ -Runs: selftune sync → selftune status - ↓ -For each skill below 80% pass rate: - selftune evals → selftune evolve → selftune watch - ↓ -Evolved SKILL.md written to disk - ↓ -OpenClaw hot-reloads the changed SKILL.md (250ms) - ↓ -Next agent turn uses improved skill description -``` - -Each cron run uses an **isolated session** — no context pollution between runs. - -**Safety controls:** -- `--dry-run` before real deploys -- <5% regression threshold on existing triggers -- Auto-rollback via `selftune watch --auto-rollback` -- Full audit trail in `evolution_audit_log.jsonl` -- `SKILL.md.bak` backup before every deploy -- Manual override: `selftune rollback --skill ` at any time - -**Limitations:** -- Each cron run costs tokens (full LLM session, ~5K tokens estimated) -- Cron tools may be blocked in Docker sandbox mode (OpenClaw issue #29921) -- Newly created cron jobs may not fire until Gateway restart (known OpenClaw bug) - -See `skill/Workflows/Cron.md` for the full cron workflow reference. +OpenClaw users can also register selftune jobs with OpenClaw's Gateway Scheduler +for fully autonomous evolution. See the [Automation](#automation) section below +and `skill/Workflows/Cron.md` for details. --- @@ -441,6 +385,80 @@ in your hook configuration, which resolves the correct script path at runtime. --- +## Automation + +selftune is designed to run unattended on any machine. The core automation +loop is four commands: + +``` +sync → status → evolve --sync-first → watch --sync-first +``` + +Run `selftune schedule` to generate ready-to-use snippets for your platform. + +### System cron (Linux/macOS — recommended default) + +The simplest path. Add to your crontab with `crontab -e`: + +```cron +# Sync source-truth telemetry every 30 minutes +*/30 * * * * selftune sync + +# Daily health check at 8am (syncs first) +0 8 * * * selftune sync && selftune status + +# Weekly evolution at 3am Sunday +0 3 * * 0 selftune evolve --sync-first --skill --skill-path + +# Monitor regressions every 6 hours +0 */6 * * * selftune watch --sync-first --skill --skill-path +``` + +Replace `` and `` with your skill name and SKILL.md path. + +### macOS launchd + +For macOS machines that need scheduling to survive reboots and sleep/wake: + +```bash +selftune schedule --format launchd > ~/Library/LaunchAgents/com.selftune.sync.plist +launchctl load ~/Library/LaunchAgents/com.selftune.sync.plist +``` + +Run `selftune schedule --format launchd` for a full example plist. + +### Linux systemd timer + +For systemd-based servers: + +```bash +selftune schedule --format systemd +``` + +Save the output as `~/.config/systemd/user/selftune-sync.timer` and +`selftune-sync.service`, then: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now selftune-sync.timer +``` + +### OpenClaw integration (optional) + +If you use OpenClaw, `selftune cron setup` registers jobs directly with +OpenClaw's Gateway Scheduler. This provides isolated sessions per cron run +and automatic hot-reloading of evolved skills. + +```bash +selftune cron setup # register 4 default jobs +selftune cron setup --dry-run # preview first +selftune cron list # check registered jobs +``` + +See `skill/Workflows/Cron.md` for the full OpenClaw cron reference. + +--- + ## Troubleshooting ### `selftune doctor` reports failing checks @@ -493,8 +511,14 @@ Run `selftune doctor` and address each failing check: 4. Use `--force` to re-ingest all sessions if the marker file is stale. 5. If using a custom agents directory: `selftune ingest-openclaw --agents-dir /custom/path` -### Cron jobs not firing +### Scheduled automation not running + +**System cron / launchd / systemd:** +1. Verify `selftune` is on PATH in the cron environment (cron has a minimal PATH). +2. Use absolute paths if needed: `which selftune` to find the binary. +3. Check cron logs: `grep selftune /var/log/syslog` (Linux) or Console.app (macOS). +**OpenClaw cron:** 1. Verify jobs are registered: `selftune cron list` 2. Check OpenClaw cron status: `openclaw cron list` 3. Newly created jobs may require a Gateway restart (known OpenClaw bug). diff --git a/skill/Workflows/Cron.md b/skill/Workflows/Cron.md index 02d32ca..8d74249 100644 --- a/skill/Workflows/Cron.md +++ b/skill/Workflows/Cron.md @@ -1,15 +1,19 @@ -# selftune Cron Workflow +# selftune Cron Workflow (OpenClaw Integration) Manage OpenClaw cron jobs that run the selftune pipeline on a schedule. -The scheduled architecture is now explicitly **source-truth first**: every +This is the **OpenClaw-specific** scheduling path. For generic scheduling +with system cron, launchd, or systemd, see `Workflows/Schedule.md` or +run `selftune schedule`. + +The scheduled architecture is **source-truth first**: every status/evolve/watch pass should sync raw agent data before making decisions. ## When to Use -- Setting up selftune automation for the first time on OpenClaw -- Checking which cron jobs are registered -- Removing selftune cron jobs (cleanup or reconfiguration) -- Enabling the autonomous observe-grade-evolve-deploy loop +- Setting up selftune automation specifically on OpenClaw +- Checking which OpenClaw cron jobs are registered +- Removing selftune cron jobs from OpenClaw +- Enabling the autonomous observe-grade-evolve-deploy loop via OpenClaw ## Prerequisites diff --git a/skill/Workflows/Schedule.md b/skill/Workflows/Schedule.md new file mode 100644 index 0000000..6ce93e0 --- /dev/null +++ b/skill/Workflows/Schedule.md @@ -0,0 +1,65 @@ +# selftune Schedule Workflow + +Generate ready-to-use scheduling examples for automating selftune with +standard system tools. This is the **primary automation path** — it works +on any machine without requiring a specific agent runtime. + +For OpenClaw-specific scheduling, see `Workflows/Cron.md`. + +## When to Use + +- Setting up selftune automation for the first time +- Generating crontab entries for a Linux/macOS server +- Creating a launchd plist for a macOS machine +- Creating a systemd timer for a Linux server +- Understanding the selftune automation loop + +## The Automation Loop + +The core selftune automation loop is four commands: + +``` +sync → status → evolve --sync-first → watch --sync-first +``` + +1. **sync** refreshes source-truth telemetry from all agent sources +2. **status** reports skill health (run after sync) +3. **evolve --sync-first** improves underperforming skills (syncs before analyzing) +4. **watch --sync-first** monitors recently evolved skills for regressions + +## Default Command + +```bash +selftune schedule +``` + +Outputs examples for all three scheduling systems (cron, launchd, systemd). + +## Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--format ` | Output only one format: `cron`, `launchd`, or `systemd` | All formats | +| `--help` | Show help message | — | + +## Steps + +1. Run `selftune schedule` to see all examples +2. Pick the scheduling system for your platform +3. Customize the snippets (skill names, paths, timezone) +4. Install using the instructions in the output + +## Common Patterns + +**"Quick setup on a Linux server"** +> Run `selftune schedule --format cron`, paste the output into `crontab -e`. + +**"Set up on macOS"** +> Run `selftune schedule --format launchd`, save as a `.plist` file, load with `launchctl`. + +**"Set up on a systemd-based server"** +> Run `selftune schedule --format systemd`, save as `.timer` and `.service` files, enable with `systemctl`. + +**"I use OpenClaw"** +> Use `selftune cron setup` instead — it registers jobs directly with OpenClaw's scheduler. +> See `Workflows/Cron.md`. diff --git a/tests/schedule/schedule.test.ts b/tests/schedule/schedule.test.ts new file mode 100644 index 0000000..2375f30 --- /dev/null +++ b/tests/schedule/schedule.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from "bun:test"; + +import { + formatOutput, + generateCrontab, + generateLaunchd, + generateSystemd, + SCHEDULE_ENTRIES, +} from "../../cli/selftune/schedule.js"; + +// --------------------------------------------------------------------------- +// 1. SCHEDULE_ENTRIES structure +// --------------------------------------------------------------------------- +describe("SCHEDULE_ENTRIES", () => { + test("has exactly 4 entries", () => { + expect(SCHEDULE_ENTRIES).toHaveLength(4); + }); + + test("all entries have required fields", () => { + for (const entry of SCHEDULE_ENTRIES) { + expect(typeof entry.name).toBe("string"); + expect(entry.name.length).toBeGreaterThan(0); + expect(typeof entry.schedule).toBe("string"); + expect(entry.schedule.length).toBeGreaterThan(0); + expect(typeof entry.command).toBe("string"); + expect(entry.command.length).toBeGreaterThan(0); + expect(typeof entry.description).toBe("string"); + expect(entry.description.length).toBeGreaterThan(0); + } + }); + + test("contains sync, status, evolve, and watch entries", () => { + const names = SCHEDULE_ENTRIES.map((e) => e.name); + expect(names).toContain("selftune-sync"); + expect(names).toContain("selftune-status"); + expect(names).toContain("selftune-evolve"); + expect(names).toContain("selftune-watch"); + }); + + test("evolve entry uses --sync-first", () => { + const evolve = SCHEDULE_ENTRIES.find((e) => e.name === "selftune-evolve"); + expect(evolve?.command).toContain("--sync-first"); + }); + + test("watch entry uses --sync-first", () => { + const watch = SCHEDULE_ENTRIES.find((e) => e.name === "selftune-watch"); + expect(watch?.command).toContain("--sync-first"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Crontab generation +// --------------------------------------------------------------------------- +describe("generateCrontab", () => { + test("includes crontab header comment", () => { + const output = generateCrontab(); + expect(output).toContain("crontab -e"); + }); + + test("includes all schedule entries", () => { + const output = generateCrontab(); + for (const entry of SCHEDULE_ENTRIES) { + expect(output).toContain(entry.schedule); + expect(output).toContain(entry.command); + } + }); +}); + +// --------------------------------------------------------------------------- +// 3. Launchd generation +// --------------------------------------------------------------------------- +describe("generateLaunchd", () => { + test("outputs valid plist structure", () => { + const output = generateLaunchd(); + expect(output).toContain("StartInterval"); + expect(output).toContain(""); + }); + + test("includes install instructions", () => { + const output = generateLaunchd(); + expect(output).toContain("launchctl load"); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Systemd generation +// --------------------------------------------------------------------------- +describe("generateSystemd", () => { + test("outputs timer and service sections", () => { + const output = generateSystemd(); + expect(output).toContain("[Timer]"); + expect(output).toContain("[Service]"); + expect(output).toContain("selftune sync"); + }); + + test("includes install instructions", () => { + const output = generateSystemd(); + expect(output).toContain("systemctl --user"); + }); +}); + +// --------------------------------------------------------------------------- +// 5. formatOutput (default and filtered) +// --------------------------------------------------------------------------- +describe("formatOutput", () => { + test("default output includes all three sections", () => { + const result = formatOutput(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toContain("## System cron"); + expect(result.data).toContain("## macOS launchd"); + expect(result.data).toContain("## Linux systemd"); + } + }); + + test("--format cron outputs only cron section", () => { + const result = formatOutput("cron"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toContain("## System cron"); + expect(result.data).not.toContain("## macOS launchd"); + expect(result.data).not.toContain("## Linux systemd"); + } + }); + + test("--format launchd outputs only launchd section", () => { + const result = formatOutput("launchd"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).not.toContain("## System cron"); + expect(result.data).toContain("## macOS launchd"); + expect(result.data).not.toContain("## Linux systemd"); + } + }); + + test("--format systemd outputs only systemd section", () => { + const result = formatOutput("systemd"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).not.toContain("## System cron"); + expect(result.data).not.toContain("## macOS launchd"); + expect(result.data).toContain("## Linux systemd"); + } + }); + + test("unknown format returns error result", () => { + const result = formatOutput("docker"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("docker"); + } + }); +}); From d5098cab145828bdc72075996e8734c7161db559 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:32:44 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20centralize=20schedule=20data,=20fix=20generators=20?= =?UTF-8?q?and=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive SCHEDULE_ENTRIES from DEFAULT_CRON_JOBS (single source of truth), generate launchd/systemd configs for all 4 entries instead of sync-only, fix biome formatting, and add markdown language tag. Co-Authored-By: Claude Opus 4.6 --- cli/selftune/schedule.ts | 167 +++++++++++++++++++++----------- docs/integration-guide.md | 2 +- tests/schedule/schedule.test.ts | 26 ++++- 3 files changed, 134 insertions(+), 61 deletions(-) diff --git a/cli/selftune/schedule.ts b/cli/selftune/schedule.ts index f625f5d..c637542 100644 --- a/cli/selftune/schedule.ts +++ b/cli/selftune/schedule.ts @@ -13,8 +13,10 @@ import { parseArgs } from "node:util"; +import { DEFAULT_CRON_JOBS } from "./cron/setup.js"; + // --------------------------------------------------------------------------- -// Schedule definitions (matches the selftune automation loop) +// Schedule definitions — derived from the shared DEFAULT_CRON_JOBS // --------------------------------------------------------------------------- export interface ScheduleEntry { @@ -24,32 +26,62 @@ export interface ScheduleEntry { description: string; } -export const SCHEDULE_ENTRIES: ScheduleEntry[] = [ - { - name: "selftune-sync", - schedule: "*/30 * * * *", - command: "selftune sync", - description: "Sync source-truth telemetry every 30 minutes", - }, - { - name: "selftune-status", - schedule: "0 8 * * *", - command: "selftune sync && selftune status", - description: "Daily health check at 8am (syncs first)", - }, - { - name: "selftune-evolve", - schedule: "0 3 * * 0", - command: "selftune evolve --sync-first --skill --skill-path ", - description: "Weekly evolution at 3am Sunday", - }, - { - name: "selftune-watch", - schedule: "0 */6 * * *", - command: "selftune watch --sync-first --skill --skill-path ", - description: "Monitor regressions every 6 hours", - }, -]; +/** Map cron job metadata to schedule entries with CLI commands. */ +function commandForJob(jobName: string): string { + switch (jobName) { + case "selftune-sync": + return "selftune sync"; + case "selftune-status": + return "selftune sync && selftune status"; + case "selftune-evolve": + return "selftune evolve --sync-first --skill --skill-path "; + case "selftune-watch": + return "selftune watch --sync-first --skill --skill-path "; + default: + return `selftune ${jobName.replace("selftune-", "")}`; + } +} + +export const SCHEDULE_ENTRIES: ScheduleEntry[] = DEFAULT_CRON_JOBS.map((job) => ({ + name: job.name, + schedule: job.cron, + command: commandForJob(job.name), + description: job.description, +})); + +// --------------------------------------------------------------------------- +// Helpers for launchd/systemd generation +// --------------------------------------------------------------------------- + +/** Convert a cron schedule to a launchd StartInterval in seconds (best-effort). */ +function cronToInterval(cron: string): number { + // Simple heuristic for common patterns + if (cron.startsWith("*/")) { + const minutes = Number.parseInt(cron.split(" ")[0].replace("*/", ""), 10); + return minutes * 60; + } + if (cron.startsWith("0 */")) { + const hours = Number.parseInt(cron.split(" ")[1].replace("*/", ""), 10); + return hours * 3600; + } + if (cron.startsWith("0 ") && cron.includes("* * 0")) { + return 604800; // weekly + } + if (cron.startsWith("0 ") && cron.endsWith("* * *")) { + return 86400; // daily + } + return 1800; // default 30 min +} + +/** Convert a cron schedule to a systemd OnCalendar value (best-effort). */ +function cronToOnCalendar(cron: string): string { + if (cron === "*/30 * * * *") return "*:0/30"; + if (cron === "0 8 * * *") return "*-*-* 08:00:00"; + if (cron === "0 3 * * 0") return "Sun *-*-* 03:00:00"; + if (cron === "0 */6 * * *") return "*-*-* 0/6:00:00"; + // Fallback: return cron expression in a comment + return cron; +} // --------------------------------------------------------------------------- // Generators @@ -72,70 +104,84 @@ export function generateCrontab(): string { } export function generateLaunchd(): string { - const plist = ` + const plists: string[] = []; + + for (const entry of SCHEDULE_ENTRIES) { + const label = `com.selftune.${entry.name.replace("selftune-", "")}`; + const lastCmd = entry.command.split(" && ").pop() ?? entry.command; + const args = lastCmd + .split(" ") + .map((a) => ` ${a}`) + .join("\n"); + const interval = cronToInterval(entry.schedule); + + plists.push(` Label - com.selftune.sync + ${label} ProgramArguments - selftune - sync +${args} StartInterval - 1800 + ${interval} StandardOutPath - /tmp/selftune-sync.log + /tmp/${entry.name}.log StandardErrorPath - /tmp/selftune-sync.err + /tmp/${entry.name}.err -`; - return plist; +`); + } + + return plists.join("\n\n"); } export function generateSystemd(): string { - const timer = `# selftune automation — systemd timer + service + const units: string[] = []; + + for (const entry of SCHEDULE_ENTRIES) { + const unitName = entry.name; + const calendar = cronToOnCalendar(entry.schedule); + const execStart = (entry.command.split(" && ").pop() ?? entry.command).trim(); + + units.push(`# --- ${unitName}.timer --- +# ${entry.description} # # Install: -# cp selftune-sync.service selftune-sync.timer ~/.config/systemd/user/ +# cp ${unitName}.service ${unitName}.timer ~/.config/systemd/user/ # systemctl --user daemon-reload -# systemctl --user enable --now selftune-sync.timer -# -# Create similar pairs for status, evolve, and watch, -# or combine them into a single wrapper script. +# systemctl --user enable --now ${unitName}.timer -# --- selftune-sync.timer --- [Unit] -Description=selftune sync timer +Description=${entry.description} [Timer] -OnCalendar=*:0/30 +OnCalendar=${calendar} Persistent=true [Install] WantedBy=timers.target -# --- selftune-sync.service --- +# --- ${unitName}.service --- [Unit] -Description=selftune sync +Description=${entry.description} [Service] Type=oneshot -ExecStart=selftune sync`; - return timer; +ExecStart=${execStart}`); + } + + return units.join("\n\n"); } // --------------------------------------------------------------------------- @@ -149,9 +195,14 @@ function isValidFormat(value: string): value is ScheduleFormat { return (VALID_FORMATS as readonly string[]).includes(value); } -export function formatOutput(format?: string): { ok: true; data: string } | { ok: false; error: string } { +export function formatOutput( + format?: string, +): { ok: true; data: string } | { ok: false; error: string } { if (format && !isValidFormat(format)) { - return { ok: false, error: `Unknown format "${format}". Valid formats: ${VALID_FORMATS.join(", ")}` }; + return { + ok: false, + error: `Unknown format "${format}". Valid formats: ${VALID_FORMATS.join(", ")}`, + }; } const sections: string[] = []; diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 05225c7..4a2a2f9 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -390,7 +390,7 @@ in your hook configuration, which resolves the correct script path at runtime. selftune is designed to run unattended on any machine. The core automation loop is four commands: -``` +```text sync → status → evolve --sync-first → watch --sync-first ``` diff --git a/tests/schedule/schedule.test.ts b/tests/schedule/schedule.test.ts index 2375f30..f1ae880 100644 --- a/tests/schedule/schedule.test.ts +++ b/tests/schedule/schedule.test.ts @@ -46,6 +46,14 @@ describe("SCHEDULE_ENTRIES", () => { const watch = SCHEDULE_ENTRIES.find((e) => e.name === "selftune-watch"); expect(watch?.command).toContain("--sync-first"); }); + + test("derives from DEFAULT_CRON_JOBS (shared source of truth)", () => { + // Schedules should match cron expressions from DEFAULT_CRON_JOBS + const sync = SCHEDULE_ENTRIES.find((e) => e.name === "selftune-sync"); + expect(sync?.schedule).toBe("*/30 * * * *"); + const status = SCHEDULE_ENTRIES.find((e) => e.name === "selftune-status"); + expect(status?.schedule).toBe("0 8 * * *"); + }); }); // --------------------------------------------------------------------------- @@ -73,7 +81,6 @@ describe("generateLaunchd", () => { test("outputs valid plist structure", () => { const output = generateLaunchd(); expect(output).toContain("StartInterval"); expect(output).toContain(""); }); @@ -82,6 +89,14 @@ describe("generateLaunchd", () => { const output = generateLaunchd(); expect(output).toContain("launchctl load"); }); + + test("generates plists for all schedule entries", () => { + const output = generateLaunchd(); + expect(output).toContain("com.selftune.sync"); + expect(output).toContain("com.selftune.status"); + expect(output).toContain("com.selftune.evolve"); + expect(output).toContain("com.selftune.watch"); + }); }); // --------------------------------------------------------------------------- @@ -92,13 +107,20 @@ describe("generateSystemd", () => { const output = generateSystemd(); expect(output).toContain("[Timer]"); expect(output).toContain("[Service]"); - expect(output).toContain("selftune sync"); }); test("includes install instructions", () => { const output = generateSystemd(); expect(output).toContain("systemctl --user"); }); + + test("generates units for all schedule entries", () => { + const output = generateSystemd(); + expect(output).toContain("selftune-sync.timer"); + expect(output).toContain("selftune-status.timer"); + expect(output).toContain("selftune-evolve.timer"); + expect(output).toContain("selftune-watch.timer"); + }); }); // --------------------------------------------------------------------------- From a8bf09e61a814d8936ed305e01ab4233e68f1de9 Mon Sep 17 00:00:00 2001 From: WellDunDun <45949032+WellDunDun@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:15:40 +0300 Subject: [PATCH 3/3] fix: use StartCalendarInterval for fixed-time launchd and shell wrappers for chained commands - launchd: use StartCalendarInterval (Hour/Minute/Weekday) for fixed-time schedules instead of approximating with StartInterval - launchd/systemd: use /bin/sh -c wrapper for commands with && chains so prerequisite steps (like sync) are not silently dropped Co-Authored-By: Claude Opus 4.6 --- cli/selftune/schedule.ts | 69 +++++++++++++++++++++++---------- tests/schedule/schedule.test.ts | 33 +++++++++++++++- 2 files changed, 80 insertions(+), 22 deletions(-) diff --git a/cli/selftune/schedule.ts b/cli/selftune/schedule.ts index c637542..74d401e 100644 --- a/cli/selftune/schedule.ts +++ b/cli/selftune/schedule.ts @@ -53,36 +53,68 @@ export const SCHEDULE_ENTRIES: ScheduleEntry[] = DEFAULT_CRON_JOBS.map((job) => // Helpers for launchd/systemd generation // --------------------------------------------------------------------------- -/** Convert a cron schedule to a launchd StartInterval in seconds (best-effort). */ -function cronToInterval(cron: string): number { - // Simple heuristic for common patterns +/** + * Convert a cron schedule to launchd scheduling XML. + * Uses StartInterval for repeating intervals (e.g. every N minutes/hours), + * and StartCalendarInterval for fixed calendar times (e.g. daily at 8am). + */ +function cronToLaunchdSchedule(cron: string): string { + // Repeating intervals: */N minutes if (cron.startsWith("*/")) { const minutes = Number.parseInt(cron.split(" ")[0].replace("*/", ""), 10); - return minutes * 60; + return ` StartInterval\n ${minutes * 60}`; } + // Repeating intervals: every N hours if (cron.startsWith("0 */")) { const hours = Number.parseInt(cron.split(" ")[1].replace("*/", ""), 10); - return hours * 3600; + return ` StartInterval\n ${hours * 3600}`; } - if (cron.startsWith("0 ") && cron.includes("* * 0")) { - return 604800; // weekly + + // Fixed calendar times use StartCalendarInterval + const parts = cron.split(" "); + const [minute, hour, , , weekday] = parts; + let dict = " StartCalendarInterval\n "; + if (weekday !== "*") { + dict += `\n Weekday\n ${weekday}`; } - if (cron.startsWith("0 ") && cron.endsWith("* * *")) { - return 86400; // daily + if (hour !== "*") { + dict += `\n Hour\n ${Number.parseInt(hour, 10)}`; } - return 1800; // default 30 min + if (minute !== "*") { + dict += `\n Minute\n ${Number.parseInt(minute, 10)}`; + } + dict += "\n "; + return dict; } -/** Convert a cron schedule to a systemd OnCalendar value (best-effort). */ +/** Convert a cron schedule to a systemd OnCalendar value. */ function cronToOnCalendar(cron: string): string { if (cron === "*/30 * * * *") return "*:0/30"; if (cron === "0 8 * * *") return "*-*-* 08:00:00"; if (cron === "0 3 * * 0") return "Sun *-*-* 03:00:00"; if (cron === "0 */6 * * *") return "*-*-* 0/6:00:00"; - // Fallback: return cron expression in a comment return cron; } +/** Build launchd ProgramArguments, using /bin/sh -c for chained commands. */ +function toLaunchdArgs(command: string): string { + if (command.includes(" && ")) { + return ["/bin/sh", "-c", command].map((a) => ` ${a}`).join("\n"); + } + return command + .split(" ") + .map((a) => ` ${a}`) + .join("\n"); +} + +/** Build systemd ExecStart, using /bin/sh -c for chained commands. */ +function toSystemdExecStart(command: string): string { + if (command.includes(" && ")) { + return `/bin/sh -c "${command}"`; + } + return command; +} + // --------------------------------------------------------------------------- // Generators // --------------------------------------------------------------------------- @@ -108,12 +140,8 @@ export function generateLaunchd(): string { for (const entry of SCHEDULE_ENTRIES) { const label = `com.selftune.${entry.name.replace("selftune-", "")}`; - const lastCmd = entry.command.split(" && ").pop() ?? entry.command; - const args = lastCmd - .split(" ") - .map((a) => ` ${a}`) - .join("\n"); - const interval = cronToInterval(entry.schedule); + const args = toLaunchdArgs(entry.command); + const schedule = cronToLaunchdSchedule(entry.schedule); plists.push(` ${args} - StartInterval - ${interval} +${schedule} StandardOutPath /tmp/${entry.name}.log StandardErrorPath @@ -152,7 +179,7 @@ export function generateSystemd(): string { for (const entry of SCHEDULE_ENTRIES) { const unitName = entry.name; const calendar = cronToOnCalendar(entry.schedule); - const execStart = (entry.command.split(" && ").pop() ?? entry.command).trim(); + const execStart = toSystemdExecStart(entry.command); units.push(`# --- ${unitName}.timer --- # ${entry.description} diff --git a/tests/schedule/schedule.test.ts b/tests/schedule/schedule.test.ts index f1ae880..c38ccd8 100644 --- a/tests/schedule/schedule.test.ts +++ b/tests/schedule/schedule.test.ts @@ -81,10 +81,30 @@ describe("generateLaunchd", () => { test("outputs valid plist structure", () => { const output = generateLaunchd(); expect(output).toContain("StartInterval"); expect(output).toContain(""); }); + test("uses StartInterval for repeating schedules", () => { + const output = generateLaunchd(); + // sync runs every 30 min — should use StartInterval + expect(output).toContain("StartInterval"); + }); + + test("uses StartCalendarInterval for fixed-time schedules", () => { + const output = generateLaunchd(); + // status runs daily at 8am — should use StartCalendarInterval + expect(output).toContain("StartCalendarInterval"); + expect(output).toContain("Hour"); + }); + + test("uses /bin/sh -c for chained commands", () => { + const output = generateLaunchd(); + // status command has && — should use shell wrapper + expect(output).toContain("/bin/sh"); + expect(output).toContain("-c"); + expect(output).toContain("selftune sync && selftune status"); + }); + test("includes install instructions", () => { const output = generateLaunchd(); expect(output).toContain("launchctl load"); @@ -109,6 +129,17 @@ describe("generateSystemd", () => { expect(output).toContain("[Service]"); }); + test("uses /bin/sh -c for chained commands", () => { + const output = generateSystemd(); + // status has && — should wrap in shell + expect(output).toContain('ExecStart=/bin/sh -c "selftune sync && selftune status"'); + }); + + test("uses bare command for simple entries", () => { + const output = generateSystemd(); + expect(output).toContain("ExecStart=selftune sync\n"); + }); + test("includes install instructions", () => { const output = generateSystemd(); expect(output).toContain("systemctl --user");