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
34 KiB
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 (scheduleis an unknown command →usageexit 2; therun-daily.shwrapper file is absent → exit 127) on the exit-0/stdout assertions; (B) for the NEWschedule.tsexports 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.tsstubs→real →run-daily.shwrapper →cli.tsscheduleverb → wiretrend-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;--installwrites only the inert launchd plist file; the tool never runslaunchctl/crontab). Go-gate D1–D9 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 |
SC1–SC6, 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 |
SC3–SC6, 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 --printtoday →usage(unknown command) exit 2, no plist on stdout → RED against the assertion (expects exit 0 + a plist withLabel com.linkedin-studio.trends.daily);--platform cron --print→ RED (expects the crontab line + thecrontab -instruction string);--printwith a tempHOME→ RED (expects<HOME>/Library/LaunchAgentsempty 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--printemit). (Fail today:scheduleis an unknown command.)run-daily.test.ts— wrapper: invoke viabash scripts/trends/run-daily.sh --pillars ai --store <tmp> --out <tmp>/mb(throughbash, 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) withLINKEDIN_STUDIO_DATA=<tmp>→ the file is absent → exit 127 → RED against the assertion (expects the dated.mdwritten + exactly onecron.logline + 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 → ""(+ theScheduleSpecinterface, 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+ thecron.logpath (stub""→ RED);crontabLine(spec)matches the<min> <hour> * * * … run-daily.sh … >> <log> 2>&1 # <label>shape (stub""→ RED);installInstructions/uninstallInstructionscarry thelaunchctl bootstrap/crontab -recipes (stub""→ RED);defaultLabel()iscom.linkedin-studio.trends.daily(stub""→ RED); twolaunchdPlistcalls 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>; }.envis 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 fromspec.env(eachK→<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}whereenvPrefix= thespec.envmap asK=V K=V(cron's inline env form). Returns a string; never executescrontab.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-Bschedule.testcases 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:
#!/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--installdoes 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, nottr's — folded — plan-critic #1).- The scheduler bakes
NODE_BIN=<process.execPath>+ a resolved-absoluteLINKEDIN_STUDIO_DATA(so a scheduled run never evaluates$HOMEunderset -u— folded — plan-critic #9) +--pillars … --fresh-days Ninto"$@"; the wrapper hard-codes thebriefsubcommand (the bakedargscarry no leadingbrief— folded — plan-critic #8) and appends--json. Makerun-daily.test.tsgreen (SC7, SC8): the seeded-store run writes the dated.md+ one compact log line + exit 0; acwd=<unrelated>run still resolvestsx(CWD-independence); a same-day re-run is byte-identical withsurfacedCountnot double-counted.
Step 4 — (GREEN) cli.ts — the schedule subcommand
- Import
{ launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel }from./schedule.js; adddirnameto thenode:pathimport (cli.ts:41isimport { join } from "node:path"—dirnameis NOT there today, folded — plan-critic #5;joinstays); importhomedirfromnode:os(the LaunchAgents path) +fileURLToPathfromnode:url.defaultStorePathis already imported (cli.ts:45). - A
schedulebranch (after thebriefbranch, before the trailingusage):const pillars = splitTopics(flags.pillars); if (pillars.length === 0) usage("schedule needs --pillars <a,b>");- parse
--at(default"07:00"): split on:,Number.parseIntboth; validate0≤H≤23,0≤M≤59→ elseusage("--at must be HH:MM (00:00–23:59)"). - parse
--fresh-days(default 7, reuse thebriefvalidation idiom). const platform = flags.platform && flags.platform !== "true" ? flags.platform : (process.platform === "darwin" ? "launchd" : "cron");validateplatform ∈ {launchd, cron, auto}(auto already resolved) → elseusage.- 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 fromdefaultStorePath()(=<root>/trends/trends.json→dirname=<root>/trends→<root>/trends/cron.log), the data-root anchor the wrapper also uses; NOTdirname(storePath)(the--storeoverride) and NOT with a spurious"..". MatchesdefaultBriefDir'sdirname(defaultStorePath())idiom (brief.ts:236), socron.logis a sibling ofmorning-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-unsetset -uedge — 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→ printuninstallInstructions(spec, plistTarget); if the launchd plist file exists,rmSyncit (reversible); exit 0.--install→ launchd:const plistTarget = join(homedir(), "Library", "LaunchAgents",${label}.plist); mkdirSync(dirname, {recursive:true}); writeFileSync(plistTarget, launchdPlist(spec))+console.logthe plist path +installInstructions(spec, plistTarget)— never runlaunchctl. cron:console.log(crontabLine (spec)); console.log(installInstructions(spec))— never runcrontab. 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 theschedule …synopsis line + a note thatscheduleis print-first (emits the plist/crontab + the install command;--installwrites only the inert launchd plist file; the tool never runslaunchctl/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 theschedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]line. Make the Phase-Acli.testschedulecases 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 thetests Nline reported by(cd scripts/trends && npm test)after Steps 1–5 — recounted live, NOT additive-guessed. Stays inside the deps guard. Append+ RE-R3c: scheduler +Nto 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, notsx). 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 carryinglaunchdPlistaccepted, 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:1259cite was wrong, that line isLIFECYCLE_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_FLOORintest-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 tempLINKEDIN_STUDIO_DATAoverride; assert equal); the wrapper uses the exactreferences/data-path-convention.mdrule-1 inline form; documented as the third twin (mirroringdata-root.mjs's comment). - R2 — a test runs
crontab/launchctland trips the global guard or mutates the real system. Mitigated: every test asserts the emitted string / written file on stdout/temp-HOME — none executescrontaborlaunchctl;--installtests use a throwawayHOMEand assert only the inert plist file + printed command. (A test that rancrontabwould 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 runsplutil -lint -(macOS, deps-present manual check — not a gate, since a deps-absent fresh clone has noplutil). - R4 — the two NEW files (
schedule.ts,run-daily.sh) blocked at Write. Mitigated: they are underscripts/trends/(allowed —cli.ts/test-runner.shwere added there; the pathguard blocks NEW.mjsunderhooks/scripts/only). If Write is nonetheless blocked, the operator authorizes the R2b!cpfallback (write to scratch,cpin). 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:MMparse / validation gaps. Mitigated: explicit0≤H≤23/0≤M≤59validation →usageexit 2; SC9 asserts25:00/7:99/noonall exit 2. - R7 — bash 3.2 incompatibility /
set -ucrash in the wrapper. Mitigated: ASCII-only, all expansions quoted, nodeclare -A/mapfile/|&; theset +e/set -efence around the node call captures the exit code cleanly; the operator's macOS (bash 3.2) is the test bed;run-daily.testruns 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 tocommand -v nodethen common locations, loggingexit=127 node not foundif truly absent;WorkingDirectoryis 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; notsx) → unconditional;TRENDS_TESTS_FLOORstays inside the deps guard. - R11 — import cycle. Mitigated:
schedule.tsis a leaf (imports nothing from the package);cli.ts(root) imports it. The DAG stays acyclic:score (leaf) ← types ← store ← brief ← cli, withschedule (leaf) ← cliadded.brief.ts/store.ts/item.ts/score.ts/types.tsuntouched.
Plan-critic — folded
Three Opus reviewers ran, each verifying claims against live code: scope-guardian ALIGNED (0 creep, 0 gaps;
every SC1–SC10 traces to a step; no AI/capture, no schema bump, counts 29/19/27 + 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 --jsonis pretty-printed (cli.ts:323JSON.stringify(…, null, 2)), so the wrapper's "one log line" / SC7 contract was false. ✅ Step 3 capturesCODE=$?first, then compactsOUT(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 resolvetsx(the plist setsWorkingDirectory, cron does not). ✅ Step 3 addscd "$DIR"; SC7 gains a CWD-independence case. - [MAJOR/MAJOR/MINOR — all three] the
logPathexpression self-contradicted (".."escapingtrends/) and used the--storeoverride base. ✅ Step 4 pinsjoin(dirname(defaultStorePath()), "cron.log")(data-root anchor, matches the wrapper); the".."andstorePathvariants removed; SC8 retargeted. - [MAJOR — brief-reviewer #4 / plan-critic #3]
ScheduleSpec.envstated 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_FLOORline-cite:1259→:1329(verified live;:1259isLIFECYCLE_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]
dirnamenot imported incli.ts(:41=joinonly). ✅ Step 4 addsdirnameto thenode:pathimport (+homedir/fileURLToPath). - [MINOR — plan-critic #8 / brief-reviewer #5] stray double
brief(cli.ts brief brief …). ✅ the bakedargsdrop the leading"brief"(Step 4); the wrapper owns the subcommand; the 16l sentinel matchescli.ts" brief(Step 6 (5)). - [MINOR — plan-critic #7] wrapper-absent RED is invocation-dependent. ✅ Step 1 pins the test through
bash run-daily.shso the absent file is exit 127 (clean assertion-RED), not a thrown ENOENT. - [MINOR — plan-critic #9 / brief-reviewer #9]
$HOME-unsetset -uedge. ✅ the CLI always bakes a resolved-absoluteLINKEDIN_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 -lintstays the deps-present manual check (Step 7). - [LOW — brief-reviewer #8] twin census undercounted (3 → 4). ✅
analytics/storage.tsnamed as the existing third;run-daily.shthe 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.share not scanned, the agent prose carries no token, the labelcom.linkedin-studio.trends.dailyis 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.