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
This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 10:13:39 +02:00
commit b43757462b
2 changed files with 800 additions and 0 deletions

View file

@ -0,0 +1,424 @@
# 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.

View file

@ -0,0 +1,376 @@
# Plan — RE-R3c: autonomous trigger — scheduler + headless entry point
> **Brief:** `docs/research-engine/brief-re-r3c.md`. **Slice:** RE-R3c (research-engine rung-2 — R3 slice 3, the
> **autonomy** slice: the scheduler trigger + the headless entry that runs the deterministic morning brief with no
> interactive session). Closes hulls (1) no autonomous trigger + (6) no headless entry point.
> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)**
> subprocess assertion-RED against the **existing** CLI (`schedule` is an unknown command → `usage` exit 2; the
> `run-daily.sh` wrapper file is absent → exit 127) on the exit-0/stdout assertions; **(B)** for the NEW
> `schedule.ts` exports the tests import, land **non-throwing stubs** first (Node16 ESM throws a missing named
> import at module-load, not on assertion), then record value-assertion RED against them. Then GREEN: `schedule.ts`
> stubs→real → `run-daily.sh` wrapper → `cli.ts` `schedule` verb → wire `trend-spotter.md` (prose) + README →
> gate floors + Section 16l → behavioural → land.
> **Counts recounted live at land, never pinned/guessed.**
> **Architectural decisions (CONFIRMED, 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; the tool never runs `launchctl`/`crontab`). Go-gate D1D9 baked to recommended
> defaults (brief §8).
> **Light-Voyage:** scope-guardian / brief-reviewer / plan-critic to run on these drafts; findings folded in
> §Plan-critic — folded before the code commit.
## Goal
Make the daily research loop **closed** and **headless**. (1) **Autonomous trigger** — a `schedule` CLI verb that
emits (print-first) a launchd plist (macOS) / crontab line (Linux) firing a daily brief, and `--install` that
writes only the inert launchd plist file (the operator runs the one activation command). (2) **Headless entry
point** — `run-daily.sh`, a bash-3.2 wrapper that runs the **deterministic** `brief` from a scheduler's
profile-less env (resolves node + the data-dir log seam + working dir), invoked identically by launchd and cron.
**No AI capture** (C1 — that is slice e, which plugs into the documented pre-brief seam); **no schema change**
(`SCHEMA_VERSION` 4 / `BRIEF_SCHEMA_VERSION` 1 untouched); **no new agent/command/reference**. Two new source
files under `scripts/trends/` (`schedule.ts` pure module + `run-daily.sh` wrapper) + their tests; EDIT `cli.ts` +
`trend-spotter.md` (prose) + README + the gate.
## Files touched (exhaustive — for scope-guardian)
| File | Change | SC |
|---|---|---|
| `scripts/trends/src/schedule.ts` | **NEW (pure module)**`ScheduleSpec` (incl. `env: Record<string,string>`); `launchdPlist`/`crontabLine`/`installInstructions`/`uninstallInstructions`/`defaultLabel`. No clock/fs/env/AI (renders `spec.env`; reads no env). | SC1, SC2 |
| `scripts/trends/run-daily.sh` | **NEW (wrapper)** — bash 3.2; resolves own dir + **`cd "$DIR"`** + node (`NODE_BIN`/`command -v`) + log seam (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log`); runs `cli.ts" brief "$@" --json`, **compacts** the pretty-printed json to one line; logs `<ts> exit=<code> <compact-json>`; (e)-seam comment. | SC7, SC8 |
| `scripts/trends/src/cli.ts` | **EDIT**`schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto\|launchd\|cron] [--install] [--uninstall] [--store <p>]`; adds `dirname` (node:path) + `homedir` (node:os) + `fileURLToPath` (node:url) imports; resolves paths from runtime (`process.execPath`/`import.meta.url`/`defaultStorePath`); `logPath = join(dirname(defaultStorePath()), "cron.log")`; `env` always `{NODE_BIN, LINKEDIN_STUDIO_DATA(resolved)}`; `args` without leading `"brief"`; print-first dispatch; `--install` writes the inert plist only (never `launchctl`/`crontab`); usage + header doc; **no new exit code** | SC1SC6, SC9 |
| `scripts/trends/tests/schedule.test.ts` | **NEW** — pure-emitter assertions: plist keys (Label/ProgramArguments/StartCalendarInterval/Std*Path/EnvironmentVariables), crontab line shape, install/uninstall instruction strings, `defaultLabel`, determinism | SC1, SC2 |
| `scripts/trends/tests/cli.test.ts` | **EDIT** — subprocess: `schedule --print` (launchd + cron) emits the artifact + instruction, exit 0; `--platform auto` branches on `process.platform`; `--print` writes nothing (temp HOME LaunchAgents empty); `--install` launchd writes the plist file + prints `launchctl` (never runs it); `--install` cron prints only; no `--pillars`/bad `--at`/bad `--platform` → exit 2 | SC3SC6, SC9 |
| `scripts/trends/tests/run-daily.test.ts` | **NEW** — subprocess: wrapper on a seeded store writes the dated brief + appends to `cron.log` + exit 0; second same-day run → byte-identical `.md`, `surfacedCount` not double-counted, second log line; data-path twin consistency (wrapper log dir == `defaultStorePath` dir == `getDataRoot('trends')`) | SC7, SC8 |
| `scripts/trends/src/brief.ts` | **UNTOUCHED** — the nightly run reuses the existing deterministic `brief`/`rankForBrief`/`renderBrief`. Listed to assert it is *not* in scope. | — |
| `scripts/trends/src/store.ts` · `types.ts` · `item.ts` · `score.ts` | **UNTOUCHED** — no data-shape / scoring / render change. Listed to assert they are *not* in scope. | — |
| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: the brief can now be scheduled to regenerate autonomously (deterministic, from the store) via `schedule`; polling stays the capture path (autonomous AI polling is a later slice). Domain-general. | — |
| `scripts/trends/README.md` | **EDIT** — the headless wrapper + `schedule` (print-first, launchd/cron, `--install`/`--uninstall`), the deterministic-brief-only boundary (C1) + the (e) seam, `cron.log`, per-day idempotency | — |
| `scripts/test-runner.sh` | **EDIT**`TRENDS_TESTS_FLOOR` 171→recount + breakdown comment (`:709`); NEW unconditional **Section 16l** (after 16k's block `~:1305`, before Section 18 header `:1307`); `ASSERT_BASELINE_FLOOR` 105→**111** (`:1329`); header-enumeration chain (`:57`) + Section-18 floor-history narration (`~:1310-1324`) | SC10 |
| `docs/research-engine/{brief,plan}-re-r3c.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — |
| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 111, gate total; schema unchanged v4). *Land bookkeeping, LOCAL-ONLY.* | — |
**Not touched (scope fence):** `brief.ts`/`store.ts`/`types.ts`/`item.ts`/`score.ts` (the nightly run reuses the
existing brief path; no data/scoring/render change) · `references/trend-scoring-modes.md` + `algorithm-signals-
reference.md` (no scoring change) · the SessionStart hook + its tests (R3c generates the brief the hook already
surfaces; no hook change, no frontmatter-schema change, no new hook test) · `config/*` · `commands/*` (29 — no
new command) · `agents/*` count (19 — `trend-spotter.md` is a prose EDIT) · `references/*` (27) · `.gitignore`
(`scripts/trends/{node_modules,build}` already covered; `cron.log` lives under the external data dir, never in the
repo) · `BRIEF_SCHEMA_VERSION` (1) · `SCHEMA_VERSION` (4).
## Step 1 — (RED, two phases) failing tests across schedule/cli/wrapper
**Phase A — subprocess assertion-RED against the pre-edit code** (no new import needed; the CLI is invoked as a
subprocess and the wrapper file is simply absent):
- `cli.test.ts`**`schedule`**: `schedule --pillars ai,gov --platform launchd --print` today → `usage` (unknown
command) **exit 2**, no plist on stdout → RED against the assertion (expects exit 0 + a plist with `Label
com.linkedin-studio.trends.daily`); `--platform cron --print` → RED (expects the crontab line + the `crontab -`
instruction string); `--print` with a temp `HOME` → RED (expects `<HOME>/Library/LaunchAgents` empty *and* exit
0); no `--pillars` / `--at 25:00` / `--platform bogus` → these already exit 2 today (unknown command), so assert
the **post-implementation** behaviour (still exit 2, but for the validation reason) — recorded as RED only where
the message/route differs (kept minimal; the load-bearing RED is the `--print` emit). *(Fail today: `schedule`
is an unknown command.)*
- `run-daily.test.ts`**wrapper**: invoke **via `bash scripts/trends/run-daily.sh --pillars ai --store <tmp>
--out <tmp>/mb`** (through `bash`, NOT a direct executable spawn — folded — plan-critic #7: a direct exec of a
missing file throws ENOENT/`status:null`, a module-not-found-class failure; `bash <missing>` exits **127**, a
clean assertion-RED) with `LINKEDIN_STUDIO_DATA=<tmp>` → the file is **absent** → exit **127** → RED against the
assertion (expects the dated `.md` written + exactly one `cron.log` line + exit 0). *(Fail today: the wrapper does
not exist.)*
**Phase B — stub-first, then value-assertion RED** (`schedule.test` imports the new `schedule.ts` exports):
- Land **non-throwing stubs** so the imports resolve: in `schedule.ts``launchdPlist → ""`, `crontabLine → ""`,
`installInstructions → ""`, `uninstallInstructions → ""`, `defaultLabel → ""` (+ the `ScheduleSpec` interface,
erased by tsx). (Wrong-value stubs Step 2 replaces.)
- `schedule.test.ts`: `launchdPlist(spec)` contains `<key>Label</key>` + `spec.label` + `StartCalendarInterval` +
`spec.hour`/`spec.minute` + the `cron.log` path (stub `""` → RED); `crontabLine(spec)` matches the
`<min> <hour> * * * … run-daily.sh … >> <log> 2>&1 # <label>` shape (stub `""` → RED); `installInstructions` /
`uninstallInstructions` carry the `launchctl bootstrap` / `crontab -` recipes (stub `""` → RED); `defaultLabel()`
is `com.linkedin-studio.trends.daily` (stub `""` → RED); two `launchdPlist` calls byte-identical.
**RED proof (record in commit, two phases):** Phase A — `(cd scripts/trends && npm test)` before any src edit →
the `schedule` cli cases fail on the exit-0/stdout assertion (logic-RED) and the wrapper case fails on exit 127,
not on a missing module. Phase B — after the non-throwing `schedule.ts` stubs land, the `schedule.test` cases fail
on **value assertion** against the `""` stubs. The plan does **not** claim a single "everything fails before any
code" run.
## Step 2 — (GREEN) `scripts/trends/src/schedule.ts` — the pure emitters
Replace the Phase-B stubs with the real, pure implementations (no clock/fs/env/AI; every value via `ScheduleSpec`
the emitters **render** `spec.env`, they never read `process.env`):
- `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>; }`. **`env` is canonical** (folded — brief-reviewer #4 / plan-critic #3: declared once,
here + brief §3 + the Files table + the Step-1 fixtures — not mutated mid-step). The CLI always builds it as
`{ NODE_BIN, LINKEDIN_STUDIO_DATA }`; the emitters only stringify it.
- `launchdPlist(spec)` — a pinned, well-formed `<?xml … !DOCTYPE plist …>` template: `Label`=`spec.label`;
`ProgramArguments` = `["/bin/bash", spec.wrapperPath, ...spec.args]` (each as a `<string>`);
`StartCalendarInterval` `<dict>` with `<key>Hour</key><integer>${spec.hour}</integer>` + `Minute`;
`EnvironmentVariables` `<dict>` rendered from **`spec.env`** (each `K``<key>K</key><string>V</string>`);
`WorkingDirectory`=`spec.workingDir`; `StandardOutPath`/`StandardErrorPath`=`spec.logPath`; `RunAtLoad`=`<false/>`.
XML-escape any value that could contain `&`/`<`/`>` (paths are safe, but escape defensively for well-formedness).
- `crontabLine(spec)` — `${spec.minute} ${spec.hour} * * * ${envPrefix} /bin/bash ${spec.wrapperPath}
${spec.args.join(" ")} >> ${spec.logPath} 2>&1 # ${spec.label}` where `envPrefix` = the `spec.env` map as
`K=V K=V` (cron's inline env form). **Returns a string; never executes `crontab`.**
- `installInstructions(spec, plistTargetPath?)` — launchd: `"Wrote ${plistTargetPath}. Activate:\n launchctl
bootstrap gui/$(id -u) ${plistTargetPath}"`; cron: `"Add the line above:\n (crontab -l 2>/dev/null; echo
'${crontabLine(spec)}') | crontab -"`.
- `uninstallInstructions(spec, plistTargetPath?)` — launchd: `"launchctl bootout gui/$(id -u)/${spec.label} && rm
${plistTargetPath}"`; cron: `"crontab -l | grep -vF '# ${spec.label}' | crontab -"`.
- `defaultLabel()``"com.linkedin-studio.trends.daily"`.
Make the Phase-B `schedule.test` cases green.
## Step 3 — (GREEN) `scripts/trends/run-daily.sh` — the headless wrapper
Create the bash-3.2 wrapper (Write a new file under `scripts/trends/` — allowed; if Write is blocked, the operator
authorizes via the R2b `!cp` fallback, Risk R4). Shape:
```sh
#!/usr/bin/env bash
# RE-R3c headless entry: runs the DETERMINISTIC morning brief from a scheduler's
# profile-less env. No AI. The (e) slice will insert a pre-brief capture step here.
# Data-path: the FOURTH sanctioned twin of store.ts:defaultStorePath / data-root.mjs:getDataRoot
# / analytics/storage.ts:getDataRoot (shell form of the references/data-path-convention.md
# inline seam). Keep in sync.
set -eu
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR" # so `--import tsx` resolves node_modules even under cron's $HOME CWD
NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || true)}"
if [ -z "$NODE_BIN" ]; then for c in /usr/local/bin/node /opt/homebrew/bin/node /usr/bin/node; do
[ -x "$c" ] && NODE_BIN="$c" && break; done; fi
LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log"
mkdir -p "$(dirname "$LOG")"
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [ -z "$NODE_BIN" ]; then printf '%s exit=127 node not found\n' "$TS" >> "$LOG"; exit 127; fi
set +e
OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)"; CODE=$?
set -e
OUT="$(printf '%s' "$OUT" | tr '\n' ' ' | tr -s ' ')" # brief --json is pretty-printed -> one line
printf '%s exit=%s %s\n' "$TS" "$CODE" "$OUT" >> "$LOG"
exit "$CODE"
```
- `chmod +x` (or the CLI's `--install` does it). All expansions quoted; ASCII-only; no bash-4 features. `cd "$DIR"`
makes the one wrapper scheduler-agnostic (folded — brief-reviewer #1). `CODE=$?` is captured **before** the
compaction pipe (so it is node's code, not `tr`'s — folded — plan-critic #1).
- The scheduler bakes `NODE_BIN=<process.execPath>` + a resolved-absolute `LINKEDIN_STUDIO_DATA` (so a scheduled
run never evaluates `$HOME` under `set -u` — folded — plan-critic #9) + `--pillars … --fresh-days N` into `"$@"`;
the wrapper hard-codes the `brief` subcommand (the baked `args` carry **no** leading `brief` — folded —
plan-critic #8) and appends `--json`.
Make `run-daily.test.ts` green (SC7, SC8): the seeded-store run writes the dated `.md` + one compact log line +
exit 0; a `cwd=<unrelated>` run still resolves `tsx` (CWD-independence); a same-day re-run is byte-identical with
`surfacedCount` not double-counted.
## Step 4 — (GREEN) `cli.ts` — the `schedule` subcommand
- Import `{ launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel }` from
`./schedule.js`; **add `dirname` to the `node:path` import** (`cli.ts:41` is `import { join } from "node:path"`
`dirname` is NOT there today, folded — plan-critic #5; `join` stays); import `homedir` from `node:os` (the
LaunchAgents path) + `fileURLToPath` from `node:url`. `defaultStorePath` is already imported (`cli.ts:45`).
- A `schedule` branch (after the `brief` branch, before the trailing `usage`):
- `const pillars = splitTopics(flags.pillars); if (pillars.length === 0) usage("schedule needs --pillars <a,b>");`
- parse `--at` (default `"07:00"`): split on `:`, `Number.parseInt` both; validate `0≤H≤23`, `0≤M≤59` → else
`usage("--at must be HH:MM (00:0023:59)")`.
- parse `--fresh-days` (default 7, reuse the `brief` validation idiom).
- `const platform = flags.platform && flags.platform !== "true" ? flags.platform : (process.platform ===
"darwin" ? "launchd" : "cron");` validate `platform ∈ {launchd, cron, auto}` (auto already resolved) → else
`usage`.
- resolve runtime paths (no hard-coding): `const here = dirname(fileURLToPath(import.meta.url)); // …/src`;
`const wrapperPath = join(here, "..", "run-daily.sh"); const workingDir = join(here, ".."); const nodeBin =
process.execPath;`. **logPath (pinned — folded, all three reviewers):** `const logPath = join(dirname
(defaultStorePath()), "cron.log");` — derived from `defaultStorePath()` (= `<root>/trends/trends.json` →
`dirname` = `<root>/trends``<root>/trends/cron.log`), the data-root anchor the wrapper also uses; **NOT**
`dirname(storePath)` (the `--store` override) and **NOT** with a spurious `".."`. Matches `defaultBriefDir`'s
`dirname(defaultStorePath())` idiom (`brief.ts:236`), so `cron.log` is a sibling of `morning-brief/`.
- `const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio");
const env: Record<string,string> = { NODE_BIN: nodeBin, LINKEDIN_STUDIO_DATA: root };` — **always** bake the
resolved-absolute root (pins the scheduled run to the install-time data dir **and** removes the wrapper's
`$HOME`-unset `set -u` edge — folded — plan-critic #9 / brief-reviewer #9).
- `const args = ["--pillars", pillars.join(","), "--fresh-days", String(freshDays)];` **(no leading `"brief"`** —
the wrapper owns the subcommand; folded — plan-critic #8) and append `["--store", storePath]` **iff** the
operator passed an explicit `--store` (so the scheduled run targets the same store; otherwise the wrapper's
default resolves it).
- `const label = defaultLabel(); const spec: ScheduleSpec = { platform, label, nodeBin, wrapperPath, args, hour,
minute, logPath, workingDir, env };`
- dispatch:
- **`--uninstall`** → print `uninstallInstructions(spec, plistTarget)`; if the launchd plist file exists,
`rmSync` it (reversible); exit 0.
- **`--install`** → launchd: `const plistTarget = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
mkdirSync(dirname, {recursive:true}); writeFileSync(plistTarget, launchdPlist(spec))` + `console.log` the
plist path + `installInstructions(spec, plistTarget)`**never run `launchctl`**. cron: `console.log(crontabLine
(spec)); console.log(installInstructions(spec))` — **never run `crontab`**. exit 0.
- **default / `--print`** → `console.log(platform === "launchd" ? launchdPlist(spec) : crontabLine(spec));
console.log(installInstructions(spec, platform === "launchd" ? join(homedir(),"Library","LaunchAgents",
`${label}.plist`) : undefined));` — **no fs**. exit 0.
- **Header doc-comment** (`cli.ts:5-14`, `:36-37`): add the `schedule …` synopsis line + a note that `schedule` is
**print-first** (emits the plist/crontab + the install command; `--install` writes only the inert launchd plist
file; the tool never runs `launchctl`/`crontab`) and runs the **deterministic** brief (no AI capture — slice e).
**No new exit code** (0 success / 2 usage): an autonomy install never *runs* the system mutation, so there is no
install-failure path to encode.
- `usage()` (`:86-100`) — add the `schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform
auto|launchd|cron] [--install|--uninstall] [--store <path>]` line.
Make the Phase-A `cli.test` `schedule` cases green (print emit, auto-platform, print-writes-nothing,
install-launchd-writes-plist, install-cron-prints-only, validation exits).
## Step 5 — wire `trend-spotter.md` (prose) + README
In `agents/trend-spotter.md`: add **one prose line** (no batch-shape change): the morning brief can now be
**scheduled** to regenerate autonomously (deterministic, from the store) via the `schedule` CLI verb; the agent's
polling stays the capture path (autonomous AI polling is a later slice). Domain-general (Section 17). Update
`scripts/trends/README.md`: the headless wrapper (`run-daily.sh`) + the `schedule` subcommand (print-first,
launchd/cron, `--install`/`--uninstall`, `--at`/`--platform`), the **deterministic-brief-only boundary (C1)** + the
documented (e) AI-capture seam, the `cron.log` location, and the R3b per-day idempotency that makes a double-fire
safe.
## Step 6 — gate: floors + new unconditional Section 16l
In `scripts/test-runner.sh`:
- Set `TRENDS_TESTS_FLOOR` (`:709`, currently 171) to the **`tests N` line** reported by `(cd scripts/trends &&
npm test)` after Steps 15 — recounted live, NOT additive-guessed. Stays **inside** the deps guard. **Append**
`+ RE-R3c: scheduler +N` to the inline breakdown comment.
- Add **Section 16l** ("Trends Scheduler / Headless Wiring", RE-R3c), mirroring Section 16k (unconditional,
deps-absent-safe, pure `grep -qF`/self-test, no `tsx`). **Placement (verified live):** Section 16k ends `~:1305`,
Section 18 begins `:1307` — insert 16l **after 16k's block (`~:1305`), before Section 18 (`:1307`)** (anti-erosion
must stay last so it counts every prior check). Six **unconditional** checks, the self-test emitting **one**
pass/fail like 16k:
(1) a non-vacuity self-test (a probe carrying `launchdPlist` accepted, one without rejected);
(2) `grep -qF 'export function launchdPlist' scripts/trends/src/schedule.ts`;
(3) `grep -qF 'export function crontabLine' scripts/trends/src/schedule.ts`;
(4) `grep -qF 'command === "schedule"' scripts/trends/src/cli.ts` (the verb);
(5) `grep -qF 'cli.ts" brief' scripts/trends/run-daily.sh` (the wrapper runs the deterministic brief — the
sentinel matches the literal `…cli.ts" brief`, folded — plan-critic #8);
(6) `grep -qF 'LINKEDIN_STUDIO_DATA:-' scripts/trends/run-daily.sh` (the data-path twin seam).
- Bump `ASSERT_BASELINE_FLOOR` (**`:1329`**, currently 105 — folded, all three reviewers: the earlier `:1259` cite
was wrong, that line is `LIFECYCLE_FILTER_LIT`) → **exactly 111** (105 + the 6 new unconditional 16l emitters;
the self-test emits one pass/fail like 16k, so 111 is deterministic — "live recount" is the safety net, not a
guess). Insert the 16l clause into the **header-enumeration prose chain at `:57`** (before "…the assertion-count
anti-erosion floor (SC6) in Section 18"), preserving sentence flow. **Also append** the RE-R3b (→105) + RE-R3c
16l (→111) narration to the **Section-18 floor-history comment** (`~:1310-1324`, which still stops at "= 99"
because R3b's +6 was never narrated — folded — scope-guardian #7), so the comment matches the live floor.
- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3c adds no hook test). It must
still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land.
## Step 7 — behavioural verification
`(cd scripts/trends && npm install)` if needed, then run brief §7's six behavioural steps: seed a store; `schedule
… --platform launchd --print` + `… --platform cron --print` (inspect); `plutil -lint -` the plist → OK;
`LINKEDIN_STUDIO_DATA=/tmp/r3c-data ./run-daily.sh brief --pillars ai,gov --store /tmp/r3c.json --out
/tmp/r3c-data/trends/morning-brief` → dated `.md` written + `cron.log` line + exit 0; re-run same day → `.md`
byte-identical, `surfacedCount:1` (per-day idempotent), second log line; `schedule … --install` with a throwaway
`HOME` → the plist file written under `<HOME>/Library/LaunchAgents/` + the `launchctl bootstrap` command printed
(do **not** activate against the real system unless intentional). Run full `bash scripts/test-runner.sh``FAIL=0`
(`ASSERT_BASELINE_FLOOR` 111); run `node --test hooks/scripts/__tests__/*.test.mjs` → still green (untouched
regression).
## Step 8 — land
Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 111, gate total;
schema unchanged v4). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3c.md`
(no suffix, tracked); **(2)** code commit — `schedule.ts` + `run-daily.sh` + `cli.ts` + three test files
(`schedule.test`/`cli.test`/`run-daily.test`) + `agents/trend-spotter.md` + `scripts/trends/README.md` +
`scripts/test-runner.sh` with `[skip-docs]` (D9: single code commit — the scheduler is one coherent feature).
Push freely (window lifted; gitleaks at commit; `origin` = PUBLIC `open/` — STATE/`*.local.*` never pushed). No
version bump (additive; `v0.5.2` dev).
## Verification (testable)
| SC | Check | Command | Expected |
|---|---|---|---|
| — | RED Phase A | `(cd scripts/trends && npm test)` before src edits | `schedule` cli cases fail on the exit-0/stdout assertion (logic-RED); wrapper case fails on exit 127, not module-not-found |
| — | RED Phase B | `npm test` after non-throwing `schedule.ts` stubs | launchdPlist/crontabLine/install/uninstall/defaultLabel fail on value assertion against the `""` stubs |
| SC1 | launchd plist emit | `npm test` (schedule.test, cli.test) | plist has Label/ProgramArguments/StartCalendarInterval(H/M)/Std*Path/EnvironmentVariables; deterministic |
| SC2 | crontab line emit (string only) | `npm test` (schedule.test, cli.test) | `<m> <h> * * * … run-daily.sh brief --pillars … >> <log> 2>&1 # <label>` + `crontab -` instruction; never executes crontab |
| SC3 | platform auto | `npm test` (cli.test) | darwin→launchd, else→cron; branches on `process.platform` |
| SC4 | print-first writes nothing | `npm test` (cli.test) | `--print` → exit 0, stdout plist, temp-HOME LaunchAgents empty |
| SC5 | `--install` launchd inert plist | `npm test` (cli.test) | plist FILE written under temp HOME LaunchAgents; `launchctl bootstrap` printed; launchctl never run; exit 0 |
| SC6 | `--install` cron prints only | `npm test` (cli.test) | line + `crontab -` instruction printed; crontab never run; exit 0 |
| SC7 | headless wrapper runs brief + logs | `npm test` (run-daily.test, via `bash`) + manual | dated `.md` written; **one** compact `cron.log` line; exit 0; second same-day run byte-identical + surfacedCount not double-counted; CWD-independent (`cd "$DIR"`); no AI |
| SC8 | data-path twin consistency | `npm test` (run-daily.test) | wrapper log dir == `dirname(defaultStorePath())` (not `--store`) == `getDataRoot('trends')`, default + override |
| SC9 | usage / validation | `npm test` (cli.test) | no `--pillars`/bad `--at`/bad `--platform` → exit 2; fs untouched |
| SC10 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16l green; ASSERT_BASELINE_FLOOR=111; Section 17; counts 29/19/27; hook suite green |
## Risks
- **R1 — the wrapper's data-path twin drifts from `store.ts`/`data-root.mjs`.** *Mitigated:* SC8 behavioral
twin-consistency test (resolve all three for a temp `LINKEDIN_STUDIO_DATA` override; assert equal); the wrapper
uses the exact `references/data-path-convention.md` rule-1 inline form; documented as the third twin (mirroring
`data-root.mjs`'s comment).
- **R2 — a test runs `crontab`/`launchctl` and trips the global guard or mutates the real system.** *Mitigated:*
every test asserts the **emitted string / written file on stdout/temp-HOME** — none executes `crontab` or
`launchctl`; `--install` tests use a throwaway `HOME` and assert only the inert plist file + printed command.
(A test that ran `crontab` would be a BLOCKER — invariant §5.)
- **R3 — malformed launchd plist (won't load).** *Mitigated:* SC1 asserts every required key; the plist is a
pinned `<!DOCTYPE plist …>` template; behavioural Step 7 runs `plutil -lint -` (macOS, deps-present manual check
— not a gate, since a deps-absent fresh clone has no `plutil`).
- **R4 — the two NEW files (`schedule.ts`, `run-daily.sh`) blocked at Write.** *Mitigated:* they are under
`scripts/trends/` (allowed — `cli.ts`/`test-runner.sh` were added there; the pathguard blocks NEW `.mjs` under
`hooks/scripts/` only). If Write is nonetheless blocked, the operator authorizes the R2b `!cp` fallback
(write to scratch, `cp` in). This is an implementation-step risk, not a docs-step blocker.
- **R5 — the nightly deterministic brief is a near-no-op without new captures (thin visible value).**
*Accepted/honest (C1):* R3c is the **mechanism**; freshness-aging + per-day surfacing accumulation are real (feed
slice b), but the visible autonomous-research payoff lands with slice (e), which plugs AI capture into the
documented pre-brief seam. Stated plainly in the README + the brief §1 value framing — no salesmanship.
- **R6 — `--at HH:MM` parse / validation gaps.** *Mitigated:* explicit `0≤H≤23`/`0≤M≤59` validation → `usage`
exit 2; SC9 asserts `25:00`/`7:99`/`noon` all exit 2.
- **R7 — bash 3.2 incompatibility / `set -u` crash in the wrapper.** *Mitigated:* ASCII-only, all expansions
quoted, no `declare -A`/`mapfile`/`|&`; the `set +e`/`set -e` fence around the node call captures the exit code
cleanly; the operator's macOS (bash 3.2) is the test bed; `run-daily.test` runs it as a subprocess in CI-shape.
- **R8 — node unresolvable in launchd/cron's profile-less env.** *Mitigated:* the scheduler bakes
`NODE_BIN=<process.execPath>` (absolute) into the artifact env; the wrapper falls back to `command -v node` then
common locations, logging `exit=127 node not found` if truly absent; `WorkingDirectory` is set so tsx resolves.
- **R9 — editing `trend-spotter.md` / the new source trips the de-niche guard.** *Mitigated:* Section 17 runs in
the gate; the added prose + the source carry only generic scheduling wording; the launchd Label is the plugin
namespace; pillars are args (no vendor/sector token).
- **R10 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16l is pure
`grep`/self-test on tracked source (`schedule.ts` + `cli.ts` + `run-daily.sh`; no `tsx`) → unconditional;
`TRENDS_TESTS_FLOOR` stays inside the deps guard.
- **R11 — import cycle.** *Mitigated:* `schedule.ts` is a **leaf** (imports nothing from the package); `cli.ts`
(root) imports it. The DAG stays acyclic: `score (leaf) ← types ← store ← brief ← cli`, with `schedule (leaf) ←
cli` added. `brief.ts`/`store.ts`/`item.ts`/`score.ts`/`types.ts` untouched.
## Plan-critic — folded
Three Opus reviewers ran, 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 + the untouched-files claim
verified live; 1 MAJOR line-cite + 6 MINOR); **brief-reviewer PROCEED_WITH_RISKS** (RED premises TRUE, the
data-path trio genuinely consistent, the C2 no-execution path confirmed; 2 MAJOR + 5 MEDIUM/LOW); **plan-critic
REVISE 73/C** (the two-phase RED, de-niche safety, the bash-3.2 `set -eu` fence, the `import.meta.url`/`process.
execPath` path resolution, and the **+6→111 gate arithmetic** all verified correct; the C grade is largely the
legacy-manifest-format penalty — hand-authored slice docs, not trekexecute manifests). Resolution, each verified
against live code:
- **[MAJOR — 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. ✅ Step 3 captures `CODE=$?` first, then **compacts** `OUT`
(`tr '\n' ' ' | tr -s ' '`); SC7 asserts exactly one line.
- **[MAJOR — brief-reviewer #1] the wrapper never `cd`s to its package dir** → cron (CWD `$HOME`) would fail to
resolve `tsx` (the plist sets `WorkingDirectory`, cron does not). ✅ Step 3 adds **`cd "$DIR"`**; SC7 gains a
CWD-independence case.
- **[MAJOR/MAJOR/MINOR — all three] the `logPath` expression** self-contradicted (`".."` escaping `trends/`) and
used the `--store` override base. ✅ Step 4 pins **`join(dirname(defaultStorePath()), "cron.log")`** (data-root
anchor, matches the wrapper); the `".."` and `storePath` variants removed; SC8 retargeted.
- **[MAJOR — brief-reviewer #4 / plan-critic #3] `ScheduleSpec.env` stated three ways.** ✅ `env:
Record<string,string>` is **canonical** (Step 2 interface + brief §3 + Files table + Step-1 fixtures), always
`{ NODE_BIN, LINKEDIN_STUDIO_DATA(resolved) }`; the emitters render it only (purity holds).
- **[MAJOR — all three] `ASSERT_BASELINE_FLOOR` line-cite `:1259``:1329`** (verified live; `:1259` is
`LIFECYCLE_FILTER_LIT`; value 105→111 correct). ✅ Step 6 + Files table corrected; `TRENDS_TESTS_FLOOR` `:705`
`:709`; header-enum insertion `:49-53``:57`; the stale Section-18 "= 99" narration gets the R3b(→105)+R3c(→111)
append.
- **[MINOR — plan-critic #5] `dirname` not imported in `cli.ts`** (`:41` = `join` only). ✅ Step 4 adds `dirname`
to the `node:path` import (+ `homedir`/`fileURLToPath`).
- **[MINOR — plan-critic #8 / brief-reviewer #5] stray double `brief`** (`cli.ts brief brief …`). ✅ the baked
`args` drop the leading `"brief"` (Step 4); the wrapper owns the subcommand; the 16l sentinel matches `cli.ts"
brief` (Step 6 (5)).
- **[MINOR — plan-critic #7] wrapper-absent RED is invocation-dependent.** ✅ Step 1 pins the test through
**`bash run-daily.sh`** so the absent file is exit 127 (clean assertion-RED), not a thrown ENOENT.
- **[MINOR — plan-critic #9 / brief-reviewer #9] `$HOME`-unset `set -u` edge.** ✅ the CLI **always** bakes a
resolved-absolute `LINKEDIN_STUDIO_DATA` (Step 4 env), so a scheduled run never evaluates `$HOME`.
- **[MINOR — scope-guardian #4] pathguard justification fabricated.** ✅ restated (brief §5): `~/repos/*` is
allowlisted; the conclusion (new files write-allowed) holds.
- **[LOW — brief-reviewer #7] SC1 "lint-valid" only grep-checked.** ✅ SC1 asserts **well-formed** (balanced-tag/
parse) + key-complete; `plutil -lint` stays the deps-present manual check (Step 7).
- **[LOW — brief-reviewer #8] twin census undercounted (3 → 4).** ✅ `analytics/storage.ts` named as the existing
third; `run-daily.sh` the fourth (brief §5 + the wrapper comment).
- **[MINOR — scope-guardian/plan-critic] de-niche safety (Section 17) confirmed:** `NICHE_TOKENS=Microsoft|Azure|
Copilot|public sector|offentlig sektor`, scoped to an allowlist (`trend-spotter.md`/`content-planner.md`/
`content-framework.md`); `schedule.ts`/`run-daily.sh` are not scanned, the agent prose carries no token, the label
`com.linkedin-studio.trends.daily` is clean. R9 holds (no change needed).
**[plan-critic headless-readiness]** — R3c **builds** a headless entry, but the slice itself is authored and landed
**in-session, operator-driven** (driftsmodell), not as a headless autonomous run, so per-step revert/halt clauses
aren't needed (R1/R2a/R2b/R3a/R3b had none either). The *artifact* it ships (`run-daily.sh`) is the headless entry;
the *development* is in-session.