linkedin-studio/docs/research-engine/brief-re-r3c.md
Kjell Tore Guttormsen b43757462b docs(linkedin-studio): RE-R3c brief + plan — autonomous trigger (scheduler + headless entry), light-Voyage hardened
Slice (c) of the R3 build-out: a `schedule` CLI verb (print-first launchd
plist / cron-table line) + `run-daily.sh`, a bash-3.2 headless wrapper that
runs the DETERMINISTIC morning brief from a profile-less scheduler env.
Closes hulls #1 (no autonomous trigger) + #6 (no headless entry point).

Operator-confirmed (AskUserQuestion 2026-06-26): C1 deterministic brief-only
(no AI capture — that is slice e, which plugs into the documented pre-brief
seam); C2 print-first installer (the tool emits the artifact + the install
command; `--install` writes only the inert launchd plist file; never runs the
scheduler activation itself).

Light-Voyage hardened — three Opus reviewers, each verifying against live
code: scope-guardian ALIGNED (0 creep/0 gaps), brief-reviewer
PROCEED_WITH_RISKS, plan-critic REVISE. All findings folded, incl. the
pretty-printed `brief --json` log-line compaction, the `cd "$DIR"` cron fix,
the logPath base pinned to `dirname(defaultStorePath())`, the canonical
`ScheduleSpec.env`, and the `ASSERT_BASELINE_FLOOR` :1259->:1329 cite. No
schema/count change (29/19/27, store v4). Tracked feature-design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 10:13:39 +02:00

38 KiB
Raw Permalink Blame History

Brief — RE-R3c: autonomous trigger — scheduler + headless entry point

Slice: RE-R3c (research-engine rung-2, R3 slice 3 — the autonomy slice: the trigger that makes the daily loop closed and the headless entry that runs the deterministic morning brief with no interactive session). R3 ("deepen the research engine") is an arc of 5 open hulls (substrate §1). R3a took relevance, R3b took the lifecycle (status + seen-log + re-score). R3c takes hulls (1) no autonomous trigger + (6) no headless entry point — the mechanism that runs the existing deterministic brief on a schedule, built and tested deterministically before the autonomous AI fan-out (slice e) plugs into it. Predecessor: RE-R3b (status exclusion + per-day-idempotent surfacedCount/lastSurfacedAt — the dedup-state a nightly loop depends on so it never re-surfaces handled work) + RE-R2b (brief.ts dated artifact + the SessionStart surfacing the nightly run feeds) + RE-R3a (composite ranking). Substrate: docs/research-engine-concepts.local.md §1 hull (1) ("ingen autonom trigger … zero cron/launchd/ scheduler i hele repoet") + (6) ("ingen headless entry point") + §B4 ("behavioral scheduling … a push/delivery window that gates delivery separately from the sweep") + §B3 (the dated digest as a flat plain-text artifact "skrevet av Stop-hook eller cron-trigget headless-sesjon"). R3c builds the cron-triggered headless path B3 anticipated and the scheduling-window discipline B4 names. TDD-order: RED before code, two phases (light-Voyage discipline, inherited): Phase A — assertion-RED via subprocess against the existing CLI (schedule is an unknown command today → usage exit 2; the wrapper file is absent → exit 127) — true assertion-RED on the exit-code/stdout assertions, not module-not-found. Phase B — schedule.ts is a NEW module whose exports the tests import; under Node16 ESM a missing named import throws at module-load, so land non-throwing stubs (launchdPlist → "", etc.) first, then record value-assertion RED against them. See plan Step 1. Architectural decisions — CONFIRMED (operator, AskUserQuestion 2026-06-26; baked into the plan):

  • C1 — deterministic brief-only. The nightly headless run regenerates the dated brief from the current store (freshness-aging drops stale trends; surfacedCount accumulates per distinct day → feeds slice b). NO AI capture. Polling stays operator-driven; the autonomous AI fan-out is slice (e), which plugs into (c)'s headless seam. Faithful to the operator's (a)→(c)→(b)→(d)→(e) sequence: build the trigger mechanism + headless plumbing (deterministic, testable) before the AI sweep it will eventually drive. Honest framing: the visible autonomous-research payoff lands with (e); (c) is the mechanism.
  • C2 — print-first installer. schedule emits the launchd plist (macOS) / crontab line (Linux) + the exact install command; the operator runs it. --install writes only the inert launchd plist FILE (never runs launchctl; never touches crontab). Matches the global [voyage] cron-persistence guard, the push-policy's operator-authorization, and the "confirm outward-facing/persistent actions" rule.

1. Operator decision context (2026-06-26)

