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

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