docs(linkedin-studio): RE-R3c brief + plan — autonomous trigger (scheduler + headless entry), light-Voyage hardened
Slice (c) of the R3 build-out: a `schedule` CLI verb (print-first launchd plist / cron-table line) + `run-daily.sh`, a bash-3.2 headless wrapper that runs the DETERMINISTIC morning brief from a profile-less scheduler env. Closes hulls #1 (no autonomous trigger) + #6 (no headless entry point). Operator-confirmed (AskUserQuestion 2026-06-26): C1 deterministic brief-only (no AI capture — that is slice e, which plugs into the documented pre-brief seam); C2 print-first installer (the tool emits the artifact + the install command; `--install` writes only the inert launchd plist file; never runs the scheduler activation itself). Light-Voyage hardened — three Opus reviewers, each verifying against live code: scope-guardian ALIGNED (0 creep/0 gaps), brief-reviewer PROCEED_WITH_RISKS, plan-critic REVISE. All findings folded, incl. the pretty-printed `brief --json` log-line compaction, the `cd "$DIR"` cron fix, the logPath base pinned to `dirname(defaultStorePath())`, the canonical `ScheduleSpec.env`, and the `ASSERT_BASELINE_FLOOR` :1259->:1329 cite. No schema/count change (29/19/27, store v4). Tracked feature-design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
This commit is contained in:
parent
b185db9a12
commit
b43757462b
2 changed files with 800 additions and 0 deletions
424
docs/research-engine/brief-re-r3c.md
Normal file
424
docs/research-engine/brief-re-r3c.md
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
# Brief — RE-R3c: autonomous trigger — scheduler + headless entry point
|
||||
|
||||
> **Slice:** RE-R3c (research-engine rung-2, R3 slice 3 — the **autonomy** slice: the trigger that makes the
|
||||
> daily loop *closed* and the headless entry that runs the deterministic morning brief with **no interactive
|
||||
> session**). R3 ("deepen the research engine") is an **arc** of 5 open hulls (substrate §1). R3a took relevance,
|
||||
> R3b took the lifecycle (status + seen-log + re-score). R3c takes hulls **(1) no autonomous trigger** + **(6) no
|
||||
> headless entry point** — the *mechanism* that runs the existing deterministic brief on a schedule, built and
|
||||
> tested deterministically **before** the autonomous AI fan-out (slice e) plugs into it.
|
||||
> **Predecessor:** RE-R3b (`status` exclusion + per-day-idempotent `surfacedCount`/`lastSurfacedAt` — the
|
||||
> dedup-state a nightly loop **depends on** so it never re-surfaces handled work) + RE-R2b (`brief.ts` dated
|
||||
> artifact + the SessionStart surfacing the nightly run feeds) + RE-R3a (composite ranking).
|
||||
> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (1) (*"ingen autonom trigger … zero cron/launchd/
|
||||
> scheduler i hele repoet"*) + (6) (*"ingen headless entry point"*) + §B4 (*"behavioral scheduling … a push/delivery
|
||||
> window that gates delivery separately from the sweep"*) + §B3 (the dated digest as a flat plain-text artifact
|
||||
> *"skrevet av Stop-hook eller cron-trigget headless-sesjon"*). R3c builds the cron-triggered headless path B3
|
||||
> anticipated and the scheduling-window discipline B4 names.
|
||||
> **TDD-order:** RED before code, **two phases** (light-Voyage discipline, inherited): Phase A — assertion-RED via
|
||||
> subprocess against the **existing** CLI (`schedule` is an unknown command today → `usage` exit 2; the wrapper
|
||||
> file is absent → exit 127) — true assertion-RED on the exit-code/stdout assertions, not module-not-found. Phase B
|
||||
> — `schedule.ts` is a NEW module whose exports the tests import; under Node16 ESM a missing named import throws at
|
||||
> module-load, so land **non-throwing stubs** (`launchdPlist → ""`, etc.) first, then record value-assertion RED
|
||||
> against them. See plan Step 1.
|
||||
> **Architectural decisions — CONFIRMED (operator, AskUserQuestion 2026-06-26; baked into the plan):**
|
||||
> - **C1 — deterministic brief-only.** The nightly headless run regenerates the dated brief from the **current
|
||||
> store** (freshness-aging drops stale trends; `surfacedCount` accumulates per distinct day → feeds slice b).
|
||||
> **NO AI capture.** Polling stays operator-driven; the autonomous AI fan-out is **slice (e)**, which plugs into
|
||||
> (c)'s headless seam. Faithful to the operator's `(a)→(c)→(b)→(d)→(e)` sequence: build the trigger mechanism +
|
||||
> headless plumbing (deterministic, testable) **before** the AI sweep it will eventually drive. *Honest framing:
|
||||
> the visible autonomous-research payoff lands with (e); (c) is the mechanism.*
|
||||
> - **C2 — print-first installer.** `schedule` **emits** the launchd plist (macOS) / crontab line (Linux) + the
|
||||
> exact install command; the operator runs it. `--install` writes only the inert launchd plist FILE (never runs
|
||||
> `launchctl`; never touches `crontab`). Matches the global `[voyage]` cron-persistence guard, the push-policy's
|
||||
> operator-authorization, and the "confirm outward-facing/persistent actions" rule.
|
||||
|
||||
## 1. Operator decision context (2026-06-26)
|
||||
|
||||
The research engine is **Tier-1** (operator, 2026-06-23). R1→R3b built the deterministic spine **and** the
|
||||
trend's life after capture: item-schema + triage (R1) → capture bridge (R2a) → dated morning brief + surfacing
|
||||
(R2b) → persisted relevance + composite ranking (R3a) → status lifecycle + seen-log + re-score (R3b). The spine is
|
||||
complete and the lifecycle is correct — **but nothing runs it on its own.** The morning brief exists only when the
|
||||
operator interactively invokes the `brief` CLI (via the `trend-spotter` agent or by hand); the SessionStart hook
|
||||
*surfaces* the latest dated brief (`session-start.mjs:534`) but **never generates one**. There is **zero
|
||||
scheduler** in the repo (verified live: only `scripts/test-runner.sh` exists; no plist, no cron, no launchd in any
|
||||
`.ts`/`.mjs`/`.sh`/config). The loop is open: a brief is only as fresh as the last time the operator remembered to
|
||||
ask for one.
|
||||
|
||||
R3c closes hulls **(1)** and **(6)** — the **autonomous trigger** and the **headless entry point** — which the
|
||||
operator chose as slice (c) of the full-R3 build-out (2026-06-24, *"ALLE gjenstående R3-slices … i rekkefølge
|
||||
(a) → (c) → (b) → (d) → (e)"*). It is sequenced **after** R3b for a load-bearing reason the operator named: an
|
||||
autonomous loop **must never re-surface handled work**, so it depends on R3b's status-exclusion (acted/skipped
|
||||
dropped from the brief) and its **per-day-idempotent** surfacing (a double-fire doesn't double-count). R3b made
|
||||
the nightly regeneration *safe to automate*; R3c automates it.
|
||||
|
||||
**What R3c is — and is not (C1).** R3c is the **mechanism**, not the AI sweep. The nightly run is the *existing
|
||||
deterministic* `brief` generation — load store → rank → write the dated `.md` → record surfacing — run with no
|
||||
interaction by a scheduler. It does **not** poll new sources (that is the AI fan-out, slice e). Its honest value
|
||||
without (e): the brief is regenerated every morning from the current store, so SessionStart surfacing is always
|
||||
fresh; freshness-aging drops trends past the window automatically; and `surfacedCount` accumulates day-over-day —
|
||||
the temporal signal slice (b) reads — **without the operator running anything**. (e) later plugs an AI capture
|
||||
step into the documented pre-brief seam to close the full `poll→score→capture→brief` loop.
|
||||
|
||||
## 2. The gap — grounded in code
|
||||
|
||||
- **No autonomous trigger (hull 1).** Repo-wide there is no scheduler: no launchd plist, no crontab artifact, no
|
||||
`launchctl`/`cron` reference in any source or config (verified). Every brief is born of an interactive session.
|
||||
- **No headless entry point (hull 6) — *almost*.** The `brief` subcommand (`cli.ts:297-328`) is **already
|
||||
non-interactive**: it reads flags, writes `<outDir>/<day>.md`, records surfacing, and exits 0 — no prompts. What
|
||||
is missing is a **robust invocation wrapper** that makes it runnable from a scheduler's *minimal* environment:
|
||||
a launchd/cron job inherits **no shell profile** (no `PATH` from `~/.zshenv`, so a bare `node` is unresolvable),
|
||||
has **no working directory** set to the repo (tsx resolves modules only from `scripts/trends/`), and has **no
|
||||
logging**. Today nothing bridges that gap.
|
||||
- **The brief is operator-pulled, never machine-pushed.** `session-start.mjs:60-77`/`:534` *reads* the latest
|
||||
dated brief (`date`+`summary`, zero-tsx) and surfaces it — it is a pure consumer. Generation lives only in the
|
||||
CLI, invoked by a human. B3's *"cron-trigget headless-sesjon"* writer does not exist.
|
||||
- **The CLI has no scheduling verb.** `cli.ts` exposes `add`/`query`/`list`/`status`/`act`/`skip`/`reset`/
|
||||
`normalize`/`score`/`capture`/`brief` (`cli.ts:5-14`, `:134-330`) — capture/read/lifecycle, all interactive.
|
||||
There is **no way to emit or install a daily schedule** for the brief.
|
||||
- **The data-dir seam is solved, but only for two runtimes.** `store.ts:252` (`defaultStorePath`) and
|
||||
`hooks/scripts/data-root.mjs:24` (`getDataRoot`) are *twins* of the one seam (`LINKEDIN_STUDIO_DATA ?? ~/.claude/
|
||||
linkedin-studio`). A scheduler entry running in **shell** needs the same seam for its log path — a **third
|
||||
sanctioned twin**, exactly the inline `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/…` form
|
||||
`references/data-path-convention.md` rule 1 prescribes. It does not exist yet.
|
||||
|
||||
## 3. Scope — what is IN (RE-R3c)
|
||||
|
||||
### S-schedule — `scripts/trends/src/schedule.ts` (NEW, pure module)
|
||||
|
||||
Pure string emitters for the schedule artifacts — no clock, no fs, no env, no AI (the CLI injects every resolved
|
||||
value). Mirrors `brief.ts`'s `renderBrief` purity → fully testable, byte-deterministic given inputs.
|
||||
|
||||
- **`export interface ScheduleSpec`** — `{ platform: "launchd" | "cron"; label: string; nodeBin: string;
|
||||
wrapperPath: string; args: string[]; hour: number; minute: number; logPath: string; workingDir: string;
|
||||
env: Record<string, string>; }`. All paths are absolute, resolved by the CLI at generation time on the
|
||||
operator's machine. **`env`** is the injected environment map (the CLI builds it — always `NODE_BIN` +
|
||||
a **resolved-absolute** `LINKEDIN_STUDIO_DATA`); the emitter only *renders* it, so it reads no env itself
|
||||
(folded — brief-reviewer #4 / plan-critic #3: the field is canonical, not mid-step).
|
||||
- **`export function launchdPlist(spec: ScheduleSpec): string`** — the plist XML: `Label`, `ProgramArguments`
|
||||
(`["/bin/bash", wrapperPath, ...args]`), `StartCalendarInterval` (`{ Hour: spec.hour, Minute: spec.minute }`),
|
||||
`EnvironmentVariables` (rendered from `spec.env` only — purity), `WorkingDirectory` (`spec.workingDir`),
|
||||
`StandardOutPath`/`StandardErrorPath` (`spec.logPath`), `RunAtLoad` false. A pinned, well-formed template
|
||||
(`<?xml … !DOCTYPE plist …>`); `schedule.test` asserts both key-completeness **and** well-formedness
|
||||
(balanced-tag/parse), `plutil -lint` is the deps-present manual check (folded — brief-reviewer #7).
|
||||
- **`export function crontabLine(spec: ScheduleSpec): string`** — one line:
|
||||
`<minute> <hour> * * * <env-prefix> /bin/bash <wrapperPath> <args…> >> <logPath> 2>&1 # <label>` where
|
||||
`<env-prefix>` is `spec.env` rendered as cron's inline `K=V K=V` form. **The function returns the line as a
|
||||
STRING; it never executes `crontab`** (the execution guard + C2; the literal `crontabLine` does not match the
|
||||
guard's `\bcrontab\b` word-boundary pattern — §5).
|
||||
- **`export function installInstructions(spec: ScheduleSpec, plistTargetPath?: string): string`** — the exact
|
||||
operator commands. launchd: *"written to `<plistTargetPath>` — activate with `launchctl bootstrap gui/$(id -u)
|
||||
<plistTargetPath>`"*. cron: *"add the line above with `(crontab -l 2>/dev/null; echo '<line>') | crontab -`"*.
|
||||
Print-first surfaces these so the operator runs them.
|
||||
- **`export function uninstallInstructions(spec, plistTargetPath?): string`** — symmetric removal (launchd:
|
||||
`launchctl bootout …` + `rm <plist>`; cron: the line-removal `grep -v` recipe).
|
||||
- **`export function defaultLabel(): string`** — `"com.linkedin-studio.trends.daily"` (the **plugin namespace**,
|
||||
domain-general — not the user's domain; no vendor/sector token).
|
||||
|
||||
### S-wrapper — `scripts/trends/run-daily.sh` (NEW, headless invocation wrapper)
|
||||
|
||||
The single tested headless entry, invoked identically by **both** the launchd plist and the crontab line (one
|
||||
entry → one test). Bash 3.2-compatible (operator's macOS: no `declare -A`, no `mapfile`, all expansions quoted,
|
||||
ASCII-only).
|
||||
|
||||
- Resolves **its own directory** (`DIR="$(cd "$(dirname "$0")" && pwd)"`) so it is relocatable — no hard-coded
|
||||
repo path — and **`cd "$DIR"`** so `--import tsx` resolves `node_modules` from the package even under cron's
|
||||
`$HOME` CWD (folded — brief-reviewer #1: the plist sets `WorkingDirectory`, but cron does not — `cd` makes the
|
||||
one wrapper scheduler-agnostic).
|
||||
- Resolves **node** from a minimal scheduler env: `NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null)}"`;
|
||||
if still empty, fall back to common locations; exit 127 with a logged error if none. (The scheduler bakes
|
||||
`NODE_BIN=<process.execPath>` so resolution always succeeds; the fallback is for a manual invocation.)
|
||||
- Resolves the **log path** from the canonical inline seam — `LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/
|
||||
linkedin-studio}/trends/cron.log"` — the **fourth sanctioned data-path twin** (shell), the exact form
|
||||
`references/data-path-convention.md` rule 1 prescribes (documented as such, like `data-root.mjs`'s twin comment).
|
||||
The scheduler **always bakes a resolved-absolute `LINKEDIN_STUDIO_DATA`** into the artifact env, so a scheduled
|
||||
run never evaluates `$HOME` (sidesteps the `set -u` `HOME`-unset edge — folded — plan-critic #9 / brief-reviewer
|
||||
#9); the `$HOME` fallback is only for a manual invocation, where `HOME` is set. `mkdir -p` its dir.
|
||||
- Runs the **deterministic** brief: `OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)";
|
||||
CODE=$?` then **compacts** `OUT` to one line (`tr '\n' ' ' | tr -s ' '`) — `brief --json` is **pretty-printed**
|
||||
(`cli.ts:323` `JSON.stringify(…, null, 2)`), so the structured log line must collapse the newlines (folded —
|
||||
plan-critic #1). The scheduler bakes `--pillars … --fresh-days N` into `"$@"`; the wrapper hard-codes the
|
||||
`brief` subcommand and adds `--json`. Appends **one** line `<ISO-ts> exit=<CODE> <compact-OUT>` to `$LOG`;
|
||||
`exit $CODE`. **No AI** — `brief` is the deterministic store→artifact path (C1).
|
||||
- **The (e) seam (documented, not built):** a one-line comment marks where slice (e) will insert a pre-brief
|
||||
capture step (`claude -p … trend-spotter | cli.ts capture`) before the `brief` call. R3c builds **only** the
|
||||
deterministic path.
|
||||
|
||||
### S-cli — `scripts/trends/src/cli.ts` (EDIT) — the `schedule` subcommand
|
||||
|
||||
- **`schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install]
|
||||
[--uninstall] [--store <path>]`**:
|
||||
- Resolves **platform**: `auto` (default) → `process.platform === "darwin" ? "launchd" : "cron"`.
|
||||
- Resolves **time** from `--at` (default `07:00`); validates `HH ∈ 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.
|
||||
376
docs/research-engine/plan-re-r3c.md
Normal file
376
docs/research-engine/plan-re-r3c.md
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# Plan — RE-R3c: autonomous trigger — scheduler + headless entry point
|
||||
|
||||
> **Brief:** `docs/research-engine/brief-re-r3c.md`. **Slice:** RE-R3c (research-engine rung-2 — R3 slice 3, the
|
||||
> **autonomy** slice: the scheduler trigger + the headless entry that runs the deterministic morning brief with no
|
||||
> interactive session). Closes hulls (1) no autonomous trigger + (6) no headless entry point.
|
||||
> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)**
|
||||
> subprocess assertion-RED against the **existing** CLI (`schedule` is an unknown command → `usage` exit 2; the
|
||||
> `run-daily.sh` wrapper file is absent → exit 127) on the exit-0/stdout assertions; **(B)** for the NEW
|
||||
> `schedule.ts` exports the tests import, land **non-throwing stubs** first (Node16 ESM throws a missing named
|
||||
> import at module-load, not on assertion), then record value-assertion RED against them. Then GREEN: `schedule.ts`
|
||||
> stubs→real → `run-daily.sh` wrapper → `cli.ts` `schedule` verb → wire `trend-spotter.md` (prose) + README →
|
||||
> gate floors + Section 16l → behavioural → land.
|
||||
> **Counts recounted live at land, never pinned/guessed.**
|
||||
> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-26):** C1 deterministic brief-only (no AI capture —
|
||||
> that is slice e) · C2 print-first installer (emit + the operator runs the system mutation; `--install` writes only
|
||||
> the inert launchd plist file; the tool never runs `launchctl`/`crontab`). Go-gate 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 --print` today → `usage` (unknown
|
||||
command) **exit 2**, no plist on stdout → RED against the assertion (expects exit 0 + a plist with `Label
|
||||
com.linkedin-studio.trends.daily`); `--platform cron --print` → RED (expects the crontab line + the `crontab -`
|
||||
instruction string); `--print` with a temp `HOME` → RED (expects `<HOME>/Library/LaunchAgents` empty *and* exit
|
||||
0); no `--pillars` / `--at 25:00` / `--platform bogus` → these already exit 2 today (unknown command), so assert
|
||||
the **post-implementation** behaviour (still exit 2, but for the validation reason) — recorded as RED only where
|
||||
the message/route differs (kept minimal; the load-bearing RED is the `--print` emit). *(Fail today: `schedule`
|
||||
is an unknown command.)*
|
||||
- `run-daily.test.ts` — **wrapper**: invoke **via `bash scripts/trends/run-daily.sh --pillars ai --store <tmp>
|
||||
--out <tmp>/mb`** (through `bash`, NOT a direct executable spawn — folded — plan-critic #7: a direct exec of a
|
||||
missing file throws ENOENT/`status:null`, a module-not-found-class failure; `bash <missing>` exits **127**, a
|
||||
clean assertion-RED) with `LINKEDIN_STUDIO_DATA=<tmp>` → the file is **absent** → exit **127** → RED against the
|
||||
assertion (expects the dated `.md` written + exactly one `cron.log` line + exit 0). *(Fail today: the wrapper does
|
||||
not exist.)*
|
||||
|
||||
**Phase B — stub-first, then value-assertion RED** (`schedule.test` imports the new `schedule.ts` exports):
|
||||
- Land **non-throwing stubs** so the imports resolve: in `schedule.ts` — `launchdPlist → ""`, `crontabLine → ""`,
|
||||
`installInstructions → ""`, `uninstallInstructions → ""`, `defaultLabel → ""` (+ the `ScheduleSpec` interface,
|
||||
erased by tsx). (Wrong-value stubs Step 2 replaces.)
|
||||
- `schedule.test.ts`: `launchdPlist(spec)` contains `<key>Label</key>` + `spec.label` + `StartCalendarInterval` +
|
||||
`spec.hour`/`spec.minute` + the `cron.log` path (stub `""` → RED); `crontabLine(spec)` matches the
|
||||
`<min> <hour> * * * … run-daily.sh … >> <log> 2>&1 # <label>` shape (stub `""` → RED); `installInstructions` /
|
||||
`uninstallInstructions` carry the `launchctl bootstrap` / `crontab -` recipes (stub `""` → RED); `defaultLabel()`
|
||||
is `com.linkedin-studio.trends.daily` (stub `""` → RED); two `launchdPlist` calls byte-identical.
|
||||
|
||||
**RED proof (record in commit, two phases):** Phase A — `(cd scripts/trends && npm test)` before any src edit →
|
||||
the `schedule` cli cases fail on the exit-0/stdout assertion (logic-RED) and the wrapper case fails on exit 127,
|
||||
not on a missing module. Phase B — after the non-throwing `schedule.ts` stubs land, the `schedule.test` cases fail
|
||||
on **value assertion** against the `""` stubs. The plan does **not** claim a single "everything fails before any
|
||||
code" run.
|
||||
|
||||
## Step 2 — (GREEN) `scripts/trends/src/schedule.ts` — the pure emitters
|
||||
|
||||
Replace the Phase-B stubs with the real, pure implementations (no clock/fs/env/AI; every value via `ScheduleSpec` —
|
||||
the emitters **render** `spec.env`, they never read `process.env`):
|
||||
- `export interface ScheduleSpec { platform: "launchd" | "cron"; label: string; nodeBin: string; wrapperPath:
|
||||
string; args: string[]; hour: number; minute: number; logPath: string; workingDir: string; env:
|
||||
Record<string, string>; }`. **`env` is canonical** (folded — brief-reviewer #4 / plan-critic #3: declared once,
|
||||
here + brief §3 + the Files table + the Step-1 fixtures — not mutated mid-step). The CLI always builds it as
|
||||
`{ NODE_BIN, LINKEDIN_STUDIO_DATA }`; the emitters only stringify it.
|
||||
- `launchdPlist(spec)` — a pinned, well-formed `<?xml … !DOCTYPE plist …>` template: `Label`=`spec.label`;
|
||||
`ProgramArguments` = `["/bin/bash", spec.wrapperPath, ...spec.args]` (each as a `<string>`);
|
||||
`StartCalendarInterval` `<dict>` with `<key>Hour</key><integer>${spec.hour}</integer>` + `Minute`;
|
||||
`EnvironmentVariables` `<dict>` rendered from **`spec.env`** (each `K`→`<key>K</key><string>V</string>`);
|
||||
`WorkingDirectory`=`spec.workingDir`; `StandardOutPath`/`StandardErrorPath`=`spec.logPath`; `RunAtLoad`=`<false/>`.
|
||||
XML-escape any value that could contain `&`/`<`/`>` (paths are safe, but escape defensively for well-formedness).
|
||||
- `crontabLine(spec)` — `${spec.minute} ${spec.hour} * * * ${envPrefix} /bin/bash ${spec.wrapperPath}
|
||||
${spec.args.join(" ")} >> ${spec.logPath} 2>&1 # ${spec.label}` where `envPrefix` = the `spec.env` map as
|
||||
`K=V K=V` (cron's inline env form). **Returns a string; never executes `crontab`.**
|
||||
- `installInstructions(spec, plistTargetPath?)` — launchd: `"Wrote ${plistTargetPath}. Activate:\n launchctl
|
||||
bootstrap gui/$(id -u) ${plistTargetPath}"`; cron: `"Add the line above:\n (crontab -l 2>/dev/null; echo
|
||||
'${crontabLine(spec)}') | crontab -"`.
|
||||
- `uninstallInstructions(spec, plistTargetPath?)` — launchd: `"launchctl bootout gui/$(id -u)/${spec.label} && rm
|
||||
${plistTargetPath}"`; cron: `"crontab -l | grep -vF '# ${spec.label}' | crontab -"`.
|
||||
- `defaultLabel()` — `"com.linkedin-studio.trends.daily"`.
|
||||
Make the Phase-B `schedule.test` cases green.
|
||||
|
||||
## Step 3 — (GREEN) `scripts/trends/run-daily.sh` — the headless wrapper
|
||||
|
||||
Create the bash-3.2 wrapper (Write a new file under `scripts/trends/` — allowed; if Write is blocked, the operator
|
||||
authorizes via the R2b `!cp` fallback, Risk R4). Shape:
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
# RE-R3c headless entry: runs the DETERMINISTIC morning brief from a scheduler's
|
||||
# profile-less env. No AI. The (e) slice will insert a pre-brief capture step here.
|
||||
# Data-path: the FOURTH sanctioned twin of store.ts:defaultStorePath / data-root.mjs:getDataRoot
|
||||
# / analytics/storage.ts:getDataRoot (shell form of the references/data-path-convention.md
|
||||
# inline seam). Keep in sync.
|
||||
set -eu
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DIR" # so `--import tsx` resolves node_modules even under cron's $HOME CWD
|
||||
NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || true)}"
|
||||
if [ -z "$NODE_BIN" ]; then for c in /usr/local/bin/node /opt/homebrew/bin/node /usr/bin/node; do
|
||||
[ -x "$c" ] && NODE_BIN="$c" && break; done; fi
|
||||
LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log"
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
if [ -z "$NODE_BIN" ]; then printf '%s exit=127 node not found\n' "$TS" >> "$LOG"; exit 127; fi
|
||||
set +e
|
||||
OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)"; CODE=$?
|
||||
set -e
|
||||
OUT="$(printf '%s' "$OUT" | tr '\n' ' ' | tr -s ' ')" # brief --json is pretty-printed -> one line
|
||||
printf '%s exit=%s %s\n' "$TS" "$CODE" "$OUT" >> "$LOG"
|
||||
exit "$CODE"
|
||||
```
|
||||
- `chmod +x` (or the CLI's `--install` does it). All expansions quoted; ASCII-only; no bash-4 features. `cd "$DIR"`
|
||||
makes the one wrapper scheduler-agnostic (folded — brief-reviewer #1). `CODE=$?` is captured **before** the
|
||||
compaction pipe (so it is node's code, not `tr`'s — folded — plan-critic #1).
|
||||
- The scheduler bakes `NODE_BIN=<process.execPath>` + a resolved-absolute `LINKEDIN_STUDIO_DATA` (so a scheduled
|
||||
run never evaluates `$HOME` under `set -u` — folded — plan-critic #9) + `--pillars … --fresh-days N` into `"$@"`;
|
||||
the wrapper hard-codes the `brief` subcommand (the baked `args` carry **no** leading `brief` — folded —
|
||||
plan-critic #8) and appends `--json`.
|
||||
Make `run-daily.test.ts` green (SC7, SC8): the seeded-store run writes the dated `.md` + one compact log line +
|
||||
exit 0; a `cwd=<unrelated>` run still resolves `tsx` (CWD-independence); a same-day re-run is byte-identical with
|
||||
`surfacedCount` not double-counted.
|
||||
|
||||
## Step 4 — (GREEN) `cli.ts` — the `schedule` subcommand
|
||||
|
||||
- Import `{ launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel }` from
|
||||
`./schedule.js`; **add `dirname` to the `node:path` import** (`cli.ts:41` is `import { join } from "node:path"`
|
||||
— `dirname` is NOT there today, folded — plan-critic #5; `join` stays); import `homedir` from `node:os` (the
|
||||
LaunchAgents path) + `fileURLToPath` from `node:url`. `defaultStorePath` is already imported (`cli.ts:45`).
|
||||
- A `schedule` branch (after the `brief` branch, before the trailing `usage`):
|
||||
- `const pillars = splitTopics(flags.pillars); if (pillars.length === 0) usage("schedule needs --pillars <a,b>");`
|
||||
- parse `--at` (default `"07:00"`): split on `:`, `Number.parseInt` both; validate `0≤H≤23`, `0≤M≤59` → else
|
||||
`usage("--at must be HH:MM (00:00–23:59)")`.
|
||||
- parse `--fresh-days` (default 7, reuse the `brief` validation idiom).
|
||||
- `const platform = flags.platform && flags.platform !== "true" ? flags.platform : (process.platform ===
|
||||
"darwin" ? "launchd" : "cron");` validate `platform ∈ {launchd, cron, auto}` (auto already resolved) → else
|
||||
`usage`.
|
||||
- resolve runtime paths (no hard-coding): `const here = dirname(fileURLToPath(import.meta.url)); // …/src`;
|
||||
`const wrapperPath = join(here, "..", "run-daily.sh"); const workingDir = join(here, ".."); const nodeBin =
|
||||
process.execPath;`. **logPath (pinned — folded, all three reviewers):** `const logPath = join(dirname
|
||||
(defaultStorePath()), "cron.log");` — derived from `defaultStorePath()` (= `<root>/trends/trends.json` →
|
||||
`dirname` = `<root>/trends` → `<root>/trends/cron.log`), the data-root anchor the wrapper also uses; **NOT**
|
||||
`dirname(storePath)` (the `--store` override) and **NOT** with a spurious `".."`. Matches `defaultBriefDir`'s
|
||||
`dirname(defaultStorePath())` idiom (`brief.ts:236`), so `cron.log` is a sibling of `morning-brief/`.
|
||||
- `const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio");
|
||||
const env: Record<string,string> = { NODE_BIN: nodeBin, LINKEDIN_STUDIO_DATA: root };` — **always** bake the
|
||||
resolved-absolute root (pins the scheduled run to the install-time data dir **and** removes the wrapper's
|
||||
`$HOME`-unset `set -u` edge — folded — plan-critic #9 / brief-reviewer #9).
|
||||
- `const args = ["--pillars", pillars.join(","), "--fresh-days", String(freshDays)];` **(no leading `"brief"`** —
|
||||
the wrapper owns the subcommand; folded — plan-critic #8) and append `["--store", storePath]` **iff** the
|
||||
operator passed an explicit `--store` (so the scheduled run targets the same store; otherwise the wrapper's
|
||||
default resolves it).
|
||||
- `const label = defaultLabel(); const spec: ScheduleSpec = { platform, label, nodeBin, wrapperPath, args, hour,
|
||||
minute, logPath, workingDir, env };`
|
||||
- dispatch:
|
||||
- **`--uninstall`** → print `uninstallInstructions(spec, plistTarget)`; if the launchd plist file exists,
|
||||
`rmSync` it (reversible); exit 0.
|
||||
- **`--install`** → launchd: `const plistTarget = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
||||
mkdirSync(dirname, {recursive:true}); writeFileSync(plistTarget, launchdPlist(spec))` + `console.log` the
|
||||
plist path + `installInstructions(spec, plistTarget)` — **never run `launchctl`**. cron: `console.log(crontabLine
|
||||
(spec)); console.log(installInstructions(spec))` — **never run `crontab`**. exit 0.
|
||||
- **default / `--print`** → `console.log(platform === "launchd" ? launchdPlist(spec) : crontabLine(spec));
|
||||
console.log(installInstructions(spec, platform === "launchd" ? join(homedir(),"Library","LaunchAgents",
|
||||
`${label}.plist`) : undefined));` — **no fs**. exit 0.
|
||||
- **Header doc-comment** (`cli.ts:5-14`, `:36-37`): add the `schedule …` synopsis line + a note that `schedule` is
|
||||
**print-first** (emits the plist/crontab + the install command; `--install` writes only the inert launchd plist
|
||||
file; the tool never runs `launchctl`/`crontab`) and runs the **deterministic** brief (no AI capture — slice e).
|
||||
**No new exit code** (0 success / 2 usage): an autonomy install never *runs* the system mutation, so there is no
|
||||
install-failure path to encode.
|
||||
- `usage()` (`:86-100`) — add the `schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform
|
||||
auto|launchd|cron] [--install|--uninstall] [--store <path>]` line.
|
||||
Make the Phase-A `cli.test` `schedule` cases green (print emit, auto-platform, print-writes-nothing,
|
||||
install-launchd-writes-plist, install-cron-prints-only, validation exits).
|
||||
|
||||
## Step 5 — wire `trend-spotter.md` (prose) + README
|
||||
|
||||
In `agents/trend-spotter.md`: add **one prose line** (no batch-shape change): the morning brief can now be
|
||||
**scheduled** to regenerate autonomously (deterministic, from the store) via the `schedule` CLI verb; the agent's
|
||||
polling stays the capture path (autonomous AI polling is a later slice). Domain-general (Section 17). Update
|
||||
`scripts/trends/README.md`: the headless wrapper (`run-daily.sh`) + the `schedule` subcommand (print-first,
|
||||
launchd/cron, `--install`/`--uninstall`, `--at`/`--platform`), the **deterministic-brief-only boundary (C1)** + the
|
||||
documented (e) AI-capture seam, the `cron.log` location, and the R3b per-day idempotency that makes a double-fire
|
||||
safe.
|
||||
|
||||
## Step 6 — gate: floors + new unconditional Section 16l
|
||||
|
||||
In `scripts/test-runner.sh`:
|
||||
- Set `TRENDS_TESTS_FLOOR` (`:709`, currently 171) to the **`tests N` line** reported by `(cd scripts/trends &&
|
||||
npm test)` after Steps 1–5 — recounted live, NOT additive-guessed. Stays **inside** the deps guard. **Append**
|
||||
`+ RE-R3c: scheduler +N` to the inline breakdown comment.
|
||||
- Add **Section 16l** ("Trends Scheduler / Headless Wiring", RE-R3c), mirroring Section 16k (unconditional,
|
||||
deps-absent-safe, pure `grep -qF`/self-test, no `tsx`). **Placement (verified live):** Section 16k ends `~:1305`,
|
||||
Section 18 begins `:1307` — insert 16l **after 16k's block (`~:1305`), before Section 18 (`:1307`)** (anti-erosion
|
||||
must stay last so it counts every prior check). Six **unconditional** checks, the self-test emitting **one**
|
||||
pass/fail like 16k:
|
||||
(1) a non-vacuity self-test (a probe carrying `launchdPlist` accepted, one without rejected);
|
||||
(2) `grep -qF 'export function launchdPlist' scripts/trends/src/schedule.ts`;
|
||||
(3) `grep -qF 'export function crontabLine' scripts/trends/src/schedule.ts`;
|
||||
(4) `grep -qF 'command === "schedule"' scripts/trends/src/cli.ts` (the verb);
|
||||
(5) `grep -qF 'cli.ts" brief' scripts/trends/run-daily.sh` (the wrapper runs the deterministic brief — the
|
||||
sentinel matches the literal `…cli.ts" brief`, folded — plan-critic #8);
|
||||
(6) `grep -qF 'LINKEDIN_STUDIO_DATA:-' scripts/trends/run-daily.sh` (the data-path twin seam).
|
||||
- Bump `ASSERT_BASELINE_FLOOR` (**`:1329`**, currently 105 — folded, all three reviewers: the earlier `:1259` cite
|
||||
was wrong, that line is `LIFECYCLE_FILTER_LIT`) → **exactly 111** (105 + the 6 new unconditional 16l emitters;
|
||||
the self-test emits one pass/fail like 16k, so 111 is deterministic — "live recount" is the safety net, not a
|
||||
guess). Insert the 16l clause into the **header-enumeration prose chain at `:57`** (before "…the assertion-count
|
||||
anti-erosion floor (SC6) in Section 18"), preserving sentence flow. **Also append** the RE-R3b (→105) + RE-R3c
|
||||
16l (→111) narration to the **Section-18 floor-history comment** (`~:1310-1324`, which still stops at "= 99"
|
||||
because R3b's +6 was never narrated — folded — scope-guardian #7), so the comment matches the live floor.
|
||||
- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3c adds no hook test). It must
|
||||
still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land.
|
||||
|
||||
## Step 7 — behavioural verification
|
||||
|
||||
`(cd scripts/trends && npm install)` if needed, then run brief §7's six behavioural steps: seed a store; `schedule
|
||||
… --platform launchd --print` + `… --platform cron --print` (inspect); `plutil -lint -` the plist → OK;
|
||||
`LINKEDIN_STUDIO_DATA=/tmp/r3c-data ./run-daily.sh brief --pillars ai,gov --store /tmp/r3c.json --out
|
||||
/tmp/r3c-data/trends/morning-brief` → dated `.md` written + `cron.log` line + exit 0; re-run same day → `.md`
|
||||
byte-identical, `surfacedCount:1` (per-day idempotent), second log line; `schedule … --install` with a throwaway
|
||||
`HOME` → the plist file written under `<HOME>/Library/LaunchAgents/` + the `launchctl bootstrap` command printed
|
||||
(do **not** activate against the real system unless intentional). Run full `bash scripts/test-runner.sh` → `FAIL=0`
|
||||
(`ASSERT_BASELINE_FLOOR` 111); run `node --test hooks/scripts/__tests__/*.test.mjs` → still green (untouched
|
||||
regression).
|
||||
|
||||
## Step 8 — land
|
||||
|
||||
Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 111, gate total;
|
||||
schema unchanged v4). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3c.md`
|
||||
(no suffix, tracked); **(2)** code commit — `schedule.ts` + `run-daily.sh` + `cli.ts` + three test files
|
||||
(`schedule.test`/`cli.test`/`run-daily.test`) + `agents/trend-spotter.md` + `scripts/trends/README.md` +
|
||||
`scripts/test-runner.sh` with `[skip-docs]` (D9: single code commit — the scheduler is one coherent feature).
|
||||
Push freely (window lifted; gitleaks at commit; `origin` = PUBLIC `open/` — STATE/`*.local.*` never pushed). No
|
||||
version bump (additive; `v0.5.2` dev).
|
||||
|
||||
## Verification (testable)
|
||||
|
||||
| SC | Check | Command | Expected |
|
||||
|---|---|---|---|
|
||||
| — | RED Phase A | `(cd scripts/trends && npm test)` before src edits | `schedule` cli cases fail on the exit-0/stdout assertion (logic-RED); wrapper case fails on exit 127, not module-not-found |
|
||||
| — | RED Phase B | `npm test` after non-throwing `schedule.ts` stubs | launchdPlist/crontabLine/install/uninstall/defaultLabel fail on value assertion against the `""` stubs |
|
||||
| SC1 | launchd plist emit | `npm test` (schedule.test, cli.test) | plist has Label/ProgramArguments/StartCalendarInterval(H/M)/Std*Path/EnvironmentVariables; deterministic |
|
||||
| SC2 | crontab line emit (string only) | `npm test` (schedule.test, cli.test) | `<m> <h> * * * … run-daily.sh brief --pillars … >> <log> 2>&1 # <label>` + `crontab -` instruction; never executes crontab |
|
||||
| SC3 | platform auto | `npm test` (cli.test) | darwin→launchd, else→cron; branches on `process.platform` |
|
||||
| SC4 | print-first writes nothing | `npm test` (cli.test) | `--print` → exit 0, stdout plist, temp-HOME LaunchAgents empty |
|
||||
| SC5 | `--install` launchd inert plist | `npm test` (cli.test) | plist FILE written under temp HOME LaunchAgents; `launchctl bootstrap` printed; launchctl never run; exit 0 |
|
||||
| SC6 | `--install` cron prints only | `npm test` (cli.test) | line + `crontab -` instruction printed; crontab never run; exit 0 |
|
||||
| SC7 | headless wrapper runs brief + logs | `npm test` (run-daily.test, via `bash`) + manual | dated `.md` written; **one** compact `cron.log` line; exit 0; second same-day run byte-identical + surfacedCount not double-counted; CWD-independent (`cd "$DIR"`); no AI |
|
||||
| SC8 | data-path twin consistency | `npm test` (run-daily.test) | wrapper log dir == `dirname(defaultStorePath())` (not `--store`) == `getDataRoot('trends')`, default + override |
|
||||
| SC9 | usage / validation | `npm test` (cli.test) | no `--pillars`/bad `--at`/bad `--platform` → exit 2; fs untouched |
|
||||
| SC10 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16l green; ASSERT_BASELINE_FLOOR=111; Section 17; counts 29/19/27; hook suite green |
|
||||
|
||||
## Risks
|
||||
|
||||
- **R1 — the wrapper's data-path twin drifts from `store.ts`/`data-root.mjs`.** *Mitigated:* SC8 behavioral
|
||||
twin-consistency test (resolve all three for a temp `LINKEDIN_STUDIO_DATA` override; assert equal); the wrapper
|
||||
uses the exact `references/data-path-convention.md` rule-1 inline form; documented as the third twin (mirroring
|
||||
`data-root.mjs`'s comment).
|
||||
- **R2 — a test runs `crontab`/`launchctl` and trips the global guard or mutates the real system.** *Mitigated:*
|
||||
every test asserts the **emitted string / written file on stdout/temp-HOME** — none executes `crontab` or
|
||||
`launchctl`; `--install` tests use a throwaway `HOME` and assert only the inert plist file + printed command.
|
||||
(A test that ran `crontab` would be a BLOCKER — invariant §5.)
|
||||
- **R3 — malformed launchd plist (won't load).** *Mitigated:* SC1 asserts every required key; the plist is a
|
||||
pinned `<!DOCTYPE plist …>` template; behavioural Step 7 runs `plutil -lint -` (macOS, deps-present manual check
|
||||
— not a gate, since a deps-absent fresh clone has no `plutil`).
|
||||
- **R4 — the two NEW files (`schedule.ts`, `run-daily.sh`) blocked at Write.** *Mitigated:* they are under
|
||||
`scripts/trends/` (allowed — `cli.ts`/`test-runner.sh` were added there; the pathguard blocks NEW `.mjs` under
|
||||
`hooks/scripts/` only). If Write is nonetheless blocked, the operator authorizes the R2b `!cp` fallback
|
||||
(write to scratch, `cp` in). This is an implementation-step risk, not a docs-step blocker.
|
||||
- **R5 — the nightly deterministic brief is a near-no-op without new captures (thin visible value).**
|
||||
*Accepted/honest (C1):* R3c is the **mechanism**; freshness-aging + per-day surfacing accumulation are real (feed
|
||||
slice b), but the visible autonomous-research payoff lands with slice (e), which plugs AI capture into the
|
||||
documented pre-brief seam. Stated plainly in the README + the brief §1 value framing — no salesmanship.
|
||||
- **R6 — `--at HH:MM` parse / validation gaps.** *Mitigated:* explicit `0≤H≤23`/`0≤M≤59` validation → `usage`
|
||||
exit 2; SC9 asserts `25:00`/`7:99`/`noon` all exit 2.
|
||||
- **R7 — bash 3.2 incompatibility / `set -u` crash in the wrapper.** *Mitigated:* ASCII-only, all expansions
|
||||
quoted, no `declare -A`/`mapfile`/`|&`; the `set +e`/`set -e` fence around the node call captures the exit code
|
||||
cleanly; the operator's macOS (bash 3.2) is the test bed; `run-daily.test` runs it as a subprocess in CI-shape.
|
||||
- **R8 — node unresolvable in launchd/cron's profile-less env.** *Mitigated:* the scheduler bakes
|
||||
`NODE_BIN=<process.execPath>` (absolute) into the artifact env; the wrapper falls back to `command -v node` then
|
||||
common locations, logging `exit=127 node not found` if truly absent; `WorkingDirectory` is set so tsx resolves.
|
||||
- **R9 — editing `trend-spotter.md` / the new source trips the de-niche guard.** *Mitigated:* Section 17 runs in
|
||||
the gate; the added prose + the source carry only generic scheduling wording; the launchd Label is the plugin
|
||||
namespace; pillars are args (no vendor/sector token).
|
||||
- **R10 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16l is pure
|
||||
`grep`/self-test on tracked source (`schedule.ts` + `cli.ts` + `run-daily.sh`; no `tsx`) → unconditional;
|
||||
`TRENDS_TESTS_FLOOR` stays inside the deps guard.
|
||||
- **R11 — import cycle.** *Mitigated:* `schedule.ts` is a **leaf** (imports nothing from the package); `cli.ts`
|
||||
(root) imports it. The DAG stays acyclic: `score (leaf) ← types ← store ← brief ← cli`, with `schedule (leaf) ←
|
||||
cli` added. `brief.ts`/`store.ts`/`item.ts`/`score.ts`/`types.ts` untouched.
|
||||
|
||||
## Plan-critic — folded
|
||||
|
||||
Three Opus reviewers ran, each verifying claims against live code: **scope-guardian ALIGNED** (0 creep, 0 gaps;
|
||||
every 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 --json` is pretty-printed** (`cli.ts:323` `JSON.stringify(…, null, 2)`), so the
|
||||
wrapper's "one log line" / SC7 contract was false. ✅ Step 3 captures `CODE=$?` first, then **compacts** `OUT`
|
||||
(`tr '\n' ' ' | tr -s ' '`); SC7 asserts exactly one line.
|
||||
- **[MAJOR — brief-reviewer #1] the wrapper never `cd`s to its package dir** → cron (CWD `$HOME`) would fail to
|
||||
resolve `tsx` (the plist sets `WorkingDirectory`, cron does not). ✅ Step 3 adds **`cd "$DIR"`**; SC7 gains a
|
||||
CWD-independence case.
|
||||
- **[MAJOR/MAJOR/MINOR — all three] the `logPath` expression** self-contradicted (`".."` escaping `trends/`) and
|
||||
used the `--store` override base. ✅ Step 4 pins **`join(dirname(defaultStorePath()), "cron.log")`** (data-root
|
||||
anchor, matches the wrapper); the `".."` and `storePath` variants removed; SC8 retargeted.
|
||||
- **[MAJOR — brief-reviewer #4 / plan-critic #3] `ScheduleSpec.env` stated three ways.** ✅ `env:
|
||||
Record<string,string>` is **canonical** (Step 2 interface + brief §3 + Files table + Step-1 fixtures), always
|
||||
`{ NODE_BIN, LINKEDIN_STUDIO_DATA(resolved) }`; the emitters render it only (purity holds).
|
||||
- **[MAJOR — all three] `ASSERT_BASELINE_FLOOR` line-cite `:1259` → `:1329`** (verified live; `:1259` is
|
||||
`LIFECYCLE_FILTER_LIT`; value 105→111 correct). ✅ Step 6 + Files table corrected; `TRENDS_TESTS_FLOOR` `:705` →
|
||||
`:709`; header-enum insertion `:49-53` → `:57`; the stale Section-18 "= 99" narration gets the R3b(→105)+R3c(→111)
|
||||
append.
|
||||
- **[MINOR — plan-critic #5] `dirname` not imported in `cli.ts`** (`:41` = `join` only). ✅ Step 4 adds `dirname`
|
||||
to the `node:path` import (+ `homedir`/`fileURLToPath`).
|
||||
- **[MINOR — plan-critic #8 / brief-reviewer #5] stray double `brief`** (`cli.ts brief brief …`). ✅ the baked
|
||||
`args` drop the leading `"brief"` (Step 4); the wrapper owns the subcommand; the 16l sentinel matches `cli.ts"
|
||||
brief` (Step 6 (5)).
|
||||
- **[MINOR — plan-critic #7] wrapper-absent RED is invocation-dependent.** ✅ Step 1 pins the test through
|
||||
**`bash run-daily.sh`** so the absent file is exit 127 (clean assertion-RED), not a thrown ENOENT.
|
||||
- **[MINOR — plan-critic #9 / brief-reviewer #9] `$HOME`-unset `set -u` edge.** ✅ the CLI **always** bakes a
|
||||
resolved-absolute `LINKEDIN_STUDIO_DATA` (Step 4 env), so a scheduled run never evaluates `$HOME`.
|
||||
- **[MINOR — scope-guardian #4] pathguard justification fabricated.** ✅ restated (brief §5): `~/repos/*` is
|
||||
allowlisted; the conclusion (new files write-allowed) holds.
|
||||
- **[LOW — brief-reviewer #7] SC1 "lint-valid" only grep-checked.** ✅ SC1 asserts **well-formed** (balanced-tag/
|
||||
parse) + key-complete; `plutil -lint` stays the deps-present manual check (Step 7).
|
||||
- **[LOW — brief-reviewer #8] twin census undercounted (3 → 4).** ✅ `analytics/storage.ts` named as the existing
|
||||
third; `run-daily.sh` the fourth (brief §5 + the wrapper comment).
|
||||
- **[MINOR — scope-guardian/plan-critic] de-niche safety (Section 17) confirmed:** `NICHE_TOKENS=Microsoft|Azure|
|
||||
Copilot|public sector|offentlig sektor`, scoped to an allowlist (`trend-spotter.md`/`content-planner.md`/
|
||||
`content-framework.md`); `schedule.ts`/`run-daily.sh` are not scanned, the agent prose carries no token, the label
|
||||
`com.linkedin-studio.trends.daily` is clean. R9 holds (no change needed).
|
||||
|
||||
**[plan-critic headless-readiness]** — R3c **builds** a headless entry, but the slice itself is authored and landed
|
||||
**in-session, operator-driven** (driftsmodell), not as a headless autonomous run, so per-step revert/halt clauses
|
||||
aren't needed (R1/R2a/R2b/R3a/R3b had none either). The *artifact* it ships (`run-daily.sh`) is the headless entry;
|
||||
the *development* is in-session.
|
||||
Loading…
Add table
Add a link
Reference in a new issue