The research engine is Tier-1 (operator, 2026-06-23). R1→R3b built the deterministic spine and the trend's life after capture: item-schema + triage (R1) → capture bridge (R2a) → dated morning brief + surfacing (R2b) → persisted relevance + composite ranking (R3a) → status lifecycle + seen-log + re-score (R3b). The spine is complete and the lifecycle is correct — but nothing runs it on its own. The morning brief exists only when the operator interactively invokes the brief CLI (via the trend-spotter agent or by hand); the SessionStart hook surfaces the latest dated brief (session-start.mjs:534) but never generates one. There is zero scheduler in the repo (verified live: only scripts/test-runner.sh exists; no plist, no cron, no launchd in any .ts/.mjs/.sh/config). The loop is open: a brief is only as fresh as the last time the operator remembered to ask for one.

R3c closes hulls (1) and (6) — the autonomous trigger and the headless entry point — which the operator chose as slice (c) of the full-R3 build-out (2026-06-24, "ALLE gjenstående R3-slices … i rekkefølge (a) → (c) → (b) → (d) → (e)"). It is sequenced after R3b for a load-bearing reason the operator named: an autonomous loop must never re-surface handled work, so it depends on R3b's status-exclusion (acted/skipped dropped from the brief) and its per-day-idempotent surfacing (a double-fire doesn't double-count). R3b made the nightly regeneration safe to automate; R3c automates it.

What R3c is — and is not (C1). R3c is the mechanism, not the AI sweep. The nightly run is the existing deterministic brief generation — load store → rank → write the dated .md → record surfacing — run with no interaction by a scheduler. It does not poll new sources (that is the AI fan-out, slice e). Its honest value without (e): the brief is regenerated every morning from the current store, so SessionStart surfacing is always fresh; freshness-aging drops trends past the window automatically; and surfacedCount accumulates day-over-day — the temporal signal slice (b) reads — without the operator running anything. (e) later plugs an AI capture step into the documented pre-brief seam to close the full poll→score→capture→brief loop.

2. The gap — grounded in code

  • No autonomous trigger (hull 1). Repo-wide there is no scheduler: no launchd plist, no crontab artifact, no launchctl/cron reference in any source or config (verified). Every brief is born of an interactive session.
  • No headless entry point (hull 6) — almost. The brief subcommand (cli.ts:297-328) is already non-interactive: it reads flags, writes <outDir>/<day>.md, records surfacing, and exits 0 — no prompts. What is missing is a robust invocation wrapper that makes it runnable from a scheduler's minimal environment: a launchd/cron job inherits no shell profile (no PATH from ~/.zshenv, so a bare node is unresolvable), has no working directory set to the repo (tsx resolves modules only from scripts/trends/), and has no logging. Today nothing bridges that gap.
  • The brief is operator-pulled, never machine-pushed. session-start.mjs:60-77/:534 reads the latest dated brief (date+summary, zero-tsx) and surfaces it — it is a pure consumer. Generation lives only in the CLI, invoked by a human. B3's "cron-trigget headless-sesjon" writer does not exist.
  • The CLI has no scheduling verb. cli.ts exposes add/query/list/status/act/skip/reset/ normalize/score/capture/brief (cli.ts:5-14, :134-330) — capture/read/lifecycle, all interactive. There is no way to emit or install a daily schedule for the brief.
  • The data-dir seam is solved, but only for two runtimes. store.ts:252 (defaultStorePath) and hooks/scripts/data-root.mjs:24 (getDataRoot) are twins of the one seam (LINKEDIN_STUDIO_DATA ?? ~/.claude/ linkedin-studio). A scheduler entry running in shell needs the same seam for its log path — a third sanctioned twin, exactly the inline ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/… form references/data-path-convention.md rule 1 prescribes. It does not exist yet.

3. Scope — what is IN (RE-R3c)

