From 3276e44dbf447f371f6d5eefe292762772bb94fe Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 11:00:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20RE-R3c=20=E2=80=94=20a?= =?UTF-8?q?utonomous=20trigger=20(scheduler=20+=20headless=20entry)=20[ski?= =?UTF-8?q?p-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store

]; 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) Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD --- agents/trend-spotter.md | 5 + scripts/test-runner.sh | 82 ++++++++++++- scripts/trends/README.md | 29 ++++- scripts/trends/run-daily.sh | 39 ++++++ scripts/trends/src/cli.ts | 96 ++++++++++++++- scripts/trends/src/schedule.ts | 111 +++++++++++++++++ scripts/trends/tests/cli.test.ts | 161 +++++++++++++++++++++++++ scripts/trends/tests/run-daily.test.ts | 134 ++++++++++++++++++++ scripts/trends/tests/schedule.test.ts | 120 ++++++++++++++++++ 9 files changed, 768 insertions(+), 9 deletions(-) create mode 100755 scripts/trends/run-daily.sh create mode 100644 scripts/trends/src/schedule.ts create mode 100644 scripts/trends/tests/run-daily.test.ts create mode 100644 scripts/trends/tests/schedule.test.ts diff --git a/agents/trend-spotter.md b/agents/trend-spotter.md index 8802b5d..dfa8ce7 100644 --- a/agents/trend-spotter.md +++ b/agents/trend-spotter.md @@ -343,6 +343,11 @@ written to `/trends/morning-brief/YYYY-MM-DD.md` and ranks only on per (pillar overlap + `publishedAt`/`capturedAt` freshness, default 7-day window — tune with `--fresh-days N`). Skip silently if the store has no deps installed — same escape hatch as Step 4.5. +The morning brief can also be **scheduled** to regenerate autonomously each morning — deterministic, +from the current store — via `src/cli.ts schedule` (print-first: it emits a launchd/cron entry firing +the `run-daily.sh` headless wrapper). That nightly run re-renders the brief only; your polling above +stays the capture path (autonomous AI polling is a later slice). + **Step 5: Compile digest** - Format using output template below diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index a1ee764..f32764d 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -54,7 +54,11 @@ # 'export type TrendStatus' AND carries 'surfacedCount', scripts/trends/src/store.ts owns # 'export function markSurfaced', scripts/trends/src/brief.ts excludes handled via 'effectiveStatus', # AND scripts/trends/src/cli.ts exposes 'command === "act"', with a non-vacuity self-test) in -# Section 16k; the assertion-count anti-erosion floor (SC6) in Section 18. All +# Section 16k; the trends-scheduler/headless wiring guard (RE-R3c: scripts/trends/src/schedule.ts +# emits 'export function launchdPlist' AND 'export function crontabLine', scripts/trends/src/cli.ts +# exposes 'command === "schedule"', scripts/trends/run-daily.sh runs 'cli.ts" brief' AND uses +# 'LINKEDIN_STUDIO_DATA:-', with a non-vacuity self-test) in Section 16l; the assertion-count +# anti-erosion floor (SC6) in Section 18. All # are live below (Sections 8–18). # # Usage: bash scripts/test-runner.sh @@ -706,7 +710,7 @@ if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" ) TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) - TRENDS_TESTS_FLOOR=171 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log) + TRENDS_TESTS_FLOOR=192 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log) + RE-R3c: schedule +9, cli +8, run-daily +4 (scheduler + headless wrapper) if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)" else @@ -1304,6 +1308,71 @@ fi echo "" +# --- Section 16l: Trends Scheduler / Headless Wiring (research-engine RE-R3c) --- +echo "--- Trends Scheduler / Headless Wiring ---" + +# RE-R3c adds the autonomous trigger + headless entry: a `schedule` CLI verb emitting a launchd plist / +# cron line (print-first), and a bash wrapper that runs the DETERMINISTIC brief headless. Five literals +# must hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx): +# (1) schedule.ts emits the launchd plist, by 'export function launchdPlist'; +# (2) schedule.ts emits the cron line, by 'export function crontabLine'; +# (3) cli.ts exposes the schedule verb, by 'command === "schedule"'; +# (4) run-daily.sh runs the deterministic brief, by 'cli.ts" brief' (the wrapper owns the subcommand); +# (5) run-daily.sh uses the data-path twin seam, by 'LINKEDIN_STUDIO_DATA:-'. +# Non-vacuity self-test mirrors Section 16k: a probe carrying the launchdPlist pointer is accepted and +# one without it rejected. Placed after Section 16k / before Section 18 (anti-erosion must run last so it +# sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +SCHED_PLIST_LIT='export function launchdPlist' +SCHED_CRON_LIT='export function crontabLine' +SCHED_VERB_LIT='command === "schedule"' +SCHED_BRIEF_LIT='cli.ts" brief' +SCHED_DATA_LIT='LINKEDIN_STUDIO_DATA:-' + +I16L_SELFTEST_OK=1 +if ! echo 'a wired scheduler declares: export function launchdPlist(spec)' | grep -qF "$SCHED_PLIST_LIT"; then + I16L_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired scheduler-emit probe was not detected" +fi +if echo 'an unwired module emits no artifact at all' | grep -qF "$SCHED_PLIST_LIT"; then + I16L_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the scheduler-emit pointer" +fi +if [ "$I16L_SELFTEST_OK" -eq 1 ]; then + pass "trends-scheduler self-test: the emit pointer is detected, the no-emit form rejected" +else + fail "trends-scheduler self-test failed — the scheduler-wiring lint is vacuous or over-eager" +fi + +if grep -qF "$SCHED_PLIST_LIT" scripts/trends/src/schedule.ts; then + pass "schedule.ts emits the launchd plist ('$SCHED_PLIST_LIT')" +else + fail "schedule.ts has no launchd emitter — add '$SCHED_PLIST_LIT' (RE-R3c scheduler)" +fi + +if grep -qF "$SCHED_CRON_LIT" scripts/trends/src/schedule.ts; then + pass "schedule.ts emits the cron line ('$SCHED_CRON_LIT')" +else + fail "schedule.ts has no cron-line emitter — add '$SCHED_CRON_LIT' (RE-R3c scheduler)" +fi + +if grep -qF "$SCHED_VERB_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the schedule verb ('$SCHED_VERB_LIT')" +else + fail "cli.ts has no schedule verb — add '$SCHED_VERB_LIT' (RE-R3c trigger)" +fi + +if grep -qF "$SCHED_BRIEF_LIT" scripts/trends/run-daily.sh; then + pass "run-daily.sh runs the deterministic brief ('$SCHED_BRIEF_LIT')" +else + fail "run-daily.sh does not run the brief — wire '$SCHED_BRIEF_LIT' (RE-R3c headless entry)" +fi + +if grep -qF "$SCHED_DATA_LIT" scripts/trends/run-daily.sh; then + pass "run-daily.sh uses the data-path twin seam ('$SCHED_DATA_LIT')" +else + fail "run-daily.sh has no data-path seam — add '$SCHED_DATA_LIT' (RE-R3c fourth twin)" +fi + +echo "" + # --- Section 18: Assertion-Count Anti-Erosion (SC6) --- # The lint self-modifies its own checks, so a green run could mask a silently dropped # assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count @@ -1321,12 +1390,17 @@ echo "" # (trends-brief self-test + cli.ts brief-handler grep + trend-spotter brief-pointer grep + # session-start surfacing grep) = 94; +5 for RE-R3a's five UNCONDITIONAL Section-16j checks # (trends-score self-test + score.ts TrendScore-iface grep + types.ts score-field grep + -# trend-spotter dimensions grep + brief.ts composite-rank grep) = 99. +# trend-spotter dimensions grep + brief.ts composite-rank grep) = 99; +6 for RE-R3b's six +# UNCONDITIONAL Section-16k checks (lifecycle self-test + types.ts TrendStatus grep + types.ts +# surfacedCount grep + store.ts markSurfaced grep + brief.ts effectiveStatus grep + cli.ts +# act-verb grep) = 105; +6 for RE-R3c's six UNCONDITIONAL Section-16l checks (scheduler self-test +# + schedule.ts launchdPlist grep + schedule.ts crontabLine grep + cli.ts schedule-verb grep + +# run-daily.sh brief-invocation grep + run-daily.sh data-twin grep) = 111. # NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop # the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the # deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh # clone). Runs last so TOTAL_CHECKS sees every prior check. -ASSERT_BASELINE_FLOOR=105 +ASSERT_BASELINE_FLOOR=111 TOTAL_CHECKS=$((PASS + FAIL)) if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" diff --git a/scripts/trends/README.md b/scripts/trends/README.md index df43796..1c340aa 100644 --- a/scripts/trends/README.md +++ b/scripts/trends/README.md @@ -100,6 +100,12 @@ node --import tsx src/cli.ts brief --pillars "agents,engineering" \ node --import tsx src/cli.ts act --id # wrote about it node --import tsx src/cli.ts skip --id # decided to pass on it node --import tsx src/cli.ts reset --id # return it to the queue + +# Autonomous trigger (RE-R3c) — emit/install a daily headless brief. PRINT-FIRST: the tool never runs +# launchctl or the cron table; --install writes only the inert launchd plist file. Deterministic +# (no AI capture — a later slice). Default 07:00; --platform auto → launchd on macOS, cron on Linux. +node --import tsx src/cli.ts schedule --pillars "agents,engineering" \ + [--at 07:00] [--fresh-days 7] [--platform auto|launchd|cron] [--install|--uninstall] [--store ] ``` Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend @@ -124,8 +130,27 @@ a scored entry shows `· ()` and the summary names the top entr As of **RE-R3b** the brief is a **work queue**: it **excludes** `acted`/`skipped` trends, shows each entry's `id` in backticks (copy-paste-ready for `act`/`skip --id`), flags a re-surfaced item with `· sett Nx` (prior-day count, ≥2), and — unless `--no-mark` — **records surfacing** on the -store (`surfacedCount`/`lastSurfacedAt`, per-day idempotent) after the pure render. An autonomous -nightly trigger and a brief-history diff remain later slices. +store (`surfacedCount`/`lastSurfacedAt`, per-day idempotent) after the pure render. + +## Autonomous trigger + headless entry (RE-R3c) + +`schedule` makes the daily loop **closed**: it emits — print-first — a launchd plist (macOS) or cron +line (Linux) firing the brief every morning, plus the exact activation command. `--install` writes only +the **inert** launchd plist file under `~/Library/LaunchAgents/`; the tool **never** runs `launchctl` +or the cron table — you run the one printed command. `--uninstall` prints the removal recipe (and +removes the plist file if present). + +Both schedulers invoke one tested headless wrapper, `run-daily.sh`, which runs the **deterministic** +brief from a scheduler's profile-less environment: it resolves node (baked `NODE_BIN`, else +`command -v`, else common locations), `cd`s into the package so `tsx` resolves, and appends one compact +line ` exit= ` to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log`. + +The nightly run is **deterministic-brief-only (C1)**: it re-renders the brief from the current store +— freshness-aging drops stale trends, `surfacedCount` accumulates day-over-day — but does **not** poll +new sources. A double-fire on the same day is a safe no-op (RE-R3b per-day idempotency: byte-identical +`.md`, `surfacedCount` not double-counted). The autonomous AI capture step (poll → score → capture +before the brief) plugs into the documented seam in `run-daily.sh` as a later slice (e); a +brief-history diff is also a later slice. ## Tests diff --git a/scripts/trends/run-daily.sh b/scripts/trends/run-daily.sh new file mode 100755 index 0000000..c6dc401 --- /dev/null +++ b/scripts/trends/run-daily.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# RE-R3c headless entry: runs the DETERMINISTIC morning brief from a scheduler's +# profile-less env (launchd/cron inherit no shell profile). No AI. +# +# The (e) slice will insert a pre-brief AI capture step here (poll -> score -> capture) +# before the `brief` call below; R3c builds ONLY the deterministic store -> artifact path. +# +# Data-path: the FOURTH sanctioned twin of store.ts:defaultStorePath / data-root.mjs:getDataRoot +# / analytics/src/utils/storage.ts:getDataRoot (the shell form of references/data-path-convention.md +# rule 1). Keep in sync. Bash 3.2-compatible: ASCII-only, all expansions quoted, no bash-4 features. +set -eu + +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" # so `--import tsx` resolves node_modules even under cron's $HOME cwd + +NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || true)}" +if [ -z "$NODE_BIN" ]; then + for c in /usr/local/bin/node /opt/homebrew/bin/node /usr/bin/node; do + if [ -x "$c" ]; then NODE_BIN="$c"; break; fi + done +fi + +LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log" +mkdir -p "$(dirname "$LOG")" +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +if [ -z "$NODE_BIN" ]; then + printf '%s exit=127 node not found\n' "$TS" >> "$LOG" + exit 127 +fi + +set +e +OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)"; CODE=$? +set -e + +# `brief --json` is pretty-printed (cli.ts JSON.stringify(…, null, 2)); collapse it to ONE line. +OUT="$(printf '%s' "$OUT" | tr '\n' ' ' | tr -s ' ')" +printf '%s exit=%s %s\n' "$TS" "$CODE" "$OUT" >> "$LOG" +exit "$CODE" diff --git a/scripts/trends/src/cli.ts b/scripts/trends/src/cli.ts index 8094c61..9e6fd13 100644 --- a/scripts/trends/src/cli.ts +++ b/scripts/trends/src/cli.ts @@ -12,6 +12,8 @@ * echo '' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N] * echo '' | node --import tsx src/cli.ts capture [--store ] [--json] * node --import tsx src/cli.ts brief [--pillars ] [--fresh-days N] [--out

] [--no-mark] [--store ] [--json] + * node --import tsx src/cli.ts schedule --pillars [--at HH:MM] [--fresh-days N] + * [--platform auto|launchd|cron] [--install|--uninstall] [--store ] * * 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 { const out: Record = {}; @@ -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 ] [--json] < raw-item-or-batch.json\n" + - " brief [--pillars ] [--fresh-days N] [--out ] [--no-mark] [--store ] [--json]", + " brief [--pillars ] [--fresh-days N] [--out ] [--no-mark] [--store ] [--json]\n" + + " schedule --pillars [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store ]", ); 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 "); + + 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 = { 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"); } diff --git a/scripts/trends/src/schedule.ts b/scripts/trends/src/schedule.ts new file mode 100644 index 0000000..7366c11 --- /dev/null +++ b/scripts/trends/src/schedule.ts @@ -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; +} + +/** Defensive XML escaping for well-formedness (paths are normally safe, but escape regardless). */ +function xmlEscape(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +/** + * The launchd plist (macOS). A pinned, well-formed `` 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) => ` ${xmlEscape(a)}`) + .join("\n"); + const environment = Object.entries(spec.env) + .map(([k, v]) => ` ${xmlEscape(k)}\n ${xmlEscape(v)}`) + .join("\n"); + return ` + + + + Label + ${xmlEscape(spec.label)} + ProgramArguments + +${programArguments} + + StartCalendarInterval + + Hour + ${spec.hour} + Minute + ${spec.minute} + + EnvironmentVariables + +${environment} + + WorkingDirectory + ${xmlEscape(spec.workingDir)} + StandardOutPath + ${xmlEscape(spec.logPath)} + StandardErrorPath + ${xmlEscape(spec.logPath)} + RunAtLoad + + + +`; +} + +/** + * The cron schedule line (Linux). Returns the line as a STRING; never executes the cron table. + * ` * * * /bin/bash >> 2>&1 #