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:
Kjell Tore Guttormsen 2026-06-26 11:00:59 +02:00
commit 3276e44dbf
9 changed files with 768 additions and 9 deletions

View file

@ -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");
}

View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/**
* 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";
}