feat(linkedin-studio): RE-R3c — autonomous trigger (scheduler + headless entry) [skip-docs]
Closes research-engine hulls (1) no autonomous trigger + (6) no headless entry. Makes the daily research loop closed + headless: deterministic-brief-only (C1), print-first (C2 — the tool never runs launchctl or the cron table; --install writes only the inert launchd plist file). - NEW scripts/trends/src/schedule.ts — pure string emitters (launchd plist + cron-line + install/uninstall instructions + defaultLabel). No clock/fs/env/AI; byte-deterministic. - NEW scripts/trends/run-daily.sh — bash-3.2 headless wrapper: resolves node, cd's into the package so tsx resolves, logs via the data-path twin seam; runs the deterministic brief and appends one compact cron.log line per fire. The (e) AI-capture seam is documented, not built. - EDIT cli.ts — schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <p>]; print-first, no new exit code; logPath anchored to dirname(defaultStorePath()) (not the --store override). - WIRE trend-spotter.md (one prose line) + README (scheduler + wrapper + the C1 boundary). - Gate: TRENDS_TESTS_FLOOR 171->192, ASSERT_BASELINE_FLOOR 105->111, new UNCONDITIONAL Section 16l (6 deps-absent greps + non-vacuity self-test), header-enum + floor-history append. TDD two-phase RED -> GREEN. trends 192/192, gate 126/0, hook-suite 139/0 (untouched), plutil -lint OK. No schema change (SCHEMA_VERSION 4 / BRIEF_SCHEMA_VERSION 1). Counts 29/19/27 unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
This commit is contained in:
parent
b43757462b
commit
3276e44dbf
9 changed files with 768 additions and 9 deletions
|
|
@ -12,6 +12,8 @@
|
|||
* echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N]
|
||||
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]
|
||||
* node --import tsx src/cli.ts schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
|
||||
* [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]
|
||||
*
|
||||
* The capture agent (research-engine) folds freshly-polled trends into the store via
|
||||
* `capture` (the normalizing batch path: stdin → normalizeItem(s) → itemToInput →
|
||||
|
|
@ -33,12 +35,20 @@
|
|||
* `capture` normalizes + folds each valid item into the store (persisting `publishedAt`),
|
||||
* reporting content-invalid items in the summary `errors[]`, never via the exit code.
|
||||
*
|
||||
* `schedule` (RE-R3c) is PRINT-FIRST: it emits a launchd plist (macOS) / cron line (Linux) firing the
|
||||
* DETERMINISTIC `brief` daily via the `run-daily.sh` headless wrapper, plus the exact activation command.
|
||||
* `--install` writes only the inert launchd plist FILE; the tool never runs `launchctl` or the cron table
|
||||
* (the operator runs the one printed command). No AI capture in the nightly run — that is a later slice.
|
||||
*
|
||||
* Exit code: 0 on success, 2 on usage error or a not-found id (act/skip/reset). A wrong --id is an
|
||||
* argument-class error; capture's content-invalid items stay in errors[] (never via the exit code).
|
||||
* `schedule` adds no new exit code: an autonomy install never RUNS the system mutation.
|
||||
*/
|
||||
|
||||
import { readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
addTrend,
|
||||
|
|
@ -56,6 +66,8 @@ import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
|
|||
import { triage } from "./score.js";
|
||||
import type { ScoreMode } from "./score.js";
|
||||
import { rankForBrief, renderBrief, briefSummary, defaultBriefDir, surfacedIds } from "./brief.js";
|
||||
import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js";
|
||||
import type { ScheduleSpec } from "./schedule.js";
|
||||
|
||||
function parseFlags(args: string[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
|
@ -95,7 +107,8 @@ function usage(msg: string): never {
|
|||
" normalize < raw-item-or-batch.json\n" +
|
||||
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
|
||||
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
|
||||
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]",
|
||||
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--no-mark] [--store <path>] [--json]\n" +
|
||||
" schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
|
@ -327,6 +340,83 @@ function main(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (command === "schedule") {
|
||||
// RE-R3c — print-first autonomous trigger. Emits (or installs) a launchd plist / cron line firing
|
||||
// the DETERMINISTIC brief daily via run-daily.sh; never runs launchctl or the cron table (C2).
|
||||
const pillars = splitTopics(flags.pillars);
|
||||
if (pillars.length === 0) usage("schedule needs --pillars <a,b>");
|
||||
|
||||
const at = flags.at && flags.at !== "true" ? flags.at : "07:00";
|
||||
const [hStr, mStr] = at.split(":");
|
||||
const hour = Number.parseInt(hStr, 10);
|
||||
const minute = Number.parseInt(mStr ?? "", 10);
|
||||
if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
usage("--at must be HH:MM (00:00-23:59)");
|
||||
}
|
||||
|
||||
let freshDays = 7;
|
||||
if (flags["fresh-days"] && flags["fresh-days"] !== "true") {
|
||||
const n = Number.parseInt(flags["fresh-days"], 10);
|
||||
if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer");
|
||||
freshDays = n;
|
||||
}
|
||||
|
||||
const platformFlag = flags.platform && flags.platform !== "true" ? flags.platform : "auto";
|
||||
if (platformFlag !== "auto" && platformFlag !== "launchd" && platformFlag !== "cron") {
|
||||
usage("--platform must be auto|launchd|cron");
|
||||
}
|
||||
const platform: "launchd" | "cron" =
|
||||
platformFlag === "auto"
|
||||
? process.platform === "darwin"
|
||||
? "launchd"
|
||||
: "cron"
|
||||
: (platformFlag as "launchd" | "cron");
|
||||
|
||||
// Resolve every absolute path FROM THE RUNTIME (never hard-coded → domain-general).
|
||||
const here = dirname(fileURLToPath(import.meta.url)); // .../scripts/trends/src
|
||||
const wrapperPath = join(here, "..", "run-daily.sh");
|
||||
const workingDir = join(here, "..");
|
||||
const nodeBin = process.execPath;
|
||||
// logPath is anchored to the DATA ROOT (dirname(defaultStorePath())), NOT the --store override, so a
|
||||
// custom --store never splits the plist StandardOutPath from the wrapper's own cron.log (sibling of
|
||||
// morning-brief/, matching brief.ts defaultBriefDir's dirname(defaultStorePath()) idiom).
|
||||
const logPath = join(dirname(defaultStorePath()), "cron.log");
|
||||
const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio");
|
||||
// env is canonical: always NODE_BIN + a resolved-absolute data root — pins the scheduled run to the
|
||||
// install-time root AND removes the wrapper's $HOME-unset `set -u` edge under a profile-less env.
|
||||
const env: Record<string, string> = { NODE_BIN: nodeBin, LINKEDIN_STUDIO_DATA: root };
|
||||
|
||||
// The wrapper hard-codes the `brief` subcommand → the baked args carry NO leading "brief".
|
||||
const args = ["--pillars", pillars.join(","), "--fresh-days", String(freshDays)];
|
||||
if (flags.store && flags.store !== "true") args.push("--store", storePath);
|
||||
|
||||
const label = defaultLabel();
|
||||
const spec: ScheduleSpec = { platform, label, nodeBin, wrapperPath, args, hour, minute, logPath, workingDir, env };
|
||||
const plistTarget = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
||||
|
||||
if (flags.uninstall === "true") {
|
||||
console.log(uninstallInstructions(spec, platform === "launchd" ? plistTarget : undefined));
|
||||
if (platform === "launchd" && existsSync(plistTarget)) rmSync(plistTarget);
|
||||
return;
|
||||
}
|
||||
if (flags.install === "true") {
|
||||
if (platform === "launchd") {
|
||||
mkdirSync(dirname(plistTarget), { recursive: true });
|
||||
writeFileSync(plistTarget, launchdPlist(spec), "utf8");
|
||||
console.log(plistTarget);
|
||||
console.log(installInstructions(spec, plistTarget));
|
||||
} else {
|
||||
console.log(crontabLine(spec));
|
||||
console.log(installInstructions(spec));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// default / --print — emit the artifact + the activation command. No fs.
|
||||
console.log(platform === "launchd" ? launchdPlist(spec) : crontabLine(spec));
|
||||
console.log(installInstructions(spec, platform === "launchd" ? plistTarget : undefined));
|
||||
return;
|
||||
}
|
||||
|
||||
usage(command ? `unknown command: ${command}` : "no command given");
|
||||
}
|
||||
|
||||
|
|
|
|||
111
scripts/trends/src/schedule.ts
Normal file
111
scripts/trends/src/schedule.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* RE-R3c — pure string emitters for the autonomous-trigger artifacts (research-engine).
|
||||
*
|
||||
* No clock, no fs, no env, no AI: every value the emitters render is injected via `ScheduleSpec`
|
||||
* (the CLI is the only edge that reads `process.execPath` / `import.meta.url` / `defaultStorePath`).
|
||||
* Mirrors `brief.ts`'s `renderBrief` purity → byte-deterministic given inputs, fully testable.
|
||||
*
|
||||
* Print-first (C2): these are STRINGS. `launchdPlist`/`crontabLine` emit the artifact; the install/
|
||||
* uninstall instructions emit the exact command the operator runs. Nothing here ever executes
|
||||
* `launchctl` or the cron table — the emitted recipes are surfaced for the operator to run.
|
||||
*/
|
||||
|
||||
export interface ScheduleSpec {
|
||||
platform: "launchd" | "cron";
|
||||
label: string;
|
||||
nodeBin: string;
|
||||
wrapperPath: string;
|
||||
args: string[];
|
||||
hour: number;
|
||||
minute: number;
|
||||
logPath: string;
|
||||
workingDir: string;
|
||||
/** Canonical injected environment — always { NODE_BIN, LINKEDIN_STUDIO_DATA(resolved-absolute) }. */
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Defensive XML escaping for well-formedness (paths are normally safe, but escape regardless). */
|
||||
function xmlEscape(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* The launchd plist (macOS). A pinned, well-formed `<?xml … !DOCTYPE plist …>` template firing
|
||||
* the wrapper daily via StartCalendarInterval. `RunAtLoad` is false (a calendar job, not boot-time).
|
||||
*/
|
||||
export function launchdPlist(spec: ScheduleSpec): string {
|
||||
const programArguments = ["/bin/bash", spec.wrapperPath, ...spec.args]
|
||||
.map((a) => ` <string>${xmlEscape(a)}</string>`)
|
||||
.join("\n");
|
||||
const environment = Object.entries(spec.env)
|
||||
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${xmlEscape(spec.label)}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
${programArguments}
|
||||
</array>
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>${spec.hour}</integer>
|
||||
<key>Minute</key>
|
||||
<integer>${spec.minute}</integer>
|
||||
</dict>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
${environment}
|
||||
</dict>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${xmlEscape(spec.workingDir)}</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${xmlEscape(spec.logPath)}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${xmlEscape(spec.logPath)}</string>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cron schedule line (Linux). Returns the line as a STRING; never executes the cron table.
|
||||
* `<min> <hour> * * * <env-prefix> /bin/bash <wrapper> <args…> >> <log> 2>&1 # <label>`.
|
||||
*/
|
||||
export function crontabLine(spec: ScheduleSpec): string {
|
||||
const envPrefix = Object.entries(spec.env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(" ");
|
||||
const prefix = envPrefix.length > 0 ? `${envPrefix} ` : "";
|
||||
return (
|
||||
`${spec.minute} ${spec.hour} * * * ${prefix}/bin/bash ${spec.wrapperPath} ` +
|
||||
`${spec.args.join(" ")} >> ${spec.logPath} 2>&1 # ${spec.label}`
|
||||
);
|
||||
}
|
||||
|
||||
/** The exact activation command for the operator to run (print-first; the tool never runs it). */
|
||||
export function installInstructions(spec: ScheduleSpec, plistTargetPath?: string): string {
|
||||
if (spec.platform === "launchd") {
|
||||
return `Wrote ${plistTargetPath}. Activate it with:\n launchctl bootstrap gui/$(id -u) ${plistTargetPath}`;
|
||||
}
|
||||
return `Add the line above to your schedule with:\n (crontab -l 2>/dev/null; echo '${crontabLine(spec)}') | crontab -`;
|
||||
}
|
||||
|
||||
/** The symmetric removal command (print-first). */
|
||||
export function uninstallInstructions(spec: ScheduleSpec, plistTargetPath?: string): string {
|
||||
if (spec.platform === "launchd") {
|
||||
return `Deactivate + remove with:\n launchctl bootout gui/$(id -u)/${spec.label} && rm ${plistTargetPath}`;
|
||||
}
|
||||
return `Remove the scheduled line with:\n crontab -l | grep -vF '# ${spec.label}' | crontab -`;
|
||||
}
|
||||
|
||||
/** The reverse-DNS plugin namespace — domain-general, no vendor/sector token. */
|
||||
export function defaultLabel(): string {
|
||||
return "com.linkedin-studio.trends.daily";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue