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

34 KiB
Raw Permalink Blame History

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 pointrun-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 EDITschedule --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 EDITTRENDS_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.tsschedule: 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.tswrapper: 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.tslaunchdPlist → "", 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.

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.

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:

#!/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.jsondirname = <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 / --printconsole.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.shFAIL=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 cds 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.