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

@ -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 818).
#
# 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"

View file

@ -100,6 +100,12 @@ node --import tsx src/cli.ts brief --pillars "agents,engineering" \
node --import tsx src/cli.ts act --id <id> # wrote about it
node --import tsx src/cli.ts skip --id <id> # decided to pass on it
node --import tsx src/cli.ts reset --id <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 <path>]
```
Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend
@ -124,8 +130,27 @@ a scored entry shows `· <priority> (<mode>)` 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 `<ISO-ts> exit=<code> <json>` 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

39
scripts/trends/run-daily.sh Executable file
View file

@ -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"

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

View file

@ -439,3 +439,164 @@ describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)",
}
});
});
describe("trends CLI — schedule subcommand (RE-R3c / autonomous trigger, print-first)", () => {
// schedule is flag-driven + print-first. Capture stdout AND stderr (usage → stderr); override HOME
// (the ~/Library/LaunchAgents target) + LINKEDIN_STUDIO_DATA (the cron.log seam) into a temp dir so
// a real HOME is never touched. (os.homedir() respects $HOME on POSIX — verified.)
function runSched(
args: string[],
env: Record<string, string> = {},
): { status: number | null; stdout: string; stderr: string } {
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "schedule", ...args], {
input: "",
encoding: "utf8",
cwd: trendsDir,
env: { ...process.env, ...env },
});
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
}
const tmpHome = () => mkdtempSync(join(tmpdir(), "sched-home-"));
// A dependency-free well-formedness check: tokenize element tags and assert they nest/balance
// (the `<?xml?>` PI and `<!DOCTYPE>` are skipped — neither starts with a letter after `<`).
// SC1 asserts balance + key-completeness; `plutil -lint` is the deps-present manual check (Step 7).
function isBalancedXml(xml: string): boolean {
const stack: string[] = [];
const re = /<(\/?)([a-zA-Z][\w.:-]*)[^>]*?(\/?)>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(xml)) !== null) {
if (m[3] === "/") continue; // self-closing (e.g. <false/>)
if (m[1] === "/") {
if (stack.pop() !== m[2]) return false;
} else {
stack.push(m[2]);
}
}
return stack.length === 0;
}
test("SC1: --platform launchd --at 07:30 --print → key-complete, well-formed plist, exit 0", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(
["--pillars", "ai,gov", "--platform", "launchd", "--at", "07:30", "--print"],
{ HOME: home, LINKEDIN_STUDIO_DATA: home },
);
assert.equal(status, 0);
assert.match(stdout, /<key>Label<\/key>\s*<string>com\.linkedin-studio\.trends\.daily<\/string>/);
assert.ok(stdout.includes("<key>ProgramArguments</key>"), "ProgramArguments present");
assert.ok(stdout.includes("run-daily.sh"), "invokes the wrapper");
assert.ok(stdout.includes("--pillars") && stdout.includes("ai,gov"), "carries the pillars");
assert.match(stdout, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7");
assert.match(stdout, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30");
assert.ok(stdout.includes("<key>StandardOutPath</key>") && stdout.includes("<key>StandardErrorPath</key>"), "Std*Path present");
assert.ok(stdout.includes("cron.log"), "Std*Path point at cron.log");
assert.ok(stdout.includes("<key>EnvironmentVariables</key>"), "EnvironmentVariables present");
assert.ok(stdout.includes("NODE_BIN") && stdout.includes("LINKEDIN_STUDIO_DATA"), "env carries NODE_BIN + data root");
assert.ok(isBalancedXml(stdout), "the plist XML is balanced / well-formed");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC1: two --print runs (same args) are byte-identical (the emitter is pure)", () => {
const home = tmpHome();
try {
const a = runSched(["--pillars", "ai,gov", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
const b = runSched(["--pillars", "ai,gov", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
assert.equal(a.status, 0);
assert.equal(a.stdout, b.stdout);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC2: --platform cron --at 07:30 --print → cron line + install recipe (string only), exit 0", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(
["--pillars", "ai,gov", "--platform", "cron", "--at", "07:30", "--print"],
{ HOME: home, LINKEDIN_STUDIO_DATA: home },
);
assert.equal(status, 0);
assert.match(stdout, /^30 7 \* \* \* /m, "the cron line fires at 07:30 daily");
assert.ok(stdout.includes("run-daily.sh"), "invokes the wrapper");
assert.ok(stdout.includes("--pillars ai,gov"), "carries the pillars");
assert.ok(stdout.includes(">> ") && stdout.includes("2>&1"), "redirects stdout+stderr to the log");
assert.ok(stdout.includes("com.linkedin-studio.trends.daily"), "the label comment");
assert.ok(stdout.includes("crontab -"), "the install recipe is printed (a string the operator runs)");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC3: platform auto (no --platform) → launchd on darwin, cron elsewhere", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(["--pillars", "ai", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
assert.equal(status, 0);
if (process.platform === "darwin") {
assert.ok(stdout.includes("<key>Label</key>"), "darwin auto → launchd plist");
} else {
assert.match(stdout, /^\d+ \d+ \* \* \* /m, "non-darwin auto → cron line");
}
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC4: --print writes nothing (temp-HOME LaunchAgents stays absent), exit 0", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
assert.equal(status, 0);
assert.ok(stdout.includes("<key>Label</key>"), "the plist is on stdout");
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "--print creates no LaunchAgents dir");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC5: --install launchd → inert plist FILE written + launchctl printed (never run), exit 0", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "launchd", "--install"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
assert.equal(status, 0);
const plist = join(home, "Library", "LaunchAgents", "com.linkedin-studio.trends.daily.plist");
assert.ok(existsSync(plist), "the inert plist file is written");
const content = readFileSync(plist, "utf8");
assert.ok(content.includes("<key>Label</key>") && content.includes("com.linkedin-studio.trends.daily"), "the file holds the plist");
assert.ok(stdout.includes("launchctl bootstrap"), "the activation command is PRINTED (the tool never runs launchctl)");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC6: --install cron → line + install recipe printed, writes no file, exit 0", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "cron", "--install"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
assert.equal(status, 0);
assert.match(stdout, /^\d+ \d+ \* \* \* /m, "the cron line is printed");
assert.ok(stdout.includes("crontab -"), "the install recipe is printed (never executed)");
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "cron --install writes no plist");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC9: no --pillars / bad --at / bad --platform → exit 2; the fs is untouched", () => {
const home = tmpHome();
try {
assert.equal(runSched([], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "no --pillars → exit 2");
assert.equal(runSched(["--pillars", "ai", "--at", "25:00"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at 25:00 → exit 2");
assert.equal(runSched(["--pillars", "ai", "--at", "7:99"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at 7:99 → exit 2");
assert.equal(runSched(["--pillars", "ai", "--at", "noon"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at noon → exit 2");
assert.equal(runSched(["--pillars", "ai", "--platform", "bogus"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--platform bogus → exit 2");
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "a validation error writes nothing");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,134 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { defaultStorePath } from "../src/store.js";
// The hooks `.mjs` twin of the data-root seam — SC8 binds it into the consistency check.
import { getDataRoot } from "../../../hooks/scripts/data-root.mjs";
// Package root (scripts/trends) so the wrapper path + `tsx` resolution are cwd-independent.
const trendsDir = fileURLToPath(new URL("..", import.meta.url));
const wrapper = join(trendsDir, "run-daily.sh");
const today = new Date().toISOString().slice(0, 10);
// Invoke THROUGH `bash` (never a direct executable spawn): an ABSENT wrapper exits 127
// (a clean assertion-RED), where a direct exec of a missing file would throw ENOENT /
// status:null (a module-not-found-class failure). Folded — plan-critic #7.
function runWrapper(
args: string[],
env: Record<string, string> = {},
cwd: string = trendsDir,
): { status: number | null; stdout: string; stderr: string } {
const res = spawnSync("bash", [wrapper, ...args], {
input: "",
encoding: "utf8",
cwd,
env: { ...process.env, ...env },
});
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
}
describe("trends headless wrapper — run-daily.sh (RE-R3c / SC7, SC8)", () => {
const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10);
// One temp dir is BOTH the LINKEDIN_STUDIO_DATA root (so cron.log lands at <dir>/trends/cron.log)
// AND holds the seeded store + brief out-dir, so the real per-user data dir is never touched.
function fixture(): { dir: string; store: string; out: string } {
const dir = mkdtempSync(join(tmpdir(), "r3c-wrap-"));
const store = join(dir, "s.json");
writeFileSync(
store,
JSON.stringify({
schemaVersion: 2,
trends: [
{
id: "a",
title: "Fresh Match",
url: "https://e/a",
source: "tavily",
capturedAt: freshIso,
publishedAt: freshIso,
topics: ["ai", "gov"],
},
],
}),
);
return { dir, store, out: join(dir, "mb") };
}
test("SC7: seeded store → dated brief .md written + exactly one compact cron.log line + exit 0", () => {
const { dir, store, out } = fixture();
try {
const { status } = runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], {
LINKEDIN_STUDIO_DATA: dir,
});
assert.equal(status, 0, "the deterministic brief exits 0");
assert.ok(existsSync(join(out, `${today}.md`)), "the dated brief .md is written");
const log = readFileSync(join(dir, "trends", "cron.log"), "utf8");
const lines = log.split("\n").filter((l) => l.trim().length > 0);
assert.equal(lines.length, 1, "exactly one log line (the pretty-printed brief json compacted to one line)");
assert.match(lines[0], /^\S+ exit=0 /, "the line is `<ts> exit=0 <compact-json>`");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("SC7: a second same-day run → byte-identical .md, surfacedCount not double-counted, second log line", () => {
const { dir, store, out } = fixture();
try {
assert.equal(runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }).status, 0);
const md1 = readFileSync(join(out, `${today}.md`), "utf8");
assert.equal(runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }).status, 0);
const md2 = readFileSync(join(out, `${today}.md`), "utf8");
assert.equal(md1, md2, "the same-day re-render is byte-identical");
const persisted = JSON.parse(readFileSync(store, "utf8"));
assert.equal(persisted.trends[0].surfacedCount, 1, "surfacedCount stays 1 (R3b per-day idempotency)");
const lines = readFileSync(join(dir, "trends", "cron.log"), "utf8").split("\n").filter((l) => l.trim().length > 0);
assert.equal(lines.length, 2, "two runs → two log lines");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("SC7: CWD-independent — runs from an unrelated cwd (the wrapper's `cd \"$DIR\"` resolves tsx)", () => {
const { dir, store, out } = fixture();
const otherCwd = mkdtempSync(join(tmpdir(), "r3c-cwd-"));
try {
const { status } = runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }, otherCwd);
assert.equal(status, 0, "the wrapper resolves tsx from a cwd that is not the package dir");
assert.ok(existsSync(join(out, `${today}.md`)), "the dated brief is still written");
} finally {
rmSync(dir, { recursive: true, force: true });
rmSync(otherCwd, { recursive: true, force: true });
}
});
test("SC8: data-path twin consistency — wrapper log dir == dirname(defaultStorePath()) == getDataRoot('trends')", () => {
const { dir, store, out } = fixture();
const saved = process.env.LINKEDIN_STUDIO_DATA;
try {
runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir });
const wrapperLogDir = join(dir, "trends"); // where the wrapper actually wrote cron.log
assert.ok(existsSync(join(wrapperLogDir, "cron.log")), "the wrapper wrote into <data>/trends");
// The TS store twin + the hooks `.mjs` twin, resolved under the SAME override.
process.env.LINKEDIN_STUDIO_DATA = dir;
assert.equal(dirname(defaultStorePath()), wrapperLogDir, "TS dirname(defaultStorePath()) == wrapper log dir");
assert.equal(getDataRoot("trends"), wrapperLogDir, "hooks getDataRoot('trends') == wrapper log dir");
// Override-independence: a different root still resolves the two TS/JS twins identically.
const other = mkdtempSync(join(tmpdir(), "r3c-twin-"));
process.env.LINKEDIN_STUDIO_DATA = other;
assert.equal(dirname(defaultStorePath()), getDataRoot("trends"), "the twins agree for any override root");
rmSync(other, { recursive: true, force: true });
} finally {
if (saved === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
else process.env.LINKEDIN_STUDIO_DATA = saved;
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,120 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import {
launchdPlist,
crontabLine,
installInstructions,
uninstallInstructions,
defaultLabel,
type ScheduleSpec,
} from "../src/schedule.js";
// A fully-resolved spec, exactly as the CLI builds it at generation time (every value injected —
// the emitters read no clock/fs/env). `env` is canonical: always { NODE_BIN, LINKEDIN_STUDIO_DATA }.
function launchdSpec(): ScheduleSpec {
return {
platform: "launchd",
label: "com.linkedin-studio.trends.daily",
nodeBin: "/usr/local/bin/node",
wrapperPath: "/repo/scripts/trends/run-daily.sh",
args: ["--pillars", "ai,gov", "--fresh-days", "7"],
hour: 7,
minute: 30,
logPath: "/data/trends/cron.log",
workingDir: "/repo/scripts/trends",
env: { NODE_BIN: "/usr/local/bin/node", LINKEDIN_STUDIO_DATA: "/data" },
};
}
const cronSpec = (): ScheduleSpec => ({ ...launchdSpec(), platform: "cron" });
function isBalancedXml(xml: string): boolean {
const stack: string[] = [];
const re = /<(\/?)([a-zA-Z][\w.:-]*)[^>]*?(\/?)>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(xml)) !== null) {
if (m[3] === "/") continue;
if (m[1] === "/") {
if (stack.pop() !== m[2]) return false;
} else {
stack.push(m[2]);
}
}
return stack.length === 0;
}
describe("schedule.ts — pure launchd plist emitter (SC1)", () => {
test("key-complete: Label, ProgramArguments, StartCalendarInterval(H/M), Std*Path, EnvironmentVariables", () => {
const p = launchdPlist(launchdSpec());
assert.ok(p.includes("<key>Label</key>") && p.includes("com.linkedin-studio.trends.daily"), "Label");
assert.ok(p.includes("<key>ProgramArguments</key>") && p.includes("/bin/bash") && p.includes("run-daily.sh"), "ProgramArguments");
assert.ok(p.includes("--pillars") && p.includes("ai,gov"), "the args are rendered as <string> entries");
assert.ok(p.includes("<key>StartCalendarInterval</key>"), "StartCalendarInterval");
assert.match(p, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7");
assert.match(p, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30");
assert.ok(p.includes("<key>StandardOutPath</key>") && p.includes("<key>StandardErrorPath</key>"), "Std*Path");
assert.ok(p.includes("/data/trends/cron.log"), "Std*Path point at the resolved cron.log");
assert.ok(p.includes("<key>EnvironmentVariables</key>") && p.includes("NODE_BIN") && p.includes("LINKEDIN_STUDIO_DATA"), "env rendered");
assert.ok(p.includes("<key>WorkingDirectory</key>") && p.includes("/repo/scripts/trends"), "WorkingDirectory");
assert.ok(p.includes("RunAtLoad"), "RunAtLoad declared");
});
test("well-formed: balanced tags + plist DOCTYPE wrapper", () => {
const p = launchdPlist(launchdSpec());
assert.ok(p.startsWith("<?xml"), "begins with the XML declaration");
assert.ok(p.includes("<!DOCTYPE plist"), "has the plist DOCTYPE");
assert.ok(p.trimEnd().endsWith("</plist>"), "closes the plist element");
assert.ok(isBalancedXml(p), "every element tag is balanced");
});
test("pure / deterministic: two calls are byte-identical", () => {
assert.equal(launchdPlist(launchdSpec()), launchdPlist(launchdSpec()));
});
});
describe("schedule.ts — pure cron line emitter (SC2, string only)", () => {
test("the line carries time, env prefix, /bin/bash, wrapper, args, log redirect, label comment", () => {
const line = crontabLine(cronSpec());
assert.match(line, /^30 7 \* \* \* /, "fires 07:30 daily");
assert.ok(line.includes("NODE_BIN=/usr/local/bin/node"), "the env prefix carries NODE_BIN");
assert.ok(line.includes("LINKEDIN_STUDIO_DATA=/data"), "the env prefix carries the data root");
assert.ok(line.includes("/bin/bash") && line.includes("run-daily.sh"), "invokes the wrapper via bash");
assert.ok(line.includes("--pillars ai,gov"), "carries the pillars");
assert.ok(line.includes(">> /data/trends/cron.log 2>&1"), "redirects stdout+stderr to the log");
assert.ok(line.trimEnd().endsWith("# com.linkedin-studio.trends.daily"), "ends with the label comment");
assert.ok(!line.includes("\n"), "a single line");
});
});
describe("schedule.ts — install / uninstall instruction strings", () => {
const plistTarget = "/home/u/Library/LaunchAgents/com.linkedin-studio.trends.daily.plist";
test("install launchd: written-path + launchctl bootstrap recipe", () => {
const out = installInstructions(launchdSpec(), plistTarget);
assert.ok(out.includes(plistTarget), "names the written plist path");
assert.ok(out.includes("launchctl bootstrap"), "the activation recipe");
});
test("install cron: the crontab - install recipe carrying the line", () => {
const out = installInstructions(cronSpec());
assert.ok(out.includes("crontab -"), "the install recipe");
assert.ok(out.includes("run-daily.sh"), "carries the line to install");
});
test("uninstall launchd: bootout + rm the plist", () => {
const out = uninstallInstructions(launchdSpec(), plistTarget);
assert.ok(out.includes("launchctl bootout"), "the deactivation recipe");
assert.ok(out.includes(plistTarget), "removes the plist file");
});
test("uninstall cron: the line-removal recipe", () => {
const out = uninstallInstructions(cronSpec());
assert.ok(out.includes("crontab -") && out.includes("com.linkedin-studio.trends.daily"), "removes by the label comment");
});
});
describe("schedule.ts — defaultLabel (SC1/SC2 the plugin namespace)", () => {
test("is the reverse-DNS plugin namespace", () => {
assert.equal(defaultLabel(), "com.linkedin-studio.trends.daily");
});
});