linkedin-studio/docs/research-engine/plan-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

376 lines
34 KiB
Markdown
Raw Permalink 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.

# 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.