Pure string emitters for the schedule artifacts — no clock, no fs, no env, no AI (the CLI injects every resolved value). Mirrors brief.ts's renderBrief purity → fully testable, byte-deterministic given inputs.

  • export interface ScheduleSpec{ platform: "launchd" | "cron"; label: string; nodeBin: string; wrapperPath: string; args: string[]; hour: number; minute: number; logPath: string; workingDir: string; env: Record<string, string>; }. All paths are absolute, resolved by the CLI at generation time on the operator's machine. env is the injected environment map (the CLI builds it — always NODE_BIN + a resolved-absolute LINKEDIN_STUDIO_DATA); the emitter only renders it, so it reads no env itself (folded — brief-reviewer #4 / plan-critic #3: the field is canonical, not mid-step).
  • export function launchdPlist(spec: ScheduleSpec): string — the plist XML: Label, ProgramArguments (["/bin/bash", wrapperPath, ...args]), StartCalendarInterval ({ Hour: spec.hour, Minute: spec.minute }), EnvironmentVariables (rendered from spec.env only — purity), WorkingDirectory (spec.workingDir), StandardOutPath/StandardErrorPath (spec.logPath), RunAtLoad false. A pinned, well-formed template (<?xml … !DOCTYPE plist …>); schedule.test asserts both key-completeness and well-formedness (balanced-tag/parse), plutil -lint is the deps-present manual check (folded — brief-reviewer #7).
  • export function crontabLine(spec: ScheduleSpec): string — one line: <minute> <hour> * * * <env-prefix> /bin/bash <wrapperPath> <args…> >> <logPath> 2>&1 # <label> where <env-prefix> is spec.env rendered as cron's inline K=V K=V form. The function returns the line as a STRING; it never executes crontab (the execution guard + C2; the literal crontabLine does not match the guard's \bcrontab\b word-boundary pattern — §5).
  • export function installInstructions(spec: ScheduleSpec, plistTargetPath?: string): string — the exact operator commands. launchd: "written to <plistTargetPath> — activate with launchctl bootstrap gui/$(id -u) <plistTargetPath>". cron: "add the line above with (crontab -l 2>/dev/null; echo '<line>') | crontab -". Print-first surfaces these so the operator runs them.
  • export function uninstallInstructions(spec, plistTargetPath?): string — symmetric removal (launchd: launchctl bootout … + rm <plist>; cron: the line-removal grep -v recipe).
  • export function defaultLabel(): string"com.linkedin-studio.trends.daily" (the plugin namespace, domain-general — not the user's domain; no vendor/sector token).

The single tested headless entry, invoked identically by both the launchd plist and the crontab line (one entry → one test). Bash 3.2-compatible (operator's macOS: no declare -A, no mapfile, all expansions quoted, ASCII-only).

  • Resolves its own directory (DIR="$(cd "$(dirname "$0")" && pwd)") so it is relocatable — no hard-coded repo path — and cd "$DIR" so --import tsx resolves node_modules from the package even under cron's $HOME CWD (folded — brief-reviewer #1: the plist sets WorkingDirectory, but cron does not — cd makes the one wrapper scheduler-agnostic).
  • Resolves node from a minimal scheduler env: NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null)}"; if still empty, fall back to common locations; exit 127 with a logged error if none. (The scheduler bakes NODE_BIN=<process.execPath> so resolution always succeeds; the fallback is for a manual invocation.)
  • Resolves the log path from the canonical inline seam — LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/ linkedin-studio}/trends/cron.log" — the fourth sanctioned data-path twin (shell), the exact form references/data-path-convention.md rule 1 prescribes (documented as such, like data-root.mjs's twin comment). The scheduler always bakes a resolved-absolute LINKEDIN_STUDIO_DATA into the artifact env, so a scheduled run never evaluates $HOME (sidesteps the set -u HOME-unset edge — folded — plan-critic #9 / brief-reviewer #9); the $HOME fallback is only for a manual invocation, where HOME is set. mkdir -p its dir.
  • Runs the deterministic brief: OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)"; CODE=$? then compacts OUT to one line (tr '\n' ' ' | tr -s ' ') — brief --json is pretty-printed (cli.ts:323 JSON.stringify(…, null, 2)), so the structured log line must collapse the newlines (folded — plan-critic #1). The scheduler bakes --pillars … --fresh-days N into "$@"; the wrapper hard-codes the brief subcommand and adds --json. Appends one line <ISO-ts> exit=<CODE> <compact-OUT> to $LOG; exit $CODE. No AIbrief is the deterministic store→artifact path (C1).
  • The (e) seam (documented, not built): a one-line comment marks where slice (e) will insert a pre-brief capture step (claude -p … trend-spotter | cli.ts capture) before the brief call. R3c builds only the deterministic path.
  • schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install] [--uninstall] [--store <path>]:
    • Resolves platform: auto (default) → process.platform === "darwin" ? "launchd" : "cron".
    • Resolves time from --at (default 07:00); validates HH ∈ 023, MM ∈ 059usage exit 2 on bad input. --pillars is required (a schedule with no pillars is meaningless) → usage exit 2 if absent.
    • Resolves the absolute paths from the runtime, never hard-coded: nodeBin = process.execPath (absolute); wrapperPath = join(dirname(fileURLToPath(import.meta.url)), "..", "run-daily.sh") (cli.ts is at scripts/trends/src/, so ..scripts/trends/); workingDir = join(dirname(fileURLToPath(import.meta.url)), ".."); logPath = join(dirname(defaultStorePath()), "cron.log") — derived from defaultStorePath() (<root>/trends/trends.json<root>/trends/cron.log), NOT from the --store override, so it matches the wrapper's data-root-anchored log exactly (folded — all three reviewers: a --store outside the data dir must not split the plist StandardOutPath from the wrapper's own log file).
    • Builds env (always): { NODE_BIN: process.execPath, LINKEDIN_STUDIO_DATA: <resolved-absolute root> } where the root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio") — baked so the scheduled run is pinned to the install-time root and never evaluates $HOME (the wrapper's set -u HOME-unset edge).
    • Builds args = ["--pillars", <p>, "--fresh-days", String(N)] (+ ["--store", storePath] when an explicit non-default --store was given, so the scheduled run targets the same store). No leading "brief" — the wrapper hard-codes the brief subcommand (folded — plan-critic #8 / brief-reviewer #5: avoids cli.ts brief brief …).
    • Builds the ScheduleSpec and dispatches:
      • default / --print → print the artifact (launchdPlist or crontabLine) + installInstructions to stdout. No fs. Exit 0.
      • --install → launchd: mkdirSync + writeFileSync the plist to ~/Library/LaunchAgents/<label>.plist (an inert file; reversible) and print the single launchctl bootstrap command — the tool never runs launchctl. cron: print the line + the crontab - install command — the tool never runs crontab (the global guard + C2). Exit 0.
      • --uninstall → launchd: print the launchctl bootout command + (if the plist file exists) rm it; cron: print the line-removal recipe. Exit 0.
    • Exit-code contract unchanged (0 success / 2 usage). schedule introduces no new exit code: an autonomy install never runs the system mutation, so there is no install-failure path to encode — the operator runs the one printed command. (Update the header doc-comment cli.ts:36-37 to note schedule is print-first and never shells launchctl/crontab.)
  • Imports launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel from ./schedule.js; adds dirname to the node:path import (cli.ts:41 imports only join today — folded — plan-critic #5), homedir from node:os, fileURLToPath from node:url (defaultStorePath is already imported, cli.ts:45). The DAG stays acyclic: schedule.ts is a leaf (imports nothing from the package); cli.ts is the existing root.
  • Usage + header synopsis (cli.ts:5-14, :86-100): add the schedule … line + a one-line header note that schedule emits/installs a daily headless brief (print-first; deterministic — no AI capture; that is slice e).

Wiring (D-default — WIRE, mirrors R3a/R3b)

  • agents/trend-spotter.md (EDIT, prose-only, minimal): one line — the morning brief can now be scheduled to regenerate autonomously (deterministic, from the store) via schedule; the agent's polling remains the capture path (autonomous AI polling is a later slice). No batch-shape change. Domain-general (no vendor/sector token).
  • scripts/trends/README.md (EDIT): document the headless wrapper + the schedule subcommand (print-first, launchd/cron, --install/--uninstall), the deterministic-brief-only boundary (C1) and the (e) AI-capture seam, the cron.log, and the R3b per-day idempotency that makes a double-fire safe.
  • scripts/test-runner.sh (EDIT): bump TRENDS_TESTS_FLOOR (:709, currently 171) to the tests N line reported after Steps 15, append + RE-R3c: scheduler +N to the inline breakdown comment. Add Section 16l ("Trends Scheduler / Headless Wiring", RE-R3c) after Section 16k's closing block (~:1305), before Section 18 (:1307) (16k is the last 16x before the anti-erosion Section 18; preserve that order). Mirror 16k's shape: unconditional, deps-absent-safe (grep -qF + a non-vacuity self-test emitting one pass/fail). Recommended 6 emitters (all on tracked source — no tsx): (1) self-test; (2) export function launchdPlist in schedule.ts; (3) export function crontabLine in schedule.ts; (4) command === "schedule" in cli.ts (the verb); (5) cli.ts" brief in run-daily.sh (the wrapper invokes the deterministic brief — the sentinel matches the literal …cli.ts" brief, folded — plan-critic #8); (6) the data-path twin in run-daily.sh (LINKEDIN_STUDIO_DATA:-). 6 unconditional emitters → bump ASSERT_BASELINE_FLOOR 105 → exactly 111 (:1329; "live recount" is the safety net; the expected value is the pinned 105 + 6). Insert the 16l clause into the header-enumeration prose chain at :57 (before "…the assertion-count anti-erosion floor (SC6) in Section 18"), and append the R3b (→105) + R3c-16l (→111) narration to the Section-18 floor-history comment (~:1310-1324, which still stops at "= 99" — folded — scope-guardian #7).

4. Non-goals — what is OUT (deferred)

  • AI capture in the nightly run (poll→score→capture via a headless claude -p trend-spotter) — slice (e), behind the post-(d) re-evaluation gate. R3c builds the deterministic headless path + the documented (e) seam; it adds no AI invocation, no claude -p, no API dependency in the scheduler context.
  • Running launchctl / crontab autonomously — OUT (C2 print-first). schedule prints the activation command; --install writes only the inert launchd plist FILE. The operator runs the one system-mutating command.
  • A /linkedin:schedule command wrapper (plugin surface) — OUT for R3c (would change the command count). R3c ships the CLI subcommand + README; a command front-door is a later ergonomic slice. Counts stay 29/19/27.
  • Windows Task Scheduler — OUT. launchd (macOS) + cron (Linux) cover the plugin's runtimes; a Windows emitter is a later portability add.
  • A lock / mutex / run-marker — unneeded. R3b's per-day-idempotent surfacing + the per-day brief filename make a double-fire a safe no-op; B4's separate delivery window is not needed for a once-daily calendar job.
  • A config-file pillar source — OUT. Pillars are --pillars, baked into the schedule artifact at generation (the operator supplies them once at install). A config/profile-resolved pillar source is a later nicety.
  • Brief history / day-over-day diff ("what's new since yesterday" — hull 7) — slice (d).
  • Re-scoring / time-decay recompute on a schedule — OUT. Re-score is on re-capture (R3b); R3c does no capture, so the nightly run re-ranks the unchanged scores against the current freshness window only.
  • Schema bumps — none. R3c touches no store field and no brief frontmatter field (SCHEMA_VERSION stays 4; BRIEF_SCHEMA_VERSION stays 1). It adds a new module + a wrapper + a CLI verb — no data shape changes.
  • New agent / new command / new reference doc — none. R3c adds two source files (schedule.ts, run-daily.sh) + their tests, and EDITs cli.ts + one agent (prose) + README + gate. store.ts/brief.ts/ item.ts/score.ts/types.ts are untouched (the nightly run reuses the existing deterministic brief path). Counts stay 29/19/27.

5. Boundaries / invariants (must hold)

  • TDD iron law (two-phase RED): failing tests land BEFORE implementation. Phase A — subprocess assertion-RED against the existing CLI (schedule unknown → exit 2; run-daily.sh absent → exit 127) on the exit-0/stdout assertions. Phase B — schedule.ts exports are imported by the test; land non-throwing stubs first (Node16 ESM throws a missing named import at module-load), then record value-assertion RED against them. The plan does not claim a single "everything fails before any code" run.
  • schedule.ts is pure (no clock, no fs, no env, no AI): every value the emitters use is injected via ScheduleSpec. The CLI is the only edge that reads process.execPath/import.meta.url/defaultStorePath. Mirrors renderBrief's purity.
  • Determinism of the nightly run: the wrapper invokes the deterministic brief (whose byte-determinism R2b/R3a/R3b proved); given (store, pillars, day, freshDays) the written .md is byte-identical. The wrapper adds only a timestamped log line + an exit code.
  • No autonomous system mutation (C2): schedule (default) writes nothing; --install writes only an inert launchd plist file (reversible rm); the tool never runs launchctl or crontab. The global guard is an execution guard (voyage pre-bash-executor.mjs, pattern \bcrontab\b|>\s*/etc/cron — verified live), so it inspects bash commands, not file content: the new files' printed strings (crontab -, launchctl bootstrap) are written by Write/emitted by the CLI and are fine, and the 16l grep uses crontabLine (no \bcrontab\b word-boundary match). No code path — source or test — ever executes a command containing the bare word crontab or launchctl …; the install commands are printed STRINGS the operator runs, and tests assert those strings on stdout/the written file without executing them (a test that ran crontab would trip the guard and mutate the real system — explicitly forbidden).
  • One data-dir seam, four sanctioned runtime twins: store.ts:253 (TS store), data-root.mjs:25 (hooks .mjs), analytics/src/utils/storage.ts:54 (TS analytics — the existing third, named in data-root.mjs:44), run-daily.sh (shell — NEW fourth). The shell form is the canonical inline ${LINKEDIN_STUDIO_DATA:-$HOME/ .claude/linkedin-studio} expansion (references/data-path-convention.md rule 1), not a new seam; documented as a twin (like data-root.mjs's comment) and asserted behaviorally (SC8 newly binds store.ts's defaultStorePath into the consistency check — dirname(defaultStorePath()) == getDataRoot('trends') == the wrapper's ${…}/trends`).
  • Domain-general: no hard-coded user/repo path in the committed source (schedule.ts/run-daily.sh/the cli.ts edit) — every concrete path is resolved at generation/run on the operator's machine and lives only in the generated artifact (outside the repo, in ~/Library/LaunchAgents / the crontab). The launchd Label is the plugin namespace; pillars are args. Section 17 de-niche stays green.
  • Bash 3.2-compatible wrapper (operator's macOS): no declare -A/mapfile/|&; all expansions quoted; ASCII-only (a multibyte char crashes under set -u on bash 3.2).
  • Minimal-env robustness: the wrapper must run from launchd/cron's profile-less env — node resolved via baked NODE_BIN (absolute process.execPath) with a command -v fallback; WorkingDirectory/cd set so tsx resolves; log dir mkdir -p'd.
  • Hook unaffected: the SessionStart surfacing reads date+summary only and never shells to tsx; R3c touches neither the hook nor the frontmatter schema, so surfacing is unchanged. The hook suite must still pass untouched (regression sanity; R3c adds no hook test).
  • No schema/SSOT change: references/trend-scoring-modes.md, types.ts, store.ts, brief.ts untouched (R3c changes no data shape and no scoring/render math).
  • Pathguard: the two NEW files are under scripts/trends/write-allowed (the global pre-write-pathguard allowlists ~/repos/*; cli.ts/test-runner.sh were added there with no friction — folded — scope-guardian #4: the earlier ".mjs-under-hooks/scripts-only" phrasing was a fabricated mechanism; the conclusion holds). EDITs are to existing files. (Implementation risk, not a docs-step blocker: if any Write is nonetheless blocked, the operator authorizes via the R2b !cp fallback — see plan Risk R4.)
  • Counts (refs/agents/commands 27/19/29) unchanged. Recounted live at land, never pinned/guessed.

6. Success criteria (testable)

  • SC1 (launchd plist emit)schedule --pillars ai,gov --platform launchd --at 07:30 --print → stdout is a key-complete + well-formed plist (balanced-tag/parse asserted, not just substring greps — folded — brief-reviewer #7) containing Label = com.linkedin-studio.trends.daily, ProgramArguments invoking run-daily.sh with --pillars ai,gov (the wrapper supplies brief), StartCalendarInterval Hour 7/Minute 30, StandardOutPath/StandardErrorPath = the resolved cron.log path, NODE_BIN + LINKEDIN_STUDIO_DATA in EnvironmentVariables; exit 0. Two --print runs (same args) → byte-identical (the emitter is pure). plutil -lint is the deps-present manual check (Step 7).
  • SC2 (crontab line emit — string only)schedule --pillars ai,gov --platform cron --at 07:30 --print → stdout contains 30 7 * * * NODE_BIN=… /bin/bash …/run-daily.sh brief --pillars ai,gov >> <log> 2>&1 # com.linkedin-studio.trends.daily + the (crontab -l 2>/dev/null; echo '<line>') | crontab - install instruction; exit 0. The test asserts the emitted string and never executes crontab.
  • SC3 (platform auto)schedule --pillars ai --print (no --platform) → launchd on darwin, cron elsewhere; asserted against process.platform (the subprocess inherits the host platform; the assertion branches on it).
  • SC4 (print-first writes nothing)schedule --pillars ai --platform launchd --print with HOME=<tmp> → exit 0, stdout has the plist, and <tmp>/Library/LaunchAgents is absent/empty (no fs write); crontab never invoked.
  • SC5 (--install launchd: inert plist file, no launchctl)schedule --pillars ai --platform launchd --install with HOME=<tmp><tmp>/Library/LaunchAgents/com.linkedin-studio.trends.daily.plist exists with the SC1 plist content; stdout prints the launchctl bootstrap command; launchctl is never run (the test asserts only the file + stdout; no system job is created); exit 0.
  • SC6 (--install cron: never self-installs)schedule --pillars ai --platform cron --install → stdout has the line + the crontab - instruction; exit 0; crontab is never invoked (no system mutation; asserted by stdout only).
  • SC7 (headless wrapper runs the deterministic brief + logs)run-daily.sh --pillars ai --store <tmp>/ s.json --out <tmp>/mb (the wrapper supplies brief) with LINKEDIN_STUDIO_DATA=<tmp> on a seeded fresh store → writes <tmp>/mb/<today>.md (the dated brief), appends exactly one <ISO-ts> exit=0 {…compact-json…} line to <tmp>/trends/cron.log (the multi-line brief --json collapsed — folded — plan-critic #1), exit 0. A second run the same day → the brief .md is byte-identical (idempotent re-render), the seen-log is not double-counted (R3b per-day idempotency), the log gains a second line. CWD-independence: the same invocation with cwd=<tmp-unrelated> (not the package dir) still resolves tsx and succeeds (the wrapper's cd "$DIR" — folded — brief-reviewer #1). The test invokes via bash run-daily.sh … so the absent-file RED is exit 127 (folded — plan-critic #7). No AI is invoked (C1).
  • SC8 (data-path twin consistency) — the wrapper's ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ trends resolves to the same directory as dirname(defaultStorePath()) (TS — the base the CLI's logPath uses, not the --store override) and getDataRoot('trends') (hooks .mjs), for both the default root and an overridden LINKEDIN_STUDIO_DATA. Asserted behaviorally (resolve all three for a temp override; assert equal). Because logPath derives from defaultStorePath(), a custom --store never splits the plist StandardOutPath from the wrapper's log (folded — all three reviewers).
  • SC9 (usage / validation)schedule with no --pillarsusage exit 2; --at 25:00 / --at 7:99 / --at noonusage exit 2; --platform bogususage exit 2. Each leaves the fs untouched.
  • SC10 (gate + wiring + de-niche)bash scripts/test-runner.shFAIL=0: trends suite green at the bumped TRENDS_TESTS_FLOOR; new Section 16l green (launchdPlist/crontabLine in schedule.ts, command === "schedule" in cli.ts, the cli.ts brief + LINKEDIN_STUDIO_DATA:- sentinels in run-daily.sh, non-vacuity self-test); ASSERT_BASELINE_FLOOR = 111 (105 + 6); Section 17 de-niche green; counts 29/19/27; the hook suite still green untouched (node --test hooks/scripts/__tests__/*.test.mjs).

7. Verification

Deterministic: bash scripts/test-runner.shFAIL=0; trends suite ≥ new floor; Section 16l self-test + greps pass; ASSERT_BASELINE_FLOOR = 111; Section 17 de-niche green; ref/agent/command counts unchanged. Regression sanity: node --test hooks/scripts/__tests__/*.test.mjs → still green untouched (R3c touches no hook; adds no hook test). Behavioural (manual):

  1. Seed a store: echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"], "publishedAt":"<~2d ago>"}]' | node --import tsx src/cli.ts capture --store /tmp/r3c.json.
  2. node --import tsx src/cli.ts schedule --pillars ai,gov --platform launchd --at 07:00 --print → inspect the plist; node --import tsx src/cli.ts schedule … --platform cron --print → inspect the crontab line + install instruction.
  3. Lint the plist (macOS): pipe the --print plist to plutil -lint - → "OK" (a malformed plist won't load).
  4. LINKEDIN_STUDIO_DATA=/tmp/r3c-data ./run-daily.sh brief --pillars ai,gov --store /tmp/r3c.json --out /tmp/r3c-data/trends/morning-brief → confirm /tmp/r3c-data/trends/morning-brief/<today>.md written + a line appended to /tmp/r3c-data/trends/cron.log; exit 0.
  5. Re-run step 4 same day → .md byte-identical; cron.log gains a second line; list --json shows surfacedCount:1 (not 2 — per-day idempotent).
  6. schedule --pillars ai --platform launchd --install with a throwaway HOME → confirm the plist file is written under <HOME>/Library/LaunchAgents/ and the launchctl bootstrap command is printed (do not run it against the real system unless intentionally activating).

8. Open questions for the go-gate

Two architectural decisions are CONFIRMED (operator, AskUserQuestion 2026-06-26): C1 deterministic brief-only (no AI capture — that is slice e); C2 print-first installer (emit + the operator runs the system mutation; --install writes only the inert launchd plist file). Residual decisions, all baked to the recommended default — confirm or redirect with "Go":

  • D1 — schedule time default 07:00, --at HH:MM overrides? YES (rec). A morning brief wants a pre-workday fire; the operator tunes it. Re-open only for a different default hour.
  • D2 — launchd Label = com.linkedin-studio.trends.daily (plugin namespace)? YES (rec). Reverse-DNS, the plugin's own namespace (domain-general; no user-domain token). Drop only for a different naming scheme.
  • D3 — --install writes the launchd plist FILE but never runs launchctl/crontab? YES (rec). The strictest honest print-first: the tool prepares the inert artifact, the operator activates it. Re-open only to make --install a pure no-op (print-only, no file write).
  • D4 — --platform auto defaults via process.platform? YES (rec). darwin→launchd, else→cron. Explicit --platform overrides (e.g. to emit a crontab line on a Mac for a Linux box). Drop only to require --platform.
  • D5 — pillars baked into the artifact at generation (no config-file source)? YES (rec). The operator supplies --pillars once at install; the schedule carries them. A profile-resolved pillar source is a later nicety.
  • D6 — cron.log under <data>/trends/cron.log? YES (rec). Colocated with the store + morning-brief under the data-dir seam, survives reinstalls. Drop only for a different log location.
  • D7 — a single .sh wrapper invoked by BOTH launchd + cron (vs a CLI run subcommand)? YES (rec). A shell wrapper handles the launchd/cron minimal-env robustness (node resolution, cd, logging) the CLI cannot; the CLI brief stays the deterministic core. One wrapper → one tested entry. Re-open only to push the robustness into a CLI run verb (more TS, but then the plist must still bake node).
  • D8 — no /linkedin:schedule command (CLI + README only)? YES (rec). Keeps the command count; a command front-door is a later ergonomic slice. Re-open only if the scheduler should be operator-facing via a slash command now.
  • D9 — commit split? Docs commit first, then one code commit (rec) — the scheduler (module + wrapper + CLI verb + wiring) is one coherent feature. Re-open only for a module-then-wiring split.

9. Light-Voyage review — folded

Three Opus reviewers ran on the drafts, each verifying claims against live code. scope-guardian: ALIGNED (0 creep / 0 gaps; every SC1SC10 traces to a step; no AI/capture, no schema bump, counts 29/19/27 + untouched- files claim verified live; 1 MAJOR line-cite + 6 MINOR accuracy/precision). brief-reviewer: PROCEED_WITH_RISKS (RED premises TRUE, the data-path trio genuinely consistent, C2 no-execution path confirmed; 2 MAJOR + 5 MEDIUM/LOW). plan-critic: REVISE → 73/C (the two-phase RED, de-niche safety, bash-3.2 wrapper, path resolution, and the +6→111 gate arithmetic all verified correct against live code; 4 MAJOR + 5 MINOR; the C grade is largely the legacy-manifest-format penalty — these are hand-authored slice docs, not trekexecute manifests). All findings folded (per-finding resolution in plan-re-r3c.md §Plan-critic — folded). Headlines:

  • [MAJOR, folded — plan-critic #1] brief --json is pretty-printed (cli.ts:323 JSON.stringify(…, null, 2)), so the wrapper's "one log line" / SC7 contract was false. → the wrapper compacts OUT (tr '\n' ' ' | tr -s ' ') after capturing CODE; SC7 asserts exactly one line.
  • [MAJOR, folded — brief-reviewer #1] the wrapper never cds to its package dir, so the cron path (CWD $HOME) would fail to resolve tsx. → the wrapper adds cd "$DIR"; SC7 gains a CWD-independence assertion.
  • [MAJOR/MINOR, folded — all three] the logPath expression was self-contradicting and used the --store override base. → pinned to join(dirname(defaultStorePath()), "cron.log") (the data-root anchor, matching the wrapper); the ".." and storePath variants removed.
  • [MAJOR, folded — brief-reviewer #4 / plan-critic #3] ScheduleSpec.env was stated three ways.env: Record<string,string> is now canonical in §3, always carrying NODE_BIN + a resolved-absolute LINKEDIN_STUDIO_DATA; the emitters render it only (purity holds), and the always-baked root also fixes the HOME-unset set -u edge.
  • [MAJOR, folded — all three] ASSERT_BASELINE_FLOOR line-cite :1259:1329 (verified live; value 105→111 correct). TRENDS_TESTS_FLOOR :705:709; header-enum insertion :49-53:57; the Section-18 floor-history comment (still "= 99") gets the R3b(→105)+R3c(→111) narration appended.
  • [MINOR, folded — plan-critic #5] dirname is not imported in cli.ts (:41 is join only). → the plan adds dirname to the node:path import (+ homedir/fileURLToPath).
  • [MINOR, folded — plan-critic #8 / brief-reviewer #5] stray double brief (cli.ts brief brief …). → the baked args drops the leading "brief"; the wrapper owns the subcommand; the 16l sentinel matches cli.ts" brief.
  • [MINOR, folded — scope-guardian #4] the pathguard justification was a fabricated mechanism. → restated: ~/repos/* is allowlisted; the conclusion (new files write-allowed) holds.
  • [LOW, folded — brief-reviewer #7] SC1 "lint-valid" was only grep-checked. → SC1 asserts well-formed (balanced-tag/parse) + key-complete; plutil -lint stays the deps-present manual check.
  • [LOW, folded — brief-reviewer #8] the twin census undercounted (3 → 4).analytics/storage.ts named as the existing third; run-daily.sh is the fourth.