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

424 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
### 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. **`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).
### 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 — 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 AI** — `brief` 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.
### 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` (default `07:00`); validates `HH ∈ 023`, `MM ∈ 059` → `usage` 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** `--pillars` → `usage` exit 2; `--at 25:00` / `--at 7:99` /
`--at noon` → `usage` exit 2; `--platform bogus` → `usage` exit 2. Each leaves the fs untouched.
- **SC10 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=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.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):**
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 `cd`s 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.