selftune 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/diagnosis-analyst.md +20 -10
- package/.claude/agents/evolution-reviewer.md +14 -1
- package/.claude/agents/integration-guide.md +18 -6
- package/.claude/agents/pattern-analyst.md +18 -5
- package/CHANGELOG.md +12 -4
- package/README.md +43 -35
- package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
- package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
- package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
- package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
- package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
- package/apps/local-dashboard/dist/favicon.png +0 -0
- package/apps/local-dashboard/dist/index.html +17 -0
- package/apps/local-dashboard/dist/logo.png +0 -0
- package/apps/local-dashboard/dist/logo.svg +9 -0
- package/cli/selftune/badge/badge-data.ts +1 -1
- package/cli/selftune/badge/badge.ts +4 -8
- package/cli/selftune/canonical-export.ts +183 -0
- package/cli/selftune/constants.ts +28 -0
- package/cli/selftune/contribute/contribute.ts +1 -1
- package/cli/selftune/cron/setup.ts +17 -17
- package/cli/selftune/dashboard-contract.ts +202 -0
- package/cli/selftune/dashboard-server.ts +653 -186
- package/cli/selftune/dashboard.ts +41 -176
- package/cli/selftune/eval/baseline.ts +5 -4
- package/cli/selftune/eval/composability-v2.ts +273 -0
- package/cli/selftune/eval/hooks-to-evals.ts +34 -15
- package/cli/selftune/eval/unit-test-cli.ts +1 -1
- package/cli/selftune/evolution/evidence.ts +26 -0
- package/cli/selftune/evolution/evolve-body.ts +105 -11
- package/cli/selftune/evolution/evolve.ts +371 -25
- package/cli/selftune/evolution/extract-patterns.ts +87 -29
- package/cli/selftune/evolution/rollback.ts +2 -2
- package/cli/selftune/grading/auto-grade.ts +200 -0
- package/cli/selftune/grading/grade-session.ts +448 -97
- package/cli/selftune/grading/results.ts +42 -0
- package/cli/selftune/hooks/prompt-log.ts +172 -2
- package/cli/selftune/hooks/session-stop.ts +123 -3
- package/cli/selftune/hooks/skill-eval.ts +119 -3
- package/cli/selftune/index.ts +395 -116
- package/cli/selftune/ingestors/claude-replay.ts +140 -114
- package/cli/selftune/ingestors/codex-rollout.ts +345 -46
- package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
- package/cli/selftune/ingestors/openclaw-ingest.ts +141 -8
- package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
- package/cli/selftune/init.ts +227 -14
- package/cli/selftune/last.ts +14 -5
- package/cli/selftune/localdb/db.ts +63 -0
- package/cli/selftune/localdb/materialize.ts +428 -0
- package/cli/selftune/localdb/queries.ts +376 -0
- package/cli/selftune/localdb/schema.ts +204 -0
- package/cli/selftune/monitoring/watch.ts +66 -15
- package/cli/selftune/normalization.ts +682 -0
- package/cli/selftune/observability.ts +19 -44
- package/cli/selftune/orchestrate.ts +1073 -0
- package/cli/selftune/quickstart.ts +203 -0
- package/cli/selftune/repair/skill-usage.ts +576 -0
- package/cli/selftune/schedule.ts +561 -0
- package/cli/selftune/status.ts +48 -26
- package/cli/selftune/sync.ts +627 -0
- package/cli/selftune/types.ts +148 -0
- package/cli/selftune/utils/canonical-log.ts +45 -0
- package/cli/selftune/utils/hooks.ts +41 -0
- package/cli/selftune/utils/html.ts +27 -0
- package/cli/selftune/utils/llm-call.ts +78 -20
- package/cli/selftune/utils/math.ts +10 -0
- package/cli/selftune/utils/query-filter.ts +139 -0
- package/cli/selftune/utils/skill-discovery.ts +340 -0
- package/cli/selftune/utils/skill-log.ts +68 -0
- package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
- package/cli/selftune/utils/transcript.ts +272 -26
- package/cli/selftune/workflows/discover.ts +254 -0
- package/cli/selftune/workflows/skill-md-writer.ts +288 -0
- package/cli/selftune/workflows/workflows.ts +188 -0
- package/package.json +21 -8
- package/packages/telemetry-contract/README.md +11 -0
- package/packages/telemetry-contract/fixtures/golden.json +87 -0
- package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
- package/packages/telemetry-contract/index.ts +1 -0
- package/packages/telemetry-contract/package.json +19 -0
- package/packages/telemetry-contract/src/index.ts +2 -0
- package/packages/telemetry-contract/src/types.ts +163 -0
- package/packages/telemetry-contract/src/validators.ts +109 -0
- package/skill/SKILL.md +84 -53
- package/skill/Workflows/AutoActivation.md +17 -16
- package/skill/Workflows/Badge.md +6 -0
- package/skill/Workflows/Baseline.md +46 -23
- package/skill/Workflows/Composability.md +12 -5
- package/skill/Workflows/Contribute.md +17 -14
- package/skill/Workflows/Cron.md +56 -79
- package/skill/Workflows/Dashboard.md +45 -34
- package/skill/Workflows/Doctor.md +30 -17
- package/skill/Workflows/Evals.md +64 -40
- package/skill/Workflows/EvolutionMemory.md +2 -0
- package/skill/Workflows/Evolve.md +102 -47
- package/skill/Workflows/EvolveBody.md +6 -6
- package/skill/Workflows/Grade.md +36 -31
- package/skill/Workflows/ImportSkillsBench.md +11 -5
- package/skill/Workflows/Ingest.md +43 -36
- package/skill/Workflows/Initialize.md +44 -30
- package/skill/Workflows/Orchestrate.md +139 -0
- package/skill/Workflows/Replay.md +39 -18
- package/skill/Workflows/Rollback.md +3 -3
- package/skill/Workflows/Schedule.md +61 -0
- package/skill/Workflows/Sync.md +88 -0
- package/skill/Workflows/UnitTest.md +34 -22
- package/skill/Workflows/Watch.md +14 -4
- package/skill/Workflows/Workflows.md +129 -0
- package/skill/assets/activation-rules-default.json +26 -0
- package/skill/assets/multi-skill-settings.json +63 -0
- package/skill/assets/single-skill-settings.json +57 -0
- package/skill/references/invocation-taxonomy.md +2 -2
- package/skill/references/logs.md +164 -2
- package/skill/references/setup-patterns.md +65 -0
- package/skill/references/version-history.md +40 -0
- package/skill/settings_snippet.json +1 -1
- package/templates/multi-skill-settings.json +7 -7
- package/templates/single-skill-settings.json +6 -6
- package/dashboard/index.html +0 -1680
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* selftune schedule — Generate scheduling examples for automated selftune runs.
|
|
4
|
+
*
|
|
5
|
+
* Outputs ready-to-use snippets for system cron, macOS launchd, and Linux systemd.
|
|
6
|
+
* This is the generic, agent-agnostic way to automate selftune.
|
|
7
|
+
*
|
|
8
|
+
* For OpenClaw-specific scheduling, see `selftune cron`.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* selftune schedule [--format cron|launchd|systemd] [--install] [--dry-run]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { parseArgs } from "node:util";
|
|
19
|
+
|
|
20
|
+
import { DEFAULT_CRON_JOBS } from "./cron/setup.js";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Schedule definitions — derived from the shared DEFAULT_CRON_JOBS
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface ScheduleEntry {
|
|
27
|
+
name: string;
|
|
28
|
+
schedule: string;
|
|
29
|
+
command: string;
|
|
30
|
+
description: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Map cron job metadata to schedule entries with CLI commands. */
|
|
34
|
+
function commandForJob(jobName: string): string {
|
|
35
|
+
switch (jobName) {
|
|
36
|
+
case "selftune-sync":
|
|
37
|
+
return "selftune sync";
|
|
38
|
+
case "selftune-status":
|
|
39
|
+
return "selftune sync && selftune status";
|
|
40
|
+
case "selftune-orchestrate":
|
|
41
|
+
return "selftune orchestrate --max-skills 3";
|
|
42
|
+
default:
|
|
43
|
+
return `selftune ${jobName.replace("selftune-", "")}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const SCHEDULE_ENTRIES: ScheduleEntry[] = DEFAULT_CRON_JOBS.map((job) => ({
|
|
48
|
+
name: job.name,
|
|
49
|
+
schedule: job.cron,
|
|
50
|
+
command: commandForJob(job.name),
|
|
51
|
+
description: job.description,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
export interface ScheduleInstallArtifact {
|
|
55
|
+
path: string;
|
|
56
|
+
content: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ScheduleInstallResult {
|
|
60
|
+
format: ScheduleFormat;
|
|
61
|
+
artifacts: ScheduleInstallArtifact[];
|
|
62
|
+
activationCommands: string[];
|
|
63
|
+
activated: boolean;
|
|
64
|
+
dryRun: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const CRON_BEGIN_MARKER = "# BEGIN SELFTUNE";
|
|
68
|
+
const CRON_END_MARKER = "# END SELFTUNE";
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Helpers for launchd/systemd generation
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Convert a cron schedule to launchd scheduling XML.
|
|
76
|
+
* Uses StartInterval for repeating intervals (e.g. every N minutes/hours),
|
|
77
|
+
* and StartCalendarInterval for fixed calendar times (e.g. daily at 8am).
|
|
78
|
+
*/
|
|
79
|
+
function cronToLaunchdSchedule(cron: string): string {
|
|
80
|
+
// Repeating intervals: */N minutes
|
|
81
|
+
if (cron.startsWith("*/")) {
|
|
82
|
+
const minutes = Number.parseInt(cron.split(" ")[0].replace("*/", ""), 10);
|
|
83
|
+
return ` <key>StartInterval</key>\n <integer>${minutes * 60}</integer>`;
|
|
84
|
+
}
|
|
85
|
+
// Repeating intervals: every N hours
|
|
86
|
+
if (cron.startsWith("0 */")) {
|
|
87
|
+
const hours = Number.parseInt(cron.split(" ")[1].replace("*/", ""), 10);
|
|
88
|
+
return ` <key>StartInterval</key>\n <integer>${hours * 3600}</integer>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Fixed calendar times use StartCalendarInterval
|
|
92
|
+
const parts = cron.split(" ");
|
|
93
|
+
const [minute, hour, , , weekday] = parts;
|
|
94
|
+
let dict = " <key>StartCalendarInterval</key>\n <dict>";
|
|
95
|
+
if (weekday !== "*") {
|
|
96
|
+
dict += `\n <key>Weekday</key>\n <integer>${weekday}</integer>`;
|
|
97
|
+
}
|
|
98
|
+
if (hour !== "*") {
|
|
99
|
+
dict += `\n <key>Hour</key>\n <integer>${Number.parseInt(hour, 10)}</integer>`;
|
|
100
|
+
}
|
|
101
|
+
if (minute !== "*") {
|
|
102
|
+
dict += `\n <key>Minute</key>\n <integer>${Number.parseInt(minute, 10)}</integer>`;
|
|
103
|
+
}
|
|
104
|
+
dict += "\n </dict>";
|
|
105
|
+
return dict;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Convert a cron schedule to a systemd OnCalendar value. */
|
|
109
|
+
function cronToOnCalendar(cron: string): string {
|
|
110
|
+
if (cron === "*/30 * * * *") return "*:0/30";
|
|
111
|
+
if (cron === "0 8 * * *") return "*-*-* 08:00:00";
|
|
112
|
+
if (cron === "0 */6 * * *") return "*-*-* 0/6:00:00";
|
|
113
|
+
return cron;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Build launchd ProgramArguments, using /bin/sh -c for chained commands. */
|
|
117
|
+
function toLaunchdArgs(command: string): string {
|
|
118
|
+
if (command.includes(" && ")) {
|
|
119
|
+
return ["/bin/sh", "-c", command].map((a) => ` <string>${a}</string>`).join("\n");
|
|
120
|
+
}
|
|
121
|
+
return command
|
|
122
|
+
.split(" ")
|
|
123
|
+
.map((a) => ` <string>${a}</string>`)
|
|
124
|
+
.join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Build systemd ExecStart, using /bin/sh -c for chained commands. */
|
|
128
|
+
function toSystemdExecStart(command: string): string {
|
|
129
|
+
if (command.includes(" && ")) {
|
|
130
|
+
return `/bin/sh -c "${command}"`;
|
|
131
|
+
}
|
|
132
|
+
return command;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Generators
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
export function generateCrontab(): string {
|
|
140
|
+
const lines = [
|
|
141
|
+
"# selftune automation — add to your crontab with: crontab -e",
|
|
142
|
+
"#",
|
|
143
|
+
"# The core loop: sync → orchestrate",
|
|
144
|
+
"# status remains a reporting job; orchestrate handles sync, candidate",
|
|
145
|
+
"# selection, low-risk description evolution, and watch/rollback follow-up.",
|
|
146
|
+
"#",
|
|
147
|
+
];
|
|
148
|
+
for (const entry of SCHEDULE_ENTRIES) {
|
|
149
|
+
lines.push(`# ${entry.description}`);
|
|
150
|
+
lines.push(`${entry.schedule} ${entry.command}`);
|
|
151
|
+
lines.push("");
|
|
152
|
+
}
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function escapeRegex(value: string): string {
|
|
157
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function wrapManagedCrontabBlock(content: string): string {
|
|
161
|
+
return `${CRON_BEGIN_MARKER}\n${content.trim()}\n${CRON_END_MARKER}\n`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function mergeManagedCrontab(existing: string, managedContent: string): string {
|
|
165
|
+
const managedBlock = wrapManagedCrontabBlock(managedContent);
|
|
166
|
+
const normalizedExisting = existing.replace(/\r\n/g, "\n");
|
|
167
|
+
const markerPattern = new RegExp(
|
|
168
|
+
`${escapeRegex(CRON_BEGIN_MARKER)}[\\s\\S]*?${escapeRegex(CRON_END_MARKER)}\\n?`,
|
|
169
|
+
"g",
|
|
170
|
+
);
|
|
171
|
+
const withoutExistingBlock = normalizedExisting.replace(markerPattern, "").trimEnd();
|
|
172
|
+
|
|
173
|
+
if (!withoutExistingBlock) {
|
|
174
|
+
return managedBlock;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return `${withoutExistingBlock}\n\n${managedBlock}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildLaunchdDefinition(entry: ScheduleEntry): { label: string; content: string } {
|
|
181
|
+
const label = `com.selftune.${entry.name.replace("selftune-", "")}`;
|
|
182
|
+
const args = toLaunchdArgs(entry.command);
|
|
183
|
+
const schedule = cronToLaunchdSchedule(entry.schedule);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
label,
|
|
187
|
+
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
188
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
189
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
190
|
+
<!--
|
|
191
|
+
${entry.description}
|
|
192
|
+
|
|
193
|
+
Install:
|
|
194
|
+
cp ${label}.plist ~/Library/LaunchAgents/
|
|
195
|
+
launchctl load ~/Library/LaunchAgents/${label}.plist
|
|
196
|
+
-->
|
|
197
|
+
<plist version="1.0">
|
|
198
|
+
<dict>
|
|
199
|
+
<key>Label</key>
|
|
200
|
+
<string>${label}</string>
|
|
201
|
+
<key>ProgramArguments</key>
|
|
202
|
+
<array>
|
|
203
|
+
${args}
|
|
204
|
+
</array>
|
|
205
|
+
${schedule}
|
|
206
|
+
<key>StandardOutPath</key>
|
|
207
|
+
<string>/tmp/${entry.name}.log</string>
|
|
208
|
+
<key>StandardErrorPath</key>
|
|
209
|
+
<string>/tmp/${entry.name}.err</string>
|
|
210
|
+
</dict>
|
|
211
|
+
</plist>`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function generateLaunchd(): string {
|
|
216
|
+
const plists: string[] = [];
|
|
217
|
+
|
|
218
|
+
for (const entry of SCHEDULE_ENTRIES) {
|
|
219
|
+
plists.push(buildLaunchdDefinition(entry).content);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return plists.join("\n\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function buildSystemdDefinition(entry: ScheduleEntry): {
|
|
226
|
+
baseName: string;
|
|
227
|
+
timerContent: string;
|
|
228
|
+
serviceContent: string;
|
|
229
|
+
} {
|
|
230
|
+
const unitName = entry.name;
|
|
231
|
+
const calendar = cronToOnCalendar(entry.schedule);
|
|
232
|
+
const execStart = toSystemdExecStart(entry.command);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
baseName: unitName,
|
|
236
|
+
timerContent: `[Unit]
|
|
237
|
+
Description=${entry.description}
|
|
238
|
+
|
|
239
|
+
[Timer]
|
|
240
|
+
OnCalendar=${calendar}
|
|
241
|
+
Persistent=true
|
|
242
|
+
|
|
243
|
+
[Install]
|
|
244
|
+
WantedBy=timers.target`,
|
|
245
|
+
serviceContent: `[Unit]
|
|
246
|
+
Description=${entry.description}
|
|
247
|
+
|
|
248
|
+
[Service]
|
|
249
|
+
Type=oneshot
|
|
250
|
+
ExecStart=${execStart}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function generateSystemd(): string {
|
|
255
|
+
const units: string[] = [];
|
|
256
|
+
|
|
257
|
+
for (const entry of SCHEDULE_ENTRIES) {
|
|
258
|
+
const definition = buildSystemdDefinition(entry);
|
|
259
|
+
|
|
260
|
+
units.push(`# --- ${definition.baseName}.timer ---
|
|
261
|
+
# ${entry.description}
|
|
262
|
+
#
|
|
263
|
+
# Install:
|
|
264
|
+
# cp ${definition.baseName}.service ${definition.baseName}.timer ~/.config/systemd/user/
|
|
265
|
+
# systemctl --user daemon-reload
|
|
266
|
+
# systemctl --user enable --now ${definition.baseName}.timer
|
|
267
|
+
|
|
268
|
+
${definition.timerContent}
|
|
269
|
+
|
|
270
|
+
# --- ${definition.baseName}.service ---
|
|
271
|
+
${definition.serviceContent}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return units.join("\n\n");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function selectInstallFormat(
|
|
278
|
+
requested?: string,
|
|
279
|
+
platform: NodeJS.Platform = process.platform,
|
|
280
|
+
): { ok: true; format: ScheduleFormat } | { ok: false; error: string } {
|
|
281
|
+
if (requested) {
|
|
282
|
+
if (!isValidFormat(requested)) {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
error: `Unknown format "${requested}". Valid formats: ${VALID_FORMATS.join(", ")}`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return { ok: true, format: requested };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (platform === "darwin") return { ok: true, format: "launchd" };
|
|
292
|
+
if (platform === "linux") return { ok: true, format: "systemd" };
|
|
293
|
+
return { ok: true, format: "cron" };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function buildInstallPlan(
|
|
297
|
+
format: ScheduleFormat,
|
|
298
|
+
homeDir = homedir(),
|
|
299
|
+
): { artifacts: ScheduleInstallArtifact[]; activationCommands: string[] } {
|
|
300
|
+
if (format === "cron") {
|
|
301
|
+
const path = join(homeDir, ".selftune", "schedule", "selftune.crontab");
|
|
302
|
+
return {
|
|
303
|
+
artifacts: [{ path, content: generateCrontab() }],
|
|
304
|
+
activationCommands: [`selftune schedule --apply-cron-artifact ${path}`],
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (format === "launchd") {
|
|
309
|
+
const launchAgentsDir = join(homeDir, "Library", "LaunchAgents");
|
|
310
|
+
const artifacts = SCHEDULE_ENTRIES.map((entry) => {
|
|
311
|
+
const definition = buildLaunchdDefinition(entry);
|
|
312
|
+
return {
|
|
313
|
+
path: join(launchAgentsDir, `${definition.label}.plist`),
|
|
314
|
+
content: definition.content,
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
artifacts,
|
|
320
|
+
activationCommands: artifacts.flatMap((artifact) => [
|
|
321
|
+
`launchctl unload ${artifact.path} >/dev/null 2>&1 || true`,
|
|
322
|
+
`launchctl load ${artifact.path}`,
|
|
323
|
+
]),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (format !== "systemd") {
|
|
328
|
+
throw new Error(`Unknown format "${format}". Valid formats: ${VALID_FORMATS.join(", ")}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const systemdDir = join(homeDir, ".config", "systemd", "user");
|
|
332
|
+
const definitions = SCHEDULE_ENTRIES.map(buildSystemdDefinition);
|
|
333
|
+
return {
|
|
334
|
+
artifacts: definitions.flatMap((definition) => [
|
|
335
|
+
{ path: join(systemdDir, `${definition.baseName}.timer`), content: definition.timerContent },
|
|
336
|
+
{
|
|
337
|
+
path: join(systemdDir, `${definition.baseName}.service`),
|
|
338
|
+
content: definition.serviceContent,
|
|
339
|
+
},
|
|
340
|
+
]),
|
|
341
|
+
activationCommands: [
|
|
342
|
+
"systemctl --user daemon-reload",
|
|
343
|
+
...definitions.map(
|
|
344
|
+
(definition) => `systemctl --user enable --now ${definition.baseName}.timer`,
|
|
345
|
+
),
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function runShellCommand(command: string): number {
|
|
351
|
+
const result = spawnSync("/bin/sh", ["-c", command], { stdio: "inherit" });
|
|
352
|
+
return result.status ?? 1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function readCurrentCrontab(): string {
|
|
356
|
+
const result = spawnSync("crontab", ["-l"], { encoding: "utf8" });
|
|
357
|
+
|
|
358
|
+
if (result.status === 0) {
|
|
359
|
+
return result.stdout;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const stderr = (result.stderr ?? "").trim();
|
|
363
|
+
if (stderr.includes("no crontab for")) {
|
|
364
|
+
return "";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
throw new Error(stderr || `crontab -l failed with exit code ${result.status ?? 1}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function applyCronArtifact(artifactPath: string): void {
|
|
371
|
+
const artifactContent = readFileSync(artifactPath, "utf-8");
|
|
372
|
+
const mergedPath = artifactPath.replace(/\.crontab$/, ".merged.crontab");
|
|
373
|
+
const mergedContent = mergeManagedCrontab(readCurrentCrontab(), artifactContent);
|
|
374
|
+
|
|
375
|
+
mkdirSync(dirname(mergedPath), { recursive: true });
|
|
376
|
+
writeFileSync(mergedPath, mergedContent, "utf-8");
|
|
377
|
+
|
|
378
|
+
const result = spawnSync("crontab", [mergedPath], { stdio: "inherit" });
|
|
379
|
+
if ((result.status ?? 1) !== 0) {
|
|
380
|
+
throw new Error(`Failed to install merged crontab from ${mergedPath}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function installSchedule(
|
|
385
|
+
options: {
|
|
386
|
+
format?: string;
|
|
387
|
+
dryRun?: boolean;
|
|
388
|
+
homeDir?: string;
|
|
389
|
+
platform?: NodeJS.Platform;
|
|
390
|
+
runCommand?: (command: string) => number;
|
|
391
|
+
} = {},
|
|
392
|
+
): ScheduleInstallResult {
|
|
393
|
+
const formatResult = selectInstallFormat(options.format, options.platform);
|
|
394
|
+
if (!formatResult.ok) {
|
|
395
|
+
throw new Error(formatResult.error);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const plan = buildInstallPlan(formatResult.format, options.homeDir);
|
|
399
|
+
const dryRun = options.dryRun ?? false;
|
|
400
|
+
|
|
401
|
+
for (const artifact of plan.artifacts) {
|
|
402
|
+
if (dryRun) continue;
|
|
403
|
+
mkdirSync(dirname(artifact.path), { recursive: true });
|
|
404
|
+
writeFileSync(artifact.path, artifact.content, "utf-8");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
let activated = false;
|
|
408
|
+
if (!dryRun) {
|
|
409
|
+
if (formatResult.format === "cron") {
|
|
410
|
+
const cronArtifact = plan.artifacts[0];
|
|
411
|
+
if (!cronArtifact) {
|
|
412
|
+
throw new Error("Cron install plan is missing the selftune crontab artifact.");
|
|
413
|
+
}
|
|
414
|
+
applyCronArtifact(cronArtifact.path);
|
|
415
|
+
activated = true;
|
|
416
|
+
} else {
|
|
417
|
+
const runCommand = options.runCommand ?? runShellCommand;
|
|
418
|
+
activated = plan.activationCommands.every((command) => runCommand(command) === 0);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
format: formatResult.format,
|
|
424
|
+
artifacts: plan.artifacts,
|
|
425
|
+
activationCommands: plan.activationCommands,
|
|
426
|
+
activated,
|
|
427
|
+
dryRun,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// CLI
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
const VALID_FORMATS = ["cron", "launchd", "systemd"] as const;
|
|
436
|
+
export type ScheduleFormat = (typeof VALID_FORMATS)[number];
|
|
437
|
+
|
|
438
|
+
function isValidFormat(value: string): value is ScheduleFormat {
|
|
439
|
+
return (VALID_FORMATS as readonly string[]).includes(value);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function formatOutput(
|
|
443
|
+
format?: string,
|
|
444
|
+
): { ok: true; data: string } | { ok: false; error: string } {
|
|
445
|
+
if (format && !isValidFormat(format)) {
|
|
446
|
+
return {
|
|
447
|
+
ok: false,
|
|
448
|
+
error: `Unknown format "${format}". Valid formats: ${VALID_FORMATS.join(", ")}`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const sections: string[] = [];
|
|
453
|
+
|
|
454
|
+
if (!format || format === "cron") {
|
|
455
|
+
sections.push("## System cron\n");
|
|
456
|
+
sections.push(generateCrontab());
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!format || format === "launchd") {
|
|
460
|
+
sections.push("## macOS launchd\n");
|
|
461
|
+
sections.push(generateLaunchd());
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!format || format === "systemd") {
|
|
465
|
+
sections.push("## Linux systemd\n");
|
|
466
|
+
sections.push(generateSystemd());
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return { ok: true, data: sections.join("\n\n") };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function cliMain(): void {
|
|
473
|
+
const { values } = parseArgs({
|
|
474
|
+
options: {
|
|
475
|
+
format: { type: "string", short: "f" },
|
|
476
|
+
install: { type: "boolean", default: false },
|
|
477
|
+
"dry-run": { type: "boolean", default: false },
|
|
478
|
+
"apply-cron-artifact": { type: "string" },
|
|
479
|
+
help: { type: "boolean", default: false },
|
|
480
|
+
},
|
|
481
|
+
strict: false,
|
|
482
|
+
allowPositionals: true,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
if (values["apply-cron-artifact"]) {
|
|
486
|
+
try {
|
|
487
|
+
applyCronArtifact(values["apply-cron-artifact"]);
|
|
488
|
+
return;
|
|
489
|
+
} catch (err) {
|
|
490
|
+
console.error(
|
|
491
|
+
`Failed to apply selftune cron artifact: ${err instanceof Error ? err.message : String(err)}`,
|
|
492
|
+
);
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (values.help) {
|
|
498
|
+
console.log(`selftune schedule — Generate scheduling examples for automation
|
|
499
|
+
|
|
500
|
+
Usage:
|
|
501
|
+
selftune schedule [--format cron|launchd|systemd] [--install] [--dry-run]
|
|
502
|
+
|
|
503
|
+
Flags:
|
|
504
|
+
--format, -f Output only one format (cron, launchd, or systemd)
|
|
505
|
+
--install Write and activate schedule artifacts for the selected platform
|
|
506
|
+
--dry-run Preview installed files and activation commands without writing
|
|
507
|
+
--help Show this help message
|
|
508
|
+
|
|
509
|
+
The selftune automation loop is:
|
|
510
|
+
sync → orchestrate
|
|
511
|
+
|
|
512
|
+
This command generates ready-to-use snippets for running that loop
|
|
513
|
+
with standard system scheduling tools. No agent runtime required.
|
|
514
|
+
|
|
515
|
+
For OpenClaw-specific scheduling, see: selftune cron`);
|
|
516
|
+
process.exit(0);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (values.install) {
|
|
520
|
+
try {
|
|
521
|
+
const result = installSchedule({
|
|
522
|
+
format: values.format,
|
|
523
|
+
dryRun: values["dry-run"] ?? false,
|
|
524
|
+
});
|
|
525
|
+
if (!result.dryRun && !result.activated) {
|
|
526
|
+
console.error("Failed to activate installed schedule artifacts.");
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
console.log(
|
|
530
|
+
JSON.stringify(
|
|
531
|
+
{
|
|
532
|
+
format: result.format,
|
|
533
|
+
installed: !result.dryRun,
|
|
534
|
+
activated: result.activated,
|
|
535
|
+
files: result.artifacts.map((artifact) => artifact.path),
|
|
536
|
+
activationCommands: result.activationCommands,
|
|
537
|
+
},
|
|
538
|
+
null,
|
|
539
|
+
2,
|
|
540
|
+
),
|
|
541
|
+
);
|
|
542
|
+
return;
|
|
543
|
+
} catch (err) {
|
|
544
|
+
console.error(
|
|
545
|
+
`Failed to install schedule artifacts: ${err instanceof Error ? err.message : String(err)}`,
|
|
546
|
+
);
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const result = formatOutput(values.format);
|
|
552
|
+
if (!result.ok) {
|
|
553
|
+
console.error(result.error);
|
|
554
|
+
process.exit(1);
|
|
555
|
+
}
|
|
556
|
+
console.log(result.data);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (import.meta.main) {
|
|
560
|
+
cliMain();
|
|
561
|
+
}
|