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
38 KiB
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 (
statusexclusion + per-day-idempotentsurfacedCount/lastSurfacedAt— the dedup-state a nightly loop depends on so it never re-surfaces handled work) + RE-R2b (brief.tsdated 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 (scheduleis an unknown command today →usageexit 2; the wrapper file is absent → exit 127) — true assertion-RED on the exit-code/stdout assertions, not module-not-found. Phase B —schedule.tsis 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;
surfacedCountaccumulates 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.
scheduleemits the launchd plist (macOS) / crontab line (Linux) + the exact install command; the operator runs it.--installwrites only the inert launchd plist FILE (never runslaunchctl; never touchescrontab). 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/cronreference in any source or config (verified). Every brief is born of an interactive session. - No headless entry point (hull 6) — almost. The
briefsubcommand (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 (noPATHfrom~/.zshenv, so a barenodeis unresolvable), has no working directory set to the repo (tsx resolves modules only fromscripts/trends/), and has no logging. Today nothing bridges that gap. - The brief is operator-pulled, never machine-pushed.
session-start.mjs:60-77/:534reads 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.tsexposesadd/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) andhooks/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}/…formreferences/data-path-convention.mdrule 1 prescribes. It does not exist yet.
3. Scope — what is IN (RE-R3c)
S-schedule — scripts/trends/src/schedule.ts (NEW, pure module)
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.envis the injected environment map (the CLI builds it — alwaysNODE_BIN+ a resolved-absoluteLINKEDIN_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 fromspec.envonly — purity),WorkingDirectory(spec.workingDir),StandardOutPath/StandardErrorPath(spec.logPath),RunAtLoadfalse. A pinned, well-formed template (<?xml … !DOCTYPE plist …>);schedule.testasserts both key-completeness and well-formedness (balanced-tag/parse),plutil -lintis 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>isspec.envrendered as cron's inlineK=V K=Vform. The function returns the line as a STRING; it never executescrontab(the execution guard + C2; the literalcrontabLinedoes not match the guard's\bcrontab\bword-boundary pattern — §5).export function installInstructions(spec: ScheduleSpec, plistTargetPath?: string): string— the exact operator commands. launchd: "written to<plistTargetPath>— activate withlaunchctl 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-removalgrep -vrecipe).export function defaultLabel(): string—"com.linkedin-studio.trends.daily"(the plugin namespace, domain-general — not the user's domain; no vendor/sector token).
S-wrapper — scripts/trends/run-daily.sh (NEW, headless invocation wrapper)
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 — andcd "$DIR"so--import tsxresolvesnode_modulesfrom the package even under cron's$HOMECWD (folded — brief-reviewer #1: the plist setsWorkingDirectory, but cron does not —cdmakes 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 bakesNODE_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 formreferences/data-path-convention.mdrule 1 prescribes (documented as such, likedata-root.mjs's twin comment). The scheduler always bakes a resolved-absoluteLINKEDIN_STUDIO_DATAinto the artifact env, so a scheduled run never evaluates$HOME(sidesteps theset -uHOME-unset edge — folded — plan-critic #9 / brief-reviewer #9); the$HOMEfallback is only for a manual invocation, whereHOMEis set.mkdir -pits dir. - Runs the deterministic brief:
OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)"; CODE=$?then compactsOUTto one line (tr '\n' ' ' | tr -s ' ') —brief --jsonis pretty-printed (cli.ts:323JSON.stringify(…, null, 2)), so the structured log line must collapse the newlines (folded — plan-critic #1). The scheduler bakes--pillars … --fresh-days Ninto"$@"; the wrapper hard-codes thebriefsubcommand and adds--json. Appends one line<ISO-ts> exit=<CODE> <compact-OUT>to$LOG;exit $CODE. No AI —briefis 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 thebriefcall. R3c builds only the deterministic path.
S-cli — scripts/trends/src/cli.ts (EDIT) — the schedule subcommand
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(default07:00); validatesHH ∈ 0–23,MM ∈ 0–59→usageexit 2 on bad input.--pillarsis required (a schedule with no pillars is meaningless) →usageexit 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.tsis atscripts/trends/src/, so..→scripts/trends/);workingDir = join(dirname(fileURLToPath(import.meta.url)), "..");logPath = join(dirname(defaultStorePath()), "cron.log")— derived fromdefaultStorePath()(<root>/trends/trends.json→<root>/trends/cron.log), NOT from the--storeoverride, so it matches the wrapper's data-root-anchored log exactly (folded — all three reviewers: a--storeoutside the data dir must not split the plistStandardOutPathfrom 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'sset -uHOME-unset edge). - Builds
args=["--pillars", <p>, "--fresh-days", String(N)](+["--store", storePath]when an explicit non-default--storewas given, so the scheduled run targets the same store). No leading"brief"— the wrapper hard-codes thebriefsubcommand (folded — plan-critic #8 / brief-reviewer #5: avoidscli.ts brief brief …). - Builds the
ScheduleSpecand dispatches:- default /
--print→ print the artifact (launchdPlistorcrontabLine) +installInstructionsto stdout. No fs. Exit 0. --install→ launchd:mkdirSync+writeFileSyncthe plist to~/Library/LaunchAgents/<label>.plist(an inert file; reversible) and print the singlelaunchctl bootstrapcommand — the tool never runslaunchctl. cron: print the line + thecrontab -install command — the tool never runscrontab(the global guard + C2). Exit 0.--uninstall→ launchd: print thelaunchctl bootoutcommand + (if the plist file exists)rmit; cron: print the line-removal recipe. Exit 0.
- default /
- Exit-code contract unchanged (0 success / 2 usage).
scheduleintroduces 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-commentcli.ts:36-37to notescheduleis print-first and never shellslaunchctl/crontab.)
- Resolves platform:
- Imports
launchdPlist,crontabLine,installInstructions,uninstallInstructions,defaultLabelfrom./schedule.js; addsdirnameto thenode:pathimport (cli.ts:41imports onlyjointoday — folded — plan-critic #5),homedirfromnode:os,fileURLToPathfromnode:url(defaultStorePathis already imported,cli.ts:45). The DAG stays acyclic:schedule.tsis a leaf (imports nothing from the package);cli.tsis the existing root. - Usage + header synopsis (
cli.ts:5-14,:86-100): add theschedule …line + a one-line header note thatscheduleemits/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) viaschedule; 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 + theschedulesubcommand (print-first, launchd/cron,--install/--uninstall), the deterministic-brief-only boundary (C1) and the (e) AI-capture seam, thecron.log, and the R3b per-day idempotency that makes a double-fire safe.scripts/test-runner.sh(EDIT): bumpTRENDS_TESTS_FLOOR(:709, currently 171) to thetests Nline reported after Steps 1–5, append+ RE-R3c: scheduler +Nto 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 — notsx): (1) self-test; (2)export function launchdPlistinschedule.ts; (3)export function crontabLineinschedule.ts; (4)command === "schedule"incli.ts(the verb); (5)cli.ts" briefinrun-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 inrun-daily.sh(LINKEDIN_STUDIO_DATA:-). 6 unconditional emitters → bumpASSERT_BASELINE_FLOOR105 → 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→capturevia a headlessclaude -ptrend-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, noclaude -p, no API dependency in the scheduler context. - Running
launchctl/crontabautonomously — OUT (C2 print-first).scheduleprints the activation command;--installwrites only the inert launchd plist FILE. The operator runs the one system-mutating command. - A
/linkedin:schedulecommand 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_VERSIONstays 4;BRIEF_SCHEMA_VERSIONstays 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 EDITscli.ts+ one agent (prose) + README + gate.store.ts/brief.ts/item.ts/score.ts/types.tsare untouched (the nightly run reuses the existing deterministicbriefpath). 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 (
scheduleunknown → exit 2;run-daily.shabsent → exit 127) on the exit-0/stdout assertions. Phase B —schedule.tsexports 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.tsis pure (no clock, no fs, no env, no AI): every value the emitters use is injected viaScheduleSpec. The CLI is the only edge that readsprocess.execPath/import.meta.url/defaultStorePath. MirrorsrenderBrief'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.mdis byte-identical. The wrapper adds only a timestamped log line + an exit code. - No autonomous system mutation (C2):
schedule(default) writes nothing;--installwrites only an inert launchd plist file (reversiblerm); the tool never runslaunchctlorcrontab. The global guard is an execution guard (voyagepre-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 byWrite/emitted by the CLI and are fine, and the 16l grep usescrontabLine(no\bcrontab\bword-boundary match). No code path — source or test — ever executes a command containing the bare wordcrontaborlaunchctl …; 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 rancrontabwould 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 indata-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.mdrule 1), not a new seam; documented as a twin (likedata-root.mjs's comment) and asserted behaviorally (SC8 newly bindsstore.ts'sdefaultStorePathinto 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/thecli.tsedit) — 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 underset -uon bash 3.2). - Minimal-env robustness: the wrapper must run from launchd/cron's profile-less env — node resolved via baked
NODE_BIN(absoluteprocess.execPath) with acommand -vfallback;WorkingDirectory/cdset so tsx resolves; log dirmkdir -p'd. - Hook unaffected: the SessionStart surfacing reads
date+summaryonly 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.tsuntouched (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.shwere 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!cpfallback — 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) containingLabel=com.linkedin-studio.trends.daily,ProgramArgumentsinvokingrun-daily.shwith--pillars ai,gov(the wrapper suppliesbrief),StartCalendarIntervalHour 7/Minute 30,StandardOutPath/StandardErrorPath= the resolvedcron.logpath,NODE_BIN+LINKEDIN_STUDIO_DATAinEnvironmentVariables; exit 0. Two--printruns (same args) → byte-identical (the emitter is pure).plutil -lintis the deps-present manual check (Step 7). - SC2 (crontab line emit — string only) —
schedule --pillars ai,gov --platform cron --at 07:30 --print→ stdout contains30 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 executescrontab. - SC3 (platform auto) —
schedule --pillars ai --print(no--platform) → launchd on darwin, cron elsewhere; asserted againstprocess.platform(the subprocess inherits the host platform; the assertion branches on it). - SC4 (print-first writes nothing) —
schedule --pillars ai --platform launchd --printwithHOME=<tmp>→ exit 0, stdout has the plist, and<tmp>/Library/LaunchAgentsis absent/empty (no fs write); crontab never invoked. - SC5 (
--installlaunchd: inert plist file, no launchctl) —schedule --pillars ai --platform launchd --installwithHOME=<tmp>→<tmp>/Library/LaunchAgents/com.linkedin-studio.trends.daily.plistexists with the SC1 plist content; stdout prints thelaunchctl bootstrapcommand;launchctlis never run (the test asserts only the file + stdout; no system job is created); exit 0. - SC6 (
--installcron: never self-installs) —schedule --pillars ai --platform cron --install→ stdout has the line + thecrontab -instruction; exit 0;crontabis 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 suppliesbrief) withLINKEDIN_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-linebrief --jsoncollapsed — folded — plan-critic #1), exit 0. A second run the same day → the brief.mdis 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 withcwd=<tmp-unrelated>(not the package dir) still resolvestsxand succeeds (the wrapper'scd "$DIR"— folded — brief-reviewer #1). The test invokes viabash 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}/ trendsresolves to the same directory asdirname(defaultStorePath())(TS — the base the CLI'slogPathuses, not the--storeoverride) andgetDataRoot('trends')(hooks.mjs), for both the default root and an overriddenLINKEDIN_STUDIO_DATA. Asserted behaviorally (resolve all three for a temp override; assert equal). BecauselogPathderives fromdefaultStorePath(), a custom--storenever splits the plistStandardOutPathfrom the wrapper's log (folded — all three reviewers). - SC9 (usage / validation) —
schedulewith no--pillars→usageexit 2;--at 25:00/--at 7:99/--at noon→usageexit 2;--platform bogus→usageexit 2. Each leaves the fs untouched. - SC10 (gate + wiring + de-niche) —
bash scripts/test-runner.sh→FAIL=0: trends suite green at the bumpedTRENDS_TESTS_FLOOR; new Section 16l green (launchdPlist/crontabLineinschedule.ts,command === "schedule"incli.ts, thecli.ts brief+LINKEDIN_STUDIO_DATA:-sentinels inrun-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.sh → FAIL=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):
- 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. 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.- Lint the plist (macOS): pipe the
--printplist toplutil -lint -→ "OK" (a malformed plist won't load). 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>.mdwritten + a line appended to/tmp/r3c-data/trends/cron.log; exit 0.- Re-run step 4 same day →
.mdbyte-identical;cron.loggains a second line;list --jsonshowssurfacedCount:1(not 2 — per-day idempotent). schedule --pillars ai --platform launchd --installwith a throwawayHOME→ confirm the plist file is written under<HOME>/Library/LaunchAgents/and thelaunchctl bootstrapcommand 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:MMoverrides? 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 —
--installwrites the launchd plist FILE but never runslaunchctl/crontab? YES (rec). The strictest honest print-first: the tool prepares the inert artifact, the operator activates it. Re-open only to make--installa pure no-op (print-only, no file write). - D4 —
--platform autodefaults viaprocess.platform? YES (rec). darwin→launchd, else→cron. Explicit--platformoverrides (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
--pillarsonce at install; the schedule carries them. A profile-resolved pillar source is a later nicety. - D6 —
cron.logunder<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
.shwrapper invoked by BOTH launchd + cron (vs a CLIrunsubcommand)? YES (rec). A shell wrapper handles the launchd/cron minimal-env robustness (node resolution,cd, logging) the CLI cannot; the CLIbriefstays the deterministic core. One wrapper → one tested entry. Re-open only to push the robustness into a CLIrunverb (more TS, but then the plist must still bake node). - D8 — no
/linkedin:schedulecommand (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 SC1–SC10 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 --jsonis pretty-printed (cli.ts:323JSON.stringify(…, null, 2)), so the wrapper's "one log line" / SC7 contract was false. → the wrapper compactsOUT(tr '\n' ' ' | tr -s ' ') after capturingCODE; 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 resolvetsx. → the wrapper addscd "$DIR"; SC7 gains a CWD-independence assertion. - [MAJOR/MINOR, folded — all three] the
logPathexpression was self-contradicting and used the--storeoverride base. → pinned tojoin(dirname(defaultStorePath()), "cron.log")(the data-root anchor, matching the wrapper); the".."andstorePathvariants removed. - [MAJOR, folded — brief-reviewer #4 / plan-critic #3]
ScheduleSpec.envwas stated three ways. →env: Record<string,string>is now canonical in §3, always carryingNODE_BIN+ a resolved-absoluteLINKEDIN_STUDIO_DATA; the emitters render it only (purity holds), and the always-baked root also fixes theHOME-unsetset -uedge. - [MAJOR, folded — all three]
ASSERT_BASELINE_FLOORline-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]
dirnameis not imported incli.ts(:41isjoinonly). → the plan addsdirnameto thenode:pathimport (+homedir/fileURLToPath). - [MINOR, folded — plan-critic #8 / brief-reviewer #5] stray double
brief(cli.ts brief brief …). → the bakedargsdrops the leading"brief"; the wrapper owns the subcommand; the 16l sentinel matchescli.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 -lintstays the deps-present manual check. - [LOW, folded — brief-reviewer #8] the twin census undercounted (3 → 4). →
analytics/storage.tsnamed as the existing third;run-daily.shis the fourth.