@sbains2/lifeos 0.2.2 → 0.3.1
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/bin/lifeos.js +68 -5
- package/package.json +2 -3
- package/src/commands/context.js +40 -0
- package/src/commands/schedule.js +748 -0
- package/src/context.js +203 -0
- package/src/doctor.js +1 -1
- package/src/index.js +4 -4
- package/templates/packs/job-search/README.md +82 -0
- package/templates/packs/job-search/SCOPE.template.md +156 -0
- package/templates/packs/job-search/bridge.md +89 -0
- package/templates/packs/job-search/council_playbook.md +117 -0
- package/templates/packs/job-search/scout_prompt.template.md +111 -0
- package/templates/prompts/weekly_digest.md +47 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lifeos schedule` — scheduled-agent runner.
|
|
3
|
+
*
|
|
4
|
+
* Generalizes a proven pattern: an OS-level scheduler (launchd on macOS, cron
|
|
5
|
+
* on Linux) fires a small shell job every N days/hours; the job runs your agent
|
|
6
|
+
* CLI non-interactively against a markdown prompt and captures the output as a
|
|
7
|
+
* local digest you read on your own time.
|
|
8
|
+
*
|
|
9
|
+
* Subcommands:
|
|
10
|
+
* add <name> --prompt <file> (--every 1d|2d|12h [--at HH:MM] | --cron "<expr>")
|
|
11
|
+
* [--runtime claude-code|codex] [--dry-run]
|
|
12
|
+
* list
|
|
13
|
+
* remove <name>
|
|
14
|
+
*
|
|
15
|
+
* Trust defaults (see docs/TRUST.md — non-negotiable):
|
|
16
|
+
* - Local-first: everything the job writes lands under <target>/.lifeos/schedule/.
|
|
17
|
+
* - Draft-only: scheduled prompts inherit draft-only rules; the runner never
|
|
18
|
+
* sends anything — it writes local files the user reviews.
|
|
19
|
+
* - No silent system mutation: on Linux we PRINT the crontab line and exact
|
|
20
|
+
* `crontab -e` guidance instead of editing the user's crontab. On macOS the
|
|
21
|
+
* plist install is explicit, announced, and reversible via `remove`.
|
|
22
|
+
* - Validate-before-write: name, cadence, prompt file, and existing state are
|
|
23
|
+
* all checked before any file is touched.
|
|
24
|
+
*
|
|
25
|
+
* Structure: all string generation (plist, cron line, job script) is pure and
|
|
26
|
+
* exported so tests never need launchctl or a real scheduler.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { execFileSync } from 'node:child_process';
|
|
30
|
+
import {
|
|
31
|
+
chmodSync,
|
|
32
|
+
existsSync,
|
|
33
|
+
mkdirSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
rmSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import { homedir as osHomedir } from 'node:os';
|
|
39
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
40
|
+
import { c } from '../utils/colors.js';
|
|
41
|
+
|
|
42
|
+
// =====================================================================
|
|
43
|
+
// Runtime adapters — the multi-runtime connector table.
|
|
44
|
+
// Add future runtimes (cursor-agent, gemini, ...) as new entries; everything
|
|
45
|
+
// downstream (job script, dry-run output, config fallback) reads this table.
|
|
46
|
+
// =====================================================================
|
|
47
|
+
|
|
48
|
+
export const RUNTIME_ADAPTERS = {
|
|
49
|
+
'claude-code': {
|
|
50
|
+
id: 'claude-code',
|
|
51
|
+
label: 'Claude Code',
|
|
52
|
+
bin: 'claude',
|
|
53
|
+
// `claude -p <prompt>` (alias --print) is Claude Code's non-interactive
|
|
54
|
+
// mode: runs the prompt, writes the result to stdout, exits.
|
|
55
|
+
args: (promptRef) => ['-p', promptRef],
|
|
56
|
+
},
|
|
57
|
+
codex: {
|
|
58
|
+
id: 'codex',
|
|
59
|
+
label: 'OpenAI Codex',
|
|
60
|
+
bin: 'codex',
|
|
61
|
+
// `codex exec <prompt>` is Codex CLI's non-interactive mode.
|
|
62
|
+
args: (promptRef) => ['exec', promptRef],
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const DEFAULT_RUNTIME = 'claude-code';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Shell fragment that invokes a runtime with the prompt held in $PROMPT.
|
|
70
|
+
* Pure; used verbatim inside the generated job script.
|
|
71
|
+
*/
|
|
72
|
+
export function buildRuntimeShellCommand(runtimeId) {
|
|
73
|
+
const adapter = RUNTIME_ADAPTERS[runtimeId];
|
|
74
|
+
if (!adapter) return null;
|
|
75
|
+
return [adapter.bin, ...adapter.args('"$PROMPT"')].join(' ');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve which runtime to use: explicit flag > lifeos.config.json
|
|
80
|
+
* (agent_runtime.provider or agent_runtime.primary) > claude-code.
|
|
81
|
+
* Unknown values from config fall back silently; unknown explicit flags
|
|
82
|
+
* return null so the command can error loudly.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveRuntime(explicit, config) {
|
|
85
|
+
if (explicit) return RUNTIME_ADAPTERS[explicit] ? explicit : null;
|
|
86
|
+
const fromConfig = config?.agent_runtime?.provider ?? config?.agent_runtime?.primary;
|
|
87
|
+
if (fromConfig && RUNTIME_ADAPTERS[fromConfig]) return fromConfig;
|
|
88
|
+
return DEFAULT_RUNTIME;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// =====================================================================
|
|
92
|
+
// Parsing — pure
|
|
93
|
+
// =====================================================================
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse an --every interval like "1d", "2d", "12h".
|
|
97
|
+
* Returns { value, unit: 'd'|'h', seconds } or null when invalid.
|
|
98
|
+
*/
|
|
99
|
+
export function parseInterval(str) {
|
|
100
|
+
if (typeof str !== 'string') return null;
|
|
101
|
+
const m = /^(\d+)([dh])$/.exec(str.trim());
|
|
102
|
+
if (!m) return null;
|
|
103
|
+
const value = Number(m[1]);
|
|
104
|
+
if (value < 1) return null;
|
|
105
|
+
const unit = m[2];
|
|
106
|
+
return { value, unit, seconds: value * (unit === 'd' ? 86400 : 3600) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const DEFAULT_AT = '08:00';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Parse an --at time like "08:00". Returns { hour, minute } or null.
|
|
113
|
+
*/
|
|
114
|
+
export function parseAt(str) {
|
|
115
|
+
if (typeof str !== 'string') return null;
|
|
116
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(str.trim());
|
|
117
|
+
if (!m) return null;
|
|
118
|
+
const hour = Number(m[1]);
|
|
119
|
+
const minute = Number(m[2]);
|
|
120
|
+
if (hour > 23 || minute > 59) return null;
|
|
121
|
+
return { hour, minute };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Names become launchd labels and filenames — keep the charset boring.
|
|
125
|
+
export function isValidScheduleName(name) {
|
|
126
|
+
return typeof name === 'string' && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(name);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Map a 5-field cron expression to launchd StartCalendarInterval keys.
|
|
131
|
+
* launchd has no step/range/list syntax, so only plain numerics and `*`
|
|
132
|
+
* translate. Returns { ok, fields } or { ok: false, error }.
|
|
133
|
+
*/
|
|
134
|
+
export function cronToCalendarFields(expr) {
|
|
135
|
+
const parts = String(expr).trim().split(/\s+/);
|
|
136
|
+
if (parts.length !== 5) {
|
|
137
|
+
return { ok: false, error: 'cron expression must have 5 fields (minute hour day month weekday)' };
|
|
138
|
+
}
|
|
139
|
+
const keys = ['Minute', 'Hour', 'Day', 'Month', 'Weekday'];
|
|
140
|
+
const max = { Minute: 59, Hour: 23, Day: 31, Month: 12, Weekday: 7 };
|
|
141
|
+
const fields = {};
|
|
142
|
+
for (let i = 0; i < 5; i++) {
|
|
143
|
+
const p = parts[i];
|
|
144
|
+
if (p === '*') continue;
|
|
145
|
+
if (!/^\d+$/.test(p)) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
error: `launchd can't express "${p}" (steps/ranges/lists) — use plain numbers, or use --every instead`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const n = Number(p);
|
|
152
|
+
if (n > max[keys[i]]) {
|
|
153
|
+
return { ok: false, error: `cron field ${keys[i].toLowerCase()} out of range: ${p}` };
|
|
154
|
+
}
|
|
155
|
+
fields[keys[i]] = n;
|
|
156
|
+
}
|
|
157
|
+
if (Object.keys(fields).length === 0) {
|
|
158
|
+
return { ok: false, error: 'cron "* * * * *" (every minute) is not supported — use --every' };
|
|
159
|
+
}
|
|
160
|
+
return { ok: true, fields };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Derive a cron expression from an --every interval (+ optional --at).
|
|
165
|
+
* "*\/N" day-of-month steps drift at month boundaries; the generated job
|
|
166
|
+
* script self-gates on a last-run stamp, so an occasional extra fire is a
|
|
167
|
+
* no-op rather than a double run.
|
|
168
|
+
*/
|
|
169
|
+
export function cronExprFromEvery(interval, at) {
|
|
170
|
+
if (interval.unit === 'h') {
|
|
171
|
+
return `0 */${interval.value} * * *`;
|
|
172
|
+
}
|
|
173
|
+
const { hour, minute } = at ?? parseAt(DEFAULT_AT);
|
|
174
|
+
if (interval.value === 1) return `${minute} ${hour} * * *`;
|
|
175
|
+
return `${minute} ${hour} */${interval.value} * *`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// =====================================================================
|
|
179
|
+
// Artifact string generation — pure, fully unit-testable
|
|
180
|
+
// =====================================================================
|
|
181
|
+
|
|
182
|
+
function xmlEscape(s) {
|
|
183
|
+
return String(s)
|
|
184
|
+
.replaceAll('&', '&')
|
|
185
|
+
.replaceAll('<', '<')
|
|
186
|
+
.replaceAll('>', '>');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// POSIX single-quote escaping: close, escaped quote, reopen.
|
|
190
|
+
function shQuote(s) {
|
|
191
|
+
return `'${String(s).replaceAll("'", `'\\''`)}'`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Decide the launchd trigger for a schedule spec.
|
|
196
|
+
* - --cron → StartCalendarInterval mapped from the cron fields
|
|
197
|
+
* - --every Nh → StartInterval (seconds)
|
|
198
|
+
* - --every Nd → StartCalendarInterval at --at, daily; launchd can't express
|
|
199
|
+
* multi-day cadence, so N>1 is enforced by the job script's last-run gate.
|
|
200
|
+
*/
|
|
201
|
+
export function launchdTrigger({ interval, at, cron }) {
|
|
202
|
+
if (cron) {
|
|
203
|
+
const mapped = cronToCalendarFields(cron);
|
|
204
|
+
if (!mapped.ok) return mapped;
|
|
205
|
+
return { ok: true, kind: 'calendar-fields', fields: mapped.fields };
|
|
206
|
+
}
|
|
207
|
+
if (interval.unit === 'h') return { ok: true, kind: 'interval', seconds: interval.seconds };
|
|
208
|
+
const time = at ?? parseAt(DEFAULT_AT);
|
|
209
|
+
return { ok: true, kind: 'calendar', hour: time.hour, minute: time.minute };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Generate the ~/Library/LaunchAgents plist as a string. Pure.
|
|
214
|
+
*/
|
|
215
|
+
export function buildLaunchdPlist({ name, jobScriptPath, trigger, stderrLogPath }) {
|
|
216
|
+
const label = `com.lifeos.${name}`;
|
|
217
|
+
let triggerXml;
|
|
218
|
+
if (trigger.kind === 'interval') {
|
|
219
|
+
triggerXml = [
|
|
220
|
+
' <key>StartInterval</key>',
|
|
221
|
+
` <integer>${trigger.seconds}</integer>`,
|
|
222
|
+
].join('\n');
|
|
223
|
+
} else if (trigger.kind === 'calendar') {
|
|
224
|
+
triggerXml = [
|
|
225
|
+
' <key>StartCalendarInterval</key>',
|
|
226
|
+
' <dict>',
|
|
227
|
+
` <key>Hour</key><integer>${trigger.hour}</integer>`,
|
|
228
|
+
` <key>Minute</key><integer>${trigger.minute}</integer>`,
|
|
229
|
+
' </dict>',
|
|
230
|
+
].join('\n');
|
|
231
|
+
} else {
|
|
232
|
+
const rows = Object.entries(trigger.fields)
|
|
233
|
+
.map(([k, v]) => ` <key>${k}</key><integer>${v}</integer>`)
|
|
234
|
+
.join('\n');
|
|
235
|
+
triggerXml = [' <key>StartCalendarInterval</key>', ' <dict>', rows, ' </dict>'].join('\n');
|
|
236
|
+
}
|
|
237
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
238
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
239
|
+
<plist version="1.0">
|
|
240
|
+
<dict>
|
|
241
|
+
<key>Label</key>
|
|
242
|
+
<string>${xmlEscape(label)}</string>
|
|
243
|
+
<key>ProgramArguments</key>
|
|
244
|
+
<array>
|
|
245
|
+
<string>/bin/sh</string>
|
|
246
|
+
<string>${xmlEscape(jobScriptPath)}</string>
|
|
247
|
+
</array>
|
|
248
|
+
${triggerXml}
|
|
249
|
+
<key>StandardErrorPath</key>
|
|
250
|
+
<string>${xmlEscape(stderrLogPath)}</string>
|
|
251
|
+
</dict>
|
|
252
|
+
</plist>
|
|
253
|
+
`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Generate the crontab line for Linux. Pure. We never install this — trust
|
|
258
|
+
* default: no silent system mutation. The caller prints it with `crontab -e`
|
|
259
|
+
* guidance.
|
|
260
|
+
*/
|
|
261
|
+
export function buildCronLine({ interval, at, cron, jobScriptPath }) {
|
|
262
|
+
const expr = cron ? String(cron).trim() : cronExprFromEvery(interval, at);
|
|
263
|
+
return `${expr} /bin/sh ${shQuote(jobScriptPath)}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Generate the job shell script. Pure. The script:
|
|
268
|
+
* 1. cds into the target dir
|
|
269
|
+
* 2. (multi-day cadence only) gates on a last-run stamp
|
|
270
|
+
* 3. runs the runtime CLI non-interactively with the prompt file contents
|
|
271
|
+
* 4. appends stdout+stderr to .lifeos/schedule/logs/<name>.log
|
|
272
|
+
* 5. overwrites .lifeos/schedule/out/<name>-latest.md — the digest
|
|
273
|
+
*/
|
|
274
|
+
export function buildJobScript({ targetDir, promptPath, runtimeId, name, gateSeconds = null }) {
|
|
275
|
+
const runtimeCmd = buildRuntimeShellCommand(runtimeId);
|
|
276
|
+
if (!runtimeCmd) return null;
|
|
277
|
+
|
|
278
|
+
// launchd/cron can't express "every N days" natively — on macOS the plist
|
|
279
|
+
// fires DAILY, so the job itself must enforce the cadence: an --every Nd job
|
|
280
|
+
// must never run more often than every N days. We gate on a last-run stamp
|
|
281
|
+
// and require the FULL interval to have elapsed before running again (no
|
|
282
|
+
// undershoot — undershooting would let the job run early, breaking the
|
|
283
|
+
// cadence guarantee). The stamp is written before the run so a same-second
|
|
284
|
+
// re-fire can't double-run.
|
|
285
|
+
const gateBlock =
|
|
286
|
+
gateSeconds == null
|
|
287
|
+
? ''
|
|
288
|
+
: `
|
|
289
|
+
STAMP=".lifeos/schedule/state/${name}.last-run"
|
|
290
|
+
NOW="$(date +%s)"
|
|
291
|
+
if [ -f "$STAMP" ]; then
|
|
292
|
+
LAST="$(cat "$STAMP" 2>/dev/null)"
|
|
293
|
+
[ -n "$LAST" ] || LAST=0
|
|
294
|
+
if [ $((NOW - LAST)) -lt ${gateSeconds} ]; then
|
|
295
|
+
exit 0
|
|
296
|
+
fi
|
|
297
|
+
fi
|
|
298
|
+
printf '%s' "$NOW" > "$STAMP"
|
|
299
|
+
`;
|
|
300
|
+
|
|
301
|
+
return `#!/bin/sh
|
|
302
|
+
# Generated by \`lifeos schedule add ${name}\`. Safe to delete; recreate with the CLI.
|
|
303
|
+
# Trust defaults: this job runs locally and never sends anything — it only
|
|
304
|
+
# writes files under .lifeos/schedule/ for you to review.
|
|
305
|
+
set -u
|
|
306
|
+
|
|
307
|
+
cd ${shQuote(targetDir)} || exit 1
|
|
308
|
+
mkdir -p .lifeos/schedule/logs .lifeos/schedule/out .lifeos/schedule/state
|
|
309
|
+
${gateBlock}
|
|
310
|
+
PROMPT="$(cat ${shQuote(promptPath)})" || exit 1
|
|
311
|
+
STARTED="$(date '+%Y-%m-%d %H:%M:%S')"
|
|
312
|
+
OUTPUT="$(${runtimeCmd} 2>&1)"
|
|
313
|
+
STATUS=$?
|
|
314
|
+
|
|
315
|
+
{
|
|
316
|
+
printf '\\n===== ${name} run %s (exit %s) =====\\n' "$STARTED" "$STATUS"
|
|
317
|
+
printf '%s\\n' "$OUTPUT"
|
|
318
|
+
} >> ".lifeos/schedule/logs/${name}.log"
|
|
319
|
+
|
|
320
|
+
printf '%s\\n' "$OUTPUT" > ".lifeos/schedule/out/${name}-latest.md"
|
|
321
|
+
exit $STATUS
|
|
322
|
+
`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// =====================================================================
|
|
326
|
+
// State — <targetDir>/.lifeos/schedule/schedules.json
|
|
327
|
+
// =====================================================================
|
|
328
|
+
|
|
329
|
+
export function schedulesPath(targetDir) {
|
|
330
|
+
return join(targetDir, '.lifeos', 'schedule', 'schedules.json');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Read schedule state. Returns { ok, data } or { ok: false, error } — a
|
|
335
|
+
* corrupt file is an error, never silently clobbered (validate-before-write).
|
|
336
|
+
*/
|
|
337
|
+
export function loadSchedules(targetDir) {
|
|
338
|
+
const p = schedulesPath(targetDir);
|
|
339
|
+
if (!existsSync(p)) return { ok: true, data: { version: 1, schedules: [] } };
|
|
340
|
+
try {
|
|
341
|
+
return { ok: true, data: JSON.parse(readFileSync(p, 'utf8')) };
|
|
342
|
+
} catch (err) {
|
|
343
|
+
return { ok: false, error: `${p} is not valid JSON (${err.message}) — fix or delete it, then retry` };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function saveSchedules(targetDir, data) {
|
|
348
|
+
const p = schedulesPath(targetDir);
|
|
349
|
+
mkdirSync(join(targetDir, '.lifeos', 'schedule'), { recursive: true });
|
|
350
|
+
writeFileSync(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// =====================================================================
|
|
354
|
+
// Planning — pure given (targetDir, opts); reads fs only to validate inputs
|
|
355
|
+
// =====================================================================
|
|
356
|
+
|
|
357
|
+
function readConfig(targetDir) {
|
|
358
|
+
const p = join(targetDir, 'lifeos.config.json');
|
|
359
|
+
if (!existsSync(p)) return null;
|
|
360
|
+
try {
|
|
361
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
362
|
+
} catch {
|
|
363
|
+
return null; // malformed config never blocks scheduling; runtime just falls back
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Validate inputs and compute every artifact for `add` without writing
|
|
369
|
+
* anything. Returns { ok, plan } or { ok: false, error, usage? }.
|
|
370
|
+
*/
|
|
371
|
+
export function planScheduleAdd(targetDir, opts) {
|
|
372
|
+
const { name, prompt, every, cron, at, runtime: runtimeFlag, platform, homedir } = opts;
|
|
373
|
+
|
|
374
|
+
if (!isValidScheduleName(name)) {
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
usage: true,
|
|
378
|
+
error: `invalid name "${name ?? ''}" — use lowercase letters, digits, hyphens, underscores (e.g. "digest")`,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (!prompt) {
|
|
382
|
+
return { ok: false, usage: true, error: 'missing --prompt <file> — the markdown prompt the agent runs' };
|
|
383
|
+
}
|
|
384
|
+
if (every && cron) {
|
|
385
|
+
return { ok: false, usage: true, error: '--every and --cron are mutually exclusive — pick one' };
|
|
386
|
+
}
|
|
387
|
+
if (!every && !cron) {
|
|
388
|
+
return { ok: false, usage: true, error: 'missing cadence — pass --every <interval> (e.g. 1d, 12h) or --cron "<expr>"' };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let interval = null;
|
|
392
|
+
let atTime = null;
|
|
393
|
+
if (every) {
|
|
394
|
+
interval = parseInterval(every);
|
|
395
|
+
if (!interval) {
|
|
396
|
+
return { ok: false, usage: true, error: `invalid --every "${every}" — use <n>d or <n>h (e.g. 1d, 2d, 12h)` };
|
|
397
|
+
}
|
|
398
|
+
if (at && interval.unit === 'h') {
|
|
399
|
+
return { ok: false, usage: true, error: '--at only applies to day-based intervals (--every 1d --at 08:00)' };
|
|
400
|
+
}
|
|
401
|
+
if (interval.unit === 'd') {
|
|
402
|
+
atTime = parseAt(at ?? DEFAULT_AT);
|
|
403
|
+
if (!atTime) {
|
|
404
|
+
return { ok: false, usage: true, error: `invalid --at "${at}" — use HH:MM (e.g. 08:00)` };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (cron && at) {
|
|
409
|
+
return { ok: false, usage: true, error: '--at only applies with --every; encode the time in the cron expression' };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const promptPath = isAbsolute(prompt) ? prompt : resolve(targetDir, prompt);
|
|
413
|
+
if (!existsSync(promptPath)) {
|
|
414
|
+
return { ok: false, error: `prompt file not found: ${promptPath}` };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const runtimeId = resolveRuntime(runtimeFlag, readConfig(targetDir));
|
|
418
|
+
if (!runtimeId) {
|
|
419
|
+
const known = Object.keys(RUNTIME_ADAPTERS).join(', ');
|
|
420
|
+
return { ok: false, usage: true, error: `unknown --runtime "${runtimeFlag}" — supported: ${known}` };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
424
|
+
return { ok: false, error: `unsupported platform "${platform}" — lifeos schedule supports macOS (launchd) and Linux (cron)` };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const jobScriptPath = join(targetDir, '.lifeos', 'schedule', 'jobs', `${name}.sh`);
|
|
428
|
+
const gateSeconds = interval && interval.unit === 'd' && interval.value > 1 ? interval.seconds : null;
|
|
429
|
+
const jobScript = buildJobScript({ targetDir, promptPath, runtimeId, name, gateSeconds });
|
|
430
|
+
|
|
431
|
+
const plan = {
|
|
432
|
+
name,
|
|
433
|
+
runtimeId,
|
|
434
|
+
promptPath,
|
|
435
|
+
interval,
|
|
436
|
+
at: atTime,
|
|
437
|
+
cron: cron ?? null,
|
|
438
|
+
platform,
|
|
439
|
+
jobScriptPath,
|
|
440
|
+
jobScript,
|
|
441
|
+
digestPath: join(targetDir, '.lifeos', 'schedule', 'out', `${name}-latest.md`),
|
|
442
|
+
logPath: join(targetDir, '.lifeos', 'schedule', 'logs', `${name}.log`),
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
if (platform === 'darwin') {
|
|
446
|
+
const trigger = launchdTrigger({ interval, at: atTime, cron });
|
|
447
|
+
if (!trigger.ok) return { ok: false, error: trigger.error };
|
|
448
|
+
plan.plistPath = join(homedir, 'Library', 'LaunchAgents', `com.lifeos.${name}.plist`);
|
|
449
|
+
plan.plist = buildLaunchdPlist({
|
|
450
|
+
name,
|
|
451
|
+
jobScriptPath,
|
|
452
|
+
trigger,
|
|
453
|
+
stderrLogPath: join(targetDir, '.lifeos', 'schedule', 'logs', `${name}.launchd.err.log`),
|
|
454
|
+
});
|
|
455
|
+
} else {
|
|
456
|
+
plan.cronLine = buildCronLine({ interval, at: atTime, cron, jobScriptPath });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return { ok: true, plan };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function cadenceLabel(entry) {
|
|
463
|
+
if (entry.cron) return `cron "${entry.cron}"`;
|
|
464
|
+
return entry.at ? `every ${entry.interval} at ${entry.at}` : `every ${entry.interval}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// =====================================================================
|
|
468
|
+
// Command orchestrator
|
|
469
|
+
// =====================================================================
|
|
470
|
+
|
|
471
|
+
export const SCHEDULE_HELP = `lifeos schedule — run an agent prompt on a schedule, digest the output locally
|
|
472
|
+
|
|
473
|
+
Usage:
|
|
474
|
+
lifeos schedule add <name> --prompt <file> (--every <interval> [--at HH:MM] | --cron "<expr>") [options]
|
|
475
|
+
lifeos schedule list
|
|
476
|
+
lifeos schedule remove <name>
|
|
477
|
+
|
|
478
|
+
Add options:
|
|
479
|
+
--prompt <file> Markdown prompt the agent runs (required)
|
|
480
|
+
--every <n>{d|h} Cadence: 1d, 2d, 12h ... (mutually exclusive with --cron)
|
|
481
|
+
--at HH:MM Time of day for day-based intervals (default 08:00)
|
|
482
|
+
--cron "<expr>" 5-field cron expression instead of --every
|
|
483
|
+
--runtime <id> claude-code | codex (default: agent_runtime from
|
|
484
|
+
lifeos.config.json, else claude-code)
|
|
485
|
+
--dry-run Print the artifacts (job script + plist/cron line),
|
|
486
|
+
install nothing
|
|
487
|
+
|
|
488
|
+
Platform behavior:
|
|
489
|
+
macOS installs ~/Library/LaunchAgents/com.lifeos.<name>.plist (launchctl load)
|
|
490
|
+
Linux prints the crontab line + "crontab -e" guidance — your crontab is
|
|
491
|
+
never edited silently
|
|
492
|
+
|
|
493
|
+
Trust defaults: scheduled prompts inherit draft-only rules. The runner never
|
|
494
|
+
sends anything — it writes local files under .lifeos/schedule/ for you to review.
|
|
495
|
+
|
|
496
|
+
Try the bundled digest prompt:
|
|
497
|
+
lifeos schedule add digest --prompt templates/prompts/weekly_digest.md --every 1d
|
|
498
|
+
`;
|
|
499
|
+
|
|
500
|
+
const VALUE_FLAGS = new Set(['--prompt', '--every', '--cron', '--at', '--runtime']);
|
|
501
|
+
const BOOL_FLAGS = new Set(['--dry-run', '--help', '-h']);
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Minimal argv parser for the schedule surface. bin/lifeos.js's global parser
|
|
505
|
+
* treats flags as valueless; schedule needs value flags, so it parses its own
|
|
506
|
+
* raw argv slice.
|
|
507
|
+
*/
|
|
508
|
+
export function parseScheduleArgs(argv) {
|
|
509
|
+
const out = { positional: [], flags: {}, errors: [] };
|
|
510
|
+
for (let i = 0; i < argv.length; i++) {
|
|
511
|
+
const a = argv[i];
|
|
512
|
+
if (VALUE_FLAGS.has(a)) {
|
|
513
|
+
const v = argv[i + 1];
|
|
514
|
+
if (v === undefined || v.startsWith('--')) out.errors.push(`${a} requires a value`);
|
|
515
|
+
else {
|
|
516
|
+
out.flags[a.slice(2)] = v;
|
|
517
|
+
i++;
|
|
518
|
+
}
|
|
519
|
+
} else if (BOOL_FLAGS.has(a)) {
|
|
520
|
+
out.flags[a === '-h' ? 'help' : a.slice(2)] = true;
|
|
521
|
+
} else if (a.startsWith('-')) {
|
|
522
|
+
out.errors.push(`unknown flag: ${a}`);
|
|
523
|
+
} else {
|
|
524
|
+
out.positional.push(a);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return out;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function defaultExec(cmd, args) {
|
|
531
|
+
execFileSync(cmd, args, { stdio: 'ignore' });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Entry point wired by bin/lifeos.js. argv is everything after the
|
|
536
|
+
* "schedule" token. deps are injectable for tests (never call launchctl in
|
|
537
|
+
* tests — pass a stub exec).
|
|
538
|
+
*/
|
|
539
|
+
export async function runSchedule(targetDir, argv = [], deps = {}) {
|
|
540
|
+
const {
|
|
541
|
+
platform = process.platform,
|
|
542
|
+
homedir = osHomedir(),
|
|
543
|
+
exec = defaultExec,
|
|
544
|
+
log = console.log,
|
|
545
|
+
logError = console.error,
|
|
546
|
+
now = () => new Date(),
|
|
547
|
+
} = deps;
|
|
548
|
+
|
|
549
|
+
const { positional, flags, errors } = parseScheduleArgs(argv);
|
|
550
|
+
const subcommand = positional[0];
|
|
551
|
+
|
|
552
|
+
if (flags.help || subcommand === 'help') {
|
|
553
|
+
log(SCHEDULE_HELP);
|
|
554
|
+
return 0;
|
|
555
|
+
}
|
|
556
|
+
if (errors.length > 0) {
|
|
557
|
+
for (const e of errors) logError(c.err(`✗ ${e}`));
|
|
558
|
+
return 2;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (subcommand === 'add') {
|
|
562
|
+
return runAdd(targetDir, positional[1], flags, { platform, homedir, exec, log, logError, now });
|
|
563
|
+
}
|
|
564
|
+
if (subcommand === 'list') {
|
|
565
|
+
return runList(targetDir, { log, logError });
|
|
566
|
+
}
|
|
567
|
+
if (subcommand === 'remove') {
|
|
568
|
+
return runRemove(targetDir, positional[1], { platform, exec, log, logError });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
logError(c.err(`✗ unknown subcommand: ${subcommand ?? '(none)'}`));
|
|
572
|
+
log('');
|
|
573
|
+
log(SCHEDULE_HELP);
|
|
574
|
+
return 2;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function runAdd(targetDir, name, flags, { platform, homedir, exec, log, logError, now }) {
|
|
578
|
+
const planned = planScheduleAdd(targetDir, {
|
|
579
|
+
name,
|
|
580
|
+
prompt: flags.prompt,
|
|
581
|
+
every: flags.every,
|
|
582
|
+
cron: flags.cron,
|
|
583
|
+
at: flags.at,
|
|
584
|
+
runtime: flags.runtime,
|
|
585
|
+
platform,
|
|
586
|
+
homedir,
|
|
587
|
+
});
|
|
588
|
+
if (!planned.ok) {
|
|
589
|
+
logError(c.err(`✗ ${planned.error}`));
|
|
590
|
+
if (planned.usage) logError(c.dim(' see: lifeos schedule --help'));
|
|
591
|
+
return planned.usage ? 2 : 1;
|
|
592
|
+
}
|
|
593
|
+
const plan = planned.plan;
|
|
594
|
+
|
|
595
|
+
if (flags['dry-run']) {
|
|
596
|
+
log('');
|
|
597
|
+
log(c.bold('lifeos schedule add --dry-run') + c.dim(' — would create (installing nothing):'));
|
|
598
|
+
log('');
|
|
599
|
+
log(c.cyan(` job script → ${plan.jobScriptPath}`));
|
|
600
|
+
log(indent(plan.jobScript));
|
|
601
|
+
if (platform === 'darwin') {
|
|
602
|
+
log(c.cyan(` launchd plist → ${plan.plistPath} (then: launchctl load)`));
|
|
603
|
+
log(indent(plan.plist));
|
|
604
|
+
} else {
|
|
605
|
+
log(c.cyan(' crontab line (printed, never installed for you):'));
|
|
606
|
+
log(indent(plan.cronLine));
|
|
607
|
+
}
|
|
608
|
+
log(c.dim(` state entry → ${schedulesPath(targetDir)}`));
|
|
609
|
+
log(c.dim(` digest lands at ${plan.digestPath}`));
|
|
610
|
+
log('');
|
|
611
|
+
return 0;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Validate-before-write: state must be readable and the name free before
|
|
615
|
+
// anything is created.
|
|
616
|
+
const state = loadSchedules(targetDir);
|
|
617
|
+
if (!state.ok) {
|
|
618
|
+
logError(c.err(`✗ ${state.error}`));
|
|
619
|
+
return 1;
|
|
620
|
+
}
|
|
621
|
+
if (state.data.schedules.some((s) => s.name === plan.name)) {
|
|
622
|
+
logError(c.err(`✗ a schedule named "${plan.name}" already exists — remove it first: lifeos schedule remove ${plan.name}`));
|
|
623
|
+
return 1;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
mkdirSync(join(targetDir, '.lifeos', 'schedule', 'jobs'), { recursive: true });
|
|
627
|
+
mkdirSync(join(targetDir, '.lifeos', 'schedule', 'logs'), { recursive: true });
|
|
628
|
+
mkdirSync(join(targetDir, '.lifeos', 'schedule', 'out'), { recursive: true });
|
|
629
|
+
writeFileSync(plan.jobScriptPath, plan.jobScript, 'utf8');
|
|
630
|
+
chmodSync(plan.jobScriptPath, 0o755);
|
|
631
|
+
|
|
632
|
+
if (platform === 'darwin') {
|
|
633
|
+
mkdirSync(join(homedir, 'Library', 'LaunchAgents'), { recursive: true });
|
|
634
|
+
writeFileSync(plan.plistPath, plan.plist, 'utf8');
|
|
635
|
+
// Unload first in case a stale job holds the label; load errors are real.
|
|
636
|
+
try {
|
|
637
|
+
exec('launchctl', ['unload', plan.plistPath]);
|
|
638
|
+
} catch {
|
|
639
|
+
/* not loaded — fine */
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
exec('launchctl', ['load', plan.plistPath]);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
logError(c.err(`✗ launchctl load failed: ${err.message}`));
|
|
645
|
+
logError(c.dim(` the plist is written at ${plan.plistPath} — retry with: launchctl load ${plan.plistPath}`));
|
|
646
|
+
return 1;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
state.data.schedules.push({
|
|
651
|
+
name: plan.name,
|
|
652
|
+
prompt: plan.promptPath,
|
|
653
|
+
interval: plan.interval ? `${plan.interval.value}${plan.interval.unit}` : null,
|
|
654
|
+
at: plan.at ? `${String(plan.at.hour).padStart(2, '0')}:${String(plan.at.minute).padStart(2, '0')}` : null,
|
|
655
|
+
cron: plan.cron,
|
|
656
|
+
runtime: plan.runtimeId,
|
|
657
|
+
platform,
|
|
658
|
+
job_script: plan.jobScriptPath,
|
|
659
|
+
plist: plan.plistPath ?? null,
|
|
660
|
+
created: now().toISOString(),
|
|
661
|
+
});
|
|
662
|
+
saveSchedules(targetDir, state.data);
|
|
663
|
+
|
|
664
|
+
log('');
|
|
665
|
+
log(c.bold(c.ok(`✓ schedule "${plan.name}" added`)) + c.dim(` (${RUNTIME_ADAPTERS[plan.runtimeId].label})`));
|
|
666
|
+
if (platform === 'darwin') {
|
|
667
|
+
log(c.dim(` launchd job installed: ${plan.plistPath}`));
|
|
668
|
+
} else {
|
|
669
|
+
log('');
|
|
670
|
+
log(c.warn(' Linux: your crontab is never edited silently. Install the job yourself:'));
|
|
671
|
+
log(` 1. run ${c.cyan('crontab -e')}`);
|
|
672
|
+
log(' 2. add this line:');
|
|
673
|
+
log(` ${c.cyan(plan.cronLine)}`);
|
|
674
|
+
}
|
|
675
|
+
log(c.dim(` digest: ${plan.digestPath}`));
|
|
676
|
+
log(c.dim(` log: ${plan.logPath}`));
|
|
677
|
+
log('');
|
|
678
|
+
log(c.dim(' Trust defaults: scheduled prompts inherit draft-only rules. This runner'));
|
|
679
|
+
log(c.dim(' never sends anything — it writes local files you review.'));
|
|
680
|
+
log('');
|
|
681
|
+
return 0;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function runList(targetDir, { log, logError }) {
|
|
685
|
+
const state = loadSchedules(targetDir);
|
|
686
|
+
if (!state.ok) {
|
|
687
|
+
logError(c.err(`✗ ${state.error}`));
|
|
688
|
+
return 1;
|
|
689
|
+
}
|
|
690
|
+
log('');
|
|
691
|
+
log(c.bold('lifeos schedule list'));
|
|
692
|
+
log('');
|
|
693
|
+
if (state.data.schedules.length === 0) {
|
|
694
|
+
log(c.dim(' no schedules installed. Try:'));
|
|
695
|
+
log(c.dim(' lifeos schedule add digest --prompt templates/prompts/weekly_digest.md --every 1d'));
|
|
696
|
+
log('');
|
|
697
|
+
return 0;
|
|
698
|
+
}
|
|
699
|
+
for (const s of state.data.schedules) {
|
|
700
|
+
log(` ${c.check} ${c.bold(s.name)} ${c.dim(`— ${cadenceLabel(s)}, ${s.runtime}`)}`);
|
|
701
|
+
log(c.dim(` prompt: ${s.prompt}`));
|
|
702
|
+
log(c.dim(` digest: ${join(targetDir, '.lifeos', 'schedule', 'out', `${s.name}-latest.md`)}`));
|
|
703
|
+
log(c.dim(` created: ${s.created}`));
|
|
704
|
+
}
|
|
705
|
+
log('');
|
|
706
|
+
return 0;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function runRemove(targetDir, name, { platform, exec, log, logError }) {
|
|
710
|
+
const state = loadSchedules(targetDir);
|
|
711
|
+
if (!state.ok) {
|
|
712
|
+
logError(c.err(`✗ ${state.error}`));
|
|
713
|
+
return 1;
|
|
714
|
+
}
|
|
715
|
+
const entry = state.data.schedules.find((s) => s.name === name);
|
|
716
|
+
if (!entry) {
|
|
717
|
+
logError(c.err(`✗ no schedule named "${name ?? ''}" — see: lifeos schedule list`));
|
|
718
|
+
return 1;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (entry.plist) {
|
|
722
|
+
try {
|
|
723
|
+
exec('launchctl', ['unload', entry.plist]);
|
|
724
|
+
} catch {
|
|
725
|
+
/* already unloaded — still remove the plist */
|
|
726
|
+
}
|
|
727
|
+
rmSync(entry.plist, { force: true });
|
|
728
|
+
}
|
|
729
|
+
if (entry.job_script) rmSync(entry.job_script, { force: true });
|
|
730
|
+
state.data.schedules = state.data.schedules.filter((s) => s.name !== name);
|
|
731
|
+
saveSchedules(targetDir, state.data);
|
|
732
|
+
|
|
733
|
+
log('');
|
|
734
|
+
log(c.bold(c.ok(`✓ schedule "${name}" removed`)));
|
|
735
|
+
if (platform === 'linux' || (!entry.plist && entry.platform === 'linux')) {
|
|
736
|
+
log(c.warn(` reminder: remove the matching line from your crontab (${c.cyan('crontab -e')}) — it was never installed by lifeos, so it's only there if you added it.`));
|
|
737
|
+
}
|
|
738
|
+
log(c.dim(` past digests/logs under .lifeos/schedule/ are kept — delete them yourself if unwanted.`));
|
|
739
|
+
log('');
|
|
740
|
+
return 0;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function indent(block) {
|
|
744
|
+
return block
|
|
745
|
+
.split('\n')
|
|
746
|
+
.map((l) => (l.length > 0 ? ` ${l}` : l))
|
|
747
|
+
.join('\n');
|
|
748
|
+
}
|