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
424 lines
38 KiB
Markdown
424 lines
38 KiB
Markdown
# Brief — RE-R3c: autonomous trigger — scheduler + headless entry point
|
||
|
||
> **Slice:** RE-R3c (research-engine rung-2, R3 slice 3 — the **autonomy** slice: the trigger that makes the
|
||
> daily loop *closed* and the headless entry that runs the deterministic morning brief with **no interactive
|
||
> session**). R3 ("deepen the research engine") is an **arc** of 5 open hulls (substrate §1). R3a took relevance,
|
||
> R3b took the lifecycle (status + seen-log + re-score). R3c takes hulls **(1) no autonomous trigger** + **(6) no
|
||
> headless entry point** — the *mechanism* that runs the existing deterministic brief on a schedule, built and
|
||
> tested deterministically **before** the autonomous AI fan-out (slice e) plugs into it.
|
||
> **Predecessor:** RE-R3b (`status` exclusion + per-day-idempotent `surfacedCount`/`lastSurfacedAt` — the
|
||
> dedup-state a nightly loop **depends on** so it never re-surfaces handled work) + RE-R2b (`brief.ts` dated
|
||
> artifact + the SessionStart surfacing the nightly run feeds) + RE-R3a (composite ranking).
|
||
> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (1) (*"ingen autonom trigger … zero cron/launchd/
|
||
> scheduler i hele repoet"*) + (6) (*"ingen headless entry point"*) + §B4 (*"behavioral scheduling … a push/delivery
|
||
> window that gates delivery separately from the sweep"*) + §B3 (the dated digest as a flat plain-text artifact
|
||
> *"skrevet av Stop-hook eller cron-trigget headless-sesjon"*). R3c builds the cron-triggered headless path B3
|
||
> anticipated and the scheduling-window discipline B4 names.
|
||
> **TDD-order:** RED before code, **two phases** (light-Voyage discipline, inherited): Phase A — assertion-RED via
|
||
> subprocess against the **existing** CLI (`schedule` is an unknown command today → `usage` exit 2; the wrapper
|
||
> file is absent → exit 127) — true assertion-RED on the exit-code/stdout assertions, not module-not-found. Phase B
|
||
> — `schedule.ts` is a NEW module whose exports the tests import; under Node16 ESM a missing named import throws at
|
||
> module-load, so land **non-throwing stubs** (`launchdPlist → ""`, etc.) first, then record value-assertion RED
|
||
> against them. See plan Step 1.
|
||
> **Architectural decisions — CONFIRMED (operator, AskUserQuestion 2026-06-26; baked into the plan):**
|
||
> - **C1 — deterministic brief-only.** The nightly headless run regenerates the dated brief from the **current
|
||
> store** (freshness-aging drops stale trends; `surfacedCount` accumulates per distinct day → feeds slice b).
|
||
> **NO AI capture.** Polling stays operator-driven; the autonomous AI fan-out is **slice (e)**, which plugs into
|
||
> (c)'s headless seam. Faithful to the operator's `(a)→(c)→(b)→(d)→(e)` sequence: build the trigger mechanism +
|
||
> headless plumbing (deterministic, testable) **before** the AI sweep it will eventually drive. *Honest framing:
|
||
> the visible autonomous-research payoff lands with (e); (c) is the mechanism.*
|
||
> - **C2 — print-first installer.** `schedule` **emits** the launchd plist (macOS) / crontab line (Linux) + the
|
||
> exact install command; the operator runs it. `--install` writes only the inert launchd plist FILE (never runs
|
||
> `launchctl`; never touches `crontab`). Matches the global `[voyage]` cron-persistence guard, the push-policy's
|
||
> operator-authorization, and the "confirm outward-facing/persistent actions" rule.
|
||
|
||
## 1. Operator decision context (2026-06-26)
|
||
|
||
The research engine is **Tier-1** (operator, 2026-06-23). R1→R3b built the deterministic spine **and** the
|
||
trend's life after capture: item-schema + triage (R1) → capture bridge (R2a) → dated morning brief + surfacing
|
||
(R2b) → persisted relevance + composite ranking (R3a) → status lifecycle + seen-log + re-score (R3b). The spine is
|
||
complete and the lifecycle is correct — **but nothing runs it on its own.** The morning brief exists only when the
|
||
operator interactively invokes the `brief` CLI (via the `trend-spotter` agent or by hand); the SessionStart hook
|
||
*surfaces* the latest dated brief (`session-start.mjs:534`) but **never generates one**. There is **zero
|
||
scheduler** in the repo (verified live: only `scripts/test-runner.sh` exists; no plist, no cron, no launchd in any
|
||
`.ts`/`.mjs`/`.sh`/config). The loop is open: a brief is only as fresh as the last time the operator remembered to
|
||
ask for one.
|
||
|
||
R3c closes hulls **(1)** and **(6)** — the **autonomous trigger** and the **headless entry point** — which the
|
||
operator chose as slice (c) of the full-R3 build-out (2026-06-24, *"ALLE gjenstående R3-slices … i rekkefølge
|
||
(a) → (c) → (b) → (d) → (e)"*). It is sequenced **after** R3b for a load-bearing reason the operator named: an
|
||
autonomous loop **must never re-surface handled work**, so it depends on R3b's status-exclusion (acted/skipped
|
||
dropped from the brief) and its **per-day-idempotent** surfacing (a double-fire doesn't double-count). R3b made
|
||
the nightly regeneration *safe to automate*; R3c automates it.
|
||
|
||
**What R3c is — and is not (C1).** R3c is the **mechanism**, not the AI sweep. The nightly run is the *existing
|
||
deterministic* `brief` generation — load store → rank → write the dated `.md` → record surfacing — run with no
|
||
interaction by a scheduler. It does **not** poll new sources (that is the AI fan-out, slice e). Its honest value
|
||
without (e): the brief is regenerated every morning from the current store, so SessionStart surfacing is always
|
||
fresh; freshness-aging drops trends past the window automatically; and `surfacedCount` accumulates day-over-day —
|
||
the temporal signal slice (b) reads — **without the operator running anything**. (e) later plugs an AI capture
|
||
step into the documented pre-brief seam to close the full `poll→score→capture→brief` loop.
|
||
|
||
## 2. The gap — grounded in code
|
||
|
||
- **No autonomous trigger (hull 1).** Repo-wide there is no scheduler: no launchd plist, no crontab artifact, no
|
||
`launchctl`/`cron` reference in any source or config (verified). Every brief is born of an interactive session.
|
||
- **No headless entry point (hull 6) — *almost*.** The `brief` subcommand (`cli.ts:297-328`) is **already
|
||
non-interactive**: it reads flags, writes `<outDir>/<day>.md`, records surfacing, and exits 0 — no prompts. What
|
||
is missing is a **robust invocation wrapper** that makes it runnable from a scheduler's *minimal* environment:
|
||
a launchd/cron job inherits **no shell profile** (no `PATH` from `~/.zshenv`, so a bare `node` is unresolvable),
|
||
has **no working directory** set to the repo (tsx resolves modules only from `scripts/trends/`), and has **no
|
||
logging**. Today nothing bridges that gap.
|
||
- **The brief is operator-pulled, never machine-pushed.** `session-start.mjs:60-77`/`:534` *reads* the latest
|
||
dated brief (`date`+`summary`, zero-tsx) and surfaces it — it is a pure consumer. Generation lives only in the
|
||
CLI, invoked by a human. B3's *"cron-trigget headless-sesjon"* writer does not exist.
|
||
- **The CLI has no scheduling verb.** `cli.ts` exposes `add`/`query`/`list`/`status`/`act`/`skip`/`reset`/
|
||
`normalize`/`score`/`capture`/`brief` (`cli.ts:5-14`, `:134-330`) — capture/read/lifecycle, all interactive.
|
||
There is **no way to emit or install a daily schedule** for the brief.
|
||
- **The data-dir seam is solved, but only for two runtimes.** `store.ts:252` (`defaultStorePath`) and
|
||
`hooks/scripts/data-root.mjs:24` (`getDataRoot`) are *twins* of the one seam (`LINKEDIN_STUDIO_DATA ?? ~/.claude/
|
||
linkedin-studio`). A scheduler entry running in **shell** needs the same seam for its log path — a **third
|
||
sanctioned twin**, exactly the inline `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/…` form
|
||
`references/data-path-convention.md` rule 1 prescribes. It does not exist yet.
|
||
|
||
## 3. Scope — what is IN (RE-R3c)
|
||
|
||
### S-schedule — `scripts/trends/src/schedule.ts` (NEW, pure module)
|
||
|
||
Pure string emitters for the schedule artifacts — no clock, no fs, no env, no AI (the CLI injects every resolved
|
||
value). Mirrors `brief.ts`'s `renderBrief` purity → fully testable, byte-deterministic given inputs.
|
||
|
||
- **`export interface ScheduleSpec`** — `{ platform: "launchd" | "cron"; label: string; nodeBin: string;
|
||
wrapperPath: string; args: string[]; hour: number; minute: number; logPath: string; workingDir: string;
|
||
env: Record<string, string>; }`. All paths are absolute, resolved by the CLI at generation time on the
|
||
operator's machine. **`env`** is the injected environment map (the CLI builds it — always `NODE_BIN` +
|
||
a **resolved-absolute** `LINKEDIN_STUDIO_DATA`); the emitter only *renders* it, so it reads no env itself
|
||
(folded — brief-reviewer #4 / plan-critic #3: the field is canonical, not mid-step).
|
||
- **`export function launchdPlist(spec: ScheduleSpec): string`** — the plist XML: `Label`, `ProgramArguments`
|
||
(`["/bin/bash", wrapperPath, ...args]`), `StartCalendarInterval` (`{ Hour: spec.hour, Minute: spec.minute }`),
|
||
`EnvironmentVariables` (rendered from `spec.env` only — purity), `WorkingDirectory` (`spec.workingDir`),
|
||
`StandardOutPath`/`StandardErrorPath` (`spec.logPath`), `RunAtLoad` false. A pinned, well-formed template
|
||
(`<?xml … !DOCTYPE plist …>`); `schedule.test` asserts both key-completeness **and** well-formedness
|
||
(balanced-tag/parse), `plutil -lint` is the deps-present manual check (folded — brief-reviewer #7).
|
||
- **`export function crontabLine(spec: ScheduleSpec): string`** — one line:
|
||
`<minute> <hour> * * * <env-prefix> /bin/bash <wrapperPath> <args…> >> <logPath> 2>&1 # <label>` where
|
||
`<env-prefix>` is `spec.env` rendered as cron's inline `K=V K=V` form. **The function returns the line as a
|
||
STRING; it never executes `crontab`** (the execution guard + C2; the literal `crontabLine` does not match the
|
||
guard's `\bcrontab\b` word-boundary pattern — §5).
|
||
- **`export function installInstructions(spec: ScheduleSpec, plistTargetPath?: string): string`** — the exact
|
||
operator commands. launchd: *"written to `<plistTargetPath>` — activate with `launchctl bootstrap gui/$(id -u)
|
||
<plistTargetPath>`"*. cron: *"add the line above with `(crontab -l 2>/dev/null; echo '<line>') | crontab -`"*.
|
||
Print-first surfaces these so the operator runs them.
|
||
- **`export function uninstallInstructions(spec, plistTargetPath?): string`** — symmetric removal (launchd:
|
||
`launchctl bootout …` + `rm <plist>`; cron: the line-removal `grep -v` recipe).
|
||
- **`export function defaultLabel(): string`** — `"com.linkedin-studio.trends.daily"` (the **plugin namespace**,
|
||
domain-general — not the user's domain; no vendor/sector token).
|
||
|
||
### S-wrapper — `scripts/trends/run-daily.sh` (NEW, headless invocation wrapper)
|
||
|
||
The single tested headless entry, invoked identically by **both** the launchd plist and the crontab line (one
|
||
entry → one test). Bash 3.2-compatible (operator's macOS: no `declare -A`, no `mapfile`, all expansions quoted,
|
||
ASCII-only).
|
||
|
||
- Resolves **its own directory** (`DIR="$(cd "$(dirname "$0")" && pwd)"`) so it is relocatable — no hard-coded
|
||
repo path — and **`cd "$DIR"`** so `--import tsx` resolves `node_modules` from the package even under cron's
|
||
`$HOME` CWD (folded — brief-reviewer #1: the plist sets `WorkingDirectory`, but cron does not — `cd` makes the
|
||
one wrapper scheduler-agnostic).
|
||
- Resolves **node** from a minimal scheduler env: `NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null)}"`;
|
||
if still empty, fall back to common locations; exit 127 with a logged error if none. (The scheduler bakes
|
||
`NODE_BIN=<process.execPath>` so resolution always succeeds; the fallback is for a manual invocation.)
|
||
- Resolves the **log path** from the canonical inline seam — `LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/
|
||
linkedin-studio}/trends/cron.log"` — the **fourth sanctioned data-path twin** (shell), the exact form
|
||
`references/data-path-convention.md` rule 1 prescribes (documented as such, like `data-root.mjs`'s twin comment).
|
||
The scheduler **always bakes a resolved-absolute `LINKEDIN_STUDIO_DATA`** into the artifact env, so a scheduled
|
||
run never evaluates `$HOME` (sidesteps the `set -u` `HOME`-unset edge — folded — plan-critic #9 / brief-reviewer
|
||
#9); the `$HOME` fallback is only for a manual invocation, where `HOME` is set. `mkdir -p` its dir.
|
||
- Runs the **deterministic** brief: `OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)";
|
||
CODE=$?` then **compacts** `OUT` to one line (`tr '\n' ' ' | tr -s ' '`) — `brief --json` is **pretty-printed**
|
||
(`cli.ts:323` `JSON.stringify(…, null, 2)`), so the structured log line must collapse the newlines (folded —
|
||
plan-critic #1). The scheduler bakes `--pillars … --fresh-days N` into `"$@"`; the wrapper hard-codes the
|
||
`brief` subcommand and adds `--json`. Appends **one** line `<ISO-ts> exit=<CODE> <compact-OUT>` to `$LOG`;
|
||
`exit $CODE`. **No AI** — `brief` is the deterministic store→artifact path (C1).
|
||
- **The (e) seam (documented, not built):** a one-line comment marks where slice (e) will insert a pre-brief
|
||
capture step (`claude -p … trend-spotter | cli.ts capture`) before the `brief` call. R3c builds **only** the
|
||
deterministic path.
|
||
|
||
### S-cli — `scripts/trends/src/cli.ts` (EDIT) — the `schedule` subcommand
|
||
|
||
- **`schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install]
|
||
[--uninstall] [--store <path>]`**:
|
||
- Resolves **platform**: `auto` (default) → `process.platform === "darwin" ? "launchd" : "cron"`.
|
||
- Resolves **time** from `--at` (default `07:00`); validates `HH ∈ 0–23`, `MM ∈ 0–59` → `usage` exit 2 on bad
|
||
input. `--pillars` is **required** (a schedule with no pillars is meaningless) → `usage` exit 2 if absent.
|
||
- Resolves the absolute paths **from the runtime**, never hard-coded: `nodeBin = process.execPath` (absolute);
|
||
`wrapperPath = join(dirname(fileURLToPath(import.meta.url)), "..", "run-daily.sh")` (`cli.ts` is at
|
||
`scripts/trends/src/`, so `..` → `scripts/trends/`); `workingDir = join(dirname(fileURLToPath(import.meta.url)),
|
||
"..")`; **`logPath = join(dirname(defaultStorePath()), "cron.log")`** — derived from `defaultStorePath()`
|
||
(`<root>/trends/trends.json` → `<root>/trends/cron.log`), **NOT** from the `--store` override, so it matches
|
||
the wrapper's data-root-anchored log exactly (folded — all three reviewers: a `--store` outside the data dir
|
||
must not split the plist `StandardOutPath` from the wrapper's own log file).
|
||
- Builds **`env`** (always): `{ NODE_BIN: process.execPath, LINKEDIN_STUDIO_DATA: <resolved-absolute root> }`
|
||
where the root = `process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio")` — baked
|
||
so the scheduled run is pinned to the install-time root **and** never evaluates `$HOME` (the wrapper's
|
||
`set -u` `HOME`-unset edge).
|
||
- Builds **`args`** = `["--pillars", <p>, "--fresh-days", String(N)]` (+ `["--store", storePath]` when an explicit
|
||
non-default `--store` was given, so the scheduled run targets the same store). **No leading `"brief"`** — the
|
||
wrapper hard-codes the `brief` subcommand (folded — plan-critic #8 / brief-reviewer #5: avoids
|
||
`cli.ts brief brief …`).
|
||
- Builds the `ScheduleSpec` and dispatches:
|
||
- **default / `--print`** → print the artifact (`launchdPlist` or `crontabLine`) **+** `installInstructions`
|
||
to stdout. **No fs.** Exit 0.
|
||
- **`--install`** → launchd: `mkdirSync` + `writeFileSync` the plist to `~/Library/LaunchAgents/<label>.plist`
|
||
(an **inert** file; reversible) and print the single `launchctl bootstrap` command — **the tool never runs
|
||
`launchctl`**. cron: print the line + the `crontab -` install command — **the tool never runs `crontab`**
|
||
(the global guard + C2). Exit 0.
|
||
- **`--uninstall`** → launchd: print the `launchctl bootout` command + (if the plist file exists) `rm` it;
|
||
cron: print the line-removal recipe. Exit 0.
|
||
- **Exit-code contract unchanged** (0 success / 2 usage). `schedule` introduces **no new exit code**: an autonomy
|
||
install never *runs* the system mutation, so there is no install-failure path to encode — the operator runs the
|
||
one printed command. (Update the header doc-comment `cli.ts:36-37` to note `schedule` is print-first and never
|
||
shells `launchctl`/`crontab`.)
|
||
- **Imports** `launchdPlist`, `crontabLine`, `installInstructions`, `uninstallInstructions`, `defaultLabel` from
|
||
`./schedule.js`; **adds `dirname` to the `node:path` import** (`cli.ts:41` imports only `join` today — folded —
|
||
plan-critic #5), `homedir` from `node:os`, `fileURLToPath` from `node:url` (`defaultStorePath` is already imported,
|
||
`cli.ts:45`). The DAG stays acyclic: `schedule.ts` is a **leaf** (imports nothing from the package); `cli.ts`
|
||
is the existing root.
|
||
- **Usage + header synopsis** (`cli.ts:5-14`, `:86-100`): add the `schedule …` line + a one-line header note that
|
||
`schedule` emits/installs a daily headless brief (print-first; deterministic — no AI capture; that is slice e).
|
||
|
||
### Wiring (D-default — WIRE, mirrors R3a/R3b)
|
||
|
||
- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): one line — the morning brief can now be **scheduled**
|
||
to regenerate autonomously (deterministic, from the store) via `schedule`; the agent's polling remains the
|
||
capture path (autonomous AI polling is a later slice). No batch-shape change. Domain-general (no vendor/sector
|
||
token).
|
||
- `scripts/trends/README.md` (EDIT): document the headless wrapper + the `schedule` subcommand (print-first,
|
||
launchd/cron, `--install`/`--uninstall`), the **deterministic-brief-only boundary (C1)** and the (e) AI-capture
|
||
seam, the `cron.log`, and the R3b per-day idempotency that makes a double-fire safe.
|
||
- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (`:709`, currently 171) to the `tests N` line reported
|
||
after Steps 1–5, **append** `+ RE-R3c: scheduler +N` to the inline breakdown comment. Add **Section 16l**
|
||
("Trends Scheduler / Headless Wiring", RE-R3c) **after Section 16k's closing block (`~:1305`), before Section 18
|
||
(`:1307`)** (16k is the last 16x before the anti-erosion Section 18; preserve that order). Mirror 16k's shape:
|
||
**unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail).
|
||
Recommended **6 emitters** (all on tracked source — no `tsx`): (1) self-test; (2) `export function launchdPlist`
|
||
in `schedule.ts`; (3) `export function crontabLine` in `schedule.ts`; (4) `command === "schedule"` in `cli.ts`
|
||
(the verb); (5) `cli.ts" brief` in `run-daily.sh` (the wrapper invokes the deterministic brief — the sentinel
|
||
matches the literal `…cli.ts" brief`, folded — plan-critic #8); (6) the data-path twin in `run-daily.sh`
|
||
(`LINKEDIN_STUDIO_DATA:-`). **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 105 → exactly 111**
|
||
(`:1329`; "live recount" is the safety net; the expected value is the pinned 105 + 6). Insert the 16l clause into
|
||
the **header-enumeration prose chain at `:57`** (before "…the assertion-count anti-erosion floor (SC6) in Section
|
||
18"), and **append the R3b (→105) + R3c-16l (→111) narration** to the Section-18 floor-history comment
|
||
(`~:1310-1324`, which still stops at "= 99" — folded — scope-guardian #7).
|
||
|
||
## 4. Non-goals — what is OUT (deferred)
|
||
|
||
- **AI capture in the nightly run** (`poll→score→capture` via a headless `claude -p` trend-spotter) — **slice (e)**,
|
||
behind the post-(d) re-evaluation gate. R3c builds the deterministic headless path + the documented (e) seam; it
|
||
adds **no** AI invocation, no `claude -p`, no API dependency in the scheduler context.
|
||
- **Running `launchctl` / `crontab` autonomously** — OUT (C2 print-first). `schedule` prints the activation
|
||
command; `--install` writes only the inert launchd plist FILE. The operator runs the one system-mutating command.
|
||
- **A `/linkedin:schedule` command wrapper** (plugin surface) — OUT for R3c (would change the command count). R3c
|
||
ships the CLI subcommand + README; a command front-door is a later ergonomic slice. Counts stay **29/19/27**.
|
||
- **Windows Task Scheduler** — OUT. launchd (macOS) + cron (Linux) cover the plugin's runtimes; a Windows emitter
|
||
is a later portability add.
|
||
- **A lock / mutex / run-marker** — unneeded. R3b's per-day-idempotent surfacing + the per-day brief filename make
|
||
a double-fire a safe no-op; B4's separate *delivery* window is not needed for a once-daily calendar job.
|
||
- **A config-file pillar source** — OUT. Pillars are `--pillars`, **baked into the schedule artifact** at
|
||
generation (the operator supplies them once at install). A config/profile-resolved pillar source is a later
|
||
nicety.
|
||
- **Brief history / day-over-day diff** ("what's new since yesterday" — hull 7) — **slice (d)**.
|
||
- **Re-scoring / time-decay recompute on a schedule** — OUT. Re-score is on **re-capture** (R3b); R3c does no
|
||
capture, so the nightly run re-ranks the *unchanged* scores against the *current* freshness window only.
|
||
- **Schema bumps** — none. R3c touches **no** store field and **no** brief frontmatter field
|
||
(`SCHEMA_VERSION` stays 4; `BRIEF_SCHEMA_VERSION` stays 1). It adds a new *module* + a *wrapper* + a *CLI verb* —
|
||
no data shape changes.
|
||
- **New agent / new command / new reference doc** — none. R3c adds **two source files** (`schedule.ts`,
|
||
`run-daily.sh`) + their tests, and EDITs `cli.ts` + one agent (prose) + README + gate. `store.ts`/`brief.ts`/
|
||
`item.ts`/`score.ts`/`types.ts` are **untouched** (the nightly run reuses the existing deterministic `brief`
|
||
path). Counts stay 29/19/27.
|
||
|
||
## 5. Boundaries / invariants (must hold)
|
||
|
||
- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — subprocess
|
||
assertion-RED against the existing CLI (`schedule` unknown → exit 2; `run-daily.sh` absent → exit 127) on the
|
||
exit-0/stdout assertions. Phase B — `schedule.ts` exports are imported by the test; land non-throwing stubs
|
||
first (Node16 ESM throws a missing named import at module-load), then record value-assertion RED against them.
|
||
The plan does **not** claim a single "everything fails before any code" run.
|
||
- **`schedule.ts` is pure** (no clock, no fs, no env, no AI): every value the emitters use is injected via
|
||
`ScheduleSpec`. The CLI is the only edge that reads `process.execPath`/`import.meta.url`/`defaultStorePath`.
|
||
Mirrors `renderBrief`'s purity.
|
||
- **Determinism of the nightly run:** the wrapper invokes the **deterministic** `brief` (whose byte-determinism
|
||
R2b/R3a/R3b proved); given `(store, pillars, day, freshDays)` the written `.md` is byte-identical. The wrapper
|
||
adds only a timestamped log line + an exit code.
|
||
- **No autonomous system mutation (C2):** `schedule` (default) writes **nothing**; `--install` writes only an
|
||
inert launchd plist file (reversible `rm`); the tool **never** runs `launchctl` or `crontab`. The global guard is
|
||
an **execution** guard (`voyage` `pre-bash-executor.mjs`, pattern `\bcrontab\b|>\s*/etc/cron` — verified live), so
|
||
it inspects **bash commands**, not file content: the new files' printed strings (`crontab -`, `launchctl
|
||
bootstrap`) are written by `Write`/emitted by the CLI and are **fine**, and the 16l grep uses `crontabLine`
|
||
(no `\bcrontab\b` word-boundary match). **No code path — source or test — ever *executes* a command containing the
|
||
bare word `crontab` or `launchctl …`**; the install commands are printed STRINGS the operator runs, and tests
|
||
assert those strings on **stdout/the written file** without executing them (a test that *ran* `crontab` would trip
|
||
the guard and mutate the real system — explicitly forbidden).
|
||
- **One data-dir seam, four sanctioned runtime twins:** `store.ts:253` (TS store), `data-root.mjs:25` (hooks
|
||
`.mjs`), `analytics/src/utils/storage.ts:54` (TS analytics — the existing third, named in `data-root.mjs:44`),
|
||
`run-daily.sh` (shell — NEW fourth). The shell form is the canonical inline `${LINKEDIN_STUDIO_DATA:-$HOME/
|
||
.claude/linkedin-studio}` expansion (`references/data-path-convention.md` rule 1), **not** a new seam; documented
|
||
as a twin (like `data-root.mjs`'s comment) and asserted behaviorally (SC8 newly binds `store.ts`'s
|
||
`defaultStorePath` into the consistency check — `dirname(defaultStorePath()) == getDataRoot('trends') == the
|
||
wrapper's `${…}/trends`).
|
||
- **Domain-general:** no hard-coded user/repo path in the committed **source** (`schedule.ts`/`run-daily.sh`/the
|
||
`cli.ts` edit) — every concrete path is resolved at generation/run on the operator's machine and lives only in
|
||
the **generated** artifact (outside the repo, in `~/Library/LaunchAgents` / the crontab). The launchd Label is
|
||
the plugin namespace; pillars are args. Section 17 de-niche stays green.
|
||
- **Bash 3.2-compatible wrapper** (operator's macOS): no `declare -A`/`mapfile`/`|&`; all expansions quoted;
|
||
ASCII-only (a multibyte char crashes under `set -u` on bash 3.2).
|
||
- **Minimal-env robustness:** the wrapper must run from launchd/cron's profile-less env — node resolved via baked
|
||
`NODE_BIN` (absolute `process.execPath`) with a `command -v` fallback; `WorkingDirectory`/`cd` set so tsx
|
||
resolves; log dir `mkdir -p`'d.
|
||
- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only and never shells to tsx; R3c touches
|
||
neither the hook nor the frontmatter schema, so surfacing is unchanged. The hook suite must still pass untouched
|
||
(regression sanity; R3c adds no hook test).
|
||
- **No schema/SSOT change:** `references/trend-scoring-modes.md`, `types.ts`, `store.ts`, `brief.ts` untouched
|
||
(R3c changes no data shape and no scoring/render math).
|
||
- **Pathguard:** the two NEW files are under `scripts/trends/` — **write-allowed** (the global pre-write-pathguard
|
||
allowlists `~/repos/*`; `cli.ts`/`test-runner.sh` were added there with no friction — folded — scope-guardian #4:
|
||
the earlier ".mjs-under-hooks/scripts-only" phrasing was a fabricated mechanism; the *conclusion* holds). EDITs
|
||
are to existing files. *(Implementation risk, not a docs-step blocker: if any Write is nonetheless blocked, the
|
||
operator authorizes via the R2b `!cp` fallback — see plan Risk R4.)*
|
||
- **Counts** (refs/agents/commands 27/19/29) unchanged. **Recounted live at land**, never pinned/guessed.
|
||
|
||
## 6. Success criteria (testable)
|
||
|
||
- **SC1 (launchd plist emit)** — `schedule --pillars ai,gov --platform launchd --at 07:30 --print` → stdout is a
|
||
**key-complete + well-formed** plist (balanced-tag/parse asserted, not just substring greps — folded —
|
||
brief-reviewer #7) containing `Label` = `com.linkedin-studio.trends.daily`, `ProgramArguments` invoking
|
||
`run-daily.sh` with `--pillars ai,gov` (the wrapper supplies `brief`), `StartCalendarInterval` `Hour 7`/`Minute
|
||
30`, `StandardOutPath`/`StandardErrorPath` = the resolved `cron.log` path, `NODE_BIN` +
|
||
`LINKEDIN_STUDIO_DATA` in `EnvironmentVariables`; exit 0. Two `--print` runs (same args) → byte-identical (the
|
||
emitter is pure). `plutil -lint` is the deps-present manual check (Step 7).
|
||
- **SC2 (crontab line emit — string only)** — `schedule --pillars ai,gov --platform cron --at 07:30 --print` →
|
||
stdout contains `30 7 * * * NODE_BIN=… /bin/bash …/run-daily.sh brief --pillars ai,gov >> <log> 2>&1 #
|
||
com.linkedin-studio.trends.daily` **+** the `(crontab -l 2>/dev/null; echo '<line>') | crontab -` install
|
||
instruction; exit 0. The test asserts the **emitted string** and **never executes `crontab`**.
|
||
- **SC3 (platform auto)** — `schedule --pillars ai --print` (no `--platform`) → launchd on darwin, cron elsewhere;
|
||
asserted against `process.platform` (the subprocess inherits the host platform; the assertion branches on it).
|
||
- **SC4 (print-first writes nothing)** — `schedule --pillars ai --platform launchd --print` with `HOME=<tmp>` →
|
||
exit 0, stdout has the plist, **and `<tmp>/Library/LaunchAgents` is absent/empty** (no fs write); crontab never
|
||
invoked.
|
||
- **SC5 (`--install` launchd: inert plist file, no launchctl)** — `schedule --pillars ai --platform launchd
|
||
--install` with `HOME=<tmp>` → `<tmp>/Library/LaunchAgents/com.linkedin-studio.trends.daily.plist` **exists**
|
||
with the SC1 plist content; stdout prints the `launchctl bootstrap` command; **`launchctl` is never run** (the
|
||
test asserts only the file + stdout; no system job is created); exit 0.
|
||
- **SC6 (`--install` cron: never self-installs)** — `schedule --pillars ai --platform cron --install` → stdout has
|
||
the line + the `crontab -` instruction; exit 0; **`crontab` is never invoked** (no system mutation; asserted by
|
||
stdout only).
|
||
- **SC7 (headless wrapper runs the deterministic brief + logs)** — `run-daily.sh --pillars ai --store <tmp>/
|
||
s.json --out <tmp>/mb` (the wrapper supplies `brief`) with `LINKEDIN_STUDIO_DATA=<tmp>` on a seeded fresh store →
|
||
writes `<tmp>/mb/<today>.md` (the dated brief), appends **exactly one** `<ISO-ts> exit=0 {…compact-json…}` line
|
||
to `<tmp>/trends/cron.log` (the multi-line `brief --json` collapsed — folded — plan-critic #1), exit 0. A
|
||
**second** run the same day → the brief `.md` is byte-identical (idempotent re-render), the seen-log is **not**
|
||
double-counted (R3b per-day idempotency), the log gains a second line. **CWD-independence:** the same invocation
|
||
with `cwd=<tmp-unrelated>` (not the package dir) still resolves `tsx` and succeeds (the wrapper's `cd "$DIR"` —
|
||
folded — brief-reviewer #1). The test invokes via `bash run-daily.sh …` so the absent-file RED is exit 127
|
||
(folded — plan-critic #7). **No AI** is invoked (C1).
|
||
- **SC8 (data-path twin consistency)** — the wrapper's `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/
|
||
trends` resolves to the **same** directory as `dirname(defaultStorePath())` (TS — the base the CLI's `logPath`
|
||
uses, **not** the `--store` override) and `getDataRoot('trends')` (hooks `.mjs`), for both the default root and
|
||
an overridden `LINKEDIN_STUDIO_DATA`. Asserted behaviorally (resolve all three for a temp override; assert
|
||
equal). Because `logPath` derives from `defaultStorePath()`, a custom `--store` never splits the plist
|
||
`StandardOutPath` from the wrapper's log (folded — all three reviewers).
|
||
- **SC9 (usage / validation)** — `schedule` with **no** `--pillars` → `usage` exit 2; `--at 25:00` / `--at 7:99` /
|
||
`--at noon` → `usage` exit 2; `--platform bogus` → `usage` exit 2. Each leaves the fs untouched.
|
||
- **SC10 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the bumped
|
||
`TRENDS_TESTS_FLOOR`; new **Section 16l** green (`launchdPlist`/`crontabLine` in `schedule.ts`, `command ===
|
||
"schedule"` in `cli.ts`, the `cli.ts brief` + `LINKEDIN_STUDIO_DATA:-` sentinels in `run-daily.sh`, non-vacuity
|
||
self-test); `ASSERT_BASELINE_FLOOR` = **111** (105 + 6); Section 17 de-niche green; counts 29/19/27; the hook
|
||
suite still green untouched (`node --test hooks/scripts/__tests__/*.test.mjs`).
|
||
|
||
## 7. Verification
|
||
|
||
**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; Section 16l self-test +
|
||
greps pass; `ASSERT_BASELINE_FLOOR` = 111; Section 17 de-niche green; ref/agent/command counts unchanged.
|
||
**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3c touches no
|
||
hook; adds no hook test).
|
||
**Behavioural (manual):**
|
||
1. Seed a store: `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],
|
||
"publishedAt":"<~2d ago>"}]' | node --import tsx src/cli.ts capture --store /tmp/r3c.json`.
|
||
2. `node --import tsx src/cli.ts schedule --pillars ai,gov --platform launchd --at 07:00 --print` → inspect the
|
||
plist; `node --import tsx src/cli.ts schedule … --platform cron --print` → inspect the crontab line + install
|
||
instruction.
|
||
3. **Lint the plist (macOS):** pipe the `--print` plist to `plutil -lint -` → "OK" (a malformed plist won't load).
|
||
4. `LINKEDIN_STUDIO_DATA=/tmp/r3c-data ./run-daily.sh brief --pillars ai,gov --store /tmp/r3c.json --out
|
||
/tmp/r3c-data/trends/morning-brief` → confirm `/tmp/r3c-data/trends/morning-brief/<today>.md` written + a line
|
||
appended to `/tmp/r3c-data/trends/cron.log`; exit 0.
|
||
5. Re-run step 4 same day → `.md` byte-identical; `cron.log` gains a second line; `list --json` shows
|
||
`surfacedCount:1` (not 2 — per-day idempotent).
|
||
6. `schedule --pillars ai --platform launchd --install` with a throwaway `HOME` → confirm the plist file is
|
||
written under `<HOME>/Library/LaunchAgents/` and the `launchctl bootstrap` command is printed (do **not** run it
|
||
against the real system unless intentionally activating).
|
||
|
||
## 8. Open questions for the go-gate
|
||
|
||
Two architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-26): **C1** deterministic
|
||
brief-only (no AI capture — that is slice e); **C2** print-first installer (emit + the operator runs the system
|
||
mutation; `--install` writes only the inert launchd plist file). Residual decisions, all baked to the recommended
|
||
default — confirm or redirect with "Go":
|
||
- **D1 — schedule time default `07:00`, `--at HH:MM` overrides?** YES (rec). A morning brief wants a pre-workday
|
||
fire; the operator tunes it. Re-open only for a different default hour.
|
||
- **D2 — launchd Label = `com.linkedin-studio.trends.daily` (plugin namespace)?** YES (rec). Reverse-DNS, the
|
||
plugin's own namespace (domain-general; no user-domain token). Drop only for a different naming scheme.
|
||
- **D3 — `--install` writes the launchd plist FILE but never runs `launchctl`/`crontab`?** YES (rec). The strictest
|
||
honest print-first: the tool prepares the inert artifact, the operator activates it. Re-open only to make
|
||
`--install` a pure no-op (print-only, no file write).
|
||
- **D4 — `--platform auto` defaults via `process.platform`?** YES (rec). darwin→launchd, else→cron. Explicit
|
||
`--platform` overrides (e.g. to emit a crontab line on a Mac for a Linux box). Drop only to require `--platform`.
|
||
- **D5 — pillars baked into the artifact at generation (no config-file source)?** YES (rec). The operator supplies
|
||
`--pillars` once at install; the schedule carries them. A profile-resolved pillar source is a later nicety.
|
||
- **D6 — `cron.log` under `<data>/trends/cron.log`?** YES (rec). Colocated with the store + morning-brief under the
|
||
data-dir seam, survives reinstalls. Drop only for a different log location.
|
||
- **D7 — a single `.sh` wrapper invoked by BOTH launchd + cron (vs a CLI `run` subcommand)?** YES (rec). A shell
|
||
wrapper handles the launchd/cron minimal-env robustness (node resolution, `cd`, logging) the CLI cannot; the CLI
|
||
`brief` stays the deterministic core. One wrapper → one tested entry. Re-open only to push the robustness into a
|
||
CLI `run` verb (more TS, but then the plist must still bake node).
|
||
- **D8 — no `/linkedin:schedule` command (CLI + README only)?** YES (rec). Keeps the command count; a command
|
||
front-door is a later ergonomic slice. Re-open only if the scheduler should be operator-facing via a slash
|
||
command now.
|
||
- **D9 — commit split?** Docs commit first, then **one** code commit (rec) — the scheduler (module + wrapper + CLI
|
||
verb + wiring) is one coherent feature. Re-open only for a module-then-wiring split.
|
||
|
||
## 9. Light-Voyage review — folded
|
||
|
||
Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED**
|
||
(0 creep / 0 gaps; every SC1–SC10 traces to a step; no AI/capture, no schema bump, counts 29/19/27 + untouched-
|
||
files claim verified live; 1 MAJOR line-cite + 6 MINOR accuracy/precision). **brief-reviewer: PROCEED_WITH_RISKS**
|
||
(RED premises TRUE, the data-path trio genuinely consistent, C2 no-execution path confirmed; 2 MAJOR + 5 MEDIUM/LOW).
|
||
**plan-critic: REVISE → 73/C** (the two-phase RED, de-niche safety, bash-3.2 wrapper, path resolution, and the
|
||
+6→111 gate arithmetic all verified correct against live code; 4 MAJOR + 5 MINOR; the C grade is largely the
|
||
legacy-manifest-format penalty — these are hand-authored slice docs, not trekexecute manifests). **All findings
|
||
folded** (per-finding resolution in `plan-re-r3c.md §Plan-critic — folded`). Headlines:
|
||
|
||
- **[MAJOR, folded — plan-critic #1] `brief --json` is pretty-printed** (`cli.ts:323` `JSON.stringify(…, null, 2)`),
|
||
so the wrapper's "one log line" / SC7 contract was false. → the wrapper **compacts** `OUT` (`tr '\n' ' ' | tr -s
|
||
' '`) after capturing `CODE`; SC7 asserts exactly one line.
|
||
- **[MAJOR, folded — brief-reviewer #1] the wrapper never `cd`s to its package dir**, so the cron path (CWD `$HOME`)
|
||
would fail to resolve `tsx`. → the wrapper adds **`cd "$DIR"`**; SC7 gains a CWD-independence assertion.
|
||
- **[MAJOR/MINOR, folded — all three] the `logPath` expression was self-contradicting and used the `--store`
|
||
override base.** → pinned to **`join(dirname(defaultStorePath()), "cron.log")`** (the data-root anchor, matching
|
||
the wrapper); the `".."` and `storePath` variants removed.
|
||
- **[MAJOR, folded — brief-reviewer #4 / plan-critic #3] `ScheduleSpec.env` was stated three ways.** → `env:
|
||
Record<string,string>` is now **canonical** in §3, always carrying `NODE_BIN` + a resolved-absolute
|
||
`LINKEDIN_STUDIO_DATA`; the emitters render it only (purity holds), and the always-baked root also fixes the
|
||
`HOME`-unset `set -u` edge.
|
||
- **[MAJOR, folded — all three] `ASSERT_BASELINE_FLOOR` line-cite `:1259` → `:1329`** (verified live; value 105→111
|
||
correct). `TRENDS_TESTS_FLOOR` `:705` → `:709`; header-enum insertion `:49-53` → `:57`; the Section-18
|
||
floor-history comment (still "= 99") gets the R3b(→105)+R3c(→111) narration appended.
|
||
- **[MINOR, folded — plan-critic #5] `dirname` is not imported in `cli.ts`** (`:41` is `join` only). → the plan adds
|
||
`dirname` to the `node:path` import (+ `homedir`/`fileURLToPath`).
|
||
- **[MINOR, folded — plan-critic #8 / brief-reviewer #5] stray double `brief`** (`cli.ts brief brief …`). → the
|
||
baked `args` drops the leading `"brief"`; the wrapper owns the subcommand; the 16l sentinel matches `cli.ts" brief`.
|
||
- **[MINOR, folded — scope-guardian #4] the pathguard justification was a fabricated mechanism.** → restated:
|
||
`~/repos/*` is allowlisted; the conclusion (new files write-allowed) holds.
|
||
- **[LOW, folded — brief-reviewer #7] SC1 "lint-valid" was only grep-checked.** → SC1 asserts **well-formed**
|
||
(balanced-tag/parse) + key-complete; `plutil -lint` stays the deps-present manual check.
|
||
- **[LOW, folded — brief-reviewer #8] the twin census undercounted (3 → 4).** → `analytics/storage.ts` named as the
|
||
existing third; `run-daily.sh` is the fourth.
|