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