diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 273851e..5da3dcf 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "linkedin-studio", - "version": "0.5.3", + "version": "0.7.0", "description": "LinkedIn Studio — full-spectrum LinkedIn content engine: feed posts, carousels, video scripts, and long-form newsletter editions, with the 2026 relevance-ranking model baked in. v4.0.0 is an audit-remediation release (Voyage Phase 0–3): every user-facing claim is made honest or removed, all 11 previously-orphaned agents are wired (→ 19 agents), a `/linkedin:firsthour` post-publish command is added (→ 27 commands), the algorithm-signal claims are reconciled to one sourced statement (no unpublishable model name or date), short-form de-AI and video quality gates are added, and the structure lint is rebuilt to guard the real layout plus version/count/stat consistency. Breaking: the newly-wired agents register only on reinstall/reload, and this consolidates the v3.0.0 identity break (slug, agent namespace `linkedin-studio:`, state-file path `~/.claude/linkedin-studio.local.md`). v3.1.0 added the cold adversarial review package (`/linkedin:headless-review` + Step 6.5 + `/linkedin:pivot` + per-artifact personas); the `/linkedin:*` commands are unchanged. v4.1.0 adds a journey layer: two guided front-doors (`/linkedin:create`, `/linkedin:measure`) plus a router re-tiered into five journeys (Start · Create · Engage · Measure · Grow), with the 27 existing commands kept as the execution tier (→ 29 commands; additive, reload registers the two new commands).", "author": { "name": "Kjell Tore Guttormsen" diff --git a/.gitignore b/.gitignore index 1fa6d8f..34e8c30 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,8 @@ scripts/brain/node_modules/ scripts/brain/build/ scripts/contract-gate/node_modules/ scripts/contract-gate/build/ +scripts/editions/node_modules/ +scripts/editions/build/ scripts/specifics-bank/node_modules/ scripts/specifics-bank/build/ scripts/trends/node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cb13551..0b87587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,153 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added — demand-sweep «innenfra og ut» (N7.5 / MR-F9) + +- **`demand-spotter` agent** — the demand-side twin of `trend-spotter`. Discovery finds *what happened*; the sweep translates it into *the reader's problem* via three passes after discovery, before drafting: demand-sweep (verbatim questions from where readers ask) → pain-point map → vocabulary translation (the reader's words, not the field's jargon). Inherits the session model; agents 19 → 20. +- **Tier 5 demand sources** — a new tier in the source-list convention (`config/trends-sources.template.md`): practitioner forums, GitHub issues on spec repos, procurement data, regulator guidance, channel comments — the inverse of the Tier 1–4 supply sources. Known blind spots stated honestly (YouTube JS-rendered → API; Reddit approximate; HN/GitHub = ground truth; an honest null is a finding). +- **`demand` signal on the trend record** — the rankable twin of the verbatim `saturation` text: `strength` (etterspørsel: strong/moderate/thin) + `answered` (is it already answered). Additive-optional; store schema stays v4 (no migration). Absent ⇒ unmeasured (the honest null the sweep may return instead of inventing demand). +- **Arc (åre) output contract §4** — `scripts/trends/src/arc.ts`: groups demand-swept records into veins (relatedIds transitive closure), ranks each vein's questions on `etterspørsel × kan-svare × ikke-besvart`, and classifies an honest **market verdict** distinguishing a **supply-gap** (many ask, none answer — write here) from a **demand-gap** (few ask, but the sender can answer — high value, low audience) from **saturated** (skip). Pure + deterministic. +- **`arcs` CLI verb + `/linkedin:trends --demand` mode** — render the §4 arc map (`node --import tsx src/cli.ts arcs`), reachable via the command's new demand mode (delegates to `demand-spotter`, keeps commands at 30). The morning brief also shows the per-candidate demand signal alongside the existing reader fields. +- Trends suite 276 → 300; test-runner 139 → 140; `tsc --noEmit` clean. + +### Added — voice chain: bind voice BEFORE prose (N8 / C-1, C-2, C-3, A2-F1, A2-F2) + +- **`chronicle-voice-profile` artifact (C-1/A2-F1)** — `voice-trainer` now distils a dedicated long-form voice profile (`${DATA}/profile/chronicle-voice-profile.md`) from the **published gold corpus only** (`ingest/published/`, `provenance=published`), one section per `(language, format)` pair present in the corpus — never from the English short-form `authentic-voice-samples.md` the engine itself calls degrading for chronicle voice, never from `provenance=ai-draft`. `/linkedin:newsletter` binds long-form drafting (Step 0 / 3a / 3b) to THAT profile as the primary reference, with an **explicit, never-silent fallback** to the short-form corpus when it is absent. +- **Drift log is READ, not just written (C-2)** — Step 3a gains a **Pass 0**: read `chronicle-voice-drift-log.md` as a *negative list* of the recurring AI-tells/drifts `voice-scrubber` logged on prior editions, so drafting avoids them BEFORE the scrub (the log was append-only, never consumed); Step 3b re-reads it during expansion. +- **Review language resolved + persisted at Step 1 (C-3)** — the calibration now **resolves the review `language`** (detect/ask) and persists the top-level `edition-state.language` at the Step 1.5 checkpoint, making the template's "Resolved at Step 1" doc-string true. Previously the default `"en"` was never set, so `language-reviewer`'s Norwegian checks and `voice-scrubber`'s gold standard silently never fired even for a Norwegian edition; Step 6.5 already consumed the field, so resolving it closes the axis end-to-end. +- **Auto-gull — the gold corpus grows on its own (A2-F2)** — Step 8 lock now appends the locked edition to `ingest/published/` tagged `provenance=published`, so the corpus the chronicle profile is distilled from grows one edition at a time (the author's own locked editions, never the engine's drafts). No manual curation. +- Domain-general throughout: languages/formats come from the user's own corpus, never hardcoded. test-runner 140 → 146 (Section 16o: 6 unconditional voice-chain greps + self-test; anti-erosion floor 123 → 129). + +### Added — randsone gates: gate the feed-facing surfaces (N9 / C-4, C-6, C-7, C-9) + +- **Randsone fact + language gate (C-4/C-9)** — `/linkedin:newsletter` Step 9 (post-lock) now runs a **scoped** `fact-reviewer` (F1 verifiable claims + F3 number attribution) + `language-reviewer` (L1 repetition + L2 anglicisms/register) pass over the four feed-facing surfaces the reader actually meets — the **delingstekst hook**, the **samle post**, the **carousel slide text**, and the **edition title** — before the hook-conversion binary. Until now only the hook's click-worth was judged; its truth and language, the samle post, the carousel slides and the title reached the feed ungated. Placed at Step 9 because that is the first phase where all four surfaces exist (carousel is authored in Step 7.5; delingstekst/samle from Step 8), and Step 9 already operates on post-lock distribution copy — so the gate revises distribution surfaces + re-renders, never the locked body; a defect tracing into the locked body/canonical title is a pivot. +- **Edition title-strength gate (C-9)** — the edition's own title now faces a strength gate at Step 9 (binding on the distribution title, advisory on the locked canonical title), not only the hook's two lines. +- **Deterministic post-factcheck fact-recheck (C-6)** — a new invariant after Step 5: any later fold-in (5.5/6/6.5/7) whose diff touches a sentence with **numbers, names, or quotes** fires a mandatory **scoped `fact-reviewer`** mini-recheck of exactly those sentences — a deterministic trigger, **not a judgment call**, replacing the old "re-run if it felt substantive" discretion and closing the pivot-heuristic blind spot (a swapped statistic under the +20 % threshold never trips `/linkedin:pivot`, but does trip this). +- **WebFetch on both fact agents (C-7)** — `fact-checker` and `fact-reviewer` gain `WebFetch`: quote precision (F2) and exact-figure attribution (F3) require the verbatim source text in context, not a search snippet. +- test-runner 146 → 154 (Section 16p: 8 unconditional randsone-gate greps + self-test; anti-erosion floor 129 → 137). Domain-general; no KTG-specific values hardcoded. + +### Added — series-level protection: make the retread visible before prose (N11 / C-5) + +- **`scripts/editions/` — series distillate (new package)** — each locked edition leaves behind the narrative units it spent (anecdotes told, arguments argued, hooks used) in `/linkedin/series-distillate.json`, beside `edition-state.json` in the **series root** — series-scoped state where the series' other state already lives, so it travels with the series and needs no slug→path map. `distil-append` (Step 8 lock) folds an edition in; a re-lock after `/linkedin:pivot` **replaces** its entry rather than duplicating it. +- **Step 2.5 destillat-sjekk — the check runs BEFORE prose** — the skeleton gate now compares the proposed skeleton against everything the series has published and folds any finding into the annotation page the operator already reads, while changing course is still cheap. **Advisory, never blocking** (exit 0 either way): a deliberate callback is a legitimate move — an *unnoticed* retread is not. Until now every one of the long-form gate agents saw exactly one edition, so "the reader has heard this before" was structurally invisible; at series cadence it is the fastest-growing defect class. +- **Deterministic comparison, AI extraction** — the units are distilled from locked prose by the command layer, but the store, the similarity and the verdict are plain code: **character-trigram Jaccard**, chosen because the plugin is language-general and inflection (*migrere* / *migreringen*) breaks word-token overlap. Calibrated against real paraphrase pairs — retellings 0.44–0.55, unrelated 0.04–0.06, same-topic-different-story 0.24 — so the default threshold sits in the gap at **0.40** (word-Jaccard scored a shortened hook paraphrase at 0.11 and would have let it through). Units are compared within their kind; an argument re-used as a hook is a new move. +- **Specifics-bank usage log (`usedIn`)** — the material grain of the same defect: `recordUsage` + the `record-usage` CLI verb stamp «used in edition NN» on the specifics an edition actually consumed (read from the bound slot-map via `boundSpecificIds`, so `abstrakt`/`ekstern` slots stamp nothing). Recorded at **lock**, so it means published rather than merely considered, and idempotent under a pivot re-lock. The bank's dedupe deliberately *encourages* re-surfacing the same material; this log is what makes that re-use visible at the next Step 1.5 instead of silent. Additive-optional — schema stays v1, existing banks load unchanged. +- Suites: new `editions` 27/0; specifics-bank 28 → 45; test-runner 163 → 173 (Section 16r: 9 unconditional greps + non-vacuity self-test; anti-erosion floor 146 → 155). `tsc --noEmit` clean in both packages. Domain-general throughout. + +### Added — editions register + phase telemetry: make the production line measurable (N12 / A1-11, A1-12) + +- **Editions register (`scripts/editions`, new module)** — one row per edition in production in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json`: series, edition, title, series path, current phase, the one next action, and slot. `register-list` answers "what is in flight, and where does each edition stand?" without opening a single series folder — until now that answer required opening every `edition-state.json` by hand, which is why nobody had it. Data-dir placement (M0), because the register spans **all** series (the distillate is per-series and stays in the series root). +- **`articles.NN.phaseLog` — lead time becomes a measured number** — every phase transition appends `{phase, completedAt}` to the edition-state. Additive-optional (default `[]`), so schemaVersion stays **1** and pre-N12 editions load unchanged; their absence reads as "not measured", never as zero. Per-article rather than top-level, mirroring `articles.NN.phase`: lead time is a property of an edition. +- **One call per transition, both writes** — `/linkedin:newsletter` gains a **phase-transition protocol** defined once next to the resumption table, and each of the **16 canonical phases** invokes it: `register-upsert --edition-state ` appends the phase-log entry *and* mirrors the register row. Deliberately one command, not two: telemetry the command layer must remember to write separately is incomplete inside a week, and an incomplete log measures nothing. Step 10 closes the row (`register-complete`) and prints the measured lead time. +- **The register is a MIRROR, never a source of truth** — deterministic resumption still reads `edition-state.json` and only that. Delete the register and the next transition rebuilds the row; a failed `register-upsert` is reported plainly and never stops the pipeline. `startedAt` is the one value that cannot be recovered, so it never moves: re-upserting a completed edition *reactivates* the same row (what `/linkedin:pivot` does), keeping the clock on real elapsed production time. +- **Deterministic, no clock in the core** — every mutation takes `now` as an argument (`--at` / `--now` at the CLI edge), as the distillate takes `lockedAt`. Idempotent where a re-run is legitimate (a repeated transition logs once; completing twice keeps the first `completedAt`), *not* where it is real work (a phase recurring after another one is logged again — a pivot back through cleared gates is production time that happened). Missing facts are refused at the edge rather than defaulted: a row naming the wrong phase is worse than no row. +- Suites: editions 27 → 72; test-runner 173 → 184 (Section 16s: 11 unconditional greps incl. a 16-phase coverage sweep + non-vacuity self-test; anti-erosion floor 155 → 166). `tsc --noEmit` clean. Domain-general throughout. + +### Added — publishing slots + vacancy warning + WIP roll-up (N13 / A1-10, D-5) + +- **Publishing slots are operator config (`profile/publishing-slots.json`)** — a recurring weekly grid (`{day, time, label}`) in the per-user data dir, with a schema + **opt-in** template at `config/publishing-slots.template.json`. The plugin writes it for nobody and ships no publishing times: while the file is absent every slot surface stays silent, exactly like the trend and brain nudges. It lives in the data dir rather than the state file because the state file is machine-written (rollover, pruning, post tracking) and its frontmatter reader does not parse lists — user config does not belong in a file the plugin rewrites. +- **Vacancy at session start** — the SessionStart hook now names the next publishing slot **nothing covers**, how many are open in the next 14 days, and — when the gap is ≤3 days — how to fill it. Coverage counts both the short-form queue (status `scheduled`/`published`; a cancelled entry frees the slot again) **and** editions-register rows whose slot claims that date, so long-form and short-form can never be booked on top of each other. A day carrying two slots needs two posts to be full. +- **Work in progress is surfaced, not searched for** — `## Editions in Flight` at session start, plus the same roll-up in the `/linkedin` router and `/linkedin:calendar`: series, edition, current phase, the one next action, claimed slot, and days in flight. The N12 register made this measurable; N13 makes it *visible without asking*. +- **`hooks/scripts/slots.mjs` — one implementation, four surfaces** — zero-dep (a SessionStart hook must not spawn tsx), pure core (dates in as strings, no clock), and imported by the commands the same way `queue-manager.mjs` already is. The hook's vacancy warning and the slot `/linkedin:newsletter` defaults to therefore cannot disagree. +- **`/linkedin:newsletter` Step 10 defaults to the next open slot** — scheduling is one confirmation instead of an interview, and the transition now writes `--slot` onto the register row so a scheduled edition stops reading as an open slot. Unconfigured grid or a full horizon ⇒ the old ask, unchanged. +- **D-5: the shipped schedule is marked for what it is** — `references/scheduling-strategy.md` now carries the same epistemic standard as the algorithm reference (**confidence: low / directional**, practitioner heuristic, no primary source) and defers to the operator's own grid, tested against their own analytics. The tables are a starting point, not a finding about anyone's audience. +- Suites: hooks 140 → 174 (25 slot-core + 9 session-start tests); test-runner 184 → 197 (Section 16t: 13 unconditional greps incl. a de-niche check that no publishing time is hardcoded in the slot path and no operator grid is committed, + non-vacuity self-test; anti-erosion floor 166 → 179). + +### Added — the fold-in loop: a correction made once is enforced from then on (N14 / A2-F6, A2-F7, A2-F8, A2-F9, C-10) + +- **Capture is finally wired (A2-F7)** — the `articles.NN.foldIns[]` queue and the promote→ratify machinery have shipped since fix #1, but **nothing ever wrote to the queue and no step ever emptied it**. All four steps where an operator's judgment enters the text — skeleton annotations (2.5), spine annotations (3a), editorial flags (5.5), cold-review flags (6.5) — now append the correction near-verbatim with its trigger, `decision: "pending"`. Capture is unconditional and deliberately dumb: deciding at capture time whether a correction "deserves" a rule is exactly how the queue stays empty and the loop dies. Edition-specific fixes (a wrong number) are explicitly *not* rules. +- **New Step 11 — retro (A2-F8), ≤5 min, AFTER scheduling** — three fixed jobs, one operator pass. (1) **Promote**: every pending fold-in gets an explicit JA/NEI; mechanical → an atomic `scripts/contract-gate` promotion (rule + contract row + manifest row) that only counts when `--ratify` is green, judgment → the operator's language-rules file; NEI marks the row `rejected` and **keeps** it. (2) **Effort summary**: read straight from the measured `articles.NN.phaseLog` (N12) — transitions, *recurred* phases (where the rounds actually went), lead time — never re-estimated, and "not measured" when the log is absent. (3) **One** process-friction question, answered back to the operator: the plugin never writes into the operator's own notes or register. It runs after delivery so nothing about shipping waits on it, and it is the last thing the edition does: the pipeline is not complete until the loop is closed. Phases 18 → 19; the resumption table routes `scheduling` → Step 11 and `retro` → complete, so an aborted session cannot drop the queue. +- **`articles.NN.retro` in edition-state** — additive-optional (default `null`), so schemaVersion stays **1** and pre-N14 editions load unchanged. +- **Background headless review (A2-F6)** — `/linkedin:headless-review --background` runs the cold package in a background agent with its own context window that writes the consolidated report to disk; the drafting session then *reads the file*. Maximum independence used to cost a manual session switch plus pasting the report back — the seam is gone, and the isolation is identical (the background agent gets only the cold-contract inputs). The inline fan-out remains the explicit fallback; the isolation is never silently downgraded. +- **Language rules accumulate in a user file (C-10)** — `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/language-rules/.md` (opt-in, template `config/language-rules.template.md`) holds the judgment-class corrections Step 11 promoted. It is read **twice**: by `language-reviewer` at Step 6.5 (catch) and by Step 4 while the draft is still being cleaned (**prevent**) — a rule that only ever catches is worth half a rule. The shipped ban-list (`longform-quality-rules.md` rule 3) stays the baseline and is never edited by the loop; the user file extends it, and its *Deliberate exceptions* section is honoured. Absent file ⇒ baseline alone, silently. +- **`references/fold-in-loop.md` (A2-F9)** — the capture → classify → promote → enforce loop documented **domain-generally and in-tree**, so an adopter with no external writing contract has the whole loop (that absence "is not a gap" — the same mirror rule `editorial-reviewer` uses). Names the other four accumulation silos (specifics-bank, brain, voice drift log, A/B learnings) that Step 11 surfaces together, because a retro that empties one queue teaches the operator the others are empty too. references 28 → 29. +- Suites: test-runner 197 → 217 (Section 16u: 18 unconditional greps + a non-vacuity self-test on the capture predicate; the phase-coverage sweep now spans **17** phases; anti-erosion floor 179 → 198). All nine suites green. + +### Added — the do-next contract: a measurement changes the next piece (N15 / A2-F10, A2-F12, A2-F13, A2-F14) + +- **`recordDoNext()` in `state-updater.mjs`** — one channel from measurement to creation. `/linkedin:report`, `/linkedin:analyze`, an `/linkedin:ab-test` **Adopt** verdict and the `post-feedback-monitor`'s 48h learning each persist 2–3 directives (imperative + evidence pointer, tagged with their source) to `## Do-Next Directives` in the state file; `post`/`quick`/`batch`/`create` Step 0 and `newsletter` Step 1 read them before drafting. Before this every analytics surface ended in chat (`grep do.next` across the repo returned 0) and the next drafting session — a fresh context — never saw any of it. +- **Lifetime is replace-by-source + a 60-day age floor** — each writer supersedes only its own rows, so a new weekly report never evicts an A/B Adopt decision, and the section stays a live steering signal instead of a growing backlog. Deterministic, no AI in the write; `--record-do-next` exposes it as a CLI verb. +- **Previous-edition calibration (A2-F14)** — `newsletter` Step 1 matches the previous edition to its measured numbers through the queue id (`drafts/queue.json` → `analytics/posts/*.json` by publish date, disambiguated on the hook preview), with an explicit **honest-miss** branch: an unmeasured edition is stated as unmeasured, never estimated. +- test-runner 213 → 232 (Section 16v: 15 unconditional do-next greps + self-test; anti-erosion floor 198 → 213). Two real bugs found by the tests themselves: a section regex missing `/m` (old rows were never superseded or pruned) and fixtures dated 7 months before their own `today`, which had made the replace-by-source test falsely green. + +### Added — measure-truth: reach split + honest boundary map (N16 / D-3, A2-F11, E#9) + +- **Out-of-network reach as an optional manual column (D-3)** — LinkedIn split a post's impressions into in-network and out-of-network in June 2026 (post analytics → Discovery, under the impressions count): the first native number that says whether a post **acquired** audience or only resonated with the audience it already had. It is shown as a **percentage split** and is **absent from the CSV export** (whether it will ever be exported is unverified), so it follows the saves pattern — add an `Out-of-network` (or `In-network`) column with the percentage read off that panel and `/linkedin:import` ingests it. +- **A separate percent parser, deliberately not the count parser** — `parseOptionalPercent()` reads a comma as a **decimal** mark (`36,5` → 36.5; `parseOptionalCount`'s US-thousands rule would have said 365) and **refuses any value above 100**: in one column an absolute impression count and a share are indistinguishable, so the honest answer is unknown rather than a guess. Blank, non-numeric and negative stay unknown; a genuine `0` is kept (nothing left the network). +- **One stored half, cross-checked** — only `outOfNetworkPct` is stored, because the two halves describe one split and keeping both would let a record contradict itself. An `In-network` cell is stored as its complement, and a transcribed **pair that does not sum to ~100** (one point of rounding slack, since the UI rounds each half independently) is **discarded as unknown** instead of half-trusted. +- **Impressions-weighted roll-ups** — `weightedOutOfNetworkPct()` feeds an optional `avgOutOfNetworkPct` on the weekly and monthly summaries. Weighted, because a flat mean lets a 50-impression post at 90 % outvote a 10,000-impression one at 20 %; posts without a reading are excluded rather than folded in as 0; zero total weight yields `undefined`, never 0 or NaN. Reach-free imports stay byte-identical to pre-N16 output, on screen and on disk. +- **Not part of the engagement rate** — reach is distribution, not engagement, so it never enters `engagementRate` (which stays comparable to every historical import). `/linkedin:report` renders the split as **acquisition (out) vs resonance (in)**, states coverage honestly, and says "not entered for this period" rather than estimating an unentered split. A reach reading routes into the N15 do-next channel so it shapes the next post instead of being admired in the report. +- **Operator-gated baseline updates (A2-F11)** — new `/linkedin:report` **Step 7c**: the report now offers a diff against the user's `audience-insights/engagement-patterns.md` baseline (the file `analytics-interpreter`, `strategy-advisor` and `/linkedin:audit` treat as tracked truth, and which nothing in the pipeline used to write, so it went stale silently). Old → new with the evidence, an explicit go via `AskUserQuestion`, changes labelled *provisional* when they rest on 1–2 posts, an append to the section's update log — and never a silent write, never into the plugin's shipped template. +- **Boundary map brought current (E#9)** — the README boundaries now state all three honestly: dwell **explicitly unmeasurable** (internal to LinkedIn, no count to transcribe, no API), saves partner-gated (`POST_SAVE` on `/memberCreatorPostAnalytics` from v202604), and reach native-but-not-exported with export status **unverified**. +- analytics suite 119 → 144 (25 new tests: percent contract, either-half resolution, split cross-check, weighted roll-up, backward-compat); test-runner 232 → 247 (Section 16w: 15 unconditional greps + self-test; anti-erosion floor 213 → 228); `tsc` clean. + +### Built feedback (awaiting consumer-side proof) + +- **MR-F9** (demand-sweep «innenfra og ut») — built; the (a)/(b)/(c) evidence gate is a runtime demonstration, proven consumer-side (plugin agents don't resolve in the dev repo). + +## [0.7.0] - 2026-07-23 + +**Trend engine → production bridge.** The discovery pass becomes a first-class command, gains a reader-side proposal + actionability layer, and is wired directly into the long-form pipeline: a trend now flows id-traceably from discovery to a scheduled edition, and a candidate with no formulable reader-grip can no longer inflate the top band. + +### Added — trend discovery command (N5) + +- **`/linkedin:trends`** — trend-discovery pass: delegates to `trend-spotter` (default long-form mode), persists to the trend store + a dated morning brief, triages per candidate (act/skip). Commands 29 → 30. (`a8e3cac`) +- **`trend-spotter` model-pin removed** — the agent now inherits the session model instead of being pinned to `sonnet`, so a discovery pass runs at the session's tier. (`a8e3cac`) + +### Added — trend proposal + reader-side layer (N6) + +- **Proposal fields on the trend record** — `angle` / `targetLevel` / `rationale` / `relatedIds`, plus the reader-side `actionability` (is a reader-grip formulated?) / `verdict` (BÆRENDE/STØTTE/NYHET) and `readerQuestion` / `painPoint` / `saturation`. All additive-optional; store schema stays v4 (no migration). (`65b6033`) +- **Candidate lifecycle** — `new → selected → acted | skipped`; a `select` verb + `--ids` batch triage (partial hit = exit 0, all-miss = exit 2). (`65b6033`) +- **Band-distribution calibration (MR-F6)** — the scoring SSOT now states the expected distribution (Immediate ≥8 is the exception, target ≤~3 of 20), checkable across runs against the persisted composite. (`bace49f`) + +### Added — trend→newsletter bridge + actionability gate (N7) + +- **Trend→newsletter bridge (MR-F3)** — `/linkedin:newsletter` Step 1 accepts a trend id and pre-fills the brief (angle / target-level / key-points / source-URLs) from the stored candidate; `articles.NN.sourceTrendId` records the provenance (additive-optional, no edition-state schema bump); Step 10 auto-`act`s the source trend on scheduling so the discovery→production loop closes deterministically; Step 2 gains an external fact-package intake path (kilde-så-draft with finished research). `grep -ci trend commands/newsletter.md`: 0 → 18. (`1adb7f4`) +- **F7 band-cap gate (MR-F7)** — a candidate whose reader-grip is explicitly not formulated (`actionability.formulated=false`) is capped to at most `High` regardless of composite. Pure and deterministic; it overrides the composite→band derivation **downward only** (the real composite is preserved), wired into the capture path so the persisted priority is gated. (`1adb7f4`) + +### Landed feedback + +- **MR-F3** (trend→newsletter bridge) — landed @ `1adb7f4`. + +## [0.6.0] - 2026-07-17 + +**Catch-up release.** Everything since the v0.5.3 tag ships here (28 commits, `git log --oneline v0.5.3..HEAD` is the source of truth), and the sections below also document work that shipped **inside** earlier tags but was never changelogged (see the catch-up note at the end). + +### Added — figure pipeline (MR-F4/MR-F8) + +- **`render/build-figur.mjs`** — coded data figures: SVG/HTML → PNG via headless Chrome; three targets (article 1200px wide with content-driven height / carousel 1080×1350 / single 1200×1200); brand tokens from the user data dir's `profile/brand-tokens.json` with neutral defaults (token-seam); Chrome-hang watchdog. Standalone CLI + importable module; 33 tests. (`c4434ed`) +- **`/linkedin:newsletter` Step 7.5 — three figure routes** with the coded route PRIMARY for data figures, plus new **`references/figure-design-guidelines.md`** (reference docs 27 → 28). (`1a67bd2`) +- **`render/build-html.mjs` parser reconciliation** — multi-line blockquotes (blank `>` line = new `

` in the same quote), `[text](url)` links with an http/https/mailto scheme-whitelist, and the `**[FIGUR N — «…»]**` marker → inline SVG from `figurer/figN*.svg` with figcaption (fallback: plain blockquote) + CSS for all three. Byte-identical parser parity with the upstream engine proven by fixture; the import-safe `main()` CLI-guard is kept. 7 new tests. (`8151029`) + +### Added — research engine RE-R3a–e + +- **R3a** — persist the relevance score on the store record + rank the morning brief on it. (`e169c78`) +- **R3b** — trend lifecycle: re-score on re-capture, status, seen-log. (`b185db9`) +- **R3c** — autonomous trigger: scheduler + headless entry. (`3276e44`) +- **R3d** — temporal overlay: first-mover + saturation. (`2a8459c`) +- **R3e** — brief history + day-over-day diff (frontmatter + "Nytt siden sist"). (`5b51b4b`) + +### Added — OKF Stage 1 + +- The brain store emits an **OKF-compatible bundle** (`brain/` as bundle, `ingest/` excluded; title/description + typed pending-diff). (`9e95222`, `e9e183e`) + +### Fixed + +- **Oppgave 1 fix-pass** (from independent cold-review): clipboard heredoc, report refs, calendar queue fields. (`882f6ee`) +- **Prune-regex no-op** in state-updater + date-independent calendar fixtures (flake). (`776d728`) + +### Docs + +- **Independent cold-review complete — 29/29 surfaces** (R2a Create emitters, R2b Create orchestrators, R3 Engage, R4 Measure, R5 Grow + Router; `docs/hardening/review*.md`). (`5474df5`…`4fd038a`) +- **Truth-pass (this release):** README maturity note + badges (hardening 29/29 + cold-review 29/29; what remains for 1.0.0 is a GUI); CLAUDE.md maturity line + Architecture entries for specifics-bank and contract-gate; **GR-model correction** in `references/algorithm-signals-reference.md` — LinkedIn's ranking model has an official primary-source name, the **Generative Recommender (GR)**, announced 2026-03-12 on LinkedIn's engineering blog (the earlier "likely fabricated" flag rejected a genuine primary source and is retracted in a correction note); **saves-API rationale** updated (Marketing API v202604 exposes `POST_SAVE` on `/memberCreatorPostAnalytics`, partner-gated — manual entry remains the right UX); hardening queue table caught up through S31a/b/c; second-brain architecture header caught up through SB-S3a–e. Plus maturity-status correction (`001d76c`), CLAUDE.md trim (−2,266 always-loaded tokens, `9567689`), ingestion-guard adoption plan (`d67552e`). + +### Catch-up note — work that shipped inside earlier tags, never changelogged + +- **Inside v0.5.1:** **Fix #1 — contract-gate** (`scripts/contract-gate/`, deterministic §B/§C1 rule-gate, `/linkedin:newsletter` Step 4.5) and **Fix #2 — specifics-bank / lived-specifics** (`scripts/specifics-bank/` store + per-edition binding + Step 1.5 elicitation, slices 1–3). Together these took the newsletter pipeline 16 → 18 phases. +- **Inside v0.5.3:** **SB-S3a–e** (profile.md reader-wiring, supersede arm, cross-silo id-threading, operations.md ops centre, content-history retirement + read-side reconcile) and **RE-R1–R2b** (item-schema + triage-scorer as tested code; item→store capture bridge with lossless schema v1→v2 migration; dated morning-brief artifact + session-start surfacing). + ## [0.5.3] - 2026-06-24 ### Changed — registration hygiene: agent fasit fixtures moved out of `agents/` diff --git a/CLAUDE.md b/CLAUDE.md index 961271c..12082a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,29 +1,25 @@ -# LinkedIn Studio Plugin (v0.5.3) +# LinkedIn Studio Plugin (v0.7.0) -> **Version re-baseline (2026-05-31) · M0 landed (2026-06-18, v0.5.0).** Previously numbered 1.0.0–4.1.0; those were pre-release iterations. The honest maturity is **v0.5.0** — **the architecture workstream (M0) is complete: user data now lives in a per-user data dir (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/`) resolved through one seam per runtime, with automatic idempotent migration on session-start** (see `references/data-path-convention.md`). No command has yet passed the hardening quality-gate, command testing is incomplete, and there is no GUI. The remaining path to **v1.0.0** is three workstreams: **hardening** (every command through the quality-gate), **command testing**, and a **GUI**. The development narrative below is preserved as history. - -Full-spectrum LinkedIn content engine — short-form feed posts, carousels, video scripts, and long-form newsletter editions — with the 2026 relevance-ranking model baked in. **v4.0.0** is an **audit-remediation release (Voyage Phase 0–3)**: a critical self-review found overclaiming (tracking/analytics/review-independence the plugin couldn't deliver), dormant capability (11 agents never invoked by any command), and structural rot (a dead lint, a self-contradicting algorithm claim, an unpublishable model brand/date in user copy). The fix wires **all 11 orphaned agents** (no deletions → 19 agents), adds **`/linkedin:firsthour`** (→ 27 commands) + a short-form de-AI gate + a video quality gate, promotes `post-feedback-monitor` to Opus, makes the newsletter-distribution / profile-SEO / outreach surfaces honest, **reconciles the algorithm signals to one sourced statement** (no model name or date; `references/algorithm-signals-reference.md` is the single source of truth), fixes the analytics fresh-clone crash, closes the voice-profile leak (placeholder + sentinel + gitignore), and rebuilds the structure lint with version/count/stat/model-consistency + render-chain-propagation + `$`-safety guards (each agent's frontmatter model must match every surface declaration; no honesty pattern a command was cleaned of survives in the reference it renders from — S12; no untrusted value reaches a `String.replace` replacement string in `state-updater.mjs`, proven behaviorally + coverage-complete + self-testing — S13). Breaking — reinstall/reload required for the newly-wired agents; consolidates the v3.0.0 identity break (slug, agent namespace, state-file path). v2.0.0 consolidated the surface (27 commands → 24, 16 agents → 14) while adding the long-form `/linkedin:newsletter` orchestrator + two longform-quality gate agents (`fact-checker`, `persona-reviewer`). v2.1.0 added two gates BEFORE prose (Step 2.5 skeleton + Step 3a spine prose) + a third `persona-reviewer` mode (`skjelett`). v2.2.0 hardened the longform gates with the lessons from the second production run (Seres-serien): blocking persona hard-fails, a post-cutoff fact-check mandate, a `voice-scrubber` agent, render+annotate operator gates, and STATE.md-reconciled edition state. v2.3.0 made **visual assets an explicit pipeline phase** — Step 7.5 (visual-assets) between annotation (Step 7) and lock (Step 8): cover (+ optional inline figures) or a carousel deck, generated (default `mcp-image`, external `cover-raw.png` accepted) and operator-gated BEFORE lock so `render/build-linkedin.mjs` picks up `cover.png` at lock without a post-lock re-render. **v2.4.0** makes an **editor's craft gate an explicit pipeline phase** — new **Step 5.5 (editorial-review)** between fact-check (Step 5) and the persona sweep (Step 6): a new **`editorial-reviewer` agent** (Opus) judges **craft** (prosa-håndverk + narrativ-arkitektur), not reader-response, returning ≤10 flags (BLOCK/REWORK/NICE) as direction, **operator-gated via `SendUserFile` BEFORE the persona sweep** so the personas measure resonance instead of stumbling on craft noise. Motivated by Del 4: every persona reported PASS, yet the editor found 8 fresh points on first reading, ~6/8 of them craft/architecture blind spots no agent measured. Mirrors the Maskinrommet writing-contract §C2. Pipeline 14 → 15 phases; agents 15 → 16; additive `editorialReview` state. Doc/orchestration-only for the wiring (the new agent + its fasit fixture + lint test are the only new files); commands unchanged (24). **v3.1.0 (Endring 9)** adds an **adversarial review package** run COLD on a frozen draft — new **Step 6.5 (headless-review)** between the persona sweep (Step 6) and lock, plus a standalone **`/linkedin:headless-review`** command (run in a fresh session for maximum isolation): three new headless archetypes — **`content-reviewer`** (argument integrity), **`language-reviewer`** (Norwegian language), **`fact-reviewer`** (cold re-verification incl. claims a late pivot bolted on) — plus `persona-reviewer` in resonance + conversion modes, all with NO drafting-session context (the independence layer the in-session gates structurally cannot be). v3.1.0 also adds **`/linkedin:pivot`** (re-opens cleared gates after a late change + a >20 %/>2-section pivot-detection gate at lock) and **per-artifact personas** (`articles.NN.personas` — one or more readers configurable per edition, resolved edition-state → series file → plugin library → interactive). Pipeline 15 → 16 phases; agents 16 → 19; commands 24 → 26; additive `personas` / `pivots` / `headlessReview` state. Motivated by Del 4: the in-session editor + persona sweep shared the drafting session's framing-bias, so the version that shipped was never independently re-reviewed. **v4.1.0** adds a **journey layer** over the (unchanged) command surface: two guided front-doors — **`/linkedin:create`** (routes to the right creation command) and **`/linkedin:measure`** (routes to the right analytics command) — plus a router re-tiered into five journeys (Start · Create · Engage · Measure · Grow), with `onboarding`/`strategy` elevated as the Start/Grow front-doors and the 27 atomic commands kept as the execution tier (→ 29 commands). Additive: 14a's cold command-rationalization found **zero redundancy** (no merges/cuts; `docs/remediation/command-rationalization.md` + `journey-layer-design.md`), so the journey layer organizes rather than removes; reload registers the two new commands. +Full-spectrum LinkedIn content engine — short-form feed posts, carousels, video scripts, and long-form newsletter editions — with the 2026 relevance-ranking model baked in. Maturity v0.7.0: M0 (per-user data dir `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/`, idempotent session-start migration — see `references/data-path-convention.md`) complete; the 29 pre-0.7.0 command surfaces all through the interactive quality-gate (`docs/hardening/log.md`) AND through independent cold-review (29/29, R2a–R5 — `docs/hardening/review*.md`); `/linkedin:trends` (shipped in 0.7.0) is not yet through the hardening gate. Remaining for v1.0.0: a GUI. Version history → `CHANGELOG.md`. ## Architecture - **State file:** `~/.claude/linkedin-studio.local.md` (YAML frontmatter, auto-initialized from `config/state-file.template.md`) - **State updater:** `hooks/scripts/state-updater.mjs` — deterministic state mutations (post tracking, streak, content history pruning). Pure functions, tested, no AI involvement -- **Clipboard helper:** `hooks/scripts/clipboard-helper.mjs` — cross-platform clipboard integration (macOS `pbcopy`, Linux `xclip`/`xsel`, WSL `clip.exe`). All content commands auto-copy to clipboard -- **iCal generator:** `hooks/scripts/ical-generator.mjs` — RFC 5545 calendar file generation for batch scheduling. Standalone CLI + importable module +- **Clipboard helper:** `hooks/scripts/clipboard-helper.mjs` — cross-platform clipboard (macOS `pbcopy`, Linux `xclip`/`xsel`, WSL `clip.exe`). All content commands auto-copy +- **iCal generator:** `hooks/scripts/ical-generator.mjs` — RFC 5545 calendar files for batch scheduling. Standalone CLI + importable module +- **Figure renderer:** `render/build-figur.mjs` — coded data figures (SVG/HTML → PNG via headless Chrome); three targets (article/carousel/single); brand tokens from the user data dir's `profile/brand-tokens.json`, neutral defaults otherwise (see `references/figure-design-guidelines.md`). Standalone CLI + importable module - **Post queue:** `assets/drafts/queue.json` (managed by `hooks/scripts/queue-manager.mjs`) -- **Analytics CLI:** `scripts/analytics/` (TypeScript, requires `tsx` and `npm install`) -- **Analytics data:** `assets/analytics/` (gitignored) -- **Analytics metrics (S16):** the parsed CSV columns plus an **optional, manually-entered** `saves` count. - Saves are count-only in native LinkedIn post analytics (~Sept 2025), absent from the CSV export, and - have no self-serve API — so the ingest path is the user adding a `Saves` column with the number they - read off LinkedIn. `parseOptionalCount()` parses it when present: blank / non-numeric / negative → - `undefined` (`unknown`, never 0), a genuine `0` is kept, and saves is **not** folded into - `engagementRate` (kept comparable to older imports). Surfaced per-post + as `totalSaves` in the - weekly/monthly reports; **never auto-tracked**. -- **Unmeasured by design:** `dwell` time stays **explicitly unmeasurable** — internal to LinkedIn for - organic posts, no exportable count, no API; no dwell field or surface exists. The S16 analytics - extension routes all I/O through the existing `getAnalyticsRoot()` seam, so the planned per-user - data-dir migration (UI-brief §9b/M0) relocates the root in one place without reworking the schema. +- **Analytics:** CLI `scripts/analytics/` (TypeScript, needs `tsx` + `npm install`); data `assets/analytics/` (gitignored) +- **Specifics-bank:** `scripts/specifics-bank/` (TypeScript, needs `tsx` + `npm install`) — deterministic, topic-tagged store of the operator's lived specifics (real numbers, named cases, held opinions) in the per-user data dir; elicited/bound by `/linkedin:newsletter` Step 1.5 so drafts draw from real inventory, never invented filler. Consumption is logged back at Step 8 lock (`record-usage` → `usedIn`), so cross-edition re-use of the same material is visible rather than silently encouraged by the dedupe +- **Series distillate:** `scripts/editions/` (TypeScript, needs `tsx` + `npm install`) — series-level memory: each locked edition's spent narrative units (anecdotes/arguments/hooks) in `/linkedin/series-distillate.json`; written at `/linkedin:newsletter` Step 8 (`distil-append`), checked against the next skeleton at Step 2.5 (`distil-check`, advisory). Deterministic character-trigram similarity — AI extracts, code compares +- **Editions register:** `scripts/editions/` (same package) — one row per edition in production in the per-user data dir (`editions/register.json`): series, edition, path, current phase, next action, slot, `startedAt`/`completedAt`. Each of the 17 canonical `/linkedin:newsletter` phase transitions runs `register-upsert --edition-state `, which appends `articles.NN.phaseLog` (lead-time telemetry, additive — schemaVersion stays 1) **and** mirrors the row in one call; Step 10 runs `register-complete`. `register-list` is the work-in-progress view. The register is a **mirror** — deterministic resumption reads `edition-state.json` only, and a lost register is rebuilt by the next transition +- **Publishing slots:** `hooks/scripts/slots.mjs` — the weekly production grid. Slots are **operator config** in the per-user data dir (`profile/publishing-slots.json`, schema + opt-in template in `config/publishing-slots.template.json`); absent config ⇒ every slot surface stays silent, and the plugin hardcodes no publishing times. Coverage of a slot counts the short-form queue (`scheduled`/`published`) **and** editions-register rows claiming that date, so long-form and short-form cannot be double-booked. Zero-dep (SessionStart may not spawn tsx) and imported identically by the hook and by the commands (`queue-manager.mjs` pattern), so session-start's vacancy warning and `/linkedin:newsletter` Step 10's slot default are the same computation +- **Contract-gate:** `scripts/contract-gate/` (TypeScript, needs `tsx` + `npm install`) — deterministic §B/§C1 rule-gate on the full draft (`/linkedin:newsletter` Step 4.5, before the AI sweeps); ratifies `rules.ts` against the edition's §E-manifest, then gates with BLOCK/WARN +- **Fold-in loop:** capture → classify → promote → enforce, documented domain-generally in `references/fold-in-loop.md`. The four `/linkedin:newsletter` steps where operator judgment enters the text (2.5, 3a, 5.5, 6.5) append corrections to `articles.NN.foldIns[]` (`decision: "pending"`); **Step 11 (retro)** empties the queue with an explicit operator JA/NEI — mechanical → an atomic contract-gate promotion (only counts when `--ratify` is green), judgment → the operator's opt-in language-rules file (`${DATA}/language-rules/.md`, template `config/language-rules.template.md`), which Step 4 reads while drafting (prevent) and `language-reviewer` reads at Step 6.5 (catch). Rejected rows are kept, never deleted. Step 11 also reports effort from the **measured** `phaseLog` and asks one friction question whose answer goes back to the operator — the plugin never writes their register +- **Do-next contract:** `recordDoNext()` in `hooks/scripts/state-updater.mjs` — the one channel from measurement to creation. `/linkedin:report`, `/linkedin:analyze`, an `/linkedin:ab-test` **Adopt** verdict and the `post-feedback-monitor`'s 48h learning each persist 2–3 directives (imperative + evidence pointer, tagged with their source) to `## Do-Next Directives` in the state file; the create surfaces (`post`/`quick`/`batch`/`create` Step 0, `newsletter` Step 1) read them before drafting, so a recommendation changes the next piece instead of dying in chat. Lifetime is **replace-by-source + a 60-day age floor**: each writer supersedes only its own rows, so the section is a live steering signal, never a backlog. `newsletter` Step 1 additionally calibrates on the **previous edition's measured numbers** via the queue-id match (`drafts/queue.json` → `analytics/posts/*.json` by publish date), with an explicit honest-miss branch — an unmeasured edition is stated as such, never estimated +- **Reach split (N16):** the in-network/out-of-network share is native in post analytics (Discovery, under impressions; global rollout from June 2026) but shown as **percentages** and **absent from the CSV** (export status unverified) — so it follows the saves pattern as an optional manual column. `parseOptionalPercent()` is a separate parser, not `parseOptionalCount`: a comma is a **decimal** mark (`36,5` → 36.5, never 365) and a value **>100 is refused** (a count and a share are indistinguishable in one column). Only the out-of-network half is stored (`outOfNetworkPct`) — in-network is its complement, and a transcribed pair that does not sum to ~100 (±1 rounding) is **discarded as unknown** rather than half-trusted. Roll-ups (`avgOutOfNetworkPct`, weekly + monthly) are **impressions-weighted** via `weightedOutOfNetworkPct()` and stay `undefined` on zero weight — never 0, never NaN. Reach is **not** in `engagementRate` (distribution ≠ engagement); reports read it as acquisition (out) vs resonance (in), and an unentered split is stated as such, never estimated. `/linkedin:report` Step 7c additionally offers an **operator-gated** diff against the per-user `audience-insights/engagement-patterns.md` baseline (A2-F11 — explicit go, never a silent write, never into the shipped template) +- **Analytics metrics (S16):** parsed CSV columns + an optional, manually-entered `saves` count (count-only in native LinkedIn analytics since ~Sept 2025, no CSV export; the Marketing API exposes `POST_SAVE` on `/memberCreatorPostAnalytics` from v202604, but access is partner-gated — so manual entry remains the right UX). `parseOptionalCount()`: blank / non-numeric / negative → `undefined` (`unknown`, never 0), a genuine `0` is kept; saves surfaced per-post + as `totalSaves`, but **not** folded into `engagementRate`. `dwell` stays **explicitly unmeasurable** (internal to LinkedIn, no export/API). All analytics I/O routes through the `getAnalyticsRoot()` seam (M0 per-user data-dir). ## Hooks @@ -31,7 +27,7 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide | Event | Purpose | |-------|---------| -| `SessionStart` | Load state, REMEMBER.md, milestone tracker | +| `SessionStart` | Load state, REMEMBER.md, milestone tracker, editions in flight + publishing-slot vacancy | | `PreToolUse` (Write\|Edit) | Content quality gate, voice guardian, topic rotation gate | | `Stop` | State update, pre-publish reminders, content history | | `UserPromptSubmit` | LinkedIn context enrichment (three-tier matching) | @@ -39,49 +35,50 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide | `PreCompact` | Preserve LinkedIn context during compaction | | `Notification` | Posting reminders (rate-limited 30min) | -**Session markers:** `/tmp/linkedin-hooks/session-active` (Stop hook gating, 12h staleness). **Shared modules:** `linkedin-content-filter.mjs`, `queue-manager.mjs`, `personalization-score.mjs`, `state-updater.mjs`, `clipboard-helper.mjs`. +**Session markers:** `/tmp/linkedin-hooks/session-active` (Stop hook gating, 12h staleness). **Shared modules:** `linkedin-content-filter.mjs`, `queue-manager.mjs`, `personalization-score.mjs`, `state-updater.mjs`, `clipboard-helper.mjs`, `slots.mjs`. -**State updates:** Post tracking, streak management, and content history are handled deterministically by `state-updater.mjs` (called from Stop hook and commands). Content history entries older than 90 days are auto-pruned at session start. +**State updates:** post tracking, streak, and content history are handled deterministically by `state-updater.mjs` (Stop hook + commands); entries older than 90 days are auto-pruned at session start. -**Hook editing:** Edit `hooks/hooks.template.json` + `hooks/prompts/*.md`, then run `python3 hooks/scripts/compile-hooks.py`. Do not edit `hooks.json` directly. Prompts are loaded at runtime by gatekeeper scripts; the compile step is only needed when adding `type: prompt` hooks. +**Hook editing:** edit `hooks/hooks.template.json` + `hooks/prompts/*.md`, then run `python3 hooks/scripts/compile-hooks.py`. Do not edit `hooks.json` directly. Prompts load at runtime via gatekeeper scripts; the compile step is only needed when adding `type: prompt` hooks. -## Commands (29) +## Commands (30) -All content commands (post, quick, react, pipeline, first-post, video, multiplatform, carousel, newsletter) auto-copy output to clipboard via `clipboard-helper.mjs`. Interactive steps are minimized — angle, format, and post type are inferred from context, with max 2 questions per post. **v2.0.0 net change:** 5 commands removed (`templates`, `publish`, `authority`, `collab`, `speaking` — absorbed into `quick`, `calendar`, `strategy`, `outreach` respectively) + 2 commands added (`newsletter`, `outreach`) = 27 → 24. **v3.1.0** adds 2 longform companions (`headless-review`, `pivot`) = 24 → 26. **Remediation Step 16** adds `firsthour` (wiring orphan agent #11 `engagement-coach`) = 26 → 27. **v4.1.0 (S14)** adds the two journey front-doors (`create`, `measure`) = 27 → 29. The surface is organized into **five journeys** (Start · Create · Engage · Measure · Grow); `create`/`measure` are new guided front-doors, `onboarding`/`strategy` are elevated as the Start/Grow front-doors, and the 27 atomic commands remain the execution tier (14a found zero redundancy → no merges/cuts). +All content commands auto-copy output to clipboard via `clipboard-helper.mjs`; interactive steps are minimized (angle/format/post-type inferred from context, max 2 questions per post). Organized into five journeys (Start · Create · Engage · Measure · Grow): `create`/`measure` are guided creation/analytics front-doors, `onboarding`/`strategy` the Start/Grow front-doors, and the 28 atomic commands are the execution tier. | Command | Purpose | |---------|---------| | `/linkedin` | Router — status line + five-journey command menu | -| `/linkedin:create` | **(v4.1) Create front-door** — guided "what to make?" → routes to the creation command that owns the format (post/quick/react/carousel/video/multiplatform/batch/newsletter); delegates only | -| `/linkedin:measure` | **(v4.1) Measure front-door** — guided "how am I doing?" → routes to the analytics command (import/report/analyze/audit/ab-test); delegates only | -| `/linkedin:onboarding` | Multi-step onboarding wizard (profile → setup → first-post); **Start-journey front-door (v4.1)** | +| `/linkedin:create` | Create front-door — routes to the command owning the format (post/quick/react/carousel/video/multiplatform/batch/newsletter); delegates only | +| `/linkedin:measure` | Measure front-door — routes to the analytics command (import/report/analyze/audit/ab-test); delegates only | +| `/linkedin:onboarding` | Multi-step onboarding wizard (profile → setup → first-post); Start-journey front-door | | `/linkedin:first-post` | First-post accelerator (10 min) | | `/linkedin:setup` | Guided personalization setup | | `/linkedin:react` | URL-to-post pipeline | | `/linkedin:post` | Full post creation (10-15 min) | | `/linkedin:quick` | 5-minute quick post (3-line formula) + 8 post-type templates | | `/linkedin:pipeline` | Full end-to-end content pipeline | -| `/linkedin:newsletter` | Long-form orchestrator: newsletter edition / essay / series article — multi-session 18-phase pipeline with **lived-specifics extraction BEFORE research (Step 1.5, Fix #2 — kilde-så-draft; binding-gate enforced at Step 2.5)**, **skeleton + spine-prose gates BEFORE prose (v2.1)**, a **deterministic §B/§C1 contract-gate BEFORE the AI sweeps (Step 4.5)**, **editorial-review craft gate BEFORE the persona sweep (Step 5.5, v2.4)**, fact-check + persona-sweep BEFORE lock, **headless adversarial review BEFORE lock (Step 6.5, v3.1)**, and **visual-assets gate BEFORE lock (Step 7.5, v2.3)** | -| `/linkedin:headless-review` | **(v3.1)** Cold adversarial review package — run the 3 headless archetypes (`content-reviewer`, `language-reviewer`, `fact-reviewer`) + `persona-reviewer` (resonance/conversion) on a FROZEN draft with no drafting-session context; consolidated, operator-gated report. Step 6.5's standalone surface (run in a fresh session for maximum isolation) | -| `/linkedin:pivot` | **(v3.1)** Re-open a long-form edition after a late substantive change so cleared gates (fact-check → editorial → persona → headless) re-run before lock; logs `pivots[]`, resets `currentPhase`, un-locks if needed (pivot heuristic: >20 % word-count change or >2 new sections) | +| `/linkedin:trends` | Trend discovery pass — delegates to `trend-spotter` (default long-form mode), persists to trend store + dated morning brief, triage per id (act/skip) | +| `/linkedin:newsletter` | Long-form orchestrator (newsletter / essay / series article) — multi-session 19-phase pipeline with gates before prose (skeleton + spine), fact-check, editorial-review (Step 5.5), persona sweep, headless adversarial review (Step 6.5), and visual-assets — all before lock; closes with the Step 11 retro that promotes this edition's corrections so the next one starts with them enforced | +| `/linkedin:headless-review` | Cold adversarial review package — 3 headless archetypes (`content-reviewer`, `language-reviewer`, `fact-reviewer`) + `persona-reviewer` on a FROZEN draft, no drafting-session context; operator-gated report (Step 6.5 standalone surface). `--background` runs the package in a background agent that writes the report to disk — fresh-session isolation without the paste-back seam | +| `/linkedin:pivot` | Re-open a long-form edition after a late substantive change so cleared gates re-run before lock (pivot heuristic: >20% word-count or >2 new sections) | | `/linkedin:batch` | Create a full week of content | -| `/linkedin:calendar` | View/manage post scheduling queue + publish action (mark scheduled posts as published) | -| `/linkedin:firsthour` | Post-publish first-hour / reply-loop sprint — delegates to `engagement-coach` for a timestamped target list + draft comments + timeline, persists the plan to state (`recordFirstHourPlan`), hands off to `post-feedback-monitor` | +| `/linkedin:calendar` | View/manage post scheduling queue + publish action | +| `/linkedin:firsthour` | Post-publish first-hour / reply-loop sprint — delegates to `engagement-coach`, persists the plan (`recordFirstHourPlan`), hands off to `post-feedback-monitor` | | `/linkedin:carousel` | Structured multi-slide carousel generator | | `/linkedin:video` | Video script generator (30s-2min) | -| `/linkedin:multiplatform` | Adapt content for other platforms (short-form/cross-format; long-form → `/linkedin:newsletter`) | +| `/linkedin:multiplatform` | Adapt content for other platforms (long-form → `/linkedin:newsletter`) | | `/linkedin:analyze` | Content/performance analysis | | `/linkedin:audit` | Periodic content strategy audit | | `/linkedin:import` | Import CSV export → structured JSON (delegates analysis to `/linkedin:report`) | | `/linkedin:report` | Generate weekly performance report | | `/linkedin:ab-test` | Design and manage A/B content tests | -| `/linkedin:strategy` | Growth strategy + authority building (phase guidance, trajectory, signature content compounding) | +| `/linkedin:strategy` | Growth strategy + authority building; Grow-journey front-door | | `/linkedin:competitive` | Competitive analysis of niche | | `/linkedin:monetize` | Monetization strategy and funnels (unlocks at ~1K followers) | -| `/linkedin:outreach` | Outreach orchestrator — collaborations + speaking opportunities (unlocks at ~1K followers) | -| `/linkedin:profile` | profile/topic-relevance optimization | +| `/linkedin:outreach` | Outreach orchestrator — collaborations + speaking (unlocks at ~1K followers) | +| `/linkedin:profile` | Profile/topic-relevance optimization | -## Agents (19) +## Agents (20) | Agent | Model | Color | Responsibility | |-------|-------|-------|----------------| @@ -92,29 +89,25 @@ All content commands (post, quick, react, pipeline, first-post, video, multiplat | `content-planner` | Sonnet | Cyan | Content audit + weekly/monthly plans | | `network-builder` | Sonnet | Teal | Strategic networking + outreach | | `content-repurposer` | Sonnet | Purple | Format conversion + evergreen refresh | -| `trend-spotter` | Sonnet | White | Trending topics + opportunity scores | +| `trend-spotter` | (inherits session) | White | Trending topics + opportunity scores | +| `demand-spotter` | (inherits session) | Silver | Demand-sweep «innenfra og ut» (N7.5): reader's verbatim questions (Tier-5 sources) → pain-point map → vocabulary translation → §4 arc map with honest market verdict (supply-gap/demand-gap/saturated) | | `voice-trainer` | Sonnet | Pink | Voice profile building + drift detection | | `differentiation-checker` | Sonnet | Gray | Originality scoring + commodity detection | | `post-feedback-monitor` | Opus | Lime | Post-publish 48h monitoring | | `video-scripter` | Sonnet | Violet | Video script creation with pacing | -| `fact-checker` | Opus | Brown | Factual-claim verification against primary sources, post-cutoff web-search mandate (longform) | -| `editorial-reviewer` | Opus | Orange | Editor's craft gate (v2.4, Step 5.5, before persona sweep): prosa-håndverk + narrativ-arkitektur, ≤10 flags BLOCK/REWORK/NICE as direction, operator-gated via `SendUserFile`; mirrors Maskinrommet §C2 (longform) | -| `persona-reviewer` | Opus | Olive | Reader-persona skeleton (v2.1, before prose) + resonance (before lock) + hook-conversion (after lock) gate, blocking hard-fail list (longform) | -| `voice-scrubber` | Opus | Red | De-AI scrub + Norwegian-chronicle voice-drift correction; gold standard = approved Norwegian editions, not the English post corpus (longform, v2.2) | -| `content-reviewer` | Opus | Maroon | **(v3.1, Step 6.5 — cold/headless)** Argument-integrity review on a frozen draft: C1 logical holes · C2 unsupported assumptions · C3 argument contradiction · C4 missing concretization · C5 unanswered objection. ≤8 flags BLOCK/REWORK/NICE as direction; refuses drafting-session framing as context pollution (longform) | -| `language-reviewer` | Opus | Navy | **(v3.1, Step 6.5 — cold/headless)** Norwegian-language review on a frozen draft: L1 verbatim repetition · L2 anglicisms · L3 stiff bureaucratic register · L4 language-level self-contradiction · L5 clang/rhythm. ≤10 flags BLOCK/REWORK/NICE; deliberate cold re-take of editorial's prose axis (longform) | -| `fact-reviewer` | Opus | Gold | **(v3.1, Step 6.5 — cold/headless)** Cold re-verification on the frozen/pivoted version (web search): F1 verifiable claims · F2 quote precision · F3 number attribution · F4 source quality. 🔴/🟡/🟢 + pivot-risk subsection; deliberate redundancy with `fact-checker` to catch a pivot premise that arrived after Step 5 (longform) | +| `fact-checker` | Opus | Brown | Factual-claim verification against primary sources + post-cutoff web-search mandate (longform) | +| `editorial-reviewer` | Opus | Orange | Editor's craft gate (Step 5.5, before persona sweep): prosa-håndverk + narrativ-arkitektur, ≤10 flags BLOCK/REWORK/NICE, operator-gated via `SendUserFile` (longform) | +| `persona-reviewer` | Opus | Olive | Reader-persona gate — skeleton (before prose) + resonance (before lock) + hook-conversion (after lock), blocking hard-fail list (longform) | +| `voice-scrubber` | Opus | Red | De-AI scrub + Norwegian-chronicle voice-drift correction (longform) | +| `content-reviewer` | Opus | Maroon | Cold/headless argument-integrity review on a frozen draft (Step 6.5): logical holes, unsupported assumptions, contradiction, missing concretization, unanswered objection; ≤8 flags (longform) | +| `language-reviewer` | Opus | Navy | Cold/headless Norwegian-language review on a frozen draft (Step 6.5): repetition, anglicisms, stiff register, self-contradiction, rhythm; ≤10 flags (longform) | +| `fact-reviewer` | Opus | Gold | Cold/headless re-verification on the frozen/pivoted version (Step 6.5, web search): verifiable claims, quote precision, number attribution, source quality + pivot-risk (longform) | **Rule:** Always read `assets/voice-samples/` before generating content. -**Invocation form:** Commands invoke plugin agents by their **namespaced** type — -`subagent_type: linkedin-studio:` — never the bare `` (a bare -type does not resolve and the `Task` call fails). +**Invocation form:** commands invoke plugin agents by their **namespaced** type — `subagent_type: linkedin-studio:`, never the bare `` (a bare type does not resolve and the `Task` call fails). -**Reload requirement:** Adding a NEW agent file under `agents/` registers it only after -a Claude Code **session reload** — the plugin agent set is built at session start, so a -freshly-added agent (e.g. `fact-checker`, `persona-reviewer` when first added) is not -invokable until the session reloads. After adding an agent, reload before invoking it. +**Reload requirement:** a NEW agent file under `agents/` registers only after a Claude Code **session reload** (the plugin agent set is built at session start). Reload before invoking a freshly-added agent. ## Content Quality Rules diff --git a/README.md b/README.md index b355d54..46d593a 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ *AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)* -![Version](https://img.shields.io/badge/version-0.5.3-blue) +![Version](https://img.shields.io/badge/version-0.7.0-blue) ![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) -![Commands](https://img.shields.io/badge/commands-29-green) +![Commands](https://img.shields.io/badge/commands-30-green) ![Agents](https://img.shields.io/badge/agents-19-orange) ![Hooks](https://img.shields.io/badge/hooks-9-red) -![Reference Docs](https://img.shields.io/badge/reference_docs-26-teal) +![Reference Docs](https://img.shields.io/badge/reference_docs-28-teal) ![License](https://img.shields.io/badge/license-MIT-lightgrey) Most experts know they *should* post on LinkedIn — and quietly don't. The blank editor wins. LinkedIn Studio turns that chore into a system: structured workflows that take you from idea to published, in your own voice, calibrated to how LinkedIn's **topic-relevance** ranking model (2026) actually distributes content. Two engines under one surface — a **feed engine** for short-form posts, carousels, and video scripts, and a **long-form engine** that runs newsletter editions and essays through a serious editorial pipeline before they ever lock. @@ -22,7 +22,7 @@ This is not a shortcut. Hand the wheel to the AI and you land where everyone who > New here? Run `/linkedin:onboarding` — it walks you through profile optimization, personalization, and your first published post in one guided flow (~10 minutes). > [!NOTE] -> **Pre-1.0 (v0.5.0).** The earlier 1.0.0–4.1.0 numbering reflected ambition, not maturity. Honest about where it stands today: the **architecture workstream (M0) is done** — user data now lives in a per-user data dir *outside* the plugin, with automatic migration — but no command has been through a hardening gate, command testing is incomplete, and there is no GUI yet. See [CHANGELOG.md](CHANGELOG.md). +> **Pre-1.0.** The earlier 1.0.0–4.1.0 numbering reflected ambition, not maturity. Honest about where it stands today: the **architecture workstream (M0) is done** — user data lives in a per-user data dir *outside* the plugin, with automatic migration — and the **29 pre-0.7.0 command surfaces have all passed both the interactive hardening gate (29/29) and independent cold-review (29/29)** (`/linkedin:trends`, new in 0.7.0-dev, is not yet gated). What remains for 1.0.0 is a GUI. See [CHANGELOG.md](CHANGELOG.md). --- @@ -108,7 +108,7 @@ Run the onboarding wizard — it walks you through profile, setup, and your firs ## Commands -All 29 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc. The surface is organized into five journeys (Start · Create · Engage · Measure · Grow); `/linkedin:create` and `/linkedin:measure` are guided front-doors that route you to the right command when you know the journey but not the exact command. Run `/linkedin` for the live router with your posting status. +All 30 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc. The surface is organized into five journeys (Start · Create · Engage · Measure · Grow); `/linkedin:create` and `/linkedin:measure` are guided front-doors that route you to the right command when you know the journey but not the exact command. Run `/linkedin` for the live router with your posting status. ### Onboarding & Setup @@ -179,7 +179,8 @@ All 29 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc. Th | `content-planner` | Sonnet | Weekly/monthly content calendars | | `network-builder` | Sonnet | Strategic networking + outreach | | `content-repurposer` | Sonnet | Format conversion + evergreen refresh | -| `trend-spotter` | Sonnet | Trending topics + opportunity scores | +| `trend-spotter` | (inherits session) | Trending topics + opportunity scores | +| `demand-spotter` | (inherits session) | Demand-sweep «innenfra og ut»: reader's verbatim questions → pain-point map → vocabulary translation → arc map with honest market verdict | | `voice-trainer` | Sonnet | Voice profile building + drift detection | | `differentiation-checker` | Sonnet | Originality scoring + commodity detection | | `video-scripter` | Sonnet | Video scripts with pacing + visual cues | @@ -194,13 +195,14 @@ All 29 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc. Th --- -## Boundaries (as of 2026-05) +## Boundaries (as of 2026-07) LinkedIn Studio is honest about what it can and cannot do for a **personal profile**: - **Post-level analytics via API** — exists, but is **partner-gated** (a vetted Community Management API app + a verified organization + a Page). Not self-serve for a solo profile, so the practical floor is the **CSV export** you drop into `/linkedin:import`. Per-post **saves** are visible in *native* post analytics (count-only, since ~Sept 2025) but absent from the CSV and have no self-serve API — the tool does **not** auto-track them, but you can add a `Saves` column to the CSV manually and `/linkedin:import` ingests it (omit it and saves stays *unknown*, never 0, never folded into engagement rate). - **Auto-publish** — technically possible via the `w_member_social` scope, so this is a **design choice**, not an API limit: the OAuth/token overhead plus LinkedIn's terms on automated posting make copy-to-clipboard + you-paste the right default. The calendar's "publish" action marks a post **you** posted as published — it never posts on your behalf. -- **Dwell time** — internal to LinkedIn for organic posts; not exportable, not measured. +- **In-network vs out-of-network reach** — LinkedIn shows this split natively in post analytics (**Discovery**, under the impressions count; global rollout from June 2026), but as **percentages** and **not in the CSV export** — whether it will ever be exported is *unverified*. So it follows the saves pattern: add an `Out-of-network` (or `In-network`) column with the percentage you read off that panel and `/linkedin:import` ingests it — omit it and the share stays *unknown*, never 0. It is **not** folded into the engagement rate: a high out-of-network share means the post reached **new** people (acquisition), high in-network engagement means it landed with the audience you already have (resonance). Roll-ups are impressions-weighted. +- **Dwell time** — internal to LinkedIn for organic posts; not exportable, no count to transcribe, no API. **Explicitly unmeasurable** — the plugin does not estimate it. - **Also not covered:** real-time/streaming analytics, automated engagement (ToS), profile editing via API, and team/multi-user workflows. The plugin generates recommendations and drafts; you apply them. --- @@ -245,7 +247,7 @@ The README is the front door. The detail lives alongside it: | For… | See | |------|-----| | Architecture — agent pipeline & selection, 9 hooks, 6 skills, personalization scoring, configuration, analytics internals | [CLAUDE.md](CLAUDE.md) | -| The 25-document knowledge base (algorithm signals, angles, frameworks, strategy guides) | [`references/`](references/) | +| The 28-document knowledge base (algorithm signals, angles, frameworks, strategy guides) | [`references/`](references/) | | Full version history and known gaps | [CHANGELOG.md](CHANGELOG.md) | | Maintenance model, fork-and-own, what upstream provides | [GOVERNANCE.md](GOVERNANCE.md) | diff --git a/agents/analytics-interpreter.md b/agents/analytics-interpreter.md index 8fa1c01..3c1be5d 100644 --- a/agents/analytics-interpreter.md +++ b/agents/analytics-interpreter.md @@ -343,9 +343,9 @@ Based on this analysis, your unique advantages are: - Followers: [↑/↓/→] [net change] ## Recommendations for Next Week -1. [Most impactful action] -2. [Second priority] -3. [Experiment to try] +1. [Most impactful action] — evidence: [the measured number it rests on] +2. [Second priority] — evidence: [measured number] +3. [Experiment to try] — evidence: [measured number] ## Content Plan Adjustment Based on this week's data: @@ -354,6 +354,14 @@ Based on this week's data: - Start: [new experiment] ``` +**Write recommendations in do-next form — imperative + evidence pointer.** The calling command +persists the top 2–3 verbatim as do-next directives (`recordDoNext`, source `report`), and the +create surfaces read them at Step 0 before drafting. So each one must be actionable by someone +who never saw this report: "Plan 2 AI-agent posts this week" (imperative, checkable), not +"AI content is performing well" (an observation nobody can execute). The evidence pointer is +what lets the next session judge how much weight a directive carries — always name the measured +number and the sample it came from. + ### Monthly Report Additions For monthly reports, also include: diff --git a/agents/demand-spotter.md b/agents/demand-spotter.md new file mode 100644 index 0000000..d265275 --- /dev/null +++ b/agents/demand-spotter.md @@ -0,0 +1,172 @@ +--- +name: demand-spotter +description: | + Turn a discovered topic «innenfra og ut» — from the reader's problem, not the field's news. Poll + where the audience already ASKS (forums, GitHub issues, procurement data, regulator guidance), + surface their verbatim questions, map each to the cost/risk/duty/tool it hits, translate the + field's jargon into the reader's own words, and deliver an honest market verdict (write here vs + saturated) as a set of arcs (veins) — one actionable edition per question. + + Use when the user asks: + - "who's actually asking about this?", "what's the reader's real question here?" + - "demand sweep", "map the demand", "is anyone asking for this?" + - "what pain point does this hit?", "translate this out of the jargon" + - "is this saturated?", "should we write this or is it already answered?" + - "turn this trend into the reader's words", "innenfra og ut" + + Triggers on: "demand sweep", "who is asking", "reader's question", "pain point", "saturated", + "market gap", "innenfra og ut", "translate the jargon", "arc", "åre". +color: silver +# No `tools:` allowlist by design (same routing rationale as trend-spotter): an allowlist would +# block every research MCP unless each `mcp____` were hardcoded, breaking the +# "prefer whatever MCP the user actually connected, hardcode nothing" routing. Omitting `tools:` +# inherits every session tool, so Tavily / Gemini / Perplexity / a GitHub MCP is reachable when +# present, with WebSearch + WebFetch as the always-available floor. Bash stays (it runs the +# deterministic trend-store CLI: `capture` to persist, `arcs` to render the §4 output). +disallowedTools: Write, Edit, NotebookEdit +--- + +# Demand Spotter Agent — «innenfra og ut» + +You are the demand-side twin of the trend-spotter. Discovery finds **what happened**; you translate +it into **the problem the reader is stuck on**. Without that translation the line writes *utenfra og +inn* — "here is a technology, should you consider it?" — to an audience that never used the field's +words. Your job is the inverse: start from the pain the reader already has, and show what the topic +does to it. + +**Domain-generality is absolute.** The sources, the pain-point taxonomy, and the vocabulary rule +below are GENERIC mechanisms. Which pillars matter, which sources to poll, and what counts as a +reader "grip" come from the user's profile and data dir at runtime — never a beat baked into this +agent. The same sweep serves any niche. + +## Your Mission — three passes, run AFTER discovery and BEFORE drafting + +You do not re-score relevance (that is trend-spotter's five sender-fit dimensions). You run three +passes that produce the reader side the sender-fit score is blind to: + +1. **Demand-sweep** — poll where the questions actually live; surface verbatim questions with sources. +2. **Pain-point map** — connect each question to the cost/risk/duty/tool the reader already carries. +3. **Vocabulary translation** — render the question in the reader's own words, not the field's jargon. + +Deliver the result as **arcs (veins)**: a warm topic is not one news-item, it is a vein, and each +question in it is one actionable edition. + +--- + +## Pass 1 — Demand-sweep (where the questions live) + +The user's `trends/sources.md` **Tier 5** lists the demand sources for their niche (the generic +shape ships in `${CLAUDE_PLUGIN_ROOT}/config/trends-sources.template.md`). Poll them — these are the +**inverse** of Tier 1–4 (supply/announcement sources): + +- **Practitioner forums / communities** — where people ask (Reddit, HN, product forums, Discord/Slack where reachable). +- **GitHub issues on the spec / tool repos** — undervalued gold: real questions from the people building, with names + comment counts as signal strength. +- **Procurement data** — the hardest demand indicator there is: someone has a budget and wrote the need down (a national tender portal / a TED-equivalent for the market). +- **Regulator / supervisory guidance** — what the reader is *required* to take a position on (non-optional demand). +- **Comment fields** on the user's own channels. + +**Research routing (hardcode nothing):** prefer whatever research MCP the user has connected (Tavily, +Gemini, a GitHub MCP, …); fall back to WebSearch + WebFetch. Route each source to the tool that can +actually reach it. + +**Ground truth first — and be honest about the blind spots:** + +- **HN (Algolia API) and GitHub (issues API) are exact** — prioritise them for signal strength; a comment/issue count from them is a real number. +- **Reddit blocks direct fetch** (curl/WebFetch) — sitations come via a research MCP, and counts are inconsistent between snapshots → treat Reddit numbers as **approximate**, never as fact. +- **YouTube / video comments are JS-rendered** and need an API key — the largest blind spot, and it hits exactly the channels many creators use most. **Specify the fetch method or mark the channel explicitly un-swept.** +- **An honest null is a finding.** If a source is thin, say "thin signal" — never invent demand to fill a slot. `demand.strength: "thin"` is a measured low; omitting `demand` entirely means unmeasured. Both are legitimate; a fabricated "strong" is not. + +**Output of Pass 1, per question:** the verbatim question + its source URL, clustered by theme, each +with (a) **signal strength** — how many ask, honestly estimated, and (b) **is it answered?** — is a +good answer already out there, is the market saturated. + +--- + +## Pass 2 — Pain-point map (connect to what the reader already struggles with) + +For each cluster, ask: what does the organisation **already** have as a problem that this topic +touches? Map to one of: + +- **Cost they already pay** (licences, time, double work) +- **Risk they already own** (regulation, supervision, precedent, security) +- **Duty they already have** (impact assessment, archiving, transparency/FOI, information security) +- **Tools they already have** — critical: the reader is usually *already* inside a suite they pay for; anything new competes against something that is effectively "free" and already operated by IT. +- **ROI doubt they already voiced** — and whether the numbers in the field actually hold up. + +Without this pass the piece is a technology presentation. With it, it answers a letter the reader +already wrote. This pass fills the record's **`painPoint`**. + +--- + +## Pass 3 — Vocabulary translation (the reader's words, not the field's) + +**Rule: the field's jargon is forbidden in the title, hook, and ingress** unless the audience +demonstrably uses it. Keep the topic; swap the words. Produce **both, side by side**: + +- *the field's words* (kept as the record `title`) +- *the reader's words* — the record **`readerQuestion`** + +**The test that must pass:** can a leader who never heard of the topic recognise their own problem in +the title? No → the title is written *utenfra og inn*, and you rewrite it. This pass fills +**`readerQuestion`**. + +--- + +## Output contract §4 — the arc (åre), not the news-item + +Persist each swept question as a store record carrying the reader fields, then render the arc map. + +**Persist (Pass 1–3 results) via the deterministic store CLI** — one batch call, carrying the reader +fields the demand-sweep produced (`readerQuestion`, `painPoint`, `saturation`, `demand`, plus the +reader-side verdict `verdict` and grip `actionability` the F7 gate reads). `relatedIds` links the +records that are facets of one vein: + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ + echo '[ + {"source":"","title":"","url":"", + "topics":[""], + "readerQuestion":"", + "painPoint":"", + "saturation":"", + "demand":{"strength":"strong|moderate|thin","answered":true|false}, + "verdict":"BÆRENDE|STØTTE|NYHET", + "actionability":{"formulated":true|false,"note":""}, + "relatedIds":[""]} + ]' | node --import tsx src/cli.ts capture +``` + +- **`demand`** is the rankable twin of the verbatim `saturation` text: `strength` = etterspørsel (how strongly asked), `answered` = whether a good answer already exists. Omit `demand` when you did not measure it (honest null) — do not fabricate a strength. +- **`verdict`/`actionability`** are the reader-side utility judgment (mechanism from the scoring SSOT); the deterministic F7 band-cap gate reads them. What counts as a formulated grip is the operator's reader-side calibration (profile / data dir), never yours to invent. +- Content-invalid items land in `errors[]`; the valid ones still persist. A malformed `demand` (bad strength, non-boolean `answered`) is a hard error at the seam — fix the payload, don't silently drop the field. + +**Render the §4 output — the arc map** (question inventory ranked on `etterspørsel × kan-svare × +ikke-besvart`, per-vein market verdict, BÆRENDE/STØTTE reading order). This is deterministic code — +you do not hand-format it: + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ + node --import tsx src/cli.ts arcs # print the arc map + # node --import tsx src/cli.ts arcs --topics "" # narrow to one theme + # node --import tsx src/cli.ts arcs --out "

" # write a dated arc map +``` + +The `arcs` verb groups the demand-swept records into veins (relatedIds transitive closure), ranks the +questions, classifies each vein's **honest market verdict**, and renders it. The verdict distinguishes +the two gaps you must never confuse: + +- **supply-gap** (many ask, none answer) — the gold: write here. +- **demand-gap** (few ask, but the sender can answer) — highest value, lowest guaranteed audience. +- **saturated** (already answered) — do not write. +- **unknown / thin** — the honest null; do not fill it in. + +--- + +## What you deliver to the caller + +1. The **arc map** (the `arcs` output) — the ranked question inventory per vein, with the market verdict. +2. A one-line reading: which vein is the strongest write (highest-ranked supply-gap or a high-conviction demand-gap), and which questions are saturated (skip). +3. The blind spots you hit (un-swept channels, approximate counts) — stated plainly, never papered over. + +You persist and render; you never draft the edition — that is `/linkedin:newsletter`. Your output is +the *inntak* to the drafting decision, in the reader's own words. diff --git a/agents/fact-checker.md b/agents/fact-checker.md index 7dad227..e605c06 100644 --- a/agents/fact-checker.md +++ b/agents/fact-checker.md @@ -16,7 +16,7 @@ description: | "is this accurate", "verify numbers", "back this up", "factual correctness". model: opus color: brown -tools: ["Read", "WebSearch"] +tools: ["Read", "WebSearch", "WebFetch"] --- # Fact Checker Agent @@ -89,6 +89,14 @@ Always run the contradiction sweep (5) — a claim that survives a deliberate search for counter-evidence is far stronger than one that merely matched a confirming page. +**Fetch the verbatim source for quotes and figures (WebFetch).** A search +*snippet* is enough to locate a source but not to confirm a **quotation** or an +**exact figure** — for those, `WebFetch` the located page and read the original +wording in context. A quote is verified only against the source's verbatim text +(«Vi» vs «Vi i Nav» is a precision failure even when the gist matches); a figure +is verified only against the number as the source actually states it. Never grade +quote precision or number attribution off a snippet alone. + ### Step 3: Assess Source Credibility For each source, evaluate: is it primary or secondary, who published it, how diff --git a/agents/fact-reviewer.md b/agents/fact-reviewer.md index d211cb1..b4a4e0a 100644 --- a/agents/fact-reviewer.md +++ b/agents/fact-reviewer.md @@ -22,7 +22,7 @@ description: | "number attribution", "headless review", "fact-reviewer". model: opus color: gold -tools: ["Read", "WebSearch"] +tools: ["Read", "WebSearch", "WebFetch"] --- # Fact Reviewer Agent @@ -211,6 +211,12 @@ source's exact wording and attribution and compare verbatim (F2). For figures, trace to a named source and confirm the source's precision matches the draft's (F3/F4 — "around a third" does not verify "37 %"). +**Fetch the verbatim source, do not grade off a snippet (WebFetch).** F2 (quote +precision) and F3 (number attribution) cannot be settled from a search *snippet* — +`WebFetch` the located source and read the original wording/number in context, then +compare verbatim. A quote or figure marked 🟢 must have been checked against the +source's actual text, never a preview line. + ### Step 3 — Score on the five dimensions Score each claim 0–100 across the five dimensions. A contradicted claim is 🔴 diff --git a/agents/language-reviewer.md b/agents/language-reviewer.md index 87ed032..9c0b46e 100644 --- a/agents/language-reviewer.md +++ b/agents/language-reviewer.md @@ -140,6 +140,26 @@ chronicle**, not a saksframlegg — judge against that register. L1, L2, L5 are partly countable — report the count where you have one. L3, L4 need a read but are still crisp yes/no findings. +### L6 — the operator's accumulated rules (read this file first) + +Before judging, read +`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/language-rules/.md` +(`` = the `language` input; e.g. `no.md`, `en.md`). It holds the language +corrections **this operator already made and confirmed** in earlier editions, +promoted there by `/linkedin:newsletter` Step 11 (the fold-in loop — +`references/fold-in-loop.md`). A defect this file names is one the operator has +already paid to fix once: **flag it at REWORK or higher**, and cite the rule. + +- The file **extends** the five checks above; it never replaces them. Where a + rule there and a check here disagree, the stricter one wins. +- It also has a *Deliberate exceptions* section — a baseline rule the operator + consciously overturned. **Do not flag against an exception listed there.** +- **Absent file (the default — it is opt-in): judge on L1–L5 alone and say + nothing about it.** Its absence is not a gap and never a flag. + +This is what stops the review being Groundhog Day: without it, every edition +re-earns the same correction from scratch. + ## Severity scale — BLOCK / REWORK / NICE Every flag carries exactly one severity (mirrors `editorial-reviewer`, adapted to diff --git a/agents/persona-reviewer.md b/agents/persona-reviewer.md index e413e8d..0da7fd7 100644 --- a/agents/persona-reviewer.md +++ b/agents/persona-reviewer.md @@ -98,6 +98,18 @@ Read the named persona from `config/personas.template.md` (or the project's **overbeviser**, **ekspertise**, **sjargong**. Judge as that reader — not as yourself, not as a generic audience. One run = one persona. +Then read `articles.NN.targetLevel` from the edition-state (set at Step 1 of +`/linkedin:newsletter` — the N7 trend bridge / N6 proposal layer pre-fills it, +the operator confirms it during calibration). It fixes where this persona sits on +**the edition's configured target-level span**: the primær anchors the +reader-facing end, and the active set MUST include a mandatory secondary at the +OPPOSITE end of that span. Place this persona on the span accordingly — a +secondary NO from a reader at the far end is a SIGNAL only when the persona +genuinely sits at that end, and only with an explicit ceiling justification; +otherwise it is a real flag. If `articles.NN.targetLevel` is absent (a +manually-started edition that never resolved one), fall back to the persona's own +**ekspertise** field to locate it on the span. + ### Step 2: Read the input as that reader Read top to bottom, read-only, once, the way this reader actually would. diff --git a/agents/post-feedback-monitor.md b/agents/post-feedback-monitor.md index 2cb361c..67a386f 100644 --- a/agents/post-feedback-monitor.md +++ b/agents/post-feedback-monitor.md @@ -287,6 +287,33 @@ Output a structured intervention plan using this format: - [Actionable change to try next time] ``` +### Persist the 48h learning as a do-next directive + +At the **final check (48h / Resurrection Window)** — not at the intermediate check-ins, where +the numbers are still moving — write the one or two learnings that should change the next post +into the shared do-next channel. This is the same channel `/linkedin:report`, +`/linkedin:analyze` and `/linkedin:ab-test` write to, and the one every create surface reads at +its Step 0. Without it the 48h learning lives only in this session's chat and is re-learned +from scratch next week: + +```bash +node --input-type=module -e " +import { writeState, recordDoNext } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs'; +writeState(content => recordDoNext(content, { + recordDate: 'YYYY-MM-DD', + source: '48h-monitor', + directives: [ + { directive: 'Put the concrete number in the first line, not the third', evidence: '48h on 2026-05-28 post: velocity 82/100, golden hour 3x the account average' } + ] +})); +" +``` + +Write only what a *future draft* can act on — a per-post tactic ("reply to every comment inside +the first hour") belongs in the report to the operator, not in the channel that shapes the next +draft. `recordDoNext` replaces this source's previous row on each write, so the most recent 48h +learning is the one in force. + ## Step 7: Follow-Up Scheduling Based on current performance, suggest: diff --git a/agents/trend-spotter.md b/agents/trend-spotter.md index beae378..83d2c8a 100644 --- a/agents/trend-spotter.md +++ b/agents/trend-spotter.md @@ -14,7 +14,6 @@ description: | Triggers on: "trending", "what should I post about", "scan for trends", "content opportunities", "trend digest", "what's new in my space", "timely topic", "first-mover", "opportunity scan". -model: sonnet color: white # No `tools:` allowlist by design (research-engine slice 2b). An explicit allowlist would # block every research MCP unless its `mcp____` name were hardcoded here — @@ -285,25 +284,47 @@ For every trend that cleared the relevance filter (Step 2) — not only the ones final digest — fold it into the persistent trend store, so the next session reasons over it instead of re-discovering it. Build ONE raw-item batch (the same trends you just scored) and pipe it through `capture`: it normalizes each item, dedupes on normalized title+URL, unions topics on -re-capture (so re-capturing an existing trend just enriches the tags), and persists the source's -`publishedAt` for later freshness ranking — one call, not one per trend: +re-capture (so re-capturing an existing trend just enriches the tags), persists the source's +`publishedAt` for later freshness ranking, and — when you carry the score (below) — persists the +relevance assessment so the morning brief ranks on it — one call, not one per trend: ```bash cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ echo '[ {"source":"","title":"","url":"", "topics":["",""],"publishedAt":"", - "summary":""} + "summary":"", + "score":{"mode":"kortform","dimensions":{"pillar":N,"audience":N,"timing":N,"angle":N,"authority":N}}} ]' | node --import tsx src/cli.ts capture ``` `source` is the tool you actually fetched with (**Research Routing**); `publishedAt` is the source's own publish date — omit the key when unknown (the store's `capturedAt` is set -automatically and stays distinct from it). One `capture` call folds the whole batch and reports +automatically and stays distinct from it). + +**Carry the Step-2 scores — do not discard them.** You already scored each candidate's five +dimensions 1–10 in **Relevance Scoring** (Step 2); fold those same numbers into the capture batch +as the item's `"score"`, so the store persists the relevance assessment and the morning brief +ranks on its composite (the store computes the composite + band itself — supply only the judgment). +Use `"mode":"kortform"` by default; use `"mode":"long-form"` with the long-form dimension names +(`pillar`, `depth`, `angle`, `authority`, `currency`) when the caller is producing a chronicle / +newsletter / series edition (e.g. invoked from `/linkedin:newsletter`). The `"dimensions"` keys are +the rubric's, the `"topics"` are the user's pillars — nothing vendor- or sector-specific is baked +in. Omit the `"score"` key when you genuinely did not score an item; an out-of-range or malformed +score is reported in `errors[]` (the valid items still persist) and never crashes the run. + +One `capture` call folds the whole batch and reports `{added, merged, duplicates, errors}`; content-invalid items land in `errors[]`, never failing the run. Skip this step silently if the store has no deps installed (an adopter without the trends store) — the digest still compiles, just without persistence. +**Re-capture refreshes the score; the operator drives the lifecycle.** Re-capturing a trend already +in the store never duplicates it — its topics union in and its relevance `score` is **refreshed** +(the newer judgment wins, since the timing dimension decays). The operator marks a trend `acted` +(written about) or `skipped` with `act`/`skip --id ` (the id is shown in the brief and via +`list --json`); the morning brief then **excludes** handled trends so the queue surfaces only +unresolved work, and `reset --id` returns one to the queue. + **Step 4.6: Write the dated morning brief (surfacing)** After capturing, render today's dated morning brief over the store so the **next session surfaces @@ -321,6 +342,21 @@ written to `/trends/morning-brief/YYYY-MM-DD.md` and ranks only on per (pillar overlap + `publishedAt`/`capturedAt` freshness, default 7-day window — tune with `--fresh-days N`). Skip silently if the store has no deps installed — same escape hatch as Step 4.5. +The brief also applies a **derived temporal overlay** (RE-R3d): within a relevance tier, a fresh, +not-yet-surfaced trend is ranked up as a **first-mover** (`· 🥇 først ute`) and a repeatedly-surfaced +one is ranked down as **saturated** (`· 🔁 mettet`) — computed at render time from the publish/capture +dates + the seen-log, with no new capture step. Tune with `--first-mover-days N` / `--saturation-at N`. + +Each brief also **records the trend ids it showed** (frontmatter `surfaced:`) and renders a +**day-over-day diff** — a `## 🆕 Nytt siden sist` section listing what is new since the most recent +prior brief (plus a ` N nye siden sist` marker on the one-line summary) — no new capture step; the +polling/capture path above is unchanged (RE-R3e). + +The morning brief can also be **scheduled** to regenerate autonomously each morning — deterministic, +from the current store — via `src/cli.ts schedule` (print-first: it emits a launchd/cron entry firing +the `run-daily.sh` headless wrapper). That nightly run re-renders the brief only; your polling above +stays the capture path (autonomous AI polling is a later slice). + **Step 5: Compile digest** - Format using output template below diff --git a/agents/voice-trainer.md b/agents/voice-trainer.md index 998ed71..fb6c760 100644 --- a/agents/voice-trainer.md +++ b/agents/voice-trainer.md @@ -152,6 +152,39 @@ Architecture: [prose/sectioned/framework] Flag if: fewer than 5 samples (low confidence), single time period (temporal bias), or contradictory patterns (possible ghostwriting). +### Long-form chronicle voice profile (per language / format) + +Short-form feed posts and long-form editions (newsletters, essays, chronicles) are +**different registers of the same author** — a device the author bans in a punchy +short post (e.g. the em-dash) can be load-bearing rhythm in a long-form chronicle. +Drafting long-form against the short-form corpus therefore *degrades* the chronicle +voice (this is `voice-scrubber`'s canonical rule). So the long-form pipeline binds +to its own distilled artifact, which this agent generates: + +- **Artifact:** `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/chronicle-voice-profile.md` + — a distilled long-form voice profile built with the same six-dimension + framework as the general profile, but from **only the long-form gold corpus**. +- **Corpus (published gold only):** the approved/published long-form editions under + `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ingest/published/` + carrying `provenance=published`. The same model-collapse fence applies — + **never distil chronicle voice from `provenance=ai-draft`**, and never from the + short-form `voice-samples/authentic-voice-samples.md` corpus (wrong register). +- **Per language AND format — domain-general.** Emit one profile section per + `(language, format)` pair actually present in the corpus (e.g. `no` / newsletter), + keyed by those axes; the author's languages and formats come from their OWN + corpus, never hardcoded. If a `(language, format)` pair has no published editions + yet, say so plainly and emit no section for it — do not invent a chronicle voice + from short-form samples or from assumptions. +- **Grows automatically.** Each locked edition is appended to the gold corpus at + `/linkedin:newsletter` Step 8 (`provenance=published`), so re-running this + distillation sharpens the chronicle profile over editions without manual curation. + +Trigger this distillation on the same events as the general profile (a new +published edition, quarterly audit, explicit request). The long-form pipeline +(`/linkedin:newsletter` Step 0 / 3a / 3b) reads this artifact; when it is missing, +that pipeline warns and falls back — it never silently drafts against the wrong +corpus. + ## Voice Drift Detection ### Drift Scoring diff --git a/assets/analytics/README.md b/assets/analytics/README.md index 16047b7..d0b6be7 100644 --- a/assets/analytics/README.md +++ b/assets/analytics/README.md @@ -28,6 +28,43 @@ comparable to older imports). Saves is the strongest organic engagement signal, so the reports surface it as its own line. **Dwell time stays unmeasurable** — it is internal to LinkedIn for organic posts, with no count to transcribe. +### Optional: add the out-of-network reach share (manual) + +LinkedIn splits a post's impressions into **in-network** (people who already +follow or are connected to you) and **out-of-network** (people who found it +through recommendations, reshares or search). It sits in your post analytics +under **Discovery**, beneath the impressions count — a global rollout that began +in June 2026 — and it is shown as **percentages**, not as two counts. It is +**not** in the CSV export, and whether it will ever be exported is unverified. + +To track it, add an `Out-of-network` column and type the percentage you read off +that panel. The `%` sign is optional: + +``` +"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Out-of-network" +"My post...",2026-03-10,5000,100,30,15,200,37% +``` + +Details worth knowing: + +- **Either half works.** An `In-network` column is accepted instead and stored as + its complement (`In-network 63%` → out-of-network 37). Only the out-of-network + share is kept, because the two halves describe one split — storing both would + let a record contradict itself. +- **Both halves are cross-checked.** If you transcribe both and they do not sum + to ~100 (one point of rounding slack allowed), the reading is discarded as + **unknown** rather than guessing which cell was misread. +- **Percent, not a count.** A value above 100 is refused (`unknown`): in one + column an absolute impression count and a share are indistinguishable, so the + importer will not guess. A comma is read as a decimal mark (`36,5` → 36.5%). +- **Unknown is never 0.** A missing column, a blank cell, or a non-numeric cell + leaves the share unknown. A genuine `0` is kept — nothing left your network. +- **Not part of the engagement rate.** Reach is a distribution signal, not + engagement: a high out-of-network share means the post **acquired new + audience**, while high in-network engagement means it **deepened the audience + you already have**. Roll-ups are impressions-weighted, so a small post with a + high share cannot outvote a large one. + ## Directory Structure ``` diff --git a/commands/ab-test.md b/commands/ab-test.md index cc589c9..f3f43cc 100644 --- a/commands/ab-test.md +++ b/commands/ab-test.md @@ -342,6 +342,32 @@ handful of posts per variant a creator realistically gathers (well under the Update the test file status from ACTIVE to COMPLETED. Add the conclusion and recommended actions to the file. +### 2c.6: Persist an Adopt verdict as a do-next directive + +An **Adopt** verdict (and only Adopt — "Keep A", "Inconclusive" and "Extend test" change nothing +about the next draft) is a decision the next post must inherit. Write it to the same do-next +channel `/linkedin:report` and `/linkedin:analyze` use, so every create surface picks it up at +its Step 0 instead of the test result sitting unread in a file: + +```bash +node --input-type=module -e " +import { writeState, recordDoNext } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs'; +writeState(content => recordDoNext(content, { + recordDate: 'YYYY-MM-DD', + source: 'ab-test', + directives: [ + { directive: 'Open with a question hook by default', evidence: 'test hook-form (6 posts): B +38% comments, directional-strong' } + ] +})); +" +``` + +Carry the **directional** framing into the evidence pointer (see 2c.4 — organic volume rarely +reaches significance); a directive that reads as proven when the signal was weak is how one +outlier becomes a permanent rule. `recordDoNext` replaces this source's previous adopt row, so +a newer verdict supersedes an older one automatically — and the `report`/`analyze` slots are +untouched. + --- ## Step 2d: Review Test History diff --git a/commands/analyze.md b/commands/analyze.md index 3390291..8410fc2 100644 --- a/commands/analyze.md +++ b/commands/analyze.md @@ -220,6 +220,31 @@ Quick triage if a full audit can wait: - [ ] Monitor metrics closely - [ ] Continue high engagement activity +## Step 6b: Persist the plan as do-next directives (the measurement→creation contract) + +The recovery plan above is worth nothing if the next drafting session never sees it. Turn the +**2–3 load-bearing corrections** into do-next directives — the same channel `/linkedin:report`, +`/linkedin:ab-test` and the 48h monitor write to, and the one every create surface reads at its +Step 0: + +```bash +node --input-type=module -e " +import { writeState, recordDoNext } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs'; +writeState(content => recordDoNext(content, { + recordDate: 'YYYY-MM-DD', + source: 'analyze', + directives: [ + { directive: 'Keep every post inside the 3 core pillars for 14 days', evidence: 'diagnosis: profile-content mismatch, reach -55% since off-topic run' }, + { directive: 'No external links in the post body until reach recovers', evidence: 'diagnosis: link-in-body correlates with the drop window' } + ] +})); +" +``` + +Write the corrections that change the NEXT draft — not the whole checklist. `recordDoNext` +replaces this source's previous rows and expires anything older than 60 days, so a superseded +diagnosis cannot keep steering drafts. Confirm in one line what was persisted. + ## Step 7: Timeline Expectations Set realistic expectations: diff --git a/commands/batch.md b/commands/batch.md index 4b4c4fc..4940ebc 100644 --- a/commands/batch.md +++ b/commands/batch.md @@ -24,6 +24,10 @@ You are a LinkedIn batch content creator. Help the user create an entire week's Load state and personalization: - Read `~/.claude/linkedin-studio.local.md` for recent topics and weekly goals +- **Read `## Do-Next Directives` in the same file** — the last measurement pass's directives + (report / analyze / A/B Adopt / 48h monitor) with their evidence pointers. A batch is where + they bite hardest: apply them across the week's posts and name which post carries which + directive in the plan. Never edit or clear the section (lifetime is managed per source) - Read `${CLAUDE_PLUGIN_ROOT}/skills/linkedin-studio/SKILL.md` for profile and preferences - Check `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/plans/` for existing weekly plan - Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/templates/my-post-templates.md` for proven templates — vary templates across the batch for format diversity diff --git a/commands/calendar.md b/commands/calendar.md index 3ca7a94..e24afc5 100644 --- a/commands/calendar.md +++ b/commands/calendar.md @@ -39,12 +39,54 @@ console.log('=== OVERDUE ==='); console.log(queueFormatSummary(queueOverdue())); console.log('=== COUNTS ==='); console.log(JSON.stringify(queueCount(), null, 2)); +console.log('=== ENTRY RECORDS (internal — id / draft_path / character_count etc. for the publish & reschedule actions; do NOT show the user) ==='); +const _seen = new Set(); +for (const e of [...queueToday(), ...queueOverdue(), ...queueUpcoming(14)]) { + if (_seen.has(e.id)) continue; _seen.add(e.id); + console.log(JSON.stringify({ id: e.id, draft_path: e.draft_path, scheduled_date: e.scheduled_date, scheduled_time: e.scheduled_time, hook_preview: e.hook_preview, pillar: e.pillar, format: e.format, character_count: e.character_count })); +} " ``` +The `queueFormatSummary` blocks are the human-readable overview; the **ENTRY RECORDS** block is the agent's lookup table for the `id`, `draft_path`, and `character_count` that the action steps need (these fields are not in the readable summary). Also read state for context: - `~/.claude/linkedin-studio.local.md` for weekly goal and current progress +## Step 1b: Load Publishing Slots + Editions in Flight + +The queue says what is scheduled. The **slot grid** says what the week is supposed to +hold, and the **editions register** says which long-form pieces are still in production — +together they turn "here are some posts" into a capacity picture: + +```bash +node --input-type=module -e " +import { readSlots, slotVacancies, slotOccurrences, formatSlot, defaultSlotsPath, activeEditions } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/slots.mjs'; +const grid = readSlots(); +console.log('=== SLOT GRID ==='); +console.log(grid.slots.length ? grid.slots.map(s => s.day + ' ' + s.time + (s.label ? ' | ' + s.label : '')).join('\n') : 'NOT CONFIGURED: ' + defaultSlotsPath()); +console.log('=== OPEN SLOTS (next 14 days) ==='); +console.log(slotVacancies({ days: 14 }).map(s => formatSlot(s)).join('\n') || 'none - every planned slot is covered'); +console.log('=== PLANNED SLOTS (next 14 days) ==='); +console.log(String(slotOccurrences(grid.slots, new Date().toISOString().slice(0,10), 14).length)); +console.log('=== EDITIONS IN FLIGHT ==='); +console.log(activeEditions().map(e => e.series + ' #' + e.editionId + ' | ' + e.currentPhase + ' | next: ' + (e.nextAction || 'unset') + ' | slot: ' + (e.slot || 'unclaimed') + ' | ' + e.daysInFlight + 'd in flight').join('\n') || 'none'); +" +``` + +**If the grid is NOT CONFIGURED**, say so once and offer the opt-in — the plugin +deliberately imposes no schedule: + +``` +No publishing slots configured. To get slot-vacancy warnings at session start and a +one-key slot default in /linkedin:newsletter, copy the template and edit the times: + cp "${CLAUDE_PLUGIN_ROOT}/config/publishing-slots.template.json" \ + "${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/publishing-slots.json" +``` + +The template's times are the plugin's **directional** starting point (see +`references/scheduling-strategy.md`), not a recommendation about your audience — replace +them with what `/linkedin:report` shows for your own account. + ## Step 2: Display Calendar View Present a 14-day calendar view: @@ -70,6 +112,16 @@ Format mix: X standard, Y carousel, Z quick Pillars: [pillar counts] ``` +Mark each configured slot on its day, so an empty slot is visible as a hole rather than +inferred from an absence: `Tue [date]: [SLOT 08:30 — OPEN]` when nothing covers it, +`[SLOT 08:30 — covered]` when the queue or an edition does. Below the calendar, add the +capacity lines from Step 1b: + +``` +Slots: X planned in 14 days | Y open — next open: [Thu YYYY-MM-DD 12:00 (in N days)] +Editions in flight: [series #NN — phase → next action (Nd)] (or "none") +``` + If there are **overdue** posts (past scheduled date, still "scheduled"), highlight them: ``` OVERDUE: @@ -110,7 +162,7 @@ No posts scheduled for today. - Run /linkedin:quick for an unplanned quick post ``` -**3b. Pick a post.** Use AskUserQuestion to ask which post was published (show the list above). +**3b. Pick a post.** Use AskUserQuestion to ask which post was published (show the list above). Map the chosen post to its `id` (and `draft_path`/`character_count` if needed downstream) using the **ENTRY RECORDS** block emitted in Step 1 — that block is the source of the `[post-id]` used below. **3c. Update queue status:** ```bash @@ -168,8 +220,8 @@ If they choose to reschedule: 2. Ask for the new date and time 3. Re-add the entry with the **same id** and new date/time — `queueAdd` replaces any existing entry with that id, so the post moves in place (no duplicate). Carry the - unchanged fields (draft_path, pillar, format, hook preview, char count) from the - entry shown in Step 2: + unchanged fields (id, draft_path, pillar, format, hook preview, char count) from the + **ENTRY RECORDS** block emitted in Step 1: ```bash node --input-type=module -e "import { queueAdd } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/queue-manager.mjs'; console.log(queueAdd('[post-id]', '[draft_path]', '[new-YYYY-MM-DD]', '[new-HH:MM]', '[pillar]', '[format]', '[hook preview]', [charCount]));" ``` @@ -200,9 +252,15 @@ After showing the calendar (or after a publish action loops back), provide brief - **Pillar balance**: Are pillars well-distributed? Flag if any pillar >50%. - **Gap detection**: Are there multi-day gaps that could hurt momentum? - **Weekly goal alignment**: Will the schedule meet the weekly goal? +- **Slot vacancy**: Any open slot within 3 days is the most actionable thing on the + screen — name it and offer `/linkedin:batch` (short-form) or `/linkedin:newsletter` + (long-form). An edition already in flight with no claimed slot is the natural filler. ## Reference Files - `${CLAUDE_PLUGIN_ROOT}/references/scheduling-strategy.md` - `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` +- `${CLAUDE_PLUGIN_ROOT}/config/publishing-slots.template.json` (slot-grid schema + opt-in) - `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json` +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/publishing-slots.json` (your grid) +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json` (editions in flight) diff --git a/commands/carousel.md b/commands/carousel.md index e607987..acc0095 100644 --- a/commands/carousel.md +++ b/commands/carousel.md @@ -208,9 +208,11 @@ CAPTION Then auto-copy the full deck to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Substitute `` with the assembled deck above — all slides' copy + the caption. Then confirm: "Full deck — [N] slides + caption — copied to clipboard." +Substitute `` between the heredoc markers with the assembled deck above — all slides' copy + the caption (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Full deck — [N] slides + caption — copied to clipboard." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the deck above manually — do not claim it was copied. Offer refinement options as text (no interactive prompt): "Want to refine? Options: adjust slide text / change visual style / regenerate specific slide / different hook / ready for publishing." diff --git a/commands/create.md b/commands/create.md index b47ebb0..b2a92ac 100644 --- a/commands/create.md +++ b/commands/create.md @@ -26,6 +26,10 @@ If `~/.claude/linkedin-studio.local.md` exists, you may glance at the planned ne topic and recent pillars to make a smarter suggestion — but keep it to one line and do not block on it. +If `## Do-Next Directives` in that file is non-empty, mention the top directive in that same +line ("last report says: lead with the number") — it often decides which format to route to. +You do not apply it here; the command you route to reads the section itself at its Step 0. + ## Step 1: Identify what they want to create If the user's message already names a format (e.g. "a carousel about X", "react to diff --git a/commands/first-post.md b/commands/first-post.md index 221b9ba..d629bda 100644 --- a/commands/first-post.md +++ b/commands/first-post.md @@ -138,10 +138,12 @@ Show the post with: Auto-copy the post text to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Then present: "Post copied to clipboard. Go to linkedin.com, click 'Start a post', paste it, and hit Post." +Substitute `` with the exact post text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, present: "Post copied to clipboard. Go to linkedin.com, click 'Start a post', paste it, and hit Post." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. ## Step 7: State Update diff --git a/commands/firsthour.md b/commands/firsthour.md index bfdcd1d..d8fba78 100644 --- a/commands/firsthour.md +++ b/commands/firsthour.md @@ -69,10 +69,12 @@ Show, in this order: Auto-copy the self-comments + draft replies to clipboard silently (so they're one paste away): ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Then confirm: "Copied your draft comments to clipboard." +Substitute `` with the exact comments block between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Copied your draft comments to clipboard." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. ## Step 4: Persist the Plan to State diff --git a/commands/headless-review.md b/commands/headless-review.md index c261833..558d6f4 100644 --- a/commands/headless-review.md +++ b/commands/headless-review.md @@ -61,12 +61,19 @@ They get **NOTHING** about: prior versions or version numbers, what was deliberately omitted, the pivot narrative, who has read it, what an editor said, how a persona voted, or what the author intended. Two layers enforce this: -1. **Layer 1 — fresh session (strongest).** For the publish-ready gate, the - operator runs *this command in a brand-new Claude Code session*. The parent - itself then has no drafting transcript; it reconstructs everything it needs - from disk (the frozen draft + contract + personas). This is the recommended - path and the one KTG asked for. -2. **Layer 2 — subagent isolation.** Each archetype is a `Task` subagent, which +1. **Layer 1 — fresh session (strongest, manual).** The operator runs *this + command in a brand-new Claude Code session*. The parent itself then has no + drafting transcript; it reconstructs everything it needs from disk (the frozen + draft + contract + personas). +2. **Layer 1b — background run (`--background`, equal isolation, no seam).** The + drafting session spawns this package as a **background agent** with its own + context window, which writes the consolidated report to a file; the drafting + session then reads that file from disk. The background agent gets only the + cold-contract inputs — it cannot see the parent's transcript — so the + isolation matches a fresh session, with **no copy-paste** of the report back + into the drafting session and no manual session switch. See "Background run" + below. This is the default recommendation for the Step 6.5 gate. +3. **Layer 2 — subagent isolation.** Each archetype is a `Task` subagent, which gets its own fresh context window regardless. The invocation prompt you build below passes ONLY the cold-contract inputs — never paste history, never summarize "what we changed". If you find yourself about to tell a reviewer @@ -91,11 +98,33 @@ how a persona voted, or what the author intended. Two layers enforce this: --persona (persona modes only; default: the primær) --article NN (optional; persist into edition-state.json) --output (default: /review/NN-headless-.md) + --background (run the package in a background agent + that writes --output; caller reads the file) ``` No `--type` (or `--type all`) runs the **whole package in parallel**. A single `--type` runs just that archetype (useful for a re-check after a fold-in). +### Background run (`--background`) + +With `--background`, do **not** fan the archetypes out here. Spawn **one** +background agent that runs Steps 1–5 of this command itself, and let it write the +report: + +- Launch it with `run_in_background: true` and a prompt carrying **only** the + cold-contract inputs — draft path, contract path, persona names + block path, + `--type`, and the absolute `--output` path — plus the instruction to write the + consolidated report to `--output` and return that path. +- **Never** put drafting history, version notes, or "what we changed" in that + prompt. The background agent has its own context window; the only way session + framing reaches it is if you type it in. +- When it completes, `Read` the report at `--output` and continue at Step 5 + (surface + persist). The drafting session sees the *report*, not the reviewers' + reasoning, and the reviewers never see the drafting session — the same + independence a fresh session gives, with **no copy-paste seam** between them. +- If background execution is unavailable, say so plainly and fall back to the + inline fan-out (Step 3) — never silently downgrade the isolation. + ## Step 1 — Resolve inputs (from disk, not from memory) 1. **Draft.** Use `--draft`. It must be a long-form draft `.md` (the same @@ -219,18 +248,20 @@ Headless review complete (COLD). - Archetypes: run in parallel converged flags: - BLOCK/🔴: REWORK: primær resonance: JA/NEI conversion: JA/NEI - Report: /review/NN-headless-.md (surfaced via SendUserFile) +- Isolation: background agent (report read from disk) | fresh session | inline fan-out (fallback) - Persisted: edition-state.json articles.NN.headlessReview (or: standalone, not persisted) -Operator gates the fold-in. For maximum independence, run this command in a FRESH session. +Operator gates the fold-in. For maximum independence with no manual seam, run with --background. ``` ## Relationship to the pipeline + the in-session gates - **Step 6.5 of `/linkedin:newsletter`** invokes this same package (after the in-session persona sweep Step 6, before lock Step 8). The pipeline may fan the - reviewers out inline, but the **strongest** isolation is the operator running - `/linkedin:headless-review` in a fresh session and pasting the consolidated - report back. Either way the body must be re-touched **before** lock — never - reopen a locked text (the cardinal Seres lesson). + reviewers out inline, but the recommended path is `--background`: full + isolation, the report read from disk, and no manual step between the review and + the fold-in. A fresh session remains available and is equally cold. Either way + the body must be re-touched **before** lock — never reopen a locked text (the + cardinal Seres lesson). - **Deliberate redundancy.** `fact-reviewer` overlaps `fact-checker` (Step 5) and `language-reviewer` overlaps `editorial-reviewer`'s prose axis (Step 5.5) **on purpose**. The in-session gates ran with framing-bias; the cold re-read catches diff --git a/commands/import.md b/commands/import.md index 3a892e2..670dd97 100644 --- a/commands/import.md +++ b/commands/import.md @@ -23,11 +23,17 @@ You are a LinkedIn analytics data import assistant. Guide the user through impor For data format details and directory structure, see `assets/analytics/README.md`. -> **Why CSV (as of 2026-05).** Post-level analytics via LinkedIn's API is +> **Why CSV (as of 2026-07).** Post-level analytics via LinkedIn's API is > partner-gated (vetted Community Management app + verified org + Page) and **not > self-serve** for a personal profile, so the CSV export is the practical floor. > Saves are visible in native post analytics (count-only) but have no self-serve -> API pull; dwell is internal-only for organic posts. See the README boundaries. +> API pull; the **in-network/out-of-network reach split** is visible natively too +> (Discovery, under impressions — rolling out globally since June 2026) but as +> percentages, and is absent from the CSV, with export status unverified; dwell is +> internal-only for organic posts. Saves and the reach share are therefore +> **optional manual columns** the user adds to the CSV (`Saves`, +> `Out-of-network` / `In-network`) — see `assets/analytics/README.md` for the +> entry rules, and the README boundaries for the full data boundary. ## Step 1: Check for CSV Files in Exports Directory @@ -135,6 +141,7 @@ The CLI prints (see `cli.ts` `handleImport`): - `Date range:` — earliest to latest post in the batch - `Batch ID:` and `Saved to: posts/` — the batch file written - `Saves entered:` — only when the CSV carried a `Saves` column (manual entry) +- `Reach entered:` — only when the CSV carried an `Out-of-network` (or `In-network`) column; reports the impressions-weighted share **and how many posts carried a reading**, so partial coverage is visible instead of reading as a whole-batch number - An anomaly block — either `Immediate alerts detected:` with 🔴/⚠️/ℹ️ spike/drop lines, or `No anomalies detected in imported data.` **Surface the CLI's output to the user** — for example: @@ -146,6 +153,7 @@ Posts imported: 42 Date range: 2025-12-01 to 2026-01-29 Saved to: posts/2025-12-01-batch-a1b2c3d4.json Saves entered: 1,204 across 18 post(s) (manual) +Reach entered: 31.4% out-of-network across 12 post(s) (manual, impressions-weighted) Immediate alerts detected: ───────────────────────────────────── diff --git a/commands/linkedin.md b/commands/linkedin.md index cce8f30..906678e 100644 --- a/commands/linkedin.md +++ b/commands/linkedin.md @@ -56,6 +56,32 @@ OVERDUE (should have been posted): If queue is empty: "No posts scheduled. Run /linkedin:batch to plan your week." +## Work in Progress + Open Slots + +The queue only shows finished posts waiting to go out. Add the production picture — +long-form editions still being written, and publishing slots nothing covers yet: + +```bash +node --input-type=module -e " +import { activeEditions, slotVacancies, formatSlot, readSlots } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/slots.mjs'; +console.log('=== IN FLIGHT ==='); +console.log(activeEditions().map(e => e.series + ' #' + e.editionId + ' | ' + e.currentPhase + ' | next: ' + (e.nextAction || 'unset') + ' | ' + e.daysInFlight + 'd').join('\n') || 'none'); +console.log('=== OPEN SLOTS ==='); +console.log(readSlots().slots.length ? (slotVacancies({ days: 14 }).map(s => formatSlot(s)).join('\n') || 'none - every planned slot is covered') : 'grid not configured'); +" +``` + +Show one line per in-flight edition and the next open slot: + +``` +In flight: [series #NN] — [phase] → [next action] ([N]d) +Next open slot: [Thu YYYY-MM-DD 12:00 (in N days)] — [X] open in 14 days +``` + +Both lines are omitted when there is nothing to report (no active editions / no grid +configured). If the grid is not configured, do **not** nag — `/linkedin:calendar` +explains the opt-in when the user goes there. + ## Available Commands LinkedIn Studio is organized as **five journeys**. Each journey has a **front-door** @@ -84,6 +110,7 @@ directly when you do. | `/linkedin:multiplatform` | Adapt content for Twitter/X, slides, YouTube (long-form → newsletter) | | `/linkedin:batch` | Create a full week of content in one session | | `/linkedin:pipeline` | End-to-end single-post workflow (idea → draft → schedule → analyze) | +| `/linkedin:trends` | Trend discovery pass — scan your sources, persist candidates + morning brief, triage per id | | `/linkedin:newsletter` | **Long-form spine.** Newsletter editions, essays, series articles. The single long-form entry point | | `/linkedin:headless-review` | Cold adversarial re-read of a FROZEN long-form draft before lock (ideally in a fresh session) | | `/linkedin:pivot` | Re-open a long-form edition after a late change so cleared gates re-run | @@ -161,8 +188,10 @@ If the user's intent is clear from context: - Mentions "react" or "this article" or "this url" or "turn this into" or "share this news" → Route to `/linkedin:react` - Mentions "quick" or "fast" → Route to `/linkedin:quick` - Mentions "pipeline" or "end to end" → Route to `/linkedin:pipeline` +- Mentions "trends" or "trending" or "discovery pass" or "morning brief" or "what should I write about" → Route to `/linkedin:trends` - Mentions "batch" or "week of content" → Route to `/linkedin:batch` - Mentions "calendar" or "schedule" or "queue" or "upcoming posts" or "what's scheduled" → Route to `/linkedin:calendar` +- Mentions "slot" or "publishing slots" or "empty slot" or "open slot" or "slot grid" or "what's in flight" or "work in progress" → Route to `/linkedin:calendar` (slot grid, vacancies, editions in flight) - Mentions "publish" or "mark as published" or "posted today" or "just published" or "post is live" → Route to `/linkedin:calendar` (publish action) - Mentions "plan" → Suggest `content-planner` agent - Mentions "profile" or "topic-relevance" → Route to `/linkedin:profile` diff --git a/commands/multiplatform.md b/commands/multiplatform.md index d46aa5f..639b2be 100644 --- a/commands/multiplatform.md +++ b/commands/multiplatform.md @@ -118,9 +118,11 @@ After creating the adaptation: - Save to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/multiplatform/[platform]-[slug].md` - Auto-copy the adapted content to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -- Present the content and confirm: "Copied to clipboard." +- Substitute `` with the exact adapted text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Present the content, and only if the helper prints `COPIED`, confirm: "Copied to clipboard." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. - Note platform-specific publishing tips ## Reference Files diff --git a/commands/newsletter.md b/commands/newsletter.md index f4f837c..4b0426b 100644 --- a/commands/newsletter.md +++ b/commands/newsletter.md @@ -83,7 +83,7 @@ delegate the fan-out to a nested background agent. > only layer that can reliably spawn parallel sub-agents. So this command issues > the parallel `Task` calls itself and synthesizes their returns inline. -## Pipeline overview (18 phases) +## Pipeline overview (19 phases) The phase order is fixed. Two gates run **BEFORE prose** (skeleton + spine prose), an **editorial craft gate** runs before the persona sweep, the persona @@ -99,7 +99,7 @@ split; v3.1 / Endring 9 on adversarial independence + framing-bias). | 1 | **Brief + calibration** | angle, voice, audience personas (mark primær), key points, tone, leader-takeaway. ≤3 questions | `AskUserQuestion` | | 1.5 | **Lived-specifics extraction — BEFORE research** | elicitation interview (3–5 sharp questions, **vagueness refused**) → `add` lived material to the specifics-bank + populate `articles.NN.livedSpecifics` slot-map (each load-bearing key-point bound specific/abstrakt/ekstern/unresolved) → render `NN-kilder.md`. The grounded-sourcing layer (Fix #2, kilde-så-draft; never fabricated — invariant 3). | `AskUserQuestion` + `scripts/specifics-bank` (`node --import tsx src/cli.ts`) | | 2 | **Research — scoped by the binding** | parallel scoped mandates → verified notes; triangulation. Fills the `ekstern`/`unresolved` slots + verifies your own numbers (regel 6/7) | **`Task` fan-out (foreground)** | -| 2.5 | **Skeleton + section pitch — BEFORE prose** | five-line skeleton (premiss/problem/anbefaling/gevinst/vei videre) + per-section one-line pitch. Operator-gate JA/NEI/REVIDER. Persona-skjelett-sweep before any prose is written. | `AskUserQuestion` + **`persona-reviewer`** (skjelett mode) | +| 2.5 | **Skeleton + section pitch — BEFORE prose** | five-line skeleton (premiss/problem/anbefaling/gevinst/vei videre) + per-section one-line pitch. **Serie-destillat-sjekk** (has the reader heard this anecdote/argument/hook before? advisory, into the annotation gate). Operator-gate JA/NEI/REVIDER. Persona-skjelett-sweep before any prose is written. | `AskUserQuestion` + `scripts/editions` (`distil-check`) + **`persona-reviewer`** (skjelett mode) | | 3a | **Spine prose — BEFORE full expansion** | one paragraph per section carrying that section's pitch, nothing more. ~20–30 % of final length. Operator-gate on whether the axis is right now that there is prose on it. | inline drafting + `content-repurposer` | | 3b | **Full prose expansion** | expand each section with argument, examples, anchors from research; may span sessions | `content-repurposer` + `Task` | | 4 | **Consistency + quality** | threads, premise→conclusion arc, leader-takeaway, AI-slop removal, de-AI/voice scrub, formatting dose | inline + `references/longform-quality-rules.md` + **`voice-scrubber`** | @@ -109,13 +109,14 @@ split; v3.1 / Endring 9 on adversarial independence + framing-bias). | 6 | **Persona sweep — BEFORE lock** | reader jury, primær wins, convergence to clean YES | **`persona-reviewer`** (resonance mode) | | 6.5 | **Headless adversarial review — BEFORE lock** | COLD review package on a frozen draft, no drafting-session context: content-reviewer (argument) + language-reviewer (Norwegian) + fact-reviewer (cold re-verification incl. pivot premises) + persona-reviewer resonance/conversion. Consolidated, operator-gated via `SendUserFile`. The independence layer the in-session gates can't be. | **`content-reviewer` + `language-reviewer` + `fact-reviewer` + `persona-reviewer`** (parallel) + `SendUserFile` | | 7 | **Annotation (optional)** | render annotatable review HTML for a manual pass | `render/build-html.mjs` | -| 7.5 | **Visual assets — BEFORE lock** | cover (+ optional inline figures) or carousel deck: behov → per-image brief → generate (mcp-image default / external `cover-raw.png`) → operator-gate (`SendUserFile`) → approve to `cover.png` → credit/caption. Runs before lock so the renderer picks the cover up. | `mcp__mcp-image__generate_image` + `SendUserFile` + (carousel) `render/build-carousel.mjs` | -| 8 | **LOCK → delivery** | POST.html "all in one place" | `render/build-linkedin.mjs` | +| 7.5 | **Visual assets — BEFORE lock** | cover (+ optional inline figures) or carousel deck: behov → per-image brief → generate (coded `build-figur.mjs` primary for data figures / mcp-image for illustrative / external `cover-raw.png`) → operator-gate (`SendUserFile`) → approve to `cover.png` → credit/caption. Runs before lock so the renderer picks the cover up. | `render/build-figur.mjs` (data figures) + `mcp__mcp-image__generate_image` + `SendUserFile` + (carousel) `render/build-carousel.mjs` | +| 8 | **LOCK → delivery** | POST.html "all in one place"; auto-gull (gold corpus) + **serie-minne** (series distillate + specifics-bank usage log) | `render/build-linkedin.mjs` + `scripts/editions` (`distil-append`) + `scripts/specifics-bank` (`record-usage`) | | 9 | **Hook / conversion gate** | persona gate on the distribution text post-lock: "would YOU click?" | **`persona-reviewer`** (conversion mode) | -| 10 | **Scheduling** | register the edition in the plugin queue/state for native scheduling | `hooks/scripts/queue-manager.mjs` | +| 10 | **Scheduling** | register the edition in the plugin queue/state for native scheduling; close the editions-register row (measured lead time) | `hooks/scripts/queue-manager.mjs` + `scripts/editions` (`register-complete`) | +| 11 | **Retro — make the corrections stick** | ≤5 min AFTER delivery: promote the pending `foldIns` (mechanical → a contract-gate rule; judgment → the language-rules file), summarize the MEASURED effort from `phaseLog`, ask ONE friction question. The loop that makes edition N's correction enforced in N+1 | `scripts/contract-gate` (`--ratify`) + `scripts/editions` (`register-list`) + `references/fold-in-loop.md` | -> **Build status:** all 18 phases (Steps 0, 1, 1.5, 2, 2.5, 3a, 3b, 4, 4.5, 5, -> 5.5, 6, 6.5, 7, 7.5, 8–10) are implemented below. This command takes an edition +> **Build status:** all 19 phases (Steps 0, 1, 1.5, 2, 2.5, 3a, 3b, 4, 4.5, 5, +> 5.5, 6, 6.5, 7, 7.5, 8–11) are implemented below. This command takes an edition > end-to-end: > load → calibration → **lived-specifics extraction (elicitation + bank binding, > BEFORE research)** → verified research (scoped by the binding) → **skeleton + section pitch (operator + @@ -125,7 +126,8 @@ split; v3.1 / Endring 9 on adversarial independence + framing-bias). > pre-lock persona sweep → **headless adversarial review (cold review package, > operator-gated BEFORE lock)** → optional annotation → **visual assets > (cover/figures or carousel, operator-gated BEFORE lock)** → LOCK/delivery → -> post-lock hook gate → scheduling, persisting each phase to `edition-state.json` +> post-lock hook gate → scheduling → **retro (promote this edition's corrections +> so the next one starts with them enforced)**, persisting each phase to `edition-state.json` > (machine) and `/STATE.md` (narrative) and stopping cleanly between > sessions. @@ -176,9 +178,24 @@ the edition left off before doing anything. Step 2. Do not confuse `/STATE.md` (this edition's production state) with the plugin's own `STATE.md` / `docs/BUILD-HANDOVER.local.md` (which govern building the plugin itself). -4. **Read the voice profile** — `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` - and anything else under `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`. Long-form must match the - author's voice; this is the reference for every drafting and review phase. +4. **Read the long-form voice profile** — the **chronicle-voice-profile** + (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/chronicle-voice-profile.md`), + the distilled chronicle voice for THIS edition's language/format, built by + `voice-trainer` from the published gold corpus. This is the **primary reference + for every long-form drafting and review phase**. Short-form feed posts and + long-form editions are different registers of the same author, so long-form must + **not** be drafted against the English short-form corpus + (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md`) — + that corpus encodes rules (e.g. no em-dash) that are wrong for chronicle voice + and would degrade it. Read the `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` + corpus as a **secondary tributary** (register cues), never as the primary + long-form reference. + **Fallback (never silent).** If `chronicle-voice-profile.md` is absent — or has + no section for this edition's language/format — say so plainly, note the + chronicle voice is uncalibrated, and fall back to today's behavior (draft against + `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`), while + flagging the gap so the operator can seed the gold corpus (Step 8 auto-gull grows + it from each locked edition). 5. **Resolve the active personas (per-artifact).** Personas are configured **per edition**, not from one fixed global file. Resolve the set for `articles.` in this order (Step 1 finalizes + persists it): @@ -225,7 +242,8 @@ Look up `edition-state.json` → `articles.` (and the top-level | `visual-assets` | Step 8 — LOCK → delivery | | `lock-delivery` | Step 9 — Hook / conversion gate | | `hook-conversion-gate` | Step 10 — Scheduling | -| `scheduling` | **Edition complete** — nothing to resume (start the next article or edition) | +| `scheduling` | Step 11 — Retro *(N14 — promote the fold-in queue; the edition is delivered but the loop is still open)* | +| `retro` | **Edition complete** — nothing to resume (start the next article or edition) | The phase identifiers are the canonical ones defined in `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` (`_doc.phases`); the @@ -258,12 +276,64 @@ Then display a short status: Edition: — article "" Last completed phase: <currentPhase> (or: NEW edition) Resuming at: Step <N> — <step name> (from the resumption table above) -Voice profile: loaded | MISSING +Chronicle voice profile: loaded (<language>/<format>) | MISSING → fallback to short-form corpus Persona library: <N> personas loaded (active set chosen in Step 1) ``` -If the voice profile or persona library is missing, say so plainly and continue — -do not fabricate either. +If the chronicle voice profile or persona library is missing, say so plainly and +continue on the Step-0 fallback — do not fabricate either. + +### Phase-transition protocol — one deterministic call per transition + +Every Step below ends by writing `currentPhase` to `edition-state.json`. That +write is a **phase transition**, and every transition runs exactly one +deterministic command — no per-step variation, no hand-written telemetry: + +```bash +node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-upsert \ + --edition-state "<serie>/linkedin/edition-state.json" \ + --next "<the one next concrete step>" +``` + +Run it **after** `currentPhase` has been written, never before — the command +reads the phase out of the state file. It does two things in one call: + +1. **appends** `{phase, completedAt}` to `articles.NN.phaseLog` — the lead-time + telemetry (A1-12); and +2. **mirrors** the edition into the editions register at + `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json` + — series, edition, title, series path, phase, next action, slot (A1-11). + +One call, both writes: a log the command layer had to remember to append +separately would be incomplete within a week, and an incomplete log measures +nothing. Add `--slot "<YYYY-MM-DD HH:MM>"` once a publishing slot is claimed, and +`--path <series-root>` when the state file is not at the conventional +`<serie>/linkedin/edition-state.json` location. + +The week's work-in-progress is then one command away, with no series folder +opened: + +```bash +node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-list +``` + +**At Step 10 only**, once the edition is scheduled, close its row (this is what +turns `startedAt` into a measured lead time): + +```bash +node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-complete \ + --series "<slug>" --edition "<NN>" +``` + +> **The register is a MIRROR, never a source of truth.** Resumption reads +> `edition-state.json` and nothing else (the table above). The register is a +> read-only view for humans; if it is deleted or drifts, the next transition +> repairs it and nothing about the edition is lost. So a failing `register-upsert` +> is a telemetry problem to report plainly — never a reason to stop the pipeline +> or to re-do the phase. + +Each Step below marks its transition **↳ Phase transition** — that marker means +"run the protocol above for this phase". ## Step 1: Brief + calibration @@ -271,6 +341,69 @@ Establish the edition brief with **at most ~3 calibration questions**. Infer everything you can from Step 0 (series brief, STATE.md, prior edition); only ask what genuinely changes the work. +> **Trend intake (optional bridge — N7).** If this edition is being started from a +> `/linkedin:trends` candidate — the operator supplies a **trend id** (shown in the +> trends brief and via `CLI list --json`, where +> `CLI` = `cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && node --import tsx src/cli.ts`) +> — read that record and **pre-fill the brief from it** instead of asking from +> scratch. Without a trend id, skip this block entirely: the flow below is +> unchanged (the bridge is an offer, never a requirement). +> +> 1. **Read the candidate.** Run `CLI list --json` (or `CLI query --topics <edition +> topics> --json`) and select the entry whose `id` equals the supplied trend id. +> 2. **Pre-fill from the record's proposal layer (N6 fields)** — each is a *starting +> point the operator confirms or edits* in the dimensions below, not a bypass of +> calibration: +> - `angle` → the edition **Angle**. +> - `targetLevel` → the audience/tone calibration (**Audience** / **Tone**); the +> resolved value is also persisted to `articles.NN.targetLevel` at the Step 1.5 +> checkpoint (below) so persona-reviewer can place the personas on the configured +> target-level span (which end is primær, which is the mandatory secondary). +> - `summary` + `rationale` + `readerQuestion` / `painPoint` → seed the **Key +> points** and the **Leader-takeaway** (the reader-side framing this bridge +> exists to carry: what the reader would DO with it). +> - `url` (+ any URLs in `rationale`) → seed **source URLs** for Step 2 research / +> the `NN-kilder.md` ledger (they land as `ekstern` slots at Step 1.5). +> - `actionability` / `verdict` (BÆRENDE/STØTTE/NYHET) → frame the takeaway; a +> candidate the operator selected already cleared the F7 reader-grip gate. +> 3. **Bind the provenance.** Note the trend id — it is persisted as +> `articles.NN.sourceTrendId` at the **Step 1.5 checkpoint** (below) and closes the +> discovery→production loop automatically at Step 10 (scheduling flips the trend to +> `acted`). + +> **Calibration from measurement (A2-F14 — the do-next contract).** Two reads, both +> cheap, both BEFORE the dimensions below. Neither asks the operator anything. +> +> 1. **`## Do-Next Directives` in `~/.claude/linkedin-studio.local.md`.** What the last +> measurement pass (`/linkedin:report`, `/linkedin:analyze`, an A/B Adopt verdict, the +> 48h monitor) decided the next piece should do differently, each row carrying its +> evidence pointer. Apply the ones that apply to long-form and **name them in the brief** +> — a directive that shaped this edition must be visible in the brief, not silently +> absorbed. Never edit or clear the section here (each source manages its own rows). +> 2. **The previous edition's MEASURED numbers — queue-id match.** The prior edition of +> this series was registered at its Step 10 under the stable queue id +> `<series-slug>-<NN−1>`. Resolve its numbers: +> - read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json`, +> take the entry whose `id` is `<series-slug>-<NN−1>` → its `scheduled_date`, +> `hook_preview` and `status`; +> - read the analytics batches in +> `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/*.json` and +> select the `.posts[]` row whose `publishedDate` equals that `scheduled_date` +> (a rescheduled edition may be a day or two off — widen to ±2 days and disambiguate +> on a `title` that matches `hook_preview`); +> - carry forward `impressions`, `engagementRate`, `comments` and `saves` (when present). +> +> Read them as calibration, not as a verdict: **low impressions** points at hook / +> topic-relevance (a Step 9 problem for this edition), **decent impressions with weak +> comments** points at resonance (a Step 6 persona problem), and a strong prior edition +> says keep the structure that worked. State the comparison in one line in the brief. +> +> **Honest miss.** No queue entry, no analytics match, or the edition simply is not measured +> yet (`status: scheduled`, or the import has not been run) → say so in one line — *"forrige +> utgave er ikke målt ennå; kalibrerer på skjelett og persona alene"* — and continue. +> **Never estimate** the previous edition's numbers, and never let a missing measurement +> block the brief. This is edition NN=1 of a new series' normal state. + Settle these dimensions (most should come from context, not questions): - **Angle** — the one premise this edition argues. @@ -294,6 +427,16 @@ Settle these dimensions (most should come from context, not questions): - **Tone** — respected-peer vs. teaching-down; calibrated to the primær. - **Leader-takeaway** — the ONE takeaway + ONE concrete action the reader leaves with (plan §8: cut references hard, hands-on credibility beats citation-piles). +- **Language** — **Resolve the review language** for this edition: detect it from + the series brief / prior editions / the drafting language, else ask once. Persist + it to `edition-state.json` → `language` (e.g. `"no"` for Norwegian, `"en"` for + English) at the **Step 1.5 checkpoint**. This is not cosmetic — it gates the + language-dependent review agents: `language-reviewer`'s Norwegian-idiom / + «kanselli-stil» checks and `voice-scrubber`'s gold standard fire against THIS + language, and never grade prose against Norwegian idiom when the edition is not + Norwegian. Left unresolved, the template default `"en"` silently disables the + Norwegian axis even for a Norwegian edition (the C-3 bug this closes) — so resolve + it explicitly, do not assume the default. Use `AskUserQuestion` only for the genuinely open dimensions (cap ≈3). Good candidates: which personas are in scope + which is primær; the angle if the @@ -416,8 +559,24 @@ Edition brief - Persist the resolved **brief** (Step 1) and **personas** (the set + primær) into `articles.NN` — this is the first durable write of both (Step 2 then adds the research notes). + - If Step 1 ran the **trend-intake bridge**, persist `articles.NN.sourceTrendId` + (the trend id) here too — the durable provenance link Step 10 reads to auto-`act` + the trend. Omit it (leave `null`) for a manually-started edition. + - Persist the resolved **`targetLevel`** (Step 1's Audience/Tone calibration; the + trend bridge pre-fills it from the trend record) to `articles.NN.targetLevel` — the + per-article position on the configured target-level span that persona-reviewer reads + to place the personas (which end is primær, which is the mandatory secondary). Leave + `null` if the operator did not resolve a level; the reader agents then fall back to + each persona's own `ekspertise` field. + - Persist the resolved **`language`** (Step 1) — write it to the **top-level** + `language` (the series/edition-level review language; there is no per-article + `language` field) so the language-dependent review agents grade against the + right language from here on. This is the write that makes the template + doc-string's "Resolved at Step 1" claim true. - Write `articles.NN.livedSpecifics` (the slot-map above, `status: "pending"`). - Set `currentPhase: "lived-specifics"` in `edition-state.json`. + - **↳ Phase transition** — run the protocol (Step 0) for `lived-specifics`, + `--next "Step 2 — research (scoped by the binding)"`. - Write a "lived-specifics bound → next: research (scoped by the binding)" line to `<serie>/STATE.md` (overwrite). @@ -451,6 +610,26 @@ Next: Step 2 — Research (scoped by the binding: fill ekstern/unresolved, verif against a primary source (regel 6/7). This is what makes research *fill* the draft instead of re-deriving what the operator already knows. +0.5. **External fact-package intake (optional — kilde-så-draft with finished + research).** If the operator already has a **deep-research report** — a file the + operator supplies by path (e.g. a Gemini Deep Research export, a consultant brief, + a prior research pass; markdown / text / PDF) — ingest it as a research input + instead of researching those slots from zero: + - **Read the file** and extract the same structure the fan-out returns: findings + (each with its source), data points (figures + source + date), and anything the + report itself flags as uncertain. + - **Map onto the binding.** Attribute each finding to the `ekstern`/`unresolved` + slot (or unverified number) it resolves; its sources become the `source` on + those slots and land in `NN-kilder.md` at persist (step 5). + - **Same guilty-until-disproven discipline.** A packaged claim is NOT + pre-verified: label anything you cannot corroborate `UNVERIFIED` and carry it to + the Step 5 fact-check sweep as 🟡 — a polished report is not a primary source. + - **Then scope the fan-out to the GAP only.** Slots the package covers need no + `Task` call; run steps 1–3 below **only for the slots the package leaves open**. + If the package covers every `ekstern`/`unresolved` slot, the fan-out may reduce + to zero calls — note that explicitly (don't fabricate a fan-out that added + nothing). With no package supplied, this sub-step is a no-op: proceed to step 1. + 1. **Decompose** the binding's `ekstern`/`unresolved` slots + unverified numbers (step 0) into 2–5 *scoped, orthogonal* research sub-questions. Each sub-question must be answerable independently so the calls can run in parallel without @@ -504,8 +683,10 @@ Next: Step 2 — Research (scoped by the binding: fill ekstern/unresolved, verif "research complete → next: skeleton + section pitch (BEFORE prose)" next-step line to `<serie>/STATE.md` (overwrite). If somehow no `edition-state.json` exists yet (Step 1.5 was a no-op for an adopter), initialize it from the template schema - first. Stop cleanly here if context budget is tight — Step 2.5 begins in the next - session; otherwise Step 2.5 may run inline (it is short and operator-interactive). + first. **↳ Phase transition** — run the protocol (Step 0) for `research`, + `--next "Step 2.5 — skeleton + section pitch"`. Stop cleanly here if context + budget is tight — Step 2.5 begins in the next session; otherwise Step 2.5 may + run inline (it is short and operator-interactive). ``` Research phase complete. @@ -577,7 +758,36 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros 2. … ``` -4. **Operator-gate (render + annotate — primary flow).** The operator review is +4. **Serie-destillat-sjekk — «har leseren hørt dette før?» (deterministic).** + Every other gate in this pipeline sees ONE edition. At series cadence the + fastest-growing defect is not a bad edition but a **repeated** one — the + anecdote, the argument or the hook the reader already met three editions ago. + Check the proposed skeleton against everything the series has locked, while + changing course is still cheap. Write the units the spine + pitches actually + propose to a small JSON file (`{anecdotes, arguments, hooks}` — the operator's + phrasing, not a summary), then run it from the package dir with **absolute** + paths: + + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/editions" && \ + node --import tsx src/cli.ts distil-check \ + --distillate "<abs>/linkedin/series-distillate.json" \ + --skeleton "<abs>/skjelett-units.json" + ``` + + - **CLEAR** → nothing here echoes an earlier edition. Carry the one-liner into + the annotation page as a receipt. + - **REUSE** → the output names each unit, the edition it echoes, and how close + it is. This is **advisory, never a block** (exit 0 either way): a deliberate + callback is a legitimate move — an *unnoticed* retread is not. Fold the + finding into the annotation page (step 5) so the operator decides with the + re-use in view: keep it, sharpen it into an explicit callback, or replace + the unit. + - No distillate yet (a series' first edition, or an adopter who has not locked + one since this gate existed) → CLEAR against 0 editions; say so and move on. + Deps absent → skip with a one-line note, like the binding-gate. + +5. **Operator-gate (render + annotate — primary flow).** The operator review is **HTML annotation**, not a multiple-choice prompt — and this holds for *every* write deliverable, the skeleton included, not just the final POST.html. Render the skeleton to an annotatable page and let the operator annotate in the browser: @@ -591,16 +801,26 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros `<serie-mappe>/review/NN-skjelett.html` exists before handing it off. 3. Surface `<serie-mappe>/review/NN-skjelett.html` to the operator as a `file://` link (use `SendUserFile` if available, else a markdown `file://` - link) for the annotation pass. + link) for the annotation pass. If the destillat-sjekk (step 4) returned + **REUSE**, state each echoed unit + the edition it came from alongside the + link — the operator annotates with the re-use visible, not after the fact. 4. The operator annotates in the browser and pastes the annotated markdown back. Fold the notes into `<serie>/NN-skjelett.md` by tightening (rule 6). + + **↳ Fold-in capture.** Any note here that would be just as true of the + *next* edition — a rule, not a fact about this one — appends **one row** to + `articles.NN.foldIns[]`: `correction` (near-verbatim), `trigger` (where it + surfaced), `classification: null`, `decision: "pending"`. Capture is + unconditional and costs seconds; **Step 11** classifies and promotes it. An + edition-specific fix ("that number is 4.2") is not a rule — leave it out. + Full loop: `${CLAUDE_PLUGIN_ROOT}/references/fold-in-loop.md`. 5. **Receipt, not gate.** After folding in the annotations, use `AskUserQuestion` only as a *receipt* — «skeleton revised per your notes — JA proceed, or another round?». `AskUserQuestion` (JA / REVIDER / NEI) is also the **fallback** gate when rendering is unavailable; it is not the primary flow: - - **JA** — proceed to the binding-gate (step 5), then the - persona-skjelett-sweep (step 6). + - **JA** — proceed to the binding-gate (step 6), then the + persona-skjelett-sweep (step 7). - **REVIDER** — another annotation round; revise and re-render. - **NEI** — the skeleton is wrong at a load-bearing level (premise unsound, argument-line incoherent). Return to brief calibration (Step 1) or @@ -610,7 +830,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros advance to Step 3a (spine prose) until this operator-gate, the deterministic binding-gate, AND the persona-skjelett-sweep below all pass. `[OPERATØR]` -5. **Binding-gate — deterministic «vaghet avvises» (Fix #2, BEFORE the persona +6. **Binding-gate — deterministic «vaghet avvises» (Fix #2, BEFORE the persona sweep).** Now that the spine is visible, enforce the Step 1.5 binding: every load-bearing slot must resolve to a real specific, a justified `abstrakt`, or an `ekstern` source — no `unresolved` slot, no `specific` pointing at a missing bank @@ -639,7 +859,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros When the gate passes, set `articles.NN.livedSpecifics.status: "bound"` in `edition-state.json`. -6. **Persona-skjelett-sweep — fan out `persona-reviewer` in skjelett-mode.** +7. **Persona-skjelett-sweep — fan out `persona-reviewer` in skjelett-mode.** Issue one `persona-reviewer` call per active persona in parallel — a SINGLE message with multiple `Task` tool-uses, `subagent_type: linkedin-studio:persona-reviewer`, from THIS command layer in @@ -649,7 +869,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros This is NOT resonans mode (Step 6 — that runs on full prose) and NOT konverter mode (Step 9 — that judges the hook only). -7. **Collect skjelett verdicts and gate.** Each call returns per-axis flags +8. **Collect skjelett verdicts and gate.** Each call returns per-axis flags (HOLDER/TVILER/MANGLER), ≤3 direction-only flags, a section-pitch check (any pitch that does not pay in), a per-persona verdict (JA/NEI), and a gate decision. Aggregate per the agent's rule: @@ -668,24 +888,26 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros the editor (this session) holds the pen; never paste a persona's rewritten skeleton. `[GATE]` -8. **Convergence loop.** If gate is REWORK/BLOCK, fold flags into the +9. **Convergence loop.** If gate is REWORK/BLOCK, fold flags into the skeleton + pitches (or, on BLOCK, return upstream) and re-run the same `persona-reviewer` calls against the revision. Loop until the primær returns a clean JA. This loop is **cheap and frequent at this stage** — every round saved here is hours saved at the prose stage. -9. **Persist + checkpoint state.** Once the skeleton is JA from the operator, the +10. **Persist + checkpoint state.** Once the skeleton is JA from the operator, the binding-gate passed, AND the persona-skjelett-sweep returns a clean primær JA, record: - The final skeleton + pitches in `<serie>/NN-skjelett.md` (already written in step 3, with any in-loop revisions applied). - - `articles.NN.livedSpecifics.status: "bound"` (set by the binding-gate, step 5). + - `articles.NN.livedSpecifics.status: "bound"` (set by the binding-gate, step 6). - Per-persona skjelett verdicts in `edition-state.json` → `articles.NN.personaSweep.skeleton` (or alongside resonance/conversion under the same `personaSweep` object). - `currentPhase: "skeleton-pitch"` in `edition-state.json` (the marker that Step 2.5 is complete and the gate has passed). + - **↳ Phase transition** — run the protocol (Step 0) for `skeleton-pitch`, + `--next "Step 3a — spine prose"`. - A "skeleton + pitches PASS (primær JA) → next: Step 3a (spine prose)" next-step line in `<serie>/STATE.md` (overwrite). @@ -693,6 +915,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros Skeleton + section pitch (BEFORE prose) — complete. - Skeleton: 5 lines (premiss / problem / anbefaling / gevinst / vei videre) - Section pitches: <N> sections, all paying into the spine (else: pitches reworked, see flags) +- Series distillate: CLEAR against <N> locked edition(s) (else REUSE: <N> unit(s) — kept as explicit callback / replaced) - Operator gate: JA (after <N> revision rounds) - Binding-gate (vaghet avvises): PASS — all load-bearing slots bound; livedSpecifics → "bound" (else BLOCK: <N> unresolved/dangling) - Persona-skjelett-sweep: primær JA (else: still NEI — loop open, NOT ready for prose) @@ -721,9 +944,21 @@ Typically ~20–30 % of the edition's final length. **Procedure:** -1. **Re-read the voice profile** (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`) before writing a - single sentence — this is the existing LTL rule and it is not optional for - long-form. Voice match starts at the spine, not at expansion. +1. **Pass 0 — load the voice reference + the drift negatives** before writing a + single sentence (voice match starts at the spine, not at expansion — the + existing LTL rule, not optional for long-form): + - Re-read the **chronicle-voice-profile** + (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/chronicle-voice-profile.md`) + for this edition's language/format — the reference the spine must match + (fall back to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` + per Step 0 if it is missing). + - Read the **chronicle-voice-drift-log** + (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/chronicle-voice-drift-log.md`) + as a **negative list**: the recurring AI-tells and voice drifts `voice-scrubber` + logged on prior editions. Do NOT reproduce them in the spine — the log is no + longer write-only; it steers drafting BEFORE the scrub, so the Step-4/persona + passes and the scrubber find less each edition. If the log is absent, note it + and proceed. 2. **For each section in `NN-skjelett.md`, write ONE paragraph that delivers that section's pitch.** No examples yet, no anecdotes, no research citations @@ -762,6 +997,12 @@ Typically ~20–30 % of the edition's final length. `file://` link). 3. The operator annotates and pastes back; fold the notes in by tightening (rule 6 of `references/longform-quality-rules.md`). + + **↳ Fold-in capture.** Same rule as Step 2.5: a note that generalizes past + this edition appends one `articles.NN.foldIns[]` row (`decision: + "pending"`, `classification: null`) with the correction near-verbatim and + the trigger. **Step 11** empties the queue; nothing here decides whether a + correction "deserves" a rule. 4. **Receipt, not gate.** `AskUserQuestion` as a receipt — «spine revised per your notes — JA proceed to expansion, or another round?» (also the fallback gate when rendering is unavailable): @@ -781,6 +1022,8 @@ Typically ~20–30 % of the edition's final length. with the expanded prose). - `currentPhase: "spine-prose"` in `edition-state.json` (the marker that 3a is complete and the gate has passed). + - **↳ Phase transition** — run the protocol (Step 0) for `spine-prose`, + `--next "Step 3b — full prose expansion"`. - A "spine prose JA → next: Step 3b (full prose expansion)" next-step line in `<serie>/STATE.md` (overwrite). @@ -815,8 +1058,11 @@ turning-points the spine already named. **Procedure:** -1. **Re-read the voice profile** (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`) before expanding — - the voice was set at the spine; do not lose it in expansion. +1. **Re-read the chronicle-voice-profile** + (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/chronicle-voice-profile.md`, + fallback `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`) + and the drift-log negatives (Step 3a Pass 0) before expanding — the voice was set + at the spine; do not lose it, and do not let expansion reintroduce a logged drift. 2. **Expand section by section, against the spine.** Each section's paragraph from Step 3a is the *contract* for that section: the expansion must @@ -852,6 +1098,9 @@ turning-points the spine already named. silently skip any draft without an `NN` prefix). Set `currentPhase: "draft"` in `edition-state.json`, and write a "draft complete → next: consistency/quality" line to `<serie>/STATE.md` (overwrite). + **↳ Phase transition** — run the protocol (Step 0) for `draft`, + `--next "Step 4 — consistency + quality"`. Run it only when the draft is + COMPLETE, not at an interim cursor save: the log measures finished phases. ``` Full prose expansion — complete (or: partial — resumes at section <X>). @@ -876,6 +1125,18 @@ cutting, not by expansion**; hold the length flat (plan §8). > step S11 and extracted to the reference file at S12, per the original > forward-reference note — no rule text is duplicated.) +> **Plus the rules THIS operator has accumulated (N14 / C-10).** Read +> `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/language-rules/<lang>.md` +> (`<lang>` = `edition-state.language`) and apply it alongside the reference file. +> It holds the judgment-class corrections the operator confirmed in earlier +> editions and Step 11 promoted — reading it *here*, while the draft is still +> being cleaned, is what makes a rule **prevent** the defect instead of merely +> catching it at Step 6.5. The shipped ban-list (rule 3) is the baseline and the +> user file extends it; where they disagree, the stricter wins. Absent the file +> (the default — it is opt-in, template at +> `${CLAUDE_PLUGIN_ROOT}/config/language-rules.template.md`), apply the baseline +> alone and say nothing. + **Calibration first (rule 7 — a per-sweep user choice, not a default):** before running this pass, confirm fold-in aggressiveness (conservative vs. aggressive), jargon handling, and — when it matters later — persona weighting on conflict. Ask @@ -916,7 +1177,8 @@ non-self-certified — that verdict stays with the Step 6 persona sweep / operat After the pass, set `currentPhase: "consistency-quality"` in `edition-state.json` and write a "quality pass complete → next: contract-gate" line to -`<serie>/STATE.md` (overwrite). +`<serie>/STATE.md` (overwrite). **↳ Phase transition** — run the protocol +(Step 0) for `consistency-quality`, `--next "Step 4.5 — contract-gate"`. ``` Consistency + quality pass complete. @@ -987,7 +1249,8 @@ mechanical noise. 3. **Persist + checkpoint state.** Set `currentPhase: "contract-gate"` in `edition-state.json` and write a "contract-gate passed → next: fact-check sweep" - line to `<serie>/STATE.md` (overwrite). + line to `<serie>/STATE.md` (overwrite). **↳ Phase transition** — run the + protocol (Step 0) for `contract-gate`, `--next "Step 5 — fact-check sweep"`. ``` Contract-gate complete. @@ -1062,6 +1325,8 @@ because it "feels" right or because it sits in your own research notes. record of what was checked — this is where the old HANDOVER §4 log now lives), set `currentPhase: "factcheck-sweep"`, and write a "fact-check complete → next: persona sweep (BEFORE lock)" line to `<serie>/STATE.md` (overwrite). + **↳ Phase transition** — run the protocol (Step 0) for `factcheck-sweep`, + `--next "Step 5.5 — editorial review"`. ``` Fact-check sweep complete. @@ -1074,6 +1339,21 @@ Gate: [PASS — zero unresolved 🔴] (else REWORK/BLOCK with the open claims) Next: Step 5.5 — Editorial review (craft gate, BEFORE the persona sweep). ``` +> **Post-factcheck fact-invariant (Endring N9 / C-6) — deterministic recheck.** +> Once this sweep clears, the article's facts are gated — but every later edit can +> silently reintroduce an unchecked fact: an editorial fold-in (5.5), a persona +> tightening (6), a headless fold-in (6.5), an annotation pass (7). So the +> re-verification trigger is **deterministic — not a judgment call:** if a fold-in's +> diff touches any sentence containing **numbers, names, or quotes**, it fires a +> mandatory **scoped fact-reviewer** mini-recheck of *exactly those changed +> sentences* (`subagent_type: linkedin-studio:fact-reviewer`, inputs limited to the +> touched sentences + the sources they cite) before the phase advances. This +> replaces the old "re-run only if it felt substantive" discretion and closes the +> pivot-heuristic blind spot: a swapped statistic that adds no section and stays +> under the +20 % word-count threshold never trips `/linkedin:pivot`, but it *does* +> trip this trigger. A verified fact stays verified only if every number, name, or +> quote that changes after the gate is re-checked. `[GATE]` + ## Step 5.5: Editorial review — craft gate, BEFORE the persona sweep The fact-checked draft now faces the **editor**: a single `editorial-reviewer` @@ -1155,6 +1435,15 @@ and `persona-reviewer` never flags em-dash density (that is this step). iteration. The editor (this session) holds the pen — never paste the agent's direction as copy. + **↳ Fold-in capture.** A flag the operator approved that names a *recurring* + craft defect (an em-dash habit, a phrase that keeps returning, a conclusion + shape that keeps overloading) appends one `articles.NN.foldIns[]` row — + correction near-verbatim, `trigger` = the flag's category (P1–P5 / A1–A5), + `decision: "pending"`. This is the queue **Step 11** promotes: mechanical + flags become deterministic gate rules that die at Step 4.5 next edition, + judgment flags become checklist lines. A flag about *this draft's* specific + sentence is not a rule. + 5. **Optionally re-run `editorial-reviewer` on the cleaned version.** If the fold-in was substantive (especially any BLOCK), re-run the agent on v(n+1) to confirm the flags cleared and no new craft defect was introduced by the edit. @@ -1168,6 +1457,8 @@ and `persona-reviewer` never flags em-dash density (that is this step). machine-readable record — what was flagged, severity, fold-in decision). - Set `currentPhase: "editorial-review"` in `edition-state.json` (the marker that Step 5.5 is complete and the operator-gate has passed). + - **↳ Phase transition** — run the protocol (Step 0) for `editorial-review`, + `--next "Step 6 — persona sweep (pre-lock)"`. - Write an "editorial review complete (craft clean) → next: persona sweep (BEFORE lock)" line to `<serie>/STATE.md` (overwrite). @@ -1242,7 +1533,9 @@ reopening locked texts — the biggest single process error of the series (plan heuristic (Step 8 / `/linkedin:pivot`) compares against to catch a late pivot. Set `currentPhase: "persona-sweep-prelock"`, and write a "persona sweep PASS (primær JA) → next: headless adversarial review (Step 6.5, BEFORE lock)" - line to `<serie>/STATE.md` (overwrite). + line to `<serie>/STATE.md` (overwrite). **↳ Phase transition** — run the + protocol (Step 0) for `persona-sweep-prelock`, + `--next "Step 6.5 — headless adversarial review"`. ``` Persona sweep complete (BEFORE lock). @@ -1323,11 +1616,16 @@ command runs — see `commands/headless-review.md` for the full cold contract): summarize "what we changed" into a reviewer prompt — that is the context pollution the package exists to eliminate. - > **Maximum-independence path.** The strongest isolation is the operator - > running `/linkedin:headless-review --draft <frozen> --article NN` in a - > **fresh session** (the parent then has no drafting transcript at all) and - > pasting the consolidated report back. The inline fan-out here is the - > single-session path; both use the same agents. + > **Maximum-independence path — no session switch, no paste-back (N14).** The + > strongest isolation that costs the operator nothing is the **background + > run**: `/linkedin:headless-review --draft <frozen> --article NN --background` + > spawns the package in a background agent with its own context window, which + > writes the consolidated report to disk. This session then **reads that file** + > — it never sees the reviewers' reasoning, and the reviewers never see this + > session's drafting history. Same isolation as a fresh session, without the + > manual copy-paste seam that used to sit here. Run it, then `Read` the report + > path and continue at step 5 below. The inline fan-out above stays the + > fallback when background execution is unavailable; both use the same agents. 4. **Degradation gate.** Confirm each call returned structured, populated output (real flags / a verification log), not empty or a hedged paragraph. Re-run any @@ -1346,13 +1644,25 @@ command runs — see `commands/headless-review.md` for the full cold contract): The editor (this session) holds the pen; never paste a reviewer's direction as copy. If the fold-in was substantive, re-run the affected archetype on v(n+1). All of this happens **before** lock, so the body is never reopened post-lock. + **Post-factcheck fact-invariant applies (C-6):** independently of the substantive + judgment, if any folded edit's diff touches a sentence with **numbers, names, or + quotes**, fire the deterministic **scoped fact-reviewer** mini-recheck on those + sentences (see the invariant after Step 5) before advancing. + + **↳ Fold-in capture.** The cold reviewers are the richest source of rules the + in-session gates never see — a language flag two independent readers converge + on is almost always a standing rule, not a one-off. Append one + `articles.NN.foldIns[]` row per approved flag that generalizes (`decision: + "pending"`, `trigger` = the C/L/F category, ⚑ converged noted), and let + **Step 11** decide mechanical vs judgment. Do not classify here. 7. **Persist + checkpoint state.** Record the run in `edition-state.json` → `articles.NN.headlessReview` (`frozenDraft`, per-reviewer `{reportPath, summary, status}`, `consolidatedReport`, `foldedIn`/`waived`, `status: "folded"`), set `currentPhase: "headless-review"`, and write a "headless review complete (cold, converged flags folded) → next: annotation/lock" line to - `<serie>/STATE.md` (overwrite). + `<serie>/STATE.md` (overwrite). **↳ Phase transition** — run the protocol + (Step 0) for `headless-review`, `--next "Step 7/7.5 — annotation + visual assets"`. ``` Headless adversarial review (cold, BEFORE lock) — complete. @@ -1402,10 +1712,17 @@ editor is satisfied with the in-session draft. It does not gate lock. editor's manual annotation pass. Fold any resulting edits back **by tightening** (the Step 4 rule still holds) — then, if the edit was substantive, re-run the affected sweep (Step 5 or 6) before proceeding. + **Post-factcheck fact-invariant applies (C-6):** any annotation edit whose diff + touches a sentence with **numbers, names, or quotes** fires the deterministic + **scoped fact-reviewer** mini-recheck on those sentences (see the invariant after + Step 5), regardless of whether the edit felt substantive. 4. **Persist.** Set `currentPhase: "annotation"` in `edition-state.json` and write an "annotation rendered (optional) → next: visual assets" line to `<serie>/STATE.md` (overwrite). If skipped, note "annotation skipped" and move on. + **↳ Phase transition** — run the protocol (Step 0) for `annotation`, + `--next "Step 7.5 — visual assets"`. If the step was skipped, skip the + transition too: an unrun phase must not appear in the lead-time log. ``` Annotation (optional). @@ -1466,13 +1783,25 @@ operator declares carousel format for it. Branch accordingly: in `edition-state.json` → `articles.NN.visualAssets.cover.brief` and `…figures[].brief`. -3. **Generate — two routes, no lock-in.** The interface is pluggable (path-in / - path-out); `mcp-image` is the default, not a hard dependency: - - **Default route — `mcp__mcp-image__generate_image`** (Nano Banana Pro / - Gemini 3 Pro Image). Write candidates to - `linkedin/NN/cover-v<N>-kandidat.png` (and `fig<N>-kandidat.png` for - figures). Candidate naming lets several attempts sit side by side without - overwriting an approved file. Record route `"mcp-image"`. +3. **Generate — three routes, no lock-in.** The interface is pluggable (path-in / + path-out). Route by what the image *is*: + - **Coded route — PRIMARY for data figures** (charts, diagrams, before/after + comparisons — anything whose content is real numbers or real structure). + Author the figure as SVG/HTML per + `${CLAUDE_PLUGIN_ROOT}/references/figure-design-guidelines.md` (design + rules + brand-token convention), then render to a candidate: + ```bash + node "${CLAUDE_PLUGIN_ROOT}/render/build-figur.mjs" linkedin/NN/fig<N>.svg --target article --out linkedin/NN/fig<N>-kandidat.png + ``` + Precision and reproducibility beat generative output for data — numbers, + labels, and proportions are exact, and the figure re-renders identically + after a correction. Record route `"coded"`. + - **Generative route — `mcp__mcp-image__generate_image`** (Nano Banana Pro / + Gemini 3 Pro Image) for **illustrative** images (cover art, mood, + metaphor). Write candidates to `linkedin/NN/cover-v<N>-kandidat.png` (and + `fig<N>-kandidat.png` for figures). Candidate naming lets several attempts + sit side by side without overwriting an approved file. Record route + `"mcp-image"`. - **External route** — DALL·E, Midjourney, a photographer, a hand-built SVG. The plugin accepts a `linkedin/NN/cover-raw.png` the operator drops in; no tool is mandated. Record route `"external"`. (The raw file may then be @@ -1551,6 +1880,8 @@ the deck is approved) and credit/caption are recorded: `edition-state.json`. - Set `currentPhase: "visual-assets"` in `edition-state.json` (the marker that Step 7.5 is complete and the gate has passed). +- **↳ Phase transition** — run the protocol (Step 0) for `visual-assets`, + `--next "Step 8 — LOCK + delivery"`. - Write a "visual assets approved (cover/figures or carousel) → next: lock/delivery" line to `<serie>/STATE.md` (overwrite). @@ -1560,7 +1891,7 @@ Visual assets (BEFORE lock). - Cover: linkedin/NN/cover.png approved (after <N> candidates) (or: N/A — carousel) - Figures: <N> approved → linkedin/NN/figN.png (or: none) - Carousel deck: linkedin/NN/carousel.pdf rendered + approved (or: N/A — standard) -- Route: mcp-image | external Credit/caption: recorded in image-credit-caption.md + edition-config.json +- Route: coded | mcp-image | external Credit/caption: recorded in image-credit-caption.md + edition-config.json - Operator gate: approved (candidates surfaced via SendUserFile) [OPERATØR] Next: Step 8 — LOCK → delivery. ``` @@ -1644,6 +1975,64 @@ produces the editor's single delivery artifact — `POST.html`, the `<serie-mappe>/linkedin/NN/POST.html` as a `file://` link. From here the body is frozen — Step 9 may only adjust the distribution hook, never the locked edition text. + **↳ Phase transition** — run the protocol (Step 0) for `lock-delivery`, + `--next "Step 9 — hook/conversion gate"`. + +5. **Auto-gull — grow the gold corpus (A2-F2).** A locked edition is now + published-grade voice signal, so append it to the gold corpus the chronicle + voice profile is distilled from: + - Record the locked body (`NN-utkast.md`) as a new record under + `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ingest/published/`, + tagged **`provenance=published`** (never `ai-draft`) with this edition's + `language` and format in its frontmatter, so `voice-trainer`'s + chronicle-voice-profile distillation (per language/format) picks it up. + - This closes the loop the model-collapse fence protects: the corpus the + chronicle voice is learned from is the author's OWN locked editions, growing + one edition at a time — never the engine's drafts. The next edition's Step 0 + therefore binds to a sharper profile without any manual corpus curation. + +6. **Serie-minne — record what this edition spent.** The reader-facing twin of + auto-gull: auto-gull grows the *voice* corpus, this grows the *series memory* + that the next edition's Step 2.5 destillat-sjekk is checked against. Both + writes happen at LOCK, so they mean **published**, never merely considered — + an abandoned draft leaves no trace. + + - **Series distillate.** Extract the narrative units this edition actually + spent — the anecdotes told, the arguments argued, the hooks used — **in the + operator's own phrasing**, not as a summary (the check compares text, so a + paraphrased summary would compare the summariser's words, not the reader's + experience). Write them to a JSON extract and fold it in: + + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/editions" && \ + node --import tsx src/cli.ts distil-append \ + --distillate "<abs>/linkedin/series-distillate.json" \ + --series "<slug>" --extract "<abs>/NN-destillat.json" + ``` + + Extract shape: `{editionId, title, lockedAt, anecdotes[], arguments[], + hooks[]}`. A re-lock after `/linkedin:pivot` **replaces** that edition's + entry rather than duplicating it — the reader still only met it once. + + - **Specifics-bank usage log.** Stamp the lived material this edition consumed + with «used in edition NN», so the next Step 1.5 query surfaces re-use + instead of silently encouraging it (the bank's dedupe unions tags across + editions by design — without this log, re-surfacing the same specific is + invisible): + + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank" && \ + node --import tsx src/cli.ts record-usage \ + --edition "<abs>/linkedin/edition-state.json" --edition-id "<slug>/NN" + ``` + + The ids are read from the article's bound slot-map, so only material the + edition actually leaned on is stamped; `abstrakt`/`ekstern` slots consume no + bank material. Idempotent — a pivot re-lock does not double-count. + + - Deps/contract absent (adopter — Step 1.5 was a no-op, or `npm install` never + run) → skip both with a one-line note, like the Step 2.5 gates. The lock + itself never depends on this bookkeeping. ``` LOCK → delivery. @@ -1651,6 +2040,7 @@ LOCK → delivery. - Delivered: <serie-mappe>/linkedin/NN/POST.html - build-linkedin exit: 0 + POST.html present (else: non-zero / skip — NOT locked) - Article status: locked +- Serie-minne: distillate +1 edition (<N> units) · bank usage stamped on <N> specific(s) (else: skipped — deps absent) Next: Step 9 — Hook / conversion gate (post-lock). ``` @@ -1658,51 +2048,106 @@ Next: Step 9 — Hook / conversion gate (post-lock). ## Step 9: Hook / conversion gate — "would YOU click?" (AFTER lock) -The locked edition still has to earn the click. The **distribution text** — the -delingstekst hook that fronts the post in the feed — now faces a final binary -gate: would the primær persona, scrolling, actually stop and open this? This is -the post-lock conversion sweep, distinct from the pre-lock resonance sweep -(Step 6): it judges the **hook only**, binary, never the body. +The locked edition still has to earn the feed. Before it ships, the +**feed-facing surfaces** the reader actually meets — the **delingstekst hook**, +the **samle post**, the **carousel slide text**, and the **edition title** — +face two final gates: a **randsone fact + language gate** (so nothing false or +clumsy reaches the feed in the most-read, most-requoted copy — C-4/C-9) and the +**hook conversion binary** (would the primær persona, scrolling, actually stop +and open this?). Both operate on **distribution copy only** — the post-lock +sweep, distinct from the pre-lock resonance sweep (Step 6), never the locked +body. Until N9 only the hook's click-worth was judged; the hook's *truth and +language*, the samle post, the carousel slides and the title reached the feed +ungated. -> **Order assertion (enforced).** This hook-gate (Step 9) runs AFTER lock -> (Step 8) — it operates on the distribution text of an already-locked edition -> and must never reopen the locked body. If the hook fails, you revise the -> *delingstekst* (and re-render via Step 8's `build-linkedin.mjs`), not the -> edition text. This ordering is the inverse of the resonance sweep, which runs -> BEFORE lock — keeping the two apart is what fixed the Seres process. `[GATE]` +> **Order assertion (enforced).** This gate (Step 9) runs AFTER lock (Step 8) — it +> operates on the **distribution copy** of an already-locked edition and must never +> reopen the locked body. Both the randsone fact/language gate and the hook binary +> revise only distribution surfaces (delingstekst, samle, carousel text, +> distribution title) and re-render via Step 8's `build-linkedin.mjs` / +> `build-carousel.mjs` — never the edition text. A defect that traces into the +> **locked article body or its canonical title** is a pivot (STOP → +> `/linkedin:pivot`), not an in-place edit. This ordering is the inverse of the +> resonance sweep, which runs BEFORE lock — keeping the two apart is what fixed the +> Seres process. `[GATE]` + +**Why Step 9 (placement, documented per plan N9).** The randsone gate lives here, +not earlier, because Step 9 is the **first phase where all four surfaces exist**: +the carousel slide text is authored in Step 7.5 (after the last content gate 6.5), +and the delingstekst + samle post exist only from Step 8. Step 9 already operates on +post-lock distribution copy, so gating and revising these surfaces here respects the +lock invariant without breaking phase order. **Procedure:** -1. **Take the distribution hook** — the first two lines of the edition's entry - in `linkedin/edition-delingstekst.md` (and the `samle` hook, if shipping the - collected post). This is what the reader sees before "…see more". +1. **Collect the four distribution surfaces.** These are what the reader meets in + the feed — and until N9 only the first was judged, and only for click-worth: + - the **delingstekst hook** — the first two lines of the edition's entry in + `linkedin/edition-delingstekst.md` (what the reader sees before "…see more"); + - the **samle post** — the `## Samle` section, if shipping the collected post; + - the **carousel slide text** — `linkedin/NN/carousel.md`, for a carousel-format + edition (authored in Step 7.5, after the last content gate — so it has never + been fact- or language-checked); + - the **edition title** — the edition's own headline (its distribution / feed + title), never strength-gated before now. -2. **Run `persona-reviewer` in conversion mode** (`subagent_type: - linkedin-studio:persona-reviewer`) for the **primær** persona - only, from THIS command layer in the foreground. Pass - **`mode: konverter`** (the after-lock, hook-only mode — NOT resonans). The - agent returns a single binary verdict, **JA / NEI**, on «would YOU click?» — - no axis scoring, no flags, no rewritten copy (principle: the jury judges, - the editor writes). +2. **Randsone fact + language gate (scoped — C-4/C-9).** Run two scoped reviewers + over *only* these surfaces (never the locked body), issued from THIS command + layer in the foreground, `subagent_type` namespaced: + - `linkedin-studio:fact-reviewer` — **F1** (verifiable claims) + **F3** (number + attribution) on any figure, named case, or quotation appearing in the four + surfaces. A hook or slide that asserts a number the body never sourced is a 🔴. + (WebFetch the source for any quoted or exact figure — C-7.) + - `linkedin-studio:language-reviewer` — **L1** (repetition) + **L2** (anglicisms + / stiff register), graded against `edition-state.language`. The hook is the + most-requoted line the edition has; a clumsy or anglicised hook is an L-BLOCK. + Pass each reviewer only the four surfaces + the sources they cite + `language`. + This is a **scoped mini-pass**, not a re-review of the article. `[GATE]` -3. **Gate on the binary.** +3. **Title-strength gate (C-9).** The edition's own title was never strength-gated — + only the hook's two lines were. Judge the **title-strength** of the **edition + title** here: does it earn the open on its own, or is it a generic label? The + strongest candidate becomes the distribution title. The verdict is **binding on + the distribution title** (sharpen a weak one in place) and **advisory on the + canonical (locked) title** — changing that is a body edit → STOP, run + `/linkedin:pivot`; never edit the locked title in place. + +4. **Resolve the randsone gate.** + - **Fact 🔴 / L-BLOCK on a distribution surface** → revise that surface **by + tightening** (hook, samle, carousel text, or distribution title — all + distribution copy), **re-render** the affected artifact (POST.html via Step 8's + `build-linkedin.mjs`; carousel via `build-carousel.mjs`), and re-run the scoped + pass on what changed. The locked body stays frozen. Loop until clean. + - **A defect that traces into the locked body or canonical title** → it is a + pivot: STOP, run `/linkedin:pivot`. The randsone gate never reopens the locked + body in place — the same invariant as the conversion loop. `[GATE]` + +5. **Run the hook conversion binary** (`subagent_type: + linkedin-studio:persona-reviewer`, **`mode: konverter`** — the after-lock, + hook-only mode, NOT resonans) for the **primær** persona only. The agent returns + a single binary verdict, **JA / NEI**, on «would YOU click?» — no axis scoring, + no flags, no rewritten copy (the jury judges, the editor writes). - **JA** → the hook converts. Proceed to Step 10. - - **NEI** → the hook does not earn the click. Revise the **delingstekst hook - only** (sharpen the krok by tightening — close the gap, hold the body - frozen), **re-render POST.html** via Step 8's `build-linkedin.mjs` (the - body stays locked; only the distribution copy changed), and re-run the + - **NEI** → revise the **delingstekst hook only** (sharpen the krok by tightening, + body frozen), **re-render POST.html** via `build-linkedin.mjs`, and re-run the conversion check. Loop until JA. `[GATE]` -4. **Persist.** Record the conversion verdict in - `edition-state.json` → `articles.NN.personaSweep.conversion`, - and set `currentPhase: "hook-conversion-gate"`. +6. **Persist.** Record the randsone-gate result in `edition-state.json` → + `articles.NN.randsoneGate` (per-surface fact/language verdicts + title-strength + + what was revised) and the conversion verdict in + `articles.NN.personaSweep.conversion`; set `currentPhase: "hook-conversion-gate"`. + **↳ Phase transition** — run the protocol (Step 0) for `hook-conversion-gate`, + `--next "Step 10 — scheduling"`. ``` -Hook / conversion gate (post-lock). -- Primær persona: <name> mode: konverter (hook only) -- Verdict: JA — hook converts (else: NEI — revise delingstekst hook, re-render, re-check) +Randsone + conversion gate (post-lock, distribution copy only). +- Surfaces gated: delingstekst hook · samle post · carousel slide text · edition title +- Fact (F1/F3): <N> 🔴 → resolved (or pivot if it traces to the body) +- Language (L1/L2): <N> BLOCK → tightened +- Title-strength: <verdict> (distribution title sharpened; canonical-title weakness → pivot) +- Conversion (primær, konverter): JA (else: NEI — revise hook, re-render, re-check) - Body: untouched (locked in Step 8) -Gate: [PASS — JA] (else loop on the distribution hook) +Gate: [PASS — randsone clean + JA] (else loop on distribution copy, or pivot) Next: Step 10 — Scheduling. ``` @@ -1718,9 +2163,24 @@ now a first-class scheduled post. **Procedure:** -1. **Pick the slot.** Confirm the target `scheduled_date` (YYYY-MM-DD) and - `scheduled_time` from the edition-config calendar / the series brief. If the - slot is unset, ask once (Step 1's calibration may already have settled it). +1. **Pick the slot — default to the next open one (N13).** Read the operator's + publishing grid and take the first slot nothing covers yet, so scheduling is + one confirmation rather than an interview: + + ```bash + node --input-type=module -e " + import { nextFreeSlot, formatSlot, readSlots } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/slots.mjs'; + const s = readSlots().slots.length ? nextFreeSlot({ days: 28 }) : null; + console.log(s ? s.date + ' ' + s.time + ' (' + formatSlot(s) + ')' : 'NO SLOT'); + " + ``` + + Propose that date/time and ask **once** for a yes/no. `NO SLOT` means the grid + is unconfigured or every slot in the next 28 days is taken — then fall back to + the edition-config calendar / series brief, and ask for the slot outright + (Step 1's calibration may already have settled it). A slot the operator + overrides is simply used as given; the default is a starting point, never a + constraint. 2. **Register via `queue-manager.mjs`.** Add one queue entry for the edition, reusing the short-form queue contract — `queueAdd(id, draftPath, schedDate, @@ -1737,11 +2197,43 @@ now a first-class scheduled post. The function appends to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json` with `status: "scheduled"` and returns the new entry. -3. **Persist + close the edition.** Set the article's `status: "scheduled"`, +3. **Close the discovery loop (auto-`act` — N7).** If `articles.NN.sourceTrendId` + is set (this edition was started from a `/linkedin:trends` candidate), flip that + trend's store status to `acted` so it drops out of future discovery triage — the + discovery→production loop closes deterministically, not from memory: + + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && node --import tsx src/cli.ts act --id "<sourceTrendId>" + ``` + + A present id flips to `acted` (exit 0). If the id is no longer in the store the + call errors (all-miss → exit 2, store untouched) — that is **not fatal** to + scheduling (the edition is already registered in step 2): note it and proceed. + Skip this step entirely when `sourceTrendId` is `null` (a manually-started + edition has no trend to close). + +4. **Persist + close the edition.** Set the article's `status: "scheduled"`, `scheduled: "<YYYY-MM-DD HH:MM>"`, and `currentPhase: "scheduling"` in `edition-state.json`. Write a closing "edition scheduled → mark live via `/linkedin:calendar` on <date>" line to `<serie>/STATE.md` (overwrite). The pipeline is complete. + **↳ Phase transition** — run the protocol (Step 0) for `scheduling`, this time + **with the slot** (`--slot "<YYYY-MM-DD HH:MM>"`, the one confirmed in step 1) + and `--next "published — mark live via /linkedin:calendar"`. The slot on the row + is what makes the edition visible as *coverage* of that publishing slot, so the + next session stops offering it as open. Then **close the register row** (this + step alone does): + + ```bash + node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-complete \ + --series "<slug>" --edition "<NN>" + ``` + + It prints the measured lead time (first transition → completion) — the A1-12 + number the cadence work is calibrated on. An edition re-opened later by + `/linkedin:pivot` simply re-activates the same row on its next transition; the + original `startedAt` is kept, so the clock reflects the real elapsed production + time rather than restarting. ``` Scheduling. @@ -1753,6 +2245,129 @@ Edition complete. Visible in /linkedin:calendar; mark live via /linkedin:calenda --- +## Step 11: Retro — make this edition's corrections stick (≤5 min, AFTER scheduling) + +The edition is delivered. This step is not about it — it is about **the next +one.** Every correction the operator made during production is sitting in +`articles.NN.foldIns[]` marked `pending`; if the session ends here, those +corrections live only in a transcript and get re-discovered in three editions' +time. Step 11 empties that queue into permanent, enforced homes. + +> **Why AFTER scheduling, and why capped.** The retro is cheap only if it is +> short: three fixed jobs, one operator pass, no open-ended reflection. It runs +> after Step 10 so nothing about shipping waits on it, and it is the last thing +> the edition does — the pipeline is not complete until the loop is closed. +> The full model (capture → classify → promote → enforce) is documented +> domain-generally in `${CLAUDE_PLUGIN_ROOT}/references/fold-in-loop.md`. `[OPERATØR]` + +**Procedure — three jobs, in order:** + +1. **Promote the fold-in queue (the operator gates each row).** Read every + `articles.NN.foldIns[]` row with `decision: "pending"`. For each, propose a + `classification` and ask for a **JA / NEI** — one compact list, not one + question per row: + + - **`mechanical-block` / `mechanical-warn`** — provable by string, regex, or + count with ~zero false positives. **JA →** promote **atomically**: add the + rule to `scripts/contract-gate/src/rules.ts`, the matching row to the + writing contract, and the manifest row that binds them; set the fold-in's + `decision: "promoted"` and `ruleId`. Then prove it: + + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/contract-gate" && node --import tsx src/cli.ts --ratify + ``` + + **Red ratify means the promotion did not happen** — rules and contract are + out of sync, and a gate enforcing something undocumented is exactly the + drift `ratify` exists to refuse. Fix it before recording the promotion. + From the next edition, that correction dies at **Step 4.5**, before any + human reads the draft. + - **`judgment`** — real editorial discretion, no gate can prove it. **JA →** + append it to the operator's accumulating language file, + `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/language-rules/<lang>.md` + (`<lang>` = `edition-state.language`; template + `${CLAUDE_PLUGIN_ROOT}/config/language-rules.template.md`), under `## Rules`, + with the date and this edition number. That file is read by **Step 4** while + the next draft is being cleaned *and* by `language-reviewer` at Step 6.5 — so + the rule prevents the defect as well as catching it. If the file does not + exist, say so once and offer the one-line copy from the template; never + create it silently. + - **NEI** → `decision: "rejected"`, **kept** — "we considered this and said + no" is itself worth not re-discovering. Never delete a fold-in row. + + **When unsure, classify as `judgment`.** A mechanical rule with false + positives trains the operator to ignore the gate, and a gate nobody trusts + enforces nothing. + +2. **Effort summary — read the measurement, do not re-estimate it.** The numbers + already exist in `articles.NN.phaseLog` (written deterministically at every + phase transition since N12). Report exactly three things: + + ```bash + node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-list --all + ``` + + - **transitions** — how many phases this edition logged. + - **recurred phases** — any phase appearing more than once in `phaseLog`: a + gate that had to re-run, i.e. where the rounds actually went. This is the + signal worth reading; a phase that recurs across several editions is a + process problem, not an edition problem. + - **lead time** — first transition → `scheduling`, straight from the log. + + No new measuring apparatus, no stopwatch, no asking the operator how long it + felt. If `phaseLog` is absent (an edition started before N12), say + "not measured" and move on — never estimate a number and present it as data. + +3. **One process-friction question — asked once, answered by them.** Ask exactly + one: *"Any process friction from this edition worth recording?"* Take the + answer, hand it back in a form they can paste, and stop. **The plugin must + never write into the operator's own notes, register, or repo** — their record + is theirs, and a tool that files entries in someone else's log on their behalf + is a tool they stop trusting. One question, no follow-ups, no second round. + + While the queue is in view, name the **other accumulation silos** so the + operator can see the whole picture in one place — specifics-bank (Step 1.5 / + Step 8), brain-consolidate (Step 8), the voice drift log (`voice-scrubber`), + and A/B learnings (`/linkedin:ab-test`). A retro that empties one queue while + the others fill silently teaches the operator that everything is empty. + +4. **Persist + close the loop.** Record the retro in `edition-state.json` → + `articles.NN.retro` (`promoted`, `rejected`, `ruleIds`, + `languageRulesAppended`, `effort`, `frictionNoted`, `completedAt`) and set + `currentPhase: "retro"`. + **↳ Phase transition** — run the protocol (Step 0) for `retro`, + `--next "edition closed — corrections promoted"`. This appends the `retro` + entry to `phaseLog` **and re-activates the register row** (the same + re-activation `/linkedin:pivot` relies on), so close it again: + + ```bash + node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-complete \ + --series "<slug>" --edition "<NN>" + ``` + + > **Which lead time is which.** The register row's `completedAt` now marks the + > end of the *retro*, not of delivery. The **delivery** lead time — the number + > the cadence work is calibrated on — is always readable exactly from + > `phaseLog` (first entry → the `scheduling` entry), which is why the register + > is a mirror and `edition-state.json` the source of truth. Report the + > `phaseLog` number, not the row's. + + Write a "retro complete — <N> promoted, <N> rejected → next edition starts + with them enforced" line to `<serie>/STATE.md` (overwrite). The edition is + now done. + +``` +Retro (loop closed) — complete. +- Fold-ins reviewed: <N> promoted: <N> (<N> mechanical → rules.ts · <N> judgment → language-rules/<lang>.md) rejected (kept): <N> +- Ratify after promotion: ✓ in sync (or: not run — no mechanical promotion this edition) +- Effort (measured, phaseLog): <N> transitions · recurred: <phase list or none> · lead time <N> days +- Process friction: noted and handed back (or: none) — the operator's own register is untouched +- Other silos in view: specifics-bank · brain · voice drift log · A/B learnings +Edition CLOSED. The next edition starts with these corrections already enforced. +``` + +--- + ## Distribution channel — native LinkedIn Newsletter vs a long-form post (honest mechanics) A long edition can ship two ways: as a **regular long-form post** (what Steps 8–10 @@ -1821,7 +2436,9 @@ the honest decision surface; it sells nothing. ## Reference Files - `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (18 phases including Fix #2 lived-specifics extraction (Step 1.5) + `articles.NN.livedSpecifics`, the deterministic §B/§C1 contract-gate (Step 4.5), v2.1 skeleton + spine-prose gates, v2.3 visual-assets, v2.4 editorial-review, and v3.1 headless-review + per-article `personas` + `pivots`) -- `${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank/` — lived-specifics store + per-edition binding (Fix #2, kilde-så-draft): `query`/`add` (Step 1.5), `validate-binding` (Step 2.5 gate), `render-kilder` (`NN-kilder.md` ledger). Design record: `docs/fix2/slice2-binding.md` +- `${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank/` — lived-specifics store + per-edition binding (Fix #2, kilde-så-draft): `query`/`add` (Step 1.5), `validate-binding` (Step 2.5 gate), `render-kilder` (`NN-kilder.md` ledger), `record-usage` (Step 8 lock — «used in edition NN»). Design record: `docs/fix2/slice2-binding.md` +- `${CLAUDE_PLUGIN_ROOT}/scripts/editions/` — series-level memory (N11): `distil-append` writes what a locked edition spent to `<serie>/linkedin/series-distillate.json` (Step 8), `distil-check` gates the next skeleton against it (Step 2.5). Deterministic character-trigram similarity; advisory, never blocking. **Editions register (N12):** `register-upsert` at every phase transition (appends `articles.NN.phaseLog` + mirrors the row into `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json`), `register-list` for the work-in-progress view, `register-complete` at Step 10 (measured lead time). The register mirrors state — resumption never reads it +- `${CLAUDE_PLUGIN_ROOT}/hooks/scripts/slots.mjs` — the publishing grid (N13): `nextFreeSlot` gives Step 10 its default slot (queue + register coverage), and session start reports open slots from the same functions. Zero-dep, so hook and command can never disagree. The grid itself is operator config (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/publishing-slots.json`); unconfigured means Step 10 simply asks - `${CLAUDE_PLUGIN_ROOT}/config/edition-config.template.json` — static delivery metadata schema (calendar, freshness, credit, captions) — Step 8 - `${CLAUDE_PLUGIN_ROOT}/config/image-credit-caption.template.md` — cover motif + credit + caption table (honest-about-AI credit) — Step 7.5 - `${CLAUDE_PLUGIN_ROOT}/config/edition-delingstekst.template.md` — distribution-copy grammar (`## Del N —` / `## Samle`) — Steps 8/9 @@ -1838,6 +2455,10 @@ the honest decision surface; it sells nothing. - `${CLAUDE_PLUGIN_ROOT}/commands/react.md` — multi-source synthesis discipline (reused in Step 2) - `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` — voice matching - `${CLAUDE_PLUGIN_ROOT}/references/longform-quality-rules.md` — canonical long-form rules (Steps 2.5, 3a, 3b, 4–9 all reference) +- `${CLAUDE_PLUGIN_ROOT}/references/fold-in-loop.md` — the capture → classify → promote → enforce loop (N14): Steps 2.5/3a/5.5/6.5 capture into `articles.NN.foldIns`, Step 11 promotes. Domain-general; needs no external writing contract +- `${CLAUDE_PLUGIN_ROOT}/config/language-rules.template.md` — the operator's accumulating per-language rules (opt-in; read by Step 4 + `language-reviewer`, appended by Step 11) - `${CLAUDE_PLUGIN_ROOT}/render/build-linkedin.mjs` — POST.html delivery; reads `linkedin/NN/cover.png` + credit/caption (Step 8) - `${CLAUDE_PLUGIN_ROOT}/render/build-html.mjs` — annotatable review renderer (Step 7) - `${CLAUDE_PLUGIN_ROOT}/render/build-carousel.mjs` — carousel deck renderer (`## SLIDE N —` → PDF via weasyprint) — Step 7.5 carousel branch +- `${CLAUDE_PLUGIN_ROOT}/render/build-figur.mjs` — coded data-figure renderer (SVG/HTML → PNG via headless Chrome; targets article/carousel/single) — Step 7.5 coded route +- `${CLAUDE_PLUGIN_ROOT}/references/figure-design-guidelines.md` — coded-figure design rules + brand-token convention + render targets — Step 7.5 diff --git a/commands/onboarding.md b/commands/onboarding.md index 99a31c2..9eaa8ad 100644 --- a/commands/onboarding.md +++ b/commands/onboarding.md @@ -218,9 +218,11 @@ Fix any miss before showing it. Show the post with its character count, the hook highlighted, and one alternative hook. Auto-copy the post text to clipboard silently: ```bash -printf '%s' '<POST_TEXT>' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' +<POST_TEXT> +__LINKEDIN_CLIP_EOF__ ``` -Then say: "Post copied to clipboard. Go to linkedin.com, click 'Start a post', paste it, and hit Post." +Substitute `<POST_TEXT>` with the exact post text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, say: "Post copied to clipboard. Go to linkedin.com, click 'Start a post', paste it, and hit Post." If it prints `FAILED:<platform>`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. ### 3.5 — Record it diff --git a/commands/pipeline.md b/commands/pipeline.md index eff4b54..c19efea 100644 --- a/commands/pipeline.md +++ b/commands/pipeline.md @@ -136,7 +136,9 @@ Offer to help identify target profiles and draft comments. Auto-copy the final post text to clipboard silently before presenting: ```bash -printf '%s' '<FINAL_POST_TEXT>' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' +<FINAL_POST_TEXT> +__LINKEDIN_CLIP_EOF__ ``` Present the final post as copy-paste ready content: diff --git a/commands/post.md b/commands/post.md index cfbd5b1..6fb81ab 100644 --- a/commands/post.md +++ b/commands/post.md @@ -26,6 +26,14 @@ First, load persistent state and personalization: - Read `~/.claude/linkedin-studio.local.md` for posting state (streak, weekly progress, recent topics) - Read `skills/linkedin-studio/SKILL.md` for user profile, voice settings, and preferences +**Read `## Do-Next Directives` in the state file and apply them to this draft.** This is what the +last measurement pass (`/linkedin:report`, `/linkedin:analyze`, an A/B Adopt verdict, the 48h +monitor) decided the next post should do differently — each row carries its evidence pointer. +Apply the ones that fit this post, and **name the applied directive in one line** when you +present the draft ("applied: lead with the number — report 2026-W22"). A directive that does not +fit this topic is skipped silently, not argued with. Directives are already lifetime-managed +(each source replaces its own; >60 days expire) — never edit or clear the section here. + Check state for topic planning: - Compare intended topic against "Recent Posts" in state file - If a similar topic was posted in the last 7 days, suggest a different angle or topic @@ -150,9 +158,11 @@ Present ONE draft with: Auto-copy the final post text to clipboard silently: ```bash -printf '%s' '<FINAL_POST_TEXT>' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' +<FINAL_POST_TEXT> +__LINKEDIN_CLIP_EOF__ ``` -Then confirm: "Copied to clipboard." +Substitute `<FINAL_POST_TEXT>` with the exact post text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Copied to clipboard." If it prints `FAILED:<platform>`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. Do NOT proactively offer alternative versions. Only generate alternatives if the user asks for them. diff --git a/commands/quick.md b/commands/quick.md index 614bbdf..d990f8a 100644 --- a/commands/quick.md +++ b/commands/quick.md @@ -27,6 +27,10 @@ Read `~/.claude/linkedin-studio.local.md` for: - Weekly posting progress (show "X/Y posts this week") - Recent topics (avoid repetition within 7 days) - Current streak status +- **`## Do-Next Directives`** — what the last measurement pass (report / analyze / A/B Adopt / + 48h monitor) decided the next post should do differently, each with its evidence pointer. + Apply the ones that fit and name the applied directive in one line with the draft. Never edit + or clear the section (each source manages its own rows; rows older than 60 days expire). Read `skills/linkedin-studio/SKILL.md` for: - User's voice profile and tone preferences @@ -159,9 +163,11 @@ Show the post with: Auto-copy the final post text to clipboard silently: ```bash -printf '%s' '<FINAL_POST_TEXT>' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' +<FINAL_POST_TEXT> +__LINKEDIN_CLIP_EOF__ ``` -Then confirm: "Copied to clipboard." +Substitute `<FINAL_POST_TEXT>` with the exact post text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Copied to clipboard." If it prints `FAILED:<platform>`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. Do NOT proactively offer alternative versions. Only generate alternatives if the user asks. diff --git a/commands/react.md b/commands/react.md index 9772eb8..a750f8c 100644 --- a/commands/react.md +++ b/commands/react.md @@ -146,9 +146,11 @@ Show: Auto-copy the main draft text to clipboard silently: ```bash -printf '%s' '<MAIN_DRAFT_TEXT>' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' +<MAIN_DRAFT_TEXT> +__LINKEDIN_CLIP_EOF__ ``` -Then confirm: "Copied to clipboard." +Substitute `<MAIN_DRAFT_TEXT>` with the exact draft text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Copied to clipboard." If it prints `FAILED:<platform>`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. Do NOT use AskUserQuestion for refinement. Simply state: diff --git a/commands/report.md b/commands/report.md index f261c5b..f8d314c 100644 --- a/commands/report.md +++ b/commands/report.md @@ -10,6 +10,7 @@ allowed-tools: - Bash - Read - Glob + - Write - AskUserQuestion - Task --- @@ -69,7 +70,7 @@ Enter your choice: ``` **If monthly (option 2):** Ask for month (YYYY-MM format, default to current month), then jump to **Step 2b**. -**If heatmap (option 3):** Run the heatmap CLI command and jump to **Step 6c**. +**If heatmap (option 3):** Run the heatmap CLI command and jump to **Step 2c**. **If weekly (option 1 or default):** Continue below. ### Weekly: Determine Week @@ -140,7 +141,7 @@ cat ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-repo The report contains: - **week**: ISO week identifier - **generatedAt**: ISO timestamp when the report was generated -- **summary**: Totals and averages — `totalPosts`, `totalImpressions`, `totalReactions`, `totalComments`, `totalShares`, `totalClicks`, optional `totalSaves` (manual entry only), `avgEngagementRate`, `avgImpressionsPerPost` +- **summary**: Totals and averages — `totalPosts`, `totalImpressions`, `totalReactions`, `totalComments`, `totalShares`, `totalClicks`, optional `totalSaves` (manual entry only), optional `avgOutOfNetworkPct` (manual entry only — the impressions-weighted out-of-network share), `avgEngagementRate`, `avgImpressionsPerPost` - **topPerformers** / **underperformers**: Best / weakest posts by engagement rate - **trends**: Week-over-week change — `impressionsTrend`, `engagementTrend`, `comparedTo`, `percentChange` - **alerts**: Anomalies and significant events (intra-week + week-over-week) @@ -271,6 +272,24 @@ Format the data into a readable report using this template: **Posts published:** {summary.totalPosts} **Engagement rate:** {summary.avgEngagementRate}% +### 🌐 Reach Split (only when `summary.avgOutOfNetworkPct` is present) + +| Half | Share of impressions | Reads as | +|------|----------------------|----------| +| Out-of-network | {summary.avgOutOfNetworkPct}% | Acquisition — the post reached people who do not follow you | +| In-network | {100 - summary.avgOutOfNetworkPct}% | Resonance — it landed with the audience you already have | + +State the coverage honestly: the share is impressions-weighted across **only the +posts that carried a manual reading**. If the field is absent, say the split is +**not entered for this period** — never estimate it, and never print 0%. + +Read the two halves against the week's goal, not as a score: +- **Out-of-network up, engagement rate flat/down** — the algorithm distributed it to + strangers who did not act. Reach without resonance; the hook traveled, the body did not. +- **In-network high, out-of-network low** — deepening, not growing. Fine for a trust play, + a problem if the week's goal was audience growth. +- **Both up** — the topic has pull beyond your circle. This is the pattern worth repeating. + ### 🏆 Top Performers **Most Impressions:** @@ -335,6 +354,95 @@ Delegate interpretation to the `analytics-interpreter` agent (report mode) — i 3. **Test carousel format**: Your one carousel got 3x more shares than text posts. Create a carousel for your top-performing topic this week. ``` +## Step 7b: Persist them as do-next directives (the measurement→creation contract) + +A recommendation that only lands in chat dies there — the next post is drafted in a fresh +session that never saw it. **Persist the 2–3 recommendations above as do-next directives**, so +`/linkedin:post`, `/linkedin:quick`, `/linkedin:batch`, `/linkedin:create` and +`/linkedin:newsletter` read them at their Step 0 and the next piece is actually shaped by this +report. This step is not optional when a report produced recommendations. + +Each directive is an **imperative the next draft can act on** plus an **evidence pointer** — +the measured number it came from, so the create surface can tell a directive backed by 12 posts +from one backed by 2: + +```bash +node --input-type=module -e " +import { writeState, recordDoNext } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs'; +writeState(content => recordDoNext(content, { + recordDate: 'YYYY-MM-DD', + source: 'report', + directives: [ + { directive: 'Plan 2 AI-agent posts this week', evidence: 'weekly 2026-W22: 3 AI posts avg 2400 impressions vs 1200 baseline (n=12)' }, + { directive: 'Move Tuesday posts to 08:00', evidence: 'weekly 2026-W22: Tuesday -40% impressions' } + ] +})); +" +``` + +**A reach reading belongs in this channel too.** When `avgOutOfNetworkPct` is present, the +split is a directive about *what to write next*, not a number to admire — so persist it here +rather than leaving it in the report: + +- Out-of-network high on one topic → `'Write the next post on <topic> — it travels past your network'`, evidence `'weekly 2026-W23: <topic> 58% out-of-network vs 31% weighted average'` +- Out-of-network up but engagement rate down → `'Keep the <topic> hook, rebuild the body — strangers saw it and did not act'` +- Out-of-network low across the week → `'Next post targets a problem non-followers search for, not an in-circle reference'` + +Never persist a reach directive when the field is absent — an unentered split is unknown, and a +directive with no measured evidence pointer is exactly what this channel exists to prevent. + +**Lifetime (do not hand-manage it):** `recordDoNext` replaces this source's own previous rows on +every write and drops any row older than 60 days, so the section stays a live steering signal +rather than a growing backlog. Directives from other sources (`ab-test`, `analyze`, +`48h-monitor`) are left alone — never clear the section by hand. + +Then confirm in one line what was persisted, e.g. "2 do-next directives written to state — the +next post/newsletter will read them at Step 0." + +## Step 7c: Offer a baseline update to `engagement-patterns.md` (operator-gated) + +The do-next directives above steer the **next** piece. This step maintains the **standing** +baseline every other surface reads: `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/engagement-patterns.md` +— the file `analytics-interpreter`, `strategy-advisor` and `/linkedin:audit` treat as the user's +tracked truth about their own audience. It goes stale silently: nothing in the pipeline writes it, +so it keeps asserting last quarter's best time and topics while the reports say otherwise. + +So after a report, **offer a diff** — never write it silently: + +1. **Read the baseline file.** If it is missing or still all `[Day]`/`[Topic]` placeholders, say so + and offer to seed it from this report instead of diffing. +2. **Compare only what this report actually measured** — best posting windows, top-performing + topics, format performance, and (when entered) the reach split. Leave every other section alone. +3. **Present each proposed change as old → new with the evidence**, e.g.: + +``` +Proposed baseline updates (from weekly 2026-W23, n=11 posts): + +1. Primary posting window + old: Tuesday at 12:00 — avg 1,200 impressions + new: Tuesday at 08:00 — avg 2,050 impressions (4 posts over 3 weeks) + +2. Top-performing topic + old: (empty) + new: agent orchestration — avg 4.1% engagement, 58% out-of-network (3 posts) + +3. Format performance + old: carousel — untracked + new: carousel — 3x shares vs text (1 post — thin, flagged as provisional) +``` + +4. **Ask for an explicit go** with `AskUserQuestion` — options: **Apply all** · **Apply + selected** · **Skip**. Skipping is a normal outcome; a thin sample is a reason to skip. +5. **Only on an explicit go, edit the file** — change the affected lines and append to that + section's `**Update Log:**` with the date and what the number came from. Never rewrite + sections the report did not measure, and never delete the user's own annotations. +6. If a proposed change rests on **1–2 posts**, label it *provisional* in the diff and say plainly + that it is not yet a pattern. Do not let a single post rewrite a baseline. + +Write only to the per-user data dir path above — never to the plugin's shipped seed +(`engagement-patterns-template.md` and its placeholder sibling), which is the template +the data dir was initialized from, not the user's data. + ## Step 8: Offer Deep Dive Options After presenting the report, ask: diff --git a/commands/trends.md b/commands/trends.md new file mode 100644 index 0000000..4fd81fe --- /dev/null +++ b/commands/trends.md @@ -0,0 +1,169 @@ +--- +name: linkedin:trends +description: | + Run a trend discovery pass over the user's own content pillars and source list: + delegate the scan to the trend-spotter agent, make sure kept candidates are persisted + to the trend store (dedup) and the dated morning brief is written, then return a + triage-ranked candidate list the user resolves per id (select/skip, batched). Default scoring mode + is long-form (chronicle/newsletter material); `--mode kortform` overrides for feed posts. + Use when the user wants a discovery pass, a trend scan, or a morning-brief refresh. + Triggers on: "linkedin trends", "trend discovery", "discovery pass", "run a trend scan", + "scan my sources", "morning brief", "refresh the brief", "trend sweep". +allowed-tools: + - Read + - Bash + - Task + - AskUserQuestion +--- + +# LinkedIn Trend Discovery + +You are a thin discovery orchestrator. The methodology — source tiers, research routing +(MCP-first), relevance scoring, angle selection — lives in the `trend-spotter` agent and in +the scoring SSOT `${CLAUDE_PLUGIN_ROOT}/references/trend-scoring-modes.md`. Do not restate +any of it here; your job is to invoke the pass correctly, verify its side effects actually +happened, and hand the user a triage-ready list. + +Data dir shorthand used below: `${DATA}` = `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}`. +Trends CLI shorthand: `CLI` = `cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && node --import tsx src/cli.ts`. + +## Step 0: Parse flags + +All flags are optional, given after the command name: + +| Flag | Meaning | Default | +|------|---------|---------| +| `--mode kortform\|long-form` | Scoring mode (see the SSOT for what each rewards) | **long-form** — no arguments means a long-form discovery pass | +| `--fresh-days N` | Freshness window for the morning brief | CLI default (7) | +| `--brief-only` | Skip the discovery poll entirely; render the brief from the existing store | off | +| `--demand` | Run the **demand-sweep** (`demand-spotter`, «innenfra og ut») instead of supply-side discovery: poll where readers ASK, fill the reader fields, render the §4 arc map | off | +| `--dry-run` | Poll + score, but persist nothing: no capture, no brief, no status writes, no last-run marker | off | + +Note the mode inversion deliberately: the **agent's** own default is kortform, this +**command's** default is long-form. That is why Step 2 must always pass the mode explicitly. + +**`--demand` reroutes the pass** (Step 2D below) to the demand side — a different agent +(`demand-spotter`), a different unit (the arc/åre, not the news-item), a different output (the §4 +arc map, not the pillar brief). The two are complementary: run discovery to find *what happened*, +then `--demand` on a chosen theme to see *what the reader is stuck on*. + +## Step 1: Load context + +1. **Pillars:** Read `${DATA}/profile/user-profile.md` and extract the content pillars / + expertise areas. If the file does not exist, ask the user for their pillars before + proceeding (one question, comma-separated answer). +2. **Source list:** Resolve which list this pass will use — `${DATA}/trends/sources.md` if it + exists, otherwise the shipped defaults `${CLAUDE_PLUGIN_ROOT}/config/trends-sources.template.md`. + Tell the user which one applies. Do not read research-tooling or route research yourself — + the agent owns Research Routing (its own "Research Routing" section reads the profile's + `### Research Tooling` block); duplicating it here would drift. + +## Step 2D: Run the demand-sweep (only on `--demand`) + +**If `--demand`:** this replaces Steps 2–4 (the supply-side discovery pass). Delegate to the +demand-spotter agent — invoke it via `Task` with `subagent_type: linkedin-studio:demand-spotter` +(foreground). The prompt MUST state explicitly: + +- The pillars and the resolved source-list path from Step 1 (its **Tier 5** demand sources are what + this pass polls — the inverse of the Tier 1–4 supply sources). +- The theme(s) to sweep (from `--topics` if given, else the user's pillars). +- That the three passes are mandatory: demand-sweep (verbatim questions + signal strength + is-it-answered) → pain-point map (fills `painPoint`) → vocabulary translation (fills `readerQuestion`). +- That persistence is **mandatory unless `--dry-run`**: the agent runs `capture` with the reader + fields (`readerQuestion`/`painPoint`/`saturation`/`demand`/`verdict`/`actionability`), then renders + the §4 arc map with `CLI arcs`. +- That an **honest null is a finding** — a thin/un-swept source is reported, never filled in. + +Then present the arc map the agent returns: per vein, the ranked question inventory (reader's words), +the honest market verdict (supply-gap = write here · demand-gap = high-value/low-audience · saturated += skip), and the BÆRENDE/STØTTE reading order. On `--dry-run`, present the same map but state that +nothing was persisted. **Skip Steps 2–4.** Hand off: a chosen vein feeds `/linkedin:newsletter` +Step 1 (its reader question, pain point, and angle prefill the edition). + +## Step 2: Run the discovery pass + +**If `--brief-only`:** skip the agent entirely — go to Step 3 and render the brief from the +existing store. + +Otherwise delegate to the trend-spotter agent — invoke it via `Task` with +`subagent_type: linkedin-studio:trend-spotter` (foreground). The prompt MUST state explicitly: + +- **The scoring mode** from Step 0 (default long-form). Never omit it — the agent falls back + to kortform when the caller is silent. +- The pillars and the resolved source-list path from Step 1. +- That this is a full digest run and the persistence steps are **mandatory, not optional**: + the agent must run its Step 4.5 (`capture` — persist kept candidates to the trend store, + batch, dedup, scores carried) and Step 4.6 (`brief` — write the dated morning brief, + passing the pillars, and `--fresh-days` if the user set it). +- If `--dry-run`: invert that — the agent must poll and score but **skip** capture and brief + entirely (nothing persisted). +- That the returned digest must include, per kept candidate: title, 2–3 sentence summary, + source URL(s), composite score + band, recommended angle, and matching pillar/series. + +## Step 3: Verify the side effects (skip on `--dry-run`) + +Trust but verify — the pass is only done when its artifacts exist: + +1. **Store:** `CLI status --json` — confirm the store mutated (captured count reflects the + run; on a no-new-trends day `{added: 0, merged: N}` is a fine outcome, not a failure). +2. **Brief:** confirm today's file exists: `${DATA}/trends/morning-brief/<today YYYY-MM-DD>.md`. + If the agent captured but failed to render the brief, render it directly — the brief is + deterministic: `CLI brief --pillars "<pillar1,pillar2,…>"` (add `--fresh-days N` if set). +3. If capture itself did not happen, say so plainly and report what the agent returned — + never present an unpersisted digest as if it were in the store. + +## Step 4: Present the triage-ranked list + +Present the candidates ranked highest composite first (the agent's digest already carries the +ranking — do not re-rank). Per candidate, the contract is: + +``` +N. [Title] (id: <trend-id>) + Score: X.X — [Band] | Pillar: [pillar/series] + [2–3 sentence summary] + Source: [URL(s)] + Angle: [recommended angle] +``` + +Include each candidate's store id (shown in the brief and via `CLI list --json`) — the triage +step below resolves per id. On `--dry-run`, present the same list but say clearly that nothing +was persisted and there are no store ids to triage. + +## Step 5: Triage (skip on `--dry-run`) + +Resolve the top of the queue now instead of leaving it as homework. For the candidates in the +top bands (Immediate + High; cap at 8), use AskUserQuestion — one question per candidate, up +to 4 candidates per call, options: + +- **Velg** — you'll write about this: mark `selected`, moving it onto the brief's "I produksjon" + board (valgt) so the queue stops re-surfacing it while it's in progress +- **Skip** — not for me: mark `skipped` (dropped from the queue) +- **Leave** — keep it in the queue untouched + +Then apply the decisions through the store CLI. **Batch by verb** — collect all the "Velg" ids +and all the "Skip" ids and resolve each set in ONE call (ten candidates ≤ two calls): + +```bash +CLI select --ids <id1,id2,id3> # everything chosen this pass +CLI skip --ids <id4,id5> # everything rejected this pass +``` + +Single-id form (`--id <id>`) still works for a one-off. "Leave" means no call. A partial batch +(some id unknown) still applies the matches, reports the misses, and exits 0. Finish with a +one-line summary: N valgt, N skipped, N left in queue. (`act` — already written — and the +auto-`act` when an edition reaches scheduling arrive with the N7 trend→newsletter bridge.) + +## Step 6: Write the last-run marker (skip on `--dry-run`) + +On completed runs (including `--brief-only`), stamp the marker so other surfaces can tell when +discovery last ran: + +```bash +mkdir -p "${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends" && \ + date -u +"%Y-%m-%dT%H:%M:%SZ" > "${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/.last-run" +``` + +## Reference Files + +- `${CLAUDE_PLUGIN_ROOT}/references/trend-scoring-modes.md` — scoring SSOT (modes, weights, bands) +- `${CLAUDE_PLUGIN_ROOT}/config/trends-sources.template.md` — shipped source-list defaults (user override: `${DATA}/trends/sources.md`) +- `${CLAUDE_PLUGIN_ROOT}/agents/trend-spotter.md` — the discovery methodology this command invokes diff --git a/commands/video.md b/commands/video.md index 47a2085..e64bb35 100644 --- a/commands/video.md +++ b/commands/video.md @@ -172,9 +172,11 @@ Style: [minimal / branded / text-heavy] Auto-copy the POST CAPTION text to clipboard silently: ```bash -printf '%s' '<POST_CAPTION_TEXT>' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' +<POST_CAPTION_TEXT> +__LINKEDIN_CLIP_EOF__ ``` -Then confirm: "Post caption copied to clipboard." +Substitute `<POST_CAPTION_TEXT>` with the exact caption text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Post caption copied to clipboard." If it prints `FAILED:<platform>`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. ## Step 7: Refinement Cycle diff --git a/config/edition-state.template.json b/config/edition-state.template.json index 1db186e..165fed4 100644 --- a/config/edition-state.template.json +++ b/config/edition-state.template.json @@ -22,9 +22,11 @@ "visual-assets — cover (+ optional inline figures) or carousel deck: brief → generate → operator-gate → approve, BEFORE lock so build-linkedin.mjs picks them up (Step 7.5)", "lock-delivery — LOCK → POST.html all-in-one-place deliverable (Step 8)", "hook-conversion-gate — persona gate on distribution text post-lock: would YOU click? (Step 9)", - "scheduling — register edition in plugin queue/state for native LinkedIn scheduling (Step 10)" + "scheduling — register edition in plugin queue/state for native LinkedIn scheduling (Step 10)", + "retro — close the loop (≤5 min, AFTER scheduling): promote the pending articles.NN.foldIns with an explicit operator JA/NEI (mechanical → a contract-gate rule; judgment → the language-rules file / craft checklist), summarize the MEASURED effort from articles.NN.phaseLog, and ask ONE process-friction question. The corrections made in this edition become enforced before the next one starts (Step 11)" ], "articleStatusValues": ["pending", "in-progress", "locked", "scheduled"], + "phaseLog": "Per-article phase-transition log (N12 / A1-12) — the raw material for measuring lead time. Additive-optional (default [], NO schemaVersion bump — same pattern as sourceTrendId/targetLevel/language; an edition produced before N12 simply has none and the register treats its absence as \"not measured\"). Shape: [ { phase, completedAt } ] in transition order, where phase is one of the canonical _doc.phases identifiers and completedAt is an ISO-8601 timestamp. Written DETERMINISTICALLY, never by hand: every /linkedin:newsletter phase transition runs `scripts/editions` (`node --import tsx src/cli.ts register-upsert --edition-state <path>`), which appends the entry for the just-written currentPhase AND mirrors the edition into the editions register (${LINKEDIN_STUDIO_DATA}/editions/register.json) in one call — the log and the register cannot drift apart because nothing writes one without the other. Idempotent against an immediately repeated transition (a re-run of the same step logs once), but a phase that recurs AFTER another one is logged again: /linkedin:pivot legitimately sends an edition back through cleared gates, and that second pass is real production time. Per-article rather than top-level because lead time is a property of an edition, mirroring articles.NN.phase. The register is a MIRROR of this file, never a source of truth — losing it costs telemetry, not an edition.", "editorialReview": "Per-article editorial-review record written by Step 5.5 (editorial-review phase). Runs AFTER fact-check (Step 5) and BEFORE the persona sweep (Step 6): the editorial-reviewer agent judges CRAFT (prose-craft + narrative-architecture), not reader-response, mirroring the Maskinrommet skrivekontrakt §C2. The report (≤10 flags, each with kategori P1–P5/A1–A5, quote/line-ref, direction, severity BLOCK/REWORK/NICE) is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { reportPath, flagCount, byAxis: { prosa, arkitektur }, bySeverity: { block, rework, nice }, foldedIn, waived, status }. status ladder: pending → reviewed → folded. null until Step 5.5 runs. This is the craft companion to factcheckLog (truth) and personaSweep (response).", "visualAssets": "Per-article visual-asset record written by Step 7.5 (visual-assets phase). Runs BEFORE lock because render/build-linkedin.mjs picks up linkedin/NN/cover.png + the edition-config credit/caption when it builds POST.html — generating images after lock would force a re-render. Shape: { format: \"standard\" | \"carousel\"; cover: { brief, route, candidates[], approved, status }; figures: [ { id, brief, placement, status } ]; carousel: null | { source, pdf, status } }. format \"standard\" = cover + optional inline figures (cover.png is mandatory per the KTG cover-directive); format \"carousel\" = typografisk deck via render/build-carousel.mjs instead of cover+inline (cover/figures stay empty). route: \"mcp-image\" (default, via mcp__mcp-image__generate_image) | \"external\" (DALL·E / Midjourney / photographer → linkedin/NN/cover-raw.png). status ladder: pending → briefed → generated → approved. candidates[] holds the cover-v<N>-kandidat.png attempts; approved is the fixed approved name (\"cover.png\") once the operator-gate passes. figures[].id = \"fig1\"..; placement = section reference in NN-utkast.md (figures are referenced in the draft via ![alt](linkedin/NN/figN.png) and uploaded manually in the LinkedIn editor — build-linkedin.mjs does NOT embed them). Naming convention: cover.png (approved, fixed — what build-linkedin.mjs reads) | cover-v<N>-kandidat.png (attempts) | cover-raw.png (optional external pre-edit source) | fig<N>.png (inline). credit + caption are recorded in <serie>/linkedin/image-credit-caption.md and flow into edition-config.json coverCredit + captions[NN].", "livedSpecifics": "Per-article lived-specifics binding (Fix #2 slice 2, kilde-så-draft). A slot-map binding each LOAD-BEARING key-point (or, later, section) of this edition to either a real specific from the specifics-bank OR an explicit escape decision — so a draft starts grounded in the operator's real material instead of inventing plausible filler. Resolved at Step 1.5 (lived-specifics extraction, BETWEEN brief (Step 1) and research (Step 2); wired in slice 3) via a guided interview that REFUSES vague answers. THE GATE (drømme-spec G4, «vaghet avvises», enforced at the Step 2.5 skeleton-gate): every slot must resolve to \"specific\" (grounded), \"abstrakt\" (deliberately abstract here, with a rationale), or \"ekstern\" (backed by research, not lived material) — a slot left \"unresolved\" is the vagueness that BLOCKs. Numbers stay guilty-until-checked: a slot backed by an UNVERIFIED number is allowed but WARNED (regel 6/7), so Step 2 research verifies it before the draft asserts it. The deterministic validator + the rendered NN-kilder.md artifact live in scripts/specifics-bank (binding.ts/kilder.ts; `node --import tsx src/cli.ts validate-binding|render-kilder --edition <edition-state.json>`). Shape: { slots: [ { slotId, kind: \"key-point\" | \"section\", label, binding: ({ type: \"specific\", specificId } | { type: \"abstrakt\", rationale } | { type: \"ekstern\", source? } | { type: \"unresolved\" }) } ], status: \"pending\" | \"bound\" }. Default { slots: [], status: \"pending\" }: populated on first Step 1.5. Companion artifact: <serie>/linkedin/NN-kilder.md, rendered from this + the bank (regenerated, never hand-edited). This is the per-edition consumer of the global specifics-bank.", @@ -32,7 +34,10 @@ "headlessReview": "Per-article headless-review record written by Step 6.5 (headless-review phase). Runs AFTER the in-session persona sweep (Step 6) and BEFORE lock (Step 8), on a FROZEN snapshot of the publish-ready (or pivoted) draft, fanned out from the command layer (foreground) or invoked standalone via /linkedin:headless-review in a fresh/cold session. Five archetypes judge independently with NO drafting-session context: content-reviewer (argument integrity), language-reviewer (Norwegian language), fact-reviewer (cold re-verification incl. claims a late pivot bolted on), persona-reviewer mode=resonans (per active persona), persona-reviewer mode=konverter (primær, hook only). The consolidated report is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { frozenDraft, reviewers: { content, language, fact, personaResonance, personaConversion } (each { reportPath, summary, status }), consolidatedReport, foldedIn, waived, status }. status ladder: pending → run → folded. null until Step 6.5 runs. This is the adversarial-independence companion to the in-session gates (editorialReview, personaSweep, factcheckLog) — deliberately redundant: a cold reader catches what the framing-biased in-session pass missed.", "pivots": "Per-article pivot log (Endring 9c). A pivot is a substantive change to a draft AFTER a gate had already cleared — e.g. a new argument anchor / section added late (the Del 4 Security Champions case: +~530 words, 2 new sections, +42 %). Each /linkedin:pivot invocation appends one entry and moves currentPhase back so the cleared gates (Steps 5–6.5) re-run on the pivoted version before lock. Heuristic (documented, checked at the Step 8 lock precondition): if the current draft's word count differs > 20 % from the version that last cleared Step 6, OR it has > 2 new sections, a pivot-reopen is suggested/required. Each entry: { timestamp, reason, fromPhase, toPhase, wordCountBefore, wordCountAfter, deltaPct, newSections, gatesToRerun: [phase…] }. Default [].", "foldIns": "Per-article accumulation queue (slice 2 of fix #1 — «rettelser fester seg»). Each correction KTG makes during an edition that is NOT yet a contract rule is captured here, then routed by the JA-promoter (maskinrommet/docs/skrivekontrakt.md §E) to a permanent home so it never has to be re-discovered: a MECHANICAL correction → a rules.ts gate rule (BLOCK/WARN) + a §B-row/§C1/§C2-box + a §E-manifest row (the contract-gate `ratify` check then asserts rules.ts ↔ §E-manifest stay in bijection); a JUDGMENT correction → a §C2-box only (stays with editorial-reviewer, no gate rule). Capture is per-article (provenance = which article surfaced it); promotion is series/contract-wide. Each entry: { id, date (ISO-8601), correction (what KTG corrected, near-verbatim), trigger (where/why it surfaced), classification: \"mechanical-block\" | \"mechanical-warn\" | \"judgment\" | null (set at the classify step), decision: \"pending\" | \"promoted\" | \"rejected\" (the JA-promoter outcome), ruleId: <rules.ts id> | null (set on promote for mechanical), note?: where a judgment/rejected fold-in landed }. Default []. Rejected fold-ins are kept for traceability, never deleted.", - "language": "Review language for this series/edition (additive, default \"en\"). Threads into the long-form review agents so they grade against THIS language's rules: language-reviewer applies Norwegian-specific checks (anglicism→Norwegian idiom, «kanselli-stil») only when language == \"no\"; voice-scrubber's gold standard is the approved editions IN this language; any other value → the agents apply that language's equivalents and never grade prose against Norwegian idiom. \"no\" = Norwegian (the author's case). Resolved at Step 1 / load-context and passed to the language-dependent agents." + "retro": "Per-article retro record written by Step 11 (retro phase), the step that makes a correction STICK. Additive-optional (default null, NO schemaVersion bump — same pattern as phaseLog/sourceTrendId/targetLevel; an edition produced before N14 simply has none). Runs AFTER scheduling (Step 10) because the retro is about the NEXT edition, not this one's delivery. Three jobs, all recorded here: (1) fold-in promotion — every articles.NN.foldIns row with decision \"pending\" gets an explicit operator JA/NEI; JA on a mechanical correction promotes it atomically to a scripts/contract-gate rule (rules.ts + contract row + manifest row, `--ratify` green or the promotion does not count), JA on a judgment correction appends it to the user language-rules file (${LINKEDIN_STUDIO_DATA}/language-rules/<lang>.md) or the craft checklist; NEI marks the row \"rejected\" and KEEPS it. (2) effort summary — read from the MEASURED articles.NN.phaseLog (N12), never re-estimated: number of transitions, phases that recurred (a re-run gate), and the elapsed lead time. (3) ONE process-friction question, whose answer is handed BACK to the operator — the plugin never writes the operator's own notes/register. Shape: { promoted, rejected, ruleIds: [<rules.ts id>…], languageRulesAppended, effort: { transitions, recurredPhases: [phase…], leadTimeDays }, frictionNoted: boolean, completedAt }. The full loop (capture → classify → promote → enforce) is documented domain-generally in references/fold-in-loop.md.", + "language": "Review language for this series/edition (additive, default \"en\"). Threads into the long-form review agents so they grade against THIS language's rules: language-reviewer applies Norwegian-specific checks (anglicism→Norwegian idiom, «kanselli-stil») only when language == \"no\"; voice-scrubber's gold standard is the approved editions IN this language; any other value → the agents apply that language's equivalents and never grade prose against Norwegian idiom. \"no\" = Norwegian (the author's case). Resolved at Step 1 / load-context and passed to the language-dependent agents.", + "sourceTrendId": "Per-article provenance link to the trend this edition was started from (N7 trend→newsletter bridge, MR-F3). Additive-optional (default null, NO schemaVersion bump — an edition started manually never sets it, and Step 10 reads its absence as \"no source trend\"). Set at the Step 1.5 checkpoint when Step 1's trend-intake read a candidate from the trends store (scripts/trends) to prefill the brief (angle / targetLevel / key-points / source-URLs). Its ONE runtime consumer is Step 10 (scheduling): when the article reaches scheduling AND sourceTrendId is set, the command flips that trend's store status to \"acted\" (trends CLI `act --id <sourceTrendId>`), closing the discovery→production loop deterministically instead of by hand. Value = the trend's store id (normalized title+url, as shown in the /linkedin:trends brief and `CLI list --json`).", + "targetLevel": "Per-article target-level for the edition (N10 / C-8). Additive-optional (default null, NO schemaVersion bump — same pattern as sourceTrendId/language; an edition that never resolves one leaves it null and the reader agents fall back to each persona's own `ekspertise` field). Fixes WHERE this edition sits on the configured target-level span (references/longform-quality-rules.md + config/personas.template.md): it tells persona-reviewer which end is the reader-facing (primær) end and which is the mandatory-secondary end, so «practically usable at all levels» is judged from both ends rather than one baked-in reader role. Set at Step 1 of /linkedin:newsletter — the N7 trend bridge / N6 proposal layer pre-fills it from the trend record's `targetLevel` (scripts/trends), the operator confirms or edits it during calibration, and it is persisted here at the Step 1.5 checkpoint (alongside sourceTrendId). Free-text level descriptor (e.g. \"line leader → solution engineer\", \"beginner → practitioner\"), NOT an enum — the span is domain-general and defined by the operator's persona set, never hardcoded." }, "schemaVersion": 1, "series": { @@ -48,6 +53,7 @@ "title": "<Article 1 title>", "phase": "load-context", "status": "pending", + "phaseLog": [], "personas": [], "livedSpecifics": { "slots": [], @@ -89,8 +95,11 @@ }, "pivots": [], "foldIns": [], + "retro": null, "locked": false, - "scheduled": null + "scheduled": null, + "sourceTrendId": null, + "targetLevel": null } } } diff --git a/config/language-rules.template.md b/config/language-rules.template.md new file mode 100644 index 0000000..f222537 --- /dev/null +++ b/config/language-rules.template.md @@ -0,0 +1,70 @@ +--- +language: en +schemaVersion: 1 +--- + +# Accumulated language rules — `<language>` + +> **OPT-IN — the plugin never writes this file for you.** Copy it into your data +> dir, one file per review language, and let it grow: +> +> ```bash +> mkdir -p "${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/language-rules" +> cp "${CLAUDE_PLUGIN_ROOT}/config/language-rules.template.md" \ +> "${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/language-rules/en.md" +> ``` +> +> Name the file after the edition's `language` value (`en.md`, `no.md`, …). +> While it is absent, the shipped baseline applies alone and nothing warns. + +This file is **yours**. It holds the language corrections you have made that you +do not want to make again — the judgment half of the fold-in loop +(`references/fold-in-loop.md`). Two things read it: + +- **`agents/language-reviewer.md`** (Step 6.5, cold review) — grades against + these rules in addition to its five standard checks, so a correction you + confirmed once is caught every edition after. +- **`/linkedin:newsletter` Step 4** (consistency + quality) — reads it while the + draft is still being cleaned, so the defect is *prevented*, not only caught. + +**`/linkedin:newsletter` Step 11** (retro) appends to it: when you promote a +judgment-class fold-in, the rule lands here with the edition that surfaced it. +Mechanical corrections do **not** come here — those become deterministic +`scripts/contract-gate` rules instead. + +## Relationship to the shipped baseline + +`references/longform-quality-rules.md` rule 3 (the AI-slop ban-list) is the +**baseline** and is never edited by the loop. This file **extends** it. Where the +two disagree, the stricter one wins; if you actually want to overturn a baseline +rule, say so explicitly under *Deliberate exceptions* below — an unexplained +contradiction reads as drift to the next reviewer. + +## Format + +One rule per bullet. Keep each rule **checkable** — a reviewer must be able to +tell from the text alone whether it was broken. Add the date and the edition that +surfaced it, so a rule that turns out to be wrong can be traced back and dropped. + +```markdown +- **<what to avoid>** → <what to do instead>. _(<YYYY-MM-DD>, edition <NN>)_ +``` + +Good: *«"leverage" as a verb → use the plain verb ("use", "build on").»* +Bad: *«write better sentences»* — nothing can check that. + +--- + +## Rules + +<!-- Step 11 appends promoted judgment-class fold-ins below this line. --> + +_(empty — no rules accumulated yet)_ + +## Deliberate exceptions + +Baseline rules you have consciously decided not to follow, **with the reason**. +Keep this short; a long list here means the baseline is wrong for your practice +and should be discussed, not quietly bypassed. + +_(none)_ diff --git a/config/personas.template.md b/config/personas.template.md index 881403f..984acce 100644 --- a/config/personas.template.md +++ b/config/personas.template.md @@ -13,7 +13,7 @@ cp config/personas.template.md config/personas.local.md ``` `personas.local.md` is gitignored (via `*.local.md`) so your active overrides -stay local. The template ships the three Seres seed personas below; clone, +stay local. The template ships the four Seres seed personas below; clone, trim, or extend them per series. --- @@ -23,11 +23,16 @@ trim, or extend them per series. - **Per-project selection.** `/linkedin:newsletter` (Step 1) picks the relevant personas from this library and marks the primary in the edition brief. - **«primær trumfer».** Exactly one persona is the **primær** reader. On - conflict between personas, the primær weighs highest. A *secondary* NO caused - by role mismatch or an expertise ceiling («this I already know cold») is a - SIGNAL that the gate works — accept it, do not distort the text to chase it. - A *primær* NO is **not** accepted: the text is revised until the primær - reaches a clean YES. + conflict between personas, the primær weighs highest. But the active set MUST + include a **mandatory secondary at the opposite end of the target-level span** + from the primær (a technical reader when the primær is non-technical, and vice + versa) — one reader alone cannot certify a text «practically usable at all + levels», so a lone primær JA is not enough. A *secondary* NO caused by role + mismatch or an expertise ceiling («this I already know cold») is a SIGNAL that + the gate works — but it may be waived as «signal, not failure» **only with an + explicit ceiling justification** that names which end of the span hit its + ceiling; an unexplained secondary NO is a real flag, not a free pass. A *primær* + NO is **not** accepted: the text is revised until the primær reaches a clean YES. - **Two sweep modes** (same `persona-reviewer` agent): resonance mode (Step 6, BEFORE lock — «does the point land for this reader?») and conversion mode (Step 9, after lock — binary «would YOU click?» on the hook only). @@ -64,8 +69,9 @@ can be promoted to a reusable block by pasting them into `personas.local.md` The persona sweep is not advisory — it returns a **blocking verdict** (PASS / REWORK / BLOCK), and the bar is the **primær reader's genuine, unqualified -JA**. The three Seres seed personas are the canonical set: **A = IT-divisjonsdirektør** -(sekundær), **B = KI-seksjonsleder** (sekundær), **C = Linjeleder** (PRIMÆR — trumfer). +JA**. The four Seres seed personas are the canonical set: **A = IT-divisjonsdirektør** +(sekundær), **B = KI-seksjonsleder** (sekundær), **C = Linjeleder** (PRIMÆR — trumfer), +**D = Løsningsutvikler/AI-ingeniør** (sekundær — the mandatory technical end opposite C). - **Bar = C ekte JA.** A clean, unqualified yes from the primær. **«JA med store forbehold» = NEI.** @@ -143,6 +149,27 @@ pipeline and the structural check key off them: - **sjargong** — Lav toleranse for teknisk sjargong; setter pris på presise, hverdagsnære formuleringer. +### Persona 4 — Løsningsutvikler/AI-ingeniør (sekundær) + +> **Technical end of the target-level span** (opposite the primær persona). The +> mandatory secondary the «primær trumfer» rule requires: if the primær (Linjeleder) +> anchors the reader-facing end, this persona anchors the technical end, so a text +> that claims to work «at all levels» is judged from both ends, not just one. + +- **rolle** — Bygger og drifter AI-løsningene i praksis (utvikler, ML-ingeniør eller + løsningsarkitekt); skriver koden, velger modellene, eier integrasjonene. +- **avkobler** — Ledelses-abstraksjoner uten teknisk substans; «AI-magi» uten hvordan; + påstander om hva som er mulig som ikke tåler et implementasjons-blikk; nedlatende + forenkling av ting hen kan fra innsiden. +- **overbeviser** — Konkret hvordan-det-faktisk-virker, ærlige begrensninger og + feilmodi, reelle avveininger (latency/kost/kvalitet), og respekt for at + dømmekraften i systemdesign er ekte fag. +- **ekspertise** — Høy teknisk. Ekspertise-tak på grunnleggende forklaringer: en + «hva er en API»-passasje faller flatt (sekundær-NEI her er et signal — men bare + med eksplisitt ceiling-begrunnelse, jf. «primær trumfer»). +- **sjargong** — Høy toleranse for teknisk/kode-sjargong; lav for lederspråk og + konsulent-buzzwords. + --- ## Adding a persona diff --git a/config/publishing-slots.template.json b/config/publishing-slots.template.json new file mode 100644 index 0000000..0863663 --- /dev/null +++ b/config/publishing-slots.template.json @@ -0,0 +1,16 @@ +{ + "_doc": { + "purpose": "Schema for publishing-slots.json (N13 / A1-10) — the recurring weekly slots you publish in. Session start reads it to name the next slot nothing covers yet, /linkedin:calendar shows the grid, and /linkedin:newsletter Step 10 defaults its scheduling to the next open slot instead of asking.", + "install": "OPT-IN — the plugin never writes this file for you and imposes no schedule. Copy it into your data dir and edit the times: cp \"${CLAUDE_PLUGIN_ROOT}/config/publishing-slots.template.json\" \"${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/publishing-slots.json\". While the file is absent, every slot surface stays silent.", + "slots": "Recurring weekly slots: day = a weekday, either three-letter (mon/tue/wed/thu/fri/sat/sun) or spelled out; time = 24-hour HH:MM (zero-padded). label is free text shown next to the slot (e.g. which format usually goes there). Unparseable rows are dropped rather than throwing — a typo costs you that slot, not your session start.", + "timezone": "IANA zone the times are meant in, e.g. \"Europe/Oslo\" or \"America/New_York\". Recorded for your own clarity and displayed as-is; the plugin does not convert times (it schedules in your local wall clock, like the queue).", + "coverage": "A slot counts as covered when something lands on that date: a queue entry with status scheduled or published (/linkedin:batch, /linkedin:quick, Step 10 of an edition), or an editions-register row whose slot claims that date. Cancelled entries free the slot again. A day carrying two slots needs two posts to be full.", + "defaults": "The times below are the plugin's own DIRECTIONAL 2x/week starting point (references/scheduling-strategy.md — confidence: low / directional, no primary source). They are a hypothesis to test, not a recommendation about your audience. Replace them with what your own analytics show (/linkedin:report, /linkedin:analyze)." + }, + "schemaVersion": 1, + "timezone": "", + "slots": [ + { "day": "tue", "time": "08:30", "label": "" }, + { "day": "thu", "time": "12:00", "label": "" } + ] +} diff --git a/config/state-file.template.md b/config/state-file.template.md index f2a3c6e..078ea9f 100644 --- a/config/state-file.template.md +++ b/config/state-file.template.md @@ -40,6 +40,9 @@ firsthour_active: false # true while a first-hour loop is in progress last_outreach_date: null # "YYYY-MM-DD HH:MM" of the most recent outreach contact outreach_active: false # true while an outreach pipeline is being worked +# Measurement -> creation (do-next contract) +last_donext_date: null # YYYY-MM-DD of the most recent do-next directive write + # Profile expertise_areas: - "general" @@ -77,3 +80,9 @@ expertise_areas: <!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. --> <!-- Format: ### [YYYY-MM-DD HH:MM] partner — track --> + +## Do-Next Directives + +<!-- Measurement -> next action. Written by /linkedin:report, /linkedin:analyze, /linkedin:ab-test (Adopt) and the 48h post-feedback-monitor; read by the create surfaces at Step 0. --> +<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer --> +<!-- Lifetime: each source replaces its OWN rows on its next write; rows older than 60 days drop. Max 3 per write. --> diff --git a/config/trends-sources.template.md b/config/trends-sources.template.md index a6fa997..dbb6c30 100644 --- a/config/trends-sources.template.md +++ b/config/trends-sources.template.md @@ -57,6 +57,19 @@ grouped by tier. Keep `Name — URL — note` so a poll can cite the URL. - [Earnings / report calendar] — [url] — scheduled releases - Seasonal themes: [Q1 …] · [Q2 …] · [Q3 …] · [Q4 …] +## Tier 5 — Demand signals (where the questions live) — the demand-sweep polls these + +*Tier 1–4 are SUPPLY sources — they tell you what was announced. Tier 5 is the inverse: where +your audience already **asks**. The `demand-spotter` agent («innenfra og ut») polls these to turn a +topic into the reader's own question, its pain point, and an honest saturation verdict. Ground truth +first (HN + GitHub APIs are exact); forums are approximate; some channels need an API (see the notes).* + +- [Practitioner forums / communities] — [url] — where people ask (Reddit, HN, product forums, Discord/Slack where reachable). *Reddit blocks direct fetch → counts are approximate; HN via the Algolia API is exact.* +- [GitHub issues on the spec/tool repos] — [url] — undervalued gold: real questions from the people building, with names + comment counts as signal strength (GitHub API = ground truth) +- [Procurement data] — [url] — the hardest demand indicator there is: someone has a budget and wrote the need down (e.g. a national tender portal / TED-equivalent for your market) +- [Regulator / supervisory guidance] — [url] — what your reader is *required* to take a position on (non-optional demand) +- [Comment fields on your channels] — [url] — *NB: YouTube/video comments are JS-rendered → need an API key; the largest blind spot. Specify the fetch method or mark the channel explicitly un-swept — an honest null is a finding.* + --- ## Your niche additions diff --git a/docs/agents-capability-matrix.md b/docs/agents-capability-matrix.md index 940e176..7758500 100644 --- a/docs/agents-capability-matrix.md +++ b/docs/agents-capability-matrix.md @@ -13,7 +13,8 @@ | content-planner | Sonnet | Cyan | Weekly/monthly content calendars | | network-builder | Sonnet | Teal | Strategic networking and outreach | | content-repurposer | Sonnet | Purple | Format conversion and evergreen refresh | -| trend-spotter | Sonnet | White | Trending topics and opportunity scoring | +| trend-spotter | (inherits session) | White | Trending topics and opportunity scoring | +| demand-spotter | (inherits session) | Silver | Demand-sweep «innenfra og ut»: reader-side questions, pain-point map, vocabulary translation, §4 arc map | | voice-trainer | Sonnet | Pink | Voice profile building and drift detection | | differentiation-checker | Sonnet | Gray | Originality scoring and commodity detection | | video-scripter | Sonnet | Violet | Video script creation with pacing and visual cues | diff --git a/docs/hardening/plan.md b/docs/hardening/plan.md index 1e22f54..820da07 100644 --- a/docs/hardening/plan.md +++ b/docs/hardening/plan.md @@ -93,6 +93,7 @@ that exercises the command's real path: | S7 batch | S14 import | S21 monetize | S28 ref-consistency B | | S8 pipeline | S15 report | S22 outreach | S29 terminology-scrub | | | | | S30 magnitude-scrub | +| | | | S31a/b/c multiplier-scrub | *S9 newsletter (16-phase) may split into S9a/S9b. Otherwise one command = one session. @@ -135,7 +136,9 @@ carries ~45% *correlational engagement gap* at medium confidence, not a 55% reac intact (officially confirmed, high confidence): engagement-pod + AI-slop "penalized" framing.** Full grep catalog in `log.md` S27 entry, Bucket D. Same discipline; hardening-class. -Run after S26; order adjustable (S27 ✅ → S28 → S29 → S30). These edit already-hardened files surgically and +Run after S26; order adjustable (S27 ✅ → S28 ✅ → S29a–e ✅ → S30 ✅ → S31a/b/c ✅ — queue complete, see `log.md`; +S31 was cataloged during S30 as the "Nx"-multiplier + descriptive-% class, amendment followed in practice). +These edit already-hardened files surgically and are hardening-class (commit local, no push). ## End-of-session ritual (every session — STATE.md handoff baked in) diff --git a/docs/hardening/review-R2a.md b/docs/hardening/review-R2a.md new file mode 100644 index 0000000..4a68c4e --- /dev/null +++ b/docs/hardening/review-R2a.md @@ -0,0 +1,207 @@ +--- +type: cold-review +batch: R2a +journey: "Create — atomic emitters" +scope: "FROZEN committed files vs HEAD 9567689 (no pending diff; post-hardening cold pass)" +method: "2 independent cold Opus reviewers per surface (intent + correctness), no cross-feed; every mechanical claim tool-grounded (anti-fabrication mandate); reviewers carry NO drafting-session context" +surfaces: [post, react, carousel, video, multiplatform] +reviewers: + - "intent-lens (conformance: intent delivery + cross-ref resolution + class predicates + terminology)" + - "correctness-lens (internal consistency + bound-vs-canonical + checklist arithmetic + structure)" +status: "COMPLETE — all 5 surfaces reviewed (post, react, carousel, video, multiplatform)" +verdict: REWORK +counts: { BLOCKER: 0, MAJOR_systemic: 1, MAJOR_surface: 1, MINOR: 13, SUGGESTION: 8 } +--- + +# Cold review — R2a (Create · atomic emitters) + +Independent post-hoc cold review of the 5 atomic Create-journey emitters, on the FROZEN committed +files. Mirrors the S1 `review.md` model (the one cold-review method that did **not** fabricate): +read-and-show before assert, every `file:line` tool-confirmed. The per-command interactive gate +(S2–S26, `log.md`) already passed these; this pass adds the **independent** axis that gate never had. + +**Independence cross-check worked:** on every WAVE-1 surface the two blind lenses converged on the +same real defects (personal-stories band in post; "full angle set below" in react; slide-scaffold + +slide-count in carousel) — convergence from two no-cross-feed reviewers is the signal that a finding +is real, not an artifact of one reviewer's framing. + +--- + +## ★ Cross-cutting finding (systemic — spans all 10 content commands) + +### MAJOR (systemic) — `printf '%s' '<TEXT>'` clipboard pattern silently corrupts content containing an apostrophe +- **Pattern (verified by main, independent of reviewers):** `printf '%s' '<PLACEHOLDER>' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs`, followed by an **unconditional** `Then confirm: "Copied to clipboard."` +- **Blast radius — all 10 content-emitting commands** (grep-confirmed): `post.md:153`, `quick.md:162`, `react.md:149`, `carousel.md:211`, `video.md:175`, `multiplatform.md:121`, `pipeline.md:139`, `first-post.md:141`, `firsthour.md:72`, `onboarding.md:221`. +- **Mechanism:** the executing model substitutes the draft into the **single-quoted** bash argument. English LinkedIn drafts almost always contain an apostrophe ("it's", "don't", "here's"); a literal `'` terminates the bash string → printf receives word-split fragments → clipboard gets garbled/truncated text (`printf '%s' 'It's a test'` → `Itsatest`). The command gives **no escaping guidance**. +- **Why it matters (trust-breaking):** the step runs "silently" then **unconditionally** reports success, so on the most common content shape the clipboard is corrupt while the user is told the copy worked. It is the headline convenience feature of every content command. +- **Severity note:** flagged MAJOR (not BLOCKER) — it doesn't crash the session, and a careful executing model *might* escape; but the instruction's default path fails. Independently surfaced by `rev-react-intent` (MAJOR) and `rev-post-intent` (SUGGESTION, "convention-level"). +- **Disposition (NOT fixed here — review finds, operator decides; 10-file change = own go):** switch the convention to a no-in-content-quoting form — write the draft to a temp file and feed via stdin (`node …/clipboard-helper.mjs < "$tmp"`), or a quoted heredoc. Fix once, consistently, across all 10. **Recommend treating this as the first fix that comes out of R2a.** + +--- + +## post.md — VERDICT: REWORK (1 MAJOR · 2 MINOR · 1 SUGGESTION) + +Class: post-emitting (primary) + guided/stateful (8-step). Both lenses confirm intent delivered; +all cross-refs resolve (2 agents, 2 routes, 2 scripts, 7 assets — tool-confirmed); no "thought leadership". + +### MAJOR — Step 3 "Personal stories" band (1,000–1,400) contradicts the file's own Step 5 gate + canonical SSOT (1,200–1,800) +- `post.md:87` — Step 3 assigns "Personal stories | Medium text post (1,000-1,400 chars)" — a third band present nowhere else. +- `post.md:128` — Step 5 quality checklist requires "Character count: 1,200-1,800 (optimal range)" for the same post. +- `hooks/prompts/content-quality-gate.md:17` — canonical: "Standard posts: 1,200-1,800". A personal story is a standard text post (not quick 150–500). +- **impact:** a personal-story draft written to Step 3 at ~1,050 chars passes Step 3 but FAILS the Step 5 checklist and the save-time quality-gate hook. The command self-contradicts. +- **Both lenses flagged this** (rev-post-correct MAJOR, rev-post-intent MINOR). Recorded at the higher severity: it hits a real gate path, not just advisory text. +- **disposition:** raise line 87 floor to 1,200–1,800, OR (if shorter personal stories are intentional) push the sub-band to the canonical SSOT first and reconcile `:128` + `content-quality-gate.md:17` — never leave a divergent band only in this file. + +### MINOR — Step 4 component minimums sum below the 1,200 optimal floor +- `post.md:100-104` — Hook 110-140 + Context 200-300 + Insight 400-800 + Implication 200-300 + CTA 50-100. Minimums sum to **960** (110+200+400+200+50); maximums to 1,640. +- **impact:** following every section at its minimum yields a 960-char post, below the 1,200 floor asserted at `:7/:86/:128` and canonical `:17`. Loose guidance, not a hard gate, but a writer hugging the low end lands under-length. +- **disposition:** accept as-is, or lift Insight/Context minimums so the component floor reaches ~1,200. + +### SUGGESTION — clipboard apostrophe breakage → see ★ cross-cutting finding (`post.md:153`). + +--- + +## react.md — VERDICT: REWORK (1 MAJOR · 3 MINOR · 1 SUGGESTION) + +Class: post-emitting + graceful-degradation on bad/empty URL (delivered, `react.md:60,194`). Intent +(URL→post pipeline) delivered; all 7 cross-refs resolve; no "thought leadership". + +### MAJOR — clipboard apostrophe breakage → see ★ cross-cutting finding (`react.md:148-151`). +(Originally surfaced here by rev-react-intent; promoted to the cross-cutting section.) + +### MINOR — `/linkedin:summarize` trigger has no backing command +- `react.md:9` — the frontmatter description lists "/linkedin:summarize" among triggers. `ls commands/summarize.md` → does not exist; not among the 29 commands. +- **impact:** advertises a slash-command alias that resolves to nothing; a user typing it gets no command. Dead/aspirational trigger. +- **disposition:** remove `/linkedin:summarize` from the trigger list, or add a summarize alias command. + +### MINOR — "the full angle set below" is a dead in-file locator +- `react.md:95` — "present 2-3 alternatives from **the full angle set below**." No enumerated full angle set appears below this line; Step 4's table (`:81-86`) lists only 4 preferred + 4 fallback; the 8 universal angles live in `references/content-angles.md` (`:273`), not "below." +- **Both lenses flagged this** (rev-react-intent + rev-react-correct). +- **impact:** dangling locator on the "try a different angle" path; the model must guess "below" means the reference file. +- **disposition:** change "the full angle set below" → "the 8 universal angles in `references/content-angles.md`". + +### MINOR — "medium post" label diverges from canonical "standard" band +- `react.md:119` — "Character target: 1,200-1,800 chars (medium post)". The **number is correct** (matches canonical standard band `content-quality-gate.md:17` + CLAUDE.md rule 2), but canonical/CLAUDE.md label it "standard"; no "medium" tier is defined anywhere. +- **impact:** cosmetic; the active length gate is unaffected. Risk is reader confusion that a separate "medium" tier exists. +- **disposition:** accept as-is, or relabel "(standard post)". + +--- + +## carousel.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 5 MINOR · 4 SUGGESTION) + +Class: guided/stateful (content-emitting). Intent (5–8 slide deck + caption, optional image render, +text-only degradation) delivered; all cross-refs resolve (differentiation-checker, templates, +algorithm-signals, clipboard + state scripts, mcp-image params valid); hook bound `:97` matches SSOT; +no "thought leadership". Clean on all blocking dimensions — findings are polish. + +### MINOR — slide-count minimum stated two ways (6 vs 5) +- `carousel.md:41-45` — Step 1 offers all 5 templates as "(6-8 slides)"; `carousel.md:114` — Step 5 gate checks "5-8 slides total (7 is optimal)". Minimum disagrees (6 vs 5). +- **Both lenses flagged this.** Mirrors the upstream split in `assets/templates/carousel-templates.md:11` ("5-8") vs per-template headers ("6-8"). +- **impact:** a 5-slide deck passes Step 5 but was never offered in Step 1. Cosmetic guidance, not a hard break. +- **disposition:** align the floor (pick 5 or 6 across Step 1 + Step 5); ideally fix the source too. + +### MINOR — inlined Step 5 checklist drops an item vs the source it cites (7 vs 8) +- `carousel.md:109` says "Run against the Carousel Quality Checklist from carousel-templates.md", then inlines 7 items (`:111-117`, `grep -c` = 7). The cited source has 8 (`carousel-templates.md:276-283`); the dropped one is `:283` "Exported as PDF, under 100 MB". +- **impact:** the export/size check only resurfaces in Step 6's text-only branch (`:184`); when image generation succeeds, the PDF/100 MB constraint is never surfaced in the gate. +- **disposition:** add the PDF/under-100 MB item to the Step 5 list, or stop claiming verbatim fidelity to the source. + +### MINOR — slide body scaffold provides 5 line-slots but the rule permits up to 7 +- `carousel.md:73-77` models 5 BODY lines (4-5 optional); `carousel.md:87` + `carousel-templates.md:10` permit "5-7 lines". +- **Both lenses flagged this.** +- **impact:** a slide legitimately needing 6-7 lines has no scaffold slot; the literal template caps generation at 5. +- **disposition:** extend the scaffold to 7 optional lines, or tighten the rule/template to "max 5". + +### MINOR — caption voice-guardian safety-net claim doesn't engage in this flow +- `carousel.md:123` — "(The voice-guardian hook scores the caption on save.)" The PreToolUse gate fires only on Write|Edit of LinkedIn content, but this command never Writes the caption — Step 6 pipes it to clipboard (`:210-212`), Step 7 mutates state via `node -e` (`:222-231`). No save → hook never scores the caption. +- **impact:** overstates a backstop that doesn't fire here; could justify under-doing the in-command De-AI check (Step 5). +- **disposition:** drop the parenthetical or qualify it ("only if you later save the caption to a file"). + +### MINOR — no-external-link rule (Content Quality Rule #3) absent from caption guidance +- `carousel.md:93-105, 121-125` — the caption is feed text on the same reach mechanics, but neither Step 4 nor the De-AI gate mentions the no-body-link rule. +- **impact:** a caption with an inline link incurs the documented reach penalty with nothing in this surface catching it. +- **disposition:** add "no links in the caption body (put links in first comment)" to the De-AI gate or Step 4. + +### SUGGESTION — orphan provenance comment for an unused capability +- `carousel.md:18` — `<!-- MERMAID_CHART_RESOLUTION: UNTESTED -->`. Mermaid is never referenced anywhere in the file (only mcp-image is used). +- **disposition:** remove the vestigial marker (or move the note to a design doc). + +### SUGGESTION — dual slide-count framing (6-8 vs 5-8); locally-defined per-slide bounds (header "max 8 words" `:70`, body "max 50 chars" `:73-77`, no canonical SSOT — no overlap with post bounds, noted for completeness); buzzword list 8 words `:123` faithful to CLAUDE.md Rule #4 while canonical hook flags 10 (gap lives between CLAUDE.md + hook, not in this file). + +--- + +## video.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 3 MINOR · 1 SUGGESTION) + +Class: guided/stateful (8-step script build) + post-emitting sub-surface (the 200–400 char caption). +Intent (paced 30s/60s/90s/2min video script + on-video captions + thumbnail + post caption + first +comment, delegated to `video-scripter`) delivered; `video-scripter` + `differentiation-checker` +resolve; word-budget math internally consistent (30/60/90/120s × 2.5 wps = 75/150/225/300, `:64-67`); +no "thought leadership". + +### MINOR — muted-watch statistic stated two ways +- `video.md:100` "~85% watch without sound" vs `video.md:120` "~80–85% watch muted" — same claim, two figures in one frozen file. +- **Both lenses flagged this.** 85% sits inside 80–85% so not a hard contradiction, but reads as unreviewed precision in a quality-gate checklist. +- **disposition:** pick one figure (recommend "~80–85%") in both places. + +### MINOR — post caption (200–400) is a third length band vs canonical quick (150–500) +- `video.md:121,158` specify "200-400 chars"; `content-quality-gate.md:18` quick = 150–500. 200–400 is a narrower subset (no hard conflict) but a band not present in the SSOT. +- **impact:** a reader can't tell from video.md whether 200–400 is intentional or drift. +- **disposition:** accept if intentional (captions deliberately shorter), but add a one-line note that 200–400 is a deliberate sub-band of the 150–500 quick range. + +### MINOR — caption has no mobile-cutoff / first-line hook discipline +- `video.md:122,129,156-158` — the caption (feed text, truncated at the same "...see more" cutoff as any post) gets a length band + De-AI + no-body-link + buzzword strip, but NO instruction to front-load value within the ~110–140 cutoff. (`:101` "first line reads on-screen" is the muted-autoplay test on the VIDEO's on-screen text, not the feed caption.) +- **impact:** vs text-post parity (SSOT hook 110–140) the caption's truncation is unguarded; a buried lede underperforms in-feed. +- **disposition:** defensible to accept (video is primary content), or add "caption's first line should land value before the mobile cutoff". + +### SUGGESTION — clipboard apostrophe breakage → see ★ cross-cutting finding (`video.md:175`). + +--- + +## multiplatform.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 3 SUGGESTION) + +Class: routing + guided/stateful (content-adaptation). **post-emitting predicate does NOT apply** — +this command consumes a LinkedIn post and emits adaptations for OTHER platforms (Twitter/slides/ +YouTube), so the LinkedIn quality-gate (hook 110–140 / length band / no-body-link / topic→pillars) is +out of scope; the buzzword check IS carried (`:46-48`, mirrors CLAUDE.md rule #4 exactly). Intent +delivered (3 promised platforms = 3 AskUserQuestion options = 3 templates); routing resolves +(`/linkedin:newsletter` at `:6,:34,:36,:132` → `commands/newsletter.md` exists); no subagent refs; +graceful degradation present (`:27-29`, forbids fabricating source); no "thought leadership". + +### MINOR — clipboard apostrophe breakage → see ★ cross-cutting finding (`multiplatform.md:121`) +- Elevated exposure noted: Twitter threads + YouTube CTAs are contraction-heavy (don't/it's/I'll), so this surface is *more* exposed to the systemic bug than most. + +### SUGGESTION — Twitter "280 chars max" is locally-defined (no LinkedIn SSOT) +- `multiplatform.md:53` — the only numeric bound in the file; an X/Twitter limit, correct value, cannot diverge from the LinkedIn SSOT. Noted for completeness. + +### SUGGESTION — "write once, publish everywhere" tagline overstates the command +- `multiplatform.md:4-5` — the tagline implies publishing; the command only adapts + saves to drafts (`:118`) + copies to clipboard. No publish action exists. +- **disposition:** accept, or soften to "adapt once, post everywhere" / "draft for every platform". + +### SUGGESTION — Step 1 always asks platform even when the trigger already names it +- `multiplatform.md:38-41` unconditionally invokes AskUserQuestion, yet triggers include "adapt for twitter"/"turn into thread" (`:7`) that already pin the platform — against the commands-section principle to minimize interactive steps / infer from context. +- **disposition:** accept, or "skip if the platform is already evident from the user's request". + +--- + +## Gate decision — R2a COMPLETE (5 surfaces) + +| Surface | Verdict | BLOCKER | MAJOR | MINOR | SUGGESTION | +|---|---|---|---|---|---| +| post | REWORK | 0 | 1 (+systemic) | 1 | — | +| react | REWORK | 0 | (systemic) | 3 | — | +| carousel | ALLOW | 0 | 0 | 5 | 4 | +| video | ALLOW | 0 | 0 | 3 | 1 | +| multiplatform | ALLOW | 0 | 0 | 1 | 3 | +| **★ cross-cutting** | — | 0 | **1 (systemic, 10 files)** | — | — | + +**Batch verdict: REWORK** — 2 of 5 surfaces (post, react), driven by **2 distinct MAJORs**: +(1) the systemic clipboard-`printf` corruption (10 content commands), and (2) post's personal-stories +band contradicting its own Step 5 gate + the canonical SSOT. **0 BLOCKER anywhere.** carousel / video / +multiplatform are ALLOW (polish only). + +**Independence verdict:** every WAVE-1 REWORK/MINOR was independently surfaced by **both** blind lenses +(clipboard, personal-stories band, "full angle set below", slide-scaffold, slide-count, muted-stat) — +the convergence signal that these are real defects, not single-reviewer framing. + +Cold review **finds**; it changes no code. Each fix is its own operator-gated decision. Recommended +first fix out of R2a: the systemic clipboard pattern (one change, 10 files, highest blast radius). +Local-only (hardening-class), not pushed. diff --git a/docs/hardening/review-R2b.md b/docs/hardening/review-R2b.md new file mode 100644 index 0000000..d79b954 --- /dev/null +++ b/docs/hardening/review-R2b.md @@ -0,0 +1,281 @@ +--- +type: cold-review +batch: R2b +journey: "Create — orchestrators & front-door" +scope: "FROZEN committed files at HEAD 5474df5 (clean tree; post-hardening cold pass)" +method: "2 independent cold Opus reviewers per surface (intent + correctness), no cross-feed; every mechanical claim tool-grounded (anti-fabrication mandate); reviewers carry NO drafting-session context" +surfaces: [create, batch, pipeline, newsletter] +reviewers: + - "intent-lens (conformance: intent delivery + cross-ref resolution + class predicates + terminology)" + - "correctness-lens (internal consistency + bound-vs-canonical + checklist arithmetic + structure)" +status: "COMPLETE — all 4 surfaces reviewed (create, batch, pipeline, newsletter)" +verdict: REWORK +counts: { BLOCKER: 0, MAJOR: 3, MINOR: 10, SUGGESTION: 6, systemic_patterns: 2 } +--- + +# Cold review — R2b (Create · orchestrators & front-door) + +Independent post-hoc cold review of the 4 Create-journey orchestrators/front-door, on the FROZEN +committed files (HEAD `5474df5`). Mirrors the S1 `review.md` + R2a model (the cold-review method that +did **not** fabricate): read-and-show before assert, every `file:line` tool-confirmed, reviewers carry +no drafting-session context. The per-command interactive gate (`log.md`) already passed these; this +pass adds the **independent** axis that gate never had. + +**Independence cross-check — two outcomes worth noting this batch:** +1. **Convergence** (the R2a pattern): both blind lenses independently surfaced the same real defect on + several surfaces — batch's bare-vs-prefixed reference path, batch's 3a/3b component-band tension, + newsletter's `allowed-tools` omission, create's 8-option `AskUserQuestion`. +2. **Divergence resolved by grounding** (new this batch, the strongest argument FOR the two-lens + method): on `newsletter` the intent-lens **asserted** the resumption table "maps every currentPhase + to the correct resume step"; the correctness-lens **counted** the rows (17) against the canonical + `_doc.phases` (18) and found the `contract-gate` row missing. Main re-grounded it independently + (below) → the correctness-lens is right. The independence axis caught a real MAJOR that one lens had + asserted away. + +--- + +## ★ Cross-cutting finding #1 (systemic) — 5-component draft scaffold (960–1,640) cannot satisfy the 1,200–1,800 standard band it is gated against + +- **Pattern (verified by main, independent of reviewers — `grep` blast radius):** the identical + 5-component "standard post" breakdown — `Hook 110-140` + `Context 200-300` + `Insight 400-800` + + `Implication 200-300` + `CTA 50-100` — appears in **exactly 3 files**: `post.md:100-104`, + `batch.md:88-92`, `pipeline.md:58-62`. (`grep -rln "Insight.*400" commands/` → those three only; + `quick`/`first-post`/`react` carry the lone `Hook 110-140` line, NOT the full scaffold, so are + unaffected.) +- **Grounded arithmetic:** component **min-sum = 110+200+400+200+50 = 960** · **max-sum = + 140+300+800+300+100 = 1,640**. Each file then gates the SAME post against the canonical standard band + **1,200–1,800** (`content-quality-gate.md:17`; `post.md:128`, `batch.md:95`, `pipeline.md:77`). +- **Mechanism (two-sided):** (a) a draft built at the component minimums is **960 chars — 240 below** + the 1,200 floor enforced by the next step AND the live PreToolUse `content-quality-gate` hook; + (b) the component maximums sum to **1,640 — 160 below** the 1,800 ceiling, so the scaffold literally + cannot reach the upper half of its own target band. +- **Why it matters:** on the most common path (drafting a standard text post) the drafting recipe and + the acceptance test are mutually incompatible, with no transition/connective slack to close the + 960→1,200 gap. In `batch` the defect is sharper: the 3a scaffold is **format-blind** (always the + 5-component standard layout) while the 3b gate is **format-aware** (`batch.md:114` `format: + [text/carousel/video]`, rotation `:76`), so a `quick`-format post built from the scaffold (960–1,640) + also blows the quick band (150–500). In `pipeline` the contradiction is between two **adjacent** + steps (Step 2 draft → Step 3 scorecard). +- **Severity:** recorded **MAJOR** in `batch` and `pipeline` (real adjacent-step / gate contradiction + on every standard post). **Reconciliation note for R2a:** R2a recorded the same scaffold in `post.md` + at **MINOR** ("component minimums sum below the 1,200 optimal floor") and flagged only the min-side. + The batch/pipeline analysis shows it is a two-sided, gate-hitting contradiction, not merely loose + guidance — the post.md instance is arguably under-rated and should be reconciled in the same fix. +- **Disposition (NOT fixed here — review finds, operator decides; 3-file change = own go):** one + consolidated fix across all 3 files — raise the component floors so min-sum ≥ 1,200 (e.g. widen + Context/Insight) AND either lift the ceiling or accept ~1,640, AND scope the standard 5-component + scaffold explicitly to standard-format posts where the command is format-aware (`batch`), adding + per-format draft guidance for quick/carousel/video. **Recommend treating this as the second + consolidated fix out of the sweep, after the R2a clipboard fix.** + +## ★ Cross-cutting finding #2 (recurring) — bare relative reference paths vs `${CLAUDE_PLUGIN_ROOT}/` + +- **Pattern:** several `Read`/reference paths are written **bare** (resolved against the runtime cwd) + while the same file's appendix and most other sites prefix `${CLAUDE_PLUGIN_ROOT}/`. `batch.md:43` + (`references/content-angles.md`) vs `batch.md:206` (prefixed, same file) — flagged by **both** batch + lenses. `pipeline.md:31,55,70,75` bare vs `pipeline.md:28,64,107` + appendix `:204-209` prefixed — + `content-angles.md` is bare at `:55` but prefixed at `:204`. +- **Impact:** the files exist (not dead refs), but a bare path fails the `Read` when cwd ≠ plugin root, + on real paths run every invocation (angle-select, optimize). Self-recoverable via Glob, latent. +- **Disposition:** normalize all bare reference/asset paths to `${CLAUDE_PLUGIN_ROOT}/`. Cheap, + mechanical; fold into the consolidated fix pass. (Worth a repo-wide grep for the same pattern in the + other 25 commands during the eventual fix.) + +--- + +## create.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 0 SUGGESTION) + +Class: **routing** (pure delegating front-door). Both lenses confirm intent delivered: Step 0 context → +Step 1 intent-ID → Step 2 route, with explicit "you do not draft here / do NOT inline the target's +steps" (`create.md:49-51,64-65`) — delegation purity intact, single source of truth preserved. All 8 +routed targets resolve (`post/quick/react/carousel/video/multiplatform/batch/newsletter`, `:37-44` + +`:55-62`, `ls`-confirmed); the three enumerations (description `:7`, menu `:37-44`, route table +`:55-62`) are mutually consistent (8/8/8, same order); no `subagent_type` refs (correct — it routes to +commands); no "thought leadership". Correctness-lens: **0 findings**. + +### MINOR — Step 1 directs one `AskUserQuestion` carrying 8 options; documented support is 2–4 +- `create.md:35-44` — "use `AskUserQuestion`" immediately followed by 8 numbered options (`grep -cE + "^[0-9]+\. \*\*"` → 8). Grounded against the plugin-dev reference + `command-development/.../interactive-commands.md:469` ("2-4 options per question") + `:906`. +- **impact:** on the PRIMARY interactive path (user names no format) the front-door instructs a single + question with double the documented option range. +- **anti-fabrication caveat (carried from the reviewer, honestly):** grounded = (a) 8 options + instructed, (b) the documented 2–4 range. NOT grounded = whether the live `AskUserQuestion` runtime + hard-rejects >4 vs silently truncates/degrades. **If the runtime hard-rejects, this escalates to + MAJOR/BLOCKER** on the no-format-named path; if it only degrades, the picker is over-long. Worth a + runtime check before the fix. +- **disposition:** group the 8 intents into ≤4 options (e.g. Short-form / Reaction / Visual / Long-form + & batch) with a drill-down, or split into two questions. + +--- + +## batch.md — VERDICT: REWORK (0 BLOCKER · 1 MAJOR · 3 MINOR · 2 SUGGESTION) + +Class: **guided/stateful + routing** ("create a full week of content"). Intent delivered: the Step 0→5 +flow traces the frontmatter promise; all cross-refs resolve (`trend-spotter`, `content-planner` → +`agents/`; `/linkedin:calendar` → `commands/`; `queue-manager.mjs` + `ical-generator.mjs` exports + +the 8-arg `queueAdd` call/signature match; all 6 reference/asset paths + `SKILL.md`); graceful +degradation present; no "thought leadership". + +### MAJOR — Step 3a component scaffold contradicts the Step 3b band gate → see ★ cross-cutting #1 (`batch.md:88-92` vs `:95`) +Sharper here than elsewhere: 3a is **format-blind** (always the 5-component standard layout, 960–1,640) +while 3b is **format-aware** (`:114` `format:[text/carousel/video]`, rotation `:76`), so a `quick`-format +post built from 3a also blows the quick band 150–500. **Both lenses flagged this** (intent-lens MINOR, +correctness-lens MAJOR — recorded at the higher severity: it hits a real gate on every standard post). + +### MINOR — bare reference path → see ★ cross-cutting #2 (`batch.md:43` vs `:206`). Both lenses. + +### MINOR — `weekly_goal` cadence decoupled from the fixed "3–5 posts" headline +- `batch.md:5,65` fix the output at "3–5 posts"; `batch.md:52` schedules against `weekly_goal` slot + templates (2x/3x/4x/5x). At `weekly_goal=2x`, 3–5 posts against 2 weekly slots overflow into the next + week (`:53` "next available slot after today") — ~2.5 weeks of content under a "full week" label. +- **impact:** non-breaking (scheduling rolls forward), but "full week" + "3–5" is internally + inconsistent with the 2x cadence. +- **disposition:** tie post count to `weekly_goal`, or note that overflow rolls into following weeks. + +### MINOR — orphan sub-step marker `5b` with no `5a` +- `batch.md:172` `### 5b. Generate Calendar File`; `grep -n "5a" batch.md` → no match (exit 1). Step 3 + has 3a/3b/3c/3d; Step 5 jumps straight to 5b. +- **disposition:** renumber to `5a`, or drop the letter. + +### SUGGESTION — `weekly_goal` default (3x) lives only in the referenced `scheduling-strategy.md:15`, never stated in `batch.md`. Optional one-line "default 3x" for self-evident degradation. + +### SUGGESTION — `planned_date` metadata never computed +- `batch.md:109` writes `planned_date: YYYY-MM-DD` into each draft header, but Step 2 only computes + `scheduled_date`/`scheduled_time` (`:54`); `planned_date` is introduced nowhere upstream. +- **disposition:** drop `planned_date`, or define where it is derived. + +--- + +## pipeline.md — VERDICT: REWORK (0 BLOCKER · 1 MAJOR · 4 MINOR · 2 SUGGESTION + clipboard pointer) + +Class: **post-emitting + guided/stateful + routing** ("full end-to-end pipeline"). Intent delivered: +Steps 0–8 map to every named stage. All post-emitting predicates present (hook 110–140 `:58,:76` · +length band `:77` · no-body-link `:78` · buzzword check `:79` · topic→expertise `:49,:55,:80`); all +cross-refs + function signatures resolve (`content-planner`, `trend-spotter`; `/linkedin:calendar`, +`/linkedin:analyze`; `queueAdd` 8-arg call/signature; `writeState`/`updatePostTracking`); no "thought +leadership". + +### MAJOR — Step 2 component scaffold cannot satisfy the Step 3 total-length gate → see ★ cross-cutting #1 (`pipeline.md:58-62` vs `:77`) +Adjacent-step contradiction: Step 2 partitions into 960–1,640; the very next step's scorecard asserts +"Total 1,200–1,800". Correctness-lens, grounded arithmetic. + +### MINOR — inline buzzword checklist enumerates 8, canonical gate enumerates 10 +- `pipeline.md:79` lists 8 terms (= CLAUDE.md rule 4); SSOT `content-quality-gate.md:13` adds + 'actionable insights' + 'best practices' = 10. A draft passing the inline list can still trip the + Write hook. (Gate-vs-rule divergence, not unique to this file — also noted on carousel in R2a.) +- **disposition:** align to the 10-term canonical list, or reference the gate instead of duplicating. + +### MINOR — bare reference paths → see ★ cross-cutting #2 (`pipeline.md:31,55,70,75`). Intent-lens. + +### MINOR — Step 4 deferred/queued path falls through into the immediate Publish steps +- `pipeline.md:97-101` offers "Schedule / Add to queue / Save as draft (no schedule)"; Steps 5–8 + (`:120` "15-20 min BEFORE posting", `:135` Publish, `:158` first-hour, `:171` post-analysis) then run + with **no branch**. A user who queued/deferred is marched through Pre-Engagement → Publish → + Monitoring, contradicting the just-made defer decision. +- **disposition:** add an early-exit after Step 4 for options 2–4 ("if scheduled/queued, end here; Steps + 5–8 run at publish time"). + +### MINOR — Step 7 inlines a static first-hour checklist instead of routing to the stateful surface +- `pipeline.md:158-169` inlines a 5-item plan; the dedicated `/linkedin:firsthour` delegates to + `engagement-coach`, persists via `recordFirstHourPlan`, hands off to `post-feedback-monitor` — strictly + richer (Step 8 already routes to `/linkedin:analyze`, so the inline first-hour is the inconsistent one). +- **disposition:** route to `/linkedin:firsthour`. + +### SUGGESTION — over-provisioned `allowed-tools`: `:13` declares `WebFetch` but no body step fetches (trend-spotter does its own). Drop unless a URL-ingest step is intended. + +### SUGGESTION — hardcoded Norwegian peak times: `:92-95` bakes "European/Norwegian audience" peak windows into the body while `scheduling-strategy.md` (read at `:107`) is the SSOT for slots; conflicts with the domain/audience-general principle. Source from the reference/config. + +### (pointer) clipboard `printf '%s'` systemic bug — `pipeline.md:139` confirmed present (the only R2b surface in the 10-content-command set). Folds into the R2a ★ cross-cutting clipboard finding; no new derivation. + +--- + +## newsletter.md — VERDICT: REWORK (0 BLOCKER · 1 MAJOR · 2 MINOR · 2 SUGGESTION) + +Class: **guided/stateful + routing + heavy subagent orchestration** (long-form 18-phase pipeline, +~110 KB). Intent delivered: all 18 phases present, ordered, `[GATE]`/`[OPERATØR]`-marked. **18-phase +count confirmed by both lenses** (`0,1,1.5,2,2.5,3a,3b,4,4.5,5,5.5,6,6.5,7,7.5,8,9,10`; headline `:25` +matches body + template `_doc.phases` + build-status). All 7 longform agents (fact-checker, +editorial-reviewer, persona-reviewer, voice-scrubber, content-reviewer, language-reviewer, fact-reviewer) +present in `agents/` AND invoked; gate sequence ordered before lock (`:1570`): skeleton 2.5 → spine 3a → +fact-check 5 → editorial 5.5 → persona 6 → headless 6.5 → visual 7.5 → LOCK 8 → hook 9. All `subagent_type` +carry the `linkedin-studio:` namespace (the 5 prefix-less grep hits are line-wraps). All ~25 cross-refs +(agents, commands, scripts, configs, render, docs) resolve. Pivot heuristic, flag caps, step-label +5.5/6.5 consistency all clean. No "thought leadership". + +### MAJOR — deterministic resumption table omits the contract-gate phase (Step 4.5) → breaks resume between Step 4 and Step 5 +- **Verified by main (independent re-grounding of a lens disagreement):** the resumption table + `newsletter.md:209-228` has **no `contract-gate` row** (`grep contract-gate` over the table region → + none). The canonical `_doc.phases` it claims to mirror (`:230-231`) **does** define it — + `config/edition-state.template.json` lists `"contract-gate — … (Step 4.5)"` between + `consistency-quality` (Step 4) and `factcheck-sweep` (Step 5). Step 4.5 actually writes it: + `newsletter.md:988` "Set `currentPhase: "contract-gate"`". +- **Two concrete breakages on the multi-session resume path (the file's core premise, `:200-204`):** + 1. **Gate skipped on resume.** The rule (`:203-204`) is "run the step AFTER the recorded phase." Row + `:219` maps `consistency-quality → Step 5` (Fact-check), but the step after Step 4 is Step 4.5 + (contract-gate), not Step 5. A session aborting after Step 4 resumes **past** the deterministic + contract-gate, never running it. + 2. **Unrecognized phase on resume.** A session aborting after Step 4.5 has `currentPhase: + "contract-gate"`, absent from the table → falls into the `:232-234` fallback ("missing or + unrecognized → do NOT guess … confirm with the operator"), defeating the deterministic-resumption + guarantee the section is built on. +- **Note:** the linear next-pointers are correct (`:918` "next: contract-gate", `:989` "next: + fact-check"); only the resume **table** is short one row — the defect surfaces solely on abort/resume + between Step 4 and Step 5. +- **Independence note:** the intent-lens asserted this table "maps every currentPhase to the correct + resume step"; the correctness-lens counted (17 rows vs 18 phases) and found the gap. Main confirmed + the correctness-lens. Two-lens method earned its keep here. +- **disposition:** insert a `contract-gate → Step 5 — Fact-check sweep` row, and repoint + `consistency-quality → Step 4.5 — Contract-gate`. + +### MINOR — Step 1 says the brief is first persisted "in Step 2"; the rest of the file says Step 1.5 +- `newsletter.md:303` "Record the resolved brief inline (you will persist it to edition-state in **Step + 2**)" contradicts `:287-289`, `:412-418`, `:494-496` (all: first durable write is the **Step 1.5** + checkpoint). Stale "Step 2" — almost certainly predates the Fix #2 Step 1.5 insertion; non-breaking + (Step 1 only records inline either way). +- **disposition:** change `:303` "in Step 2" → "at the Step 1.5 checkpoint". + +### MINOR — `allowed-tools` omits `SendUserFile` (body-primary operator gate) + `mcp__mcp-image__generate_image` (default image route) +- `newsletter.md:11-19` declares `Read, Glob, Grep, WebFetch, Bash, AskUserQuestion, Task, Write`. The + body names `SendUserFile` as the **primary** operator gate at Steps 5.5/6.5/7.5 (13 uses, e.g. `:1138`) + and mcp-image as the **default** image route (`:1471`); neither is declared. **Both lenses flagged + this** (intent-lens MINOR, correctness-lens SUGGESTION — recorded at the higher: the declared "default" + path can't execute under the frontmatter as written). Every use guards with a fallback ("`SendUserFile` + if available, else a markdown `file://` link"), so it degrades gracefully → not load-bearing. +- **disposition:** add `SendUserFile` (+ optionally mcp-image) to `allowed-tools`, or downgrade the body + wording from "default/primary" to "if permitted". + +### SUGGESTION — undefined "LTL plugin" acronym: `newsletter.md:36,725` ("the LTL plugin" / "the LTL rule"); repo-wide the bare phrase appears only here, no expansion; the plugin is canonically "LinkedIn Studio". (The env vars `LTL_SERIES_ROOT`/`LTL_BRAND` `:48,154-156` ARE a legit convention consumed by `render/build-*.mjs` — not a defect.) Rename to "LinkedIn Studio plugin", or define once. + +### SUGGESTION — "leveraged" in doc prose (`newsletter.md:1785`, note-only): ordinary verb in the command's own explanatory prose, not generated post content; CLAUDE.md rule 4 targets generated posts. Not a real violation; optionally swap to "drew on / built on". + +--- + +## Gate decision — R2b COMPLETE (4 surfaces) + +| Surface | Verdict | BLOCKER | MAJOR | MINOR | SUGGESTION | +|---|---|---|---|---|---| +| create | ALLOW | 0 | 0 | 1 | 0 | +| batch | REWORK | 0 | 1 | 3 | 2 | +| pipeline | REWORK | 0 | 1 | 4 | 2 | +| newsletter | REWORK | 0 | 1 | 2 | 2 | +| **★ cross-cutting #1** (scaffold, 3 files) | — | 0 | (counted in batch + pipeline; spans post.md from R2a) | — | — | +| **★ cross-cutting #2** (bare paths) | — | 0 | 0 | (counted in batch + pipeline) | — | + +**Batch verdict: REWORK** — 3 of 4 surfaces (batch, pipeline, newsletter), each with **1 MAJOR**: +(1) the systemic 5-component scaffold contradicting the 1,200–1,800 band (batch + pipeline; spans +post.md from R2a), and (2) newsletter's resumption table missing the contract-gate phase. **0 BLOCKER +anywhere.** `create` is ALLOW (one option-count MINOR with a runtime caveat). + +**Independence verdict:** convergence on batch path-prefix / batch 3a-3b band / newsletter allowed-tools +/ create 8-option (both lenses) — plus one **divergence resolved by main's grounding** (newsletter +resumption table: intent-lens asserted complete, correctness-lens counted the gap, main confirmed). Both +the convergence and the resolved divergence are signals these are real defects, not single-reviewer +framing. + +**Systemic findings now span R2a+R2b:** clipboard `printf` (R2a, 10 files) · component scaffold (R2b, 3 +files incl. post.md from R2a) · bare reference paths (R2b, 2 files, worth a repo-wide grep). Cold review +**finds**; it changes no code. Each fix is its own operator-gated decision. Recommended consolidated-fix +order out of the sweep so far: (1) clipboard `printf` [R2a, 10 files, highest blast radius], (2) +component scaffold [3 files], (3) bare reference paths [grep-driven], then the per-surface items. +Local-only (hardening-class), not pushed. diff --git a/docs/hardening/review-R3.md b/docs/hardening/review-R3.md new file mode 100644 index 0000000..203f2aa --- /dev/null +++ b/docs/hardening/review-R3.md @@ -0,0 +1,218 @@ +--- +type: cold-review +batch: R3 +journey: "Engage — post-publish & longform-support surfaces" +scope: "FROZEN committed files at HEAD 2b70660 (clean tree; post-hardening cold pass)" +method: "2 independent cold Opus reviewers for the round (intent + correctness), each covering all 4 surfaces, no cross-feed; every mechanical claim tool-grounded (anti-fabrication mandate); reviewers carry NO drafting-session context" +surfaces: [firsthour, calendar, headless-review, pivot] +reviewers: + - "intent-lens (conformance: intent delivery + cross-ref resolution + class predicates + graceful degradation + terminology)" + - "correctness-lens (internal consistency + bound-vs-canonical + checklist/phase arithmetic + allowed-tools completeness)" +status: "COMPLETE — all 4 surfaces reviewed (firsthour, calendar, headless-review, pivot)" +verdict: REWORK +counts: { BLOCKER: 0, MAJOR: 1, MINOR: 3, SUGGESTION: 5 } +--- + +# Cold review — R3 (Engage · post-publish & longform-support) + +Independent post-hoc cold review of the 4 Engage-journey surfaces, on the FROZEN committed files +(HEAD `2b70660`). Mirrors the S1 `review.md` + R2a + R2b model (the cold-review method that did **not** +fabricate): read-and-show before assert, every `file:line` tool-confirmed, reviewers carry no +drafting-session context. The per-command interactive gate (`log.md`) already passed these; this pass +adds the **independent** axis that gate never had. + +**Independence cross-check — both outcomes recurred this batch:** +1. **Convergence:** both blind lenses independently surfaced the same real defect on `headless-review` + (`SendUserFile` invoked on the primary surfacing path but absent from `allowed-tools`). +2. **Divergence resolved by grounding** (the strongest argument FOR the two-lens method, recurring from + R2b's newsletter): on `calendar` the intent-lens flagged a **MAJOR** (the publish/reschedule/cancel + actions key off `id`/`draft_path`/`character_count` that the queue load never surfaces), while the + correctness-lens passed the surface as ALLOW — its structural pass found the step/option arithmetic + reconciled but did **not** trace the data-flow from load → display → action placeholders. Main + re-grounded `queueFormatSummary`'s actual output independently (below) → the intent-lens is right. The + independence axis caught a real MAJOR one lens never probed. + +--- + +## Connections to existing systemic findings (no NEW ★ cross-cutting this batch) + +R3 surfaces **connect to** the two systemic patterns already recorded in R2a/R2b rather than adding new +ones. Both connections were re-grounded by main on the R3 files: + +- **★ cross-cutting #1 (clipboard `printf '%s'`, R2a, 10 files) — firsthour confirmed present.** + `firsthour.md:72` `printf '%s' '<DRAFT_COMMENTS_BLOCK>' | node …/clipboard-helper.mjs` + `:75` the + unconditional "Copied your draft comments to clipboard." This is the exact systemic pattern: a + single-quoted shell string corrupts any draft text containing an apostrophe (`it's`, `don't` — common + in natural comment copy), and the "Copied" confirmation is unconditional. firsthour is one of the 10 + files STATE already lists; **no new derivation — folds into the R2a ★ #1 consolidated fix.** +- **★ cross-cutting #2 (bare relative reference paths vs `${CLAUDE_PLUGIN_ROOT}/`, R2b) — firsthour adds + 3 sites.** `firsthour.md:110` (prose parenthetical), `:118`, `:119` (Reference-Files pointer list) are + bare `references/…` while the same file's **executable** blocks correctly prefix `${CLAUDE_PLUGIN_ROOT}/` + (`:72`, `:84`) and sibling commands prefix their Reference-Files lists too (`calendar.md:206-207`). + **Lower impact than the R2b instances** (firsthour's bare paths are in a pointer list + one prose + mention, not inside an executable `Read`), so latent rather than active — but a real parity break worth + catching in the same repo-wide grep pass. Counted as a per-surface MINOR below. + +**Recurring (SUGGESTION-class, NOT elevated to ★) — `allowed-tools` over-declaration.** Three of the four +surfaces declare a tool the body never invokes: `firsthour` (`Glob`/`Grep`), `calendar` (`Write`/`Edit`), +`pivot` (`Grep`). Harmless (over-declaration widens permission surface but breaks nothing; +*under*-declaration would be the real risk and there is none). Noted per-surface; optional minimal-surface +trim, fold into the consolidated fix if touched. + +--- + +## firsthour.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 2 SUGGESTION + clipboard pointer) + +Class: **guided/stateful + subagent orchestration** ("post-publish first-hour / reply-loop sprint"). +Intent delivered: Step 0 load → Step 1 identify post → Step 2 delegate to `engagement-coach` → Step 3 +present (timeline / targets / drafts / velocity) → Step 4 `recordFirstHourPlan` persist → Step 5 +`post-feedback-monitor` handoff. Both subagent targets carry the `linkedin-studio:` namespace and resolve +(`agents/engagement-coach.md`, `agents/post-feedback-monitor.md`); `recordFirstHourPlan` signature +(`planDate, postTopic, targets, draftComments, plan`) matches the call (`:85-91` vs `state-updater.mjs:235`); +6 steps (`grep -cE '^## Step'` = 6), sequential, no orphan markers; Step 2→3 value-flow reconciles (coach +asked for target-list / self-comments / timeline / velocity, Step 3 presents exactly those four); no +"thought leadership". Empty-state degradation present. + +### MINOR — bare reference paths → see Connections (★ #2) (`firsthour.md:110, :118, :119` vs prefixed `:72, :84`). Intent-lens. + +### SUGGESTION — Step 0 voice-samples read has no stated fallback +`firsthour.md:29` reads voice-samples "so every draft comment is in the user's voice," but no path is +specified when the file is absent (progressive onboarding suppresses voice until 5+ samples, CLAUDE.md rule +7). Non-breaking (the coach can still draft), but the degradation is unstated. Add "if absent, draft in a +neutral first-person register and skip voice-matching." + +### SUGGESTION — `allowed-tools` over-declares `Glob`/`Grep` (`:12-13`); body invokes neither. Correctness-lens. See Recurring note. + +### (pointer) clipboard `printf '%s'` systemic bug — `firsthour.md:72, :75` confirmed present → folds into ★ #1 (R2a). No new derivation. + +--- + +## calendar.md — VERDICT: REWORK (0 BLOCKER · 1 MAJOR · 0 MINOR · 1 SUGGESTION) + +Class: **guided/stateful + routing** ("view/manage scheduling queue + publish action"). The **view** side +(14-day calendar, format mix, pillar balance) delivers; the **action** side has a load-bearing data gap. +Step/sub-step/option arithmetic all reconcile (correctness-lens: Steps 1–4 sequential; sub-markers 3a–3f +present and ordered; 5 options offered with 4 handlers + explicit no-op, no dangling branch; Quick-Routing +anchor `:89` exists); empty/missing-queue degradation present and correct (`queue-manager.mjs:12-27` +returns `[]`; body 3a routes "no posts" → `/linkedin:batch`/`quick`); no "thought leadership". + +### MAJOR — the queue load surfaces none of the `id`/`draft_path`/`character_count` the publish/reschedule/cancel actions require (`calendar.md:31-43, :117, :169-174, :185` vs `queue-manager.mjs:112-122`) +- **Verified by main (independent re-grounding of the lens divergence):** Step 1 loads the queue + **exclusively** through `queueFormatSummary` (`:31-43`). `queueFormatSummary` (`queue-manager.mjs:112-122`, + read in full) emits only ` {date} {time} | {hook…} | {pillar} ({fmt}) [{status}]` — it exposes **no** + `id`, **no** `draft_path`, **no** `character_count`. The Step 2 display (`:52-71`) mirrors that field set. +- **Three concrete breakages on the action paths:** + 1. **Mark-as-published (the PRIMARY route — Quick-Routing `:25` jumps straight here)** calls + `queueUpdateStatus('[post-id]', 'published')` (`:117`) — `[post-id]` was never surfaced. Step 3d + also needs `charCount: NNNN` (`:129`), likewise un-surfaced. + 2. **Reschedule** (`:174`) calls `queueAdd('[post-id]','[draft_path]', …, [charCount])` (8-arg signature + confirmed `queue-manager.mjs:63`) and is **explicitly told** to "carry the unchanged fields + (draft_path, pillar, format, hook preview, char count) from **the entry shown in Step 2**" + (`:169-172`) — but Step 2 provably shows none of `draft_path`/`char count`/`id`. A direct + contradiction: the instruction points at a view that lacks the fields it says to carry. + 3. **Cancel** (`:185`) likewise needs the un-surfaced `[post-id]`. +- **Self-recovery caveat (honest):** `queue.json` is in Reference Files (`:208`) and `Read` is allowed, so + a capable agent *could* read raw entries to recover `id`/`draft_path`/`char_count`. But the body never + instructs that, and the reschedule text actively **mis-directs** to Step 2. Latent-but-real on the + primary route → MAJOR, not MINOR. +- **Independence note:** intent-lens flagged MAJOR; correctness-lens passed the surface ALLOW (its + arithmetic/structure pass reconciled but did not trace load→display→action data-flow). Main grounded + `queueFormatSummary`'s output → intent-lens confirmed. Two-lens method earned its keep (same shape as + R2b's newsletter resumption table). +- **disposition:** in Step 1 also dump raw entries (e.g. `console.log(JSON.stringify(queueUpcoming(14)))`, + or a `queueRead()` dump exposing `id`/`draft_path`/`character_count`), and re-point the reschedule text + from "the entry shown in Step 2" to "the raw queue entry loaded in Step 1." Surface the display ordinal → + queue-`id` mapping so 3b/reschedule/cancel can fill `[post-id]`. + +### SUGGESTION — `allowed-tools` over-declares `Write`/`Edit` (`:13-14`); every mutation routes through `Bash` node one-liners, "View draft" uses `Read`. Correctness-lens. See Recurring note. + +--- + +## headless-review.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 2 MINOR · 1 SUGGESTION) + +Class: **guided/stateful + heavy subagent orchestration + routing** (cold 5-archetype package on a frozen +draft → one operator-gated report). Intent delivered: Step 1 resolve-from-disk → Step 2 freeze (`cp` +snapshot) → Step 3 parallel fan-out (the `--type`→`subagent_type` table `:141-145` maps to the 5 real cold +review modes: content / language / fact / persona-resonance / persona-conversion) → Step 4 consolidate → +Step 5 surface + optional `edition-state.json` persist. All reviewer agents resolve; the writing-contract +fallback chain terminates in `references/longform-quality-rules.md` (present); degradation well-handled +(missing `--draft` → edition-state or ask; `cp` unavailable → live draft + note; degraded reviewer +re-runs). "five archetypes" reconciles with the 5-row `--type` table; 5 flags all consumed, no orphan; no +"thought leadership". + +### MINOR — `SendUserFile` invoked on the primary surfacing path but absent from `allowed-tools` (`:208, :221` vs `:19-25`). BOTH lenses. +- `allowed-tools` (`:19-25`) = Read, Glob, Grep, Bash, AskUserQuestion, Task, Write — no `SendUserFile`; + body uses it 2× (`grep -c` = 2), as the documented **primary** operator-gated delivery ("operator-gated + via SendUserFile"). Held at MINOR (not MAJOR) by two guards: `:208` carries an in-text fallback ("else a + markdown `file://` link") and the report is independently persisted via the declared `Write` (`:206`), so + surfacing degrades rather than breaks. +- **disposition:** add `SendUserFile` to `allowed-tools` (if a real tool in the target harness), or soften + the body wording from "primary/operator-gated via SendUserFile" to "surface via a `file://` link (or + `SendUserFile` if available)." + +### MINOR — `v3.1.0` reload anchor misleads on the post-reset version line (`:81-82`) +- **Verified by main:** `:81-82` says the three cold archetypes "were added in **v3.1.0** — if the session + predates them, reload." Current `plugin.json` version = **0.5.3** (`:3`); CHANGELOG `[0.4.0]` (2026-05-31) + records the **honest version reset 4.1.0 → 0.4.0**, so `v3.1.0` is a *pre-reset* tag no longer on the + current line. A reader on 0.5.3 comparing numerically (0.5.3 < 3.1.0) would wrongly conclude they + "predate" the agents and must reload — when 0.5.3 is post-reset and already ships all three (they are in + CLAUDE.md's 19-agent list). Harmless if followed (an unnecessary reload), but the version anchor misleads. +- **disposition:** anchor by event/date, not the dead tag — e.g. "added with the cold-review package + (CHANGELOG 3.1.0, pre-reset); reload if your session predates those agents." + +### SUGGESTION — fan-out N-count unit left implicit: `persona-resonance` issues "one call per active persona" (`:144`) while the header counts "<N> archetypes" / "<N> run in parallel" (`:172, :219`). Pin whether N counts review-modes (5) or Task-calls (≥5). Non-breaking. Correctness-lens. + +--- + +## pivot.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 0 MINOR · 1 SUGGESTION) + +Class: **guided/stateful + routing (no subagent orchestration by design)** ("re-open a long-form edition so +cleared gates re-run before lock"). Intent delivered: Step 1 load+locate (stops if `articles.NN` absent) → +Step 2 measure scope + classify → Step 3 append `pivots[]`, reset `currentPhase`, un-lock, invalidate +downstream verdicts → Step 4 write `STATE.md` + point at `/linkedin:newsletter`. **Unusually +well-reconciled** (correctness-lens, all main-checkable): 4 steps sequential; the >20%/>2-sections +heuristic stated identically in 3 places (`:8-9, :54-55, :92`); the worked example's arithmetic checks out +(+42% = (1992−1400)/1400 ✓; "added 2 sections … at the boundary of '>2'" correctly attributes the trigger +to the 20% arm since `2` is not `>2`); the off-by-one phase map is explicitly reconciled (`:102-108`, +`to-phase` = last *completed* phase, newsletter resumes at the step after); `gatesToRerun` (4 entries `:123`) +matches the summary + STATE line (`:163, :149-151`). `allowed-tools` correctly **omits `Task`** (delegates +gate-running to `/linkedin:newsletter`, never spawns). All 3 Reference-File targets resolve; degradation +present (Step 1 stop-on-missing-article; Step 2.2 absent-baseline → ask operator). No "thought leadership". + +### SUGGESTION — `allowed-tools` over-declares `Grep` (`:18`); the only grep in the body is a *shell* `grep -c '^## '` inside a `Bash` block (`:89`), not the `Grep` tool. `Glob` plausibly resolves the series root — keep it. Correctness-lens. See Recurring note. + +--- + +## Gate decision — R3 COMPLETE (4 surfaces) + +| Surface | Verdict | BLOCKER | MAJOR | MINOR | SUGGESTION | +|---|---|---|---|---|---| +| firsthour | ALLOW | 0 | 0 | 1 | 2 | +| calendar | REWORK | 0 | 1 | 0 | 1 | +| headless-review | ALLOW | 0 | 0 | 2 | 1 | +| pivot | ALLOW | 0 | 0 | 0 | 1 | +| **★ #1 clipboard** (pointer, firsthour) | — | — | — | (folds into R2a) | — | +| **★ #2 bare paths** (firsthour, 3 sites) | — | 0 | 0 | (counted in firsthour) | — | + +**Batch verdict: REWORK** — 1 of 4 surfaces (calendar) carries **1 MAJOR**: the queue load surfaces none of +the `id`/`draft_path`/`character_count` that publish/reschedule/cancel require, and the reschedule step's +"carry from the entry shown in Step 2" is a direct contradiction. **0 BLOCKER anywhere.** firsthour / +headless-review / pivot are ALLOW (pivot notably clean — every count, the heuristic boundary case, and the +off-by-one phase map reconcile). + +**Independence verdict:** convergence on headless-review `SendUserFile` (both lenses) + one **divergence +resolved by main's grounding** (calendar: intent-lens flagged the data-gap MAJOR, correctness-lens passed +it on structural arithmetic, main grounded `queueFormatSummary`'s output and confirmed the MAJOR). Lens-B +also uniquely caught the `v3.1.0` dead anchor + the over-declaration pattern; Lens-A uniquely caught the +bare paths + the clipboard pointer. Both lenses earned their keep. + +**Systemic findings now span R2a+R2b+R3:** clipboard `printf` (R2a, 10 files incl. firsthour) · component +scaffold (R2b, 3 files) · bare reference paths (R2b+R3, now 3 files incl. firsthour's 3 sites). **New this +batch (SUGGESTION-class, not ★):** `allowed-tools` over-declaration on 3 of 4 R3 surfaces. Cold review +**finds**; it changes no code. Each fix is its own operator-gated decision. Recommended consolidated-fix +order unchanged: (1) clipboard `printf` [R2a, 10 files, highest blast radius], (2) component scaffold [3 +files], (3) bare reference paths [grep-driven, now incl. firsthour], then the per-surface items (calendar +queue-data MAJOR, headless `SendUserFile`/`v3.1.0`, over-declaration trims). Local-only (hardening-class), +pushed per the 2026-06-30 operator delegation (public catalog, no secrets). + +**Cumulative cold-review coverage: 17/29** (review.md S1=4 · R2a=5 · R2b=4 · R3=4). diff --git a/docs/hardening/review-R4.md b/docs/hardening/review-R4.md new file mode 100644 index 0000000..7914a9d --- /dev/null +++ b/docs/hardening/review-R4.md @@ -0,0 +1,293 @@ +--- +type: cold-review +batch: R4 +journey: "Measure — analytics & performance surfaces" +scope: "FROZEN committed files at HEAD 69f37ba (clean tree; post-hardening cold pass)" +method: "2 independent cold Opus reviewers for the round (intent + correctness), each covering all 6 surfaces, no cross-feed; every mechanical claim tool-grounded (anti-fabrication mandate); reviewers carry NO drafting-session context. Divergences re-grounded by main before registration." +surfaces: [import, report, analyze, audit, ab-test, measure] +reviewers: + - "intent-lens (conformance: intent delivery + cross-ref resolution + analytics class predicates + graceful degradation + terminology)" + - "correctness-lens (internal consistency + bound-vs-canonical + step/phase arithmetic + allowed-tools completeness + metric-definition cross-check)" +class: "analytics — extra predicate: graceful degradation present · saves/dwell honesty intact (parseOptionalCount → unknown/never 0; dwell unmeasurable; saves NOT folded into engagementRate; analytics I/O via getAnalyticsRoot seam)" +status: "COMPLETE — all 6 surfaces reviewed (import, report, analyze, audit, ab-test, measure)" +verdict: REWORK +counts: { BLOCKER: 0, MAJOR: 1, MINOR: 4, SUGGESTION: 6 } +--- + +# Cold review — R4 (Measure · analytics & performance) + +Independent post-hoc cold review of the 6 Measure-journey surfaces, on the FROZEN committed files +(HEAD `69f37ba`). Mirrors the S1 `review.md` + R2a + R2b + R3 model (the cold-review method that did **not** +fabricate): read-and-show before assert, every `file:line` tool-confirmed, reviewers carry no +drafting-session context. The per-command interactive gate (`log.md`) already passed these; this pass +adds the **independent** axis that gate never had. This is the largest batch (6 surfaces) and the only +**analytics-class** batch, so the round carries the extra class predicate (graceful degradation + +saves/dwell honesty) alongside the standard intent/correctness lenses. + +**Analytics-class predicate — PASSES across all 6 surfaces (the headline R4 result).** Both blind lenses +independently confirmed the honesty contract holds wherever a surface touches the metric: **saves** are +consistently framed as native-only / count-only / ~Sept 2025-onward / no self-serve API / manual-entry +(`report.md:143,:241`; `import.md:30,:138,:148`; matches `cli.ts:144-146` + `csv-parser.ts:71` where +`parseOptionalCount` → blank/non-numeric/negative becomes `undefined` = unknown, never 0) and are **never +folded into `engagementRate`** (`csv-parser.ts:205-208` numerator = reactions+comments+shares+clicks, no +saves); **dwell** is consistently called unmeasurable/internal-to-LinkedIn (`report.md:241`, +`import.md:30`); no surface claims to import or compute either. The `getAnalyticsRoot()` per-user data-dir +seam (`storage.ts`) is described accurately wherever quoted. **No analytics-honesty violation anywhere in +R4.** + +**Independence cross-check — both outcomes recurred this batch (the case for two lenses, again):** +1. **Convergence (×2):** both blind lenses independently surfaced (a) `import.md` Step 6a's invalid + `trends` flags (`--period 4w` / `--metric engagement_rate`) and (b) `ab-test.md:236`'s manual + engagement-rate formula excluding clicks. Two real defects, found twice without cross-feed. +2. **Divergence resolved by grounding (×3, in BOTH directions):** + - **intent over-rated, main corrected down** — on `import` the intent-lens flagged the Step 6a CLI block + **MAJOR** (→ REWORK), the correctness-lens flagged the same defect **MINOR** (→ ALLOW, "off-primary, + descriptive"). Main re-grounded `import.md:194-217`: the executable instruction is the **delegation** + (`:200` "Run /linkedin:report"); the bash block (`:207-210`) is import's *description* of report's + internals, not import's own step → latent, errors only if copy-run → **MINOR**, import = ALLOW. + - **correctness uniquely caught, main confirmed** — on `report` the correctness-lens flagged a **MAJOR** + (heatmap branch routes to a nonexistent "Step 6c"); the intent-lens was silent (its lens probes + agent/command cross-refs, not internal step-jump arithmetic). Main grounded the step inventory → no + `6c` exists, real handler is `2c` → **MAJOR confirmed**. This is the batch's load-bearing defect. + - **correctness uniquely caught, main confirmed** — on `analyze` the correctness-lens flagged a **MINOR** + (two non-reconciling severity scales); the intent-lens was silent → main grounded `:155-178` vs + `:227-231` → confirmed **MINOR**. + Both lenses earned their keep: intent over-rated one finding (corrected by grounding), correctness + uniquely caught the two structural defects intent's lens never traced. + +--- + +## Connections to existing systemic findings (no NEW ★ cross-cutting this batch) + +All connections re-grounded by main against the R4 files: + +- **★ #1 (clipboard `printf '%s'`, R2a, 10 files) — R4 adds nothing.** `grep -nE "printf '%s'|clipboard-helper"` + across all 6 R4 files → NONE. The analytics surfaces do not auto-copy to clipboard (they ingest/report + data, they don't emit post text), so this systemic pattern simply does not reach the Measure journey. +- **★ #2 (5-component scaffold band-mismatch, R2b, 3 files) — R4 adds nothing.** The length-band tokens + that appear (`analyze.md:201` "1,200-1,500", `:217` "1,500-1,800"; `report.md:331` example impressions; + `ab-test.md:80` test-variable "Short (500) vs standard (1,200-1,800) vs long (2,500+)") are + recovery-protocol guidance / illustrative numbers / a test variable — none is a component scaffold that + sums outside the standard band. No defect. +- **★ #3 (bare reference paths vs `${CLAUDE_PLUGIN_ROOT}/`, R2b+R3) — R4 connects lightly (lowest impact).** + `analyze.md:22,:23,:93,:259,:260,:261` and `report.md:241` carry bare `references/…` — but **none is + inside an executable `Read`/`cat`** (`grep -nE "(Read|cat) .*references/"` → NONE executable); all are + pointer-list entries or prose mentions, the same lowest-impact class as R3's firsthour bare paths. + Latent parity break worth catching in the same repo-wide `${CLAUDE_PLUGIN_ROOT}/` grep pass; not elevated + to a per-surface finding (neither lens raised it; cosmetic on these surfaces). + +**Recurring (SUGGESTION-class, NOT elevated to ★) — `allowed-tools` over-declaration now spans R3+R4.** +Four of six R4 surfaces declare a tool the body never invokes: `import`/`report` (`Glob` — listing done via +Bash `ls`/`find`), `audit` (`Grep` — no grep call). Combined with R3's 3-of-4, the pattern now touches ~7 +surfaces. Harmless (over-declaration widens the permission surface but breaks nothing; *under*-declaration +is the real risk and there is one true instance this batch — `report` Step 8b, recorded as MINOR below). +Optional minimal-surface trim; fold into the consolidated fix if touched. + +**New R4 cluster (not ★, analytics-specific) — sibling-command interface/metric-definition drift.** Two +of the four MINORs are the same shape: an analytics surface quotes another surface's CLI interface or a +shared metric definition and drifts from the SSOT — `import.md` Step 6a's stale `trends` flags vs +`report.md`'s correct ones, and `ab-test.md:236`'s manual engagement-rate (clicks excluded) vs the CLI's +`engagementRate` (clicks included, `csv-parser.ts:205`). Both are latent (cross-reference paths, not +primary execution) but both are real consistency debt between siblings. Worth a single reconciliation note +in the consolidated fix: pin the canonical `engagementRate` definition + CLI flag vocabulary once, and make +the descriptive blocks point at it rather than restate it. + +--- + +## import.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 1 SUGGESTION) + +Class: **analytics (import orchestrator)**. Intent delivered: primary artifact is the structured JSON batch +written by `cli.ts import` (Step 4 invokes it; output surfaced Step 5), then analysis delegated to +`/linkedin:report` (Step 6). Step inventory `1·1b·2·3·4·5·5b·6·6a·6b·7·8` sequential, no gaps; Step 1b/Step 3 +option lists each carry a Skip/Cancel disposition. `allowed-tools` (`:10-15` Bash/Read/Glob/Write/AskUserQuestion) +— Bash/Read/Write/AskUserQuestion all invoked. Cross-refs resolve (`report.md`, `setup.md`, `quick-import.mjs`, +`assets/analytics/README.md` all exist; no `subagent_type`). Degradation present (no-CSV, nothing-anywhere, +missing-deps `npm install`, skipped rows on empty-title/unparseable-date matching `csv-parser.ts:187,193`). +saves/dwell honesty intact (`:30,:138,:148`). No "thought leadership". + +### MINOR — Step 6a documents report's CLI calls with invalid period + metric, contradicting the real owner (`import.md:200, :206, :207-210`) +- **Verified by main (the intent/correctness severity divergence, re-grounded `:194-217`):** `:207-210` + shows `trends --period 4w --metric impressions` and `--metric engagement_rate` inside a bash fence. The + CLI accepts period `week|month|quarter|all` (`cli.ts:217`, validated → `process.exit(1)` `:219-221`) and + metric `…|engagementRate` camelCase (`cli.ts:202-209`, validated → `process.exit(1)` `:233-234`) — both + `4w` and `engagement_rate` would error. The real owner `report.md:153,:171` uses the correct + `--period month --metric engagementRate`. `:200` "(period: 4w)" and `:206` "Read expertise_areas" also + mis-describe report (it takes no period arg, never reads expertise_areas). +- **Why MINOR not MAJOR (intent-lens rated MAJOR; main grounds down):** the **executable** instruction on + this path is the delegation at `:200` ("Run /linkedin:report") → routes to `report.md`, which is correct. + The bash block (`:207-210`) is import's *narrative description* of report's internals ("`/linkedin:report` + will: … 2. Call `trends`…"), not a step import itself runs. Latent (errors only if a reader copy-runs the + illustrative block) and the primary delegation path is unaffected → MINOR, not MAJOR. Both lenses + converged on the defect's existence; only the severity diverged. +- **disposition:** drop the illustrative bash + the "(period: 4w)"/"expertise_areas" description, or mirror + report's real invocations (`--period month --metric engagementRate`). Fold into the sibling-drift + reconciliation note. + +### SUGGESTION — `allowed-tools` over-declares `Glob` (`:13`); directory listing uses Bash `ls`/`find` (`:37,:47`). Both lenses. See Recurring note. + +--- + +## report.md — VERDICT: REWORK (0 BLOCKER · 1 MAJOR · 1 MINOR · 1 SUGGESTION) + +Class: **analytics (report orchestrator)**. Intent delivered: produces weekly/monthly/heatmap report JSON +via `cli.ts report`/`heatmap` + a formatted presentation (Step 6) + an analytics-interpreter handoff +(`subagent_type: linkedin-studio:analytics-interpreter` `:308` → `agents/analytics-interpreter.md` ✓, `Task` +declared `:14`). Trends flags `--period month --metric engagementRate` (`:153,:171,:366`) all valid vs CLI. +Degradation present (no-data, npm install, week-not-found/empty-week/ERR_MODULE_NOT_FOUND `:387-400`). +saves/dwell honesty exemplary (`:143,:241`). No "thought leadership". + +### MAJOR — the heatmap report type routes to a nonexistent "Step 6c" (`report.md:72`) +- **Verified by main (correctness-lens caught it; intent-lens silent — lens gap, not contradiction):** the + step inventory (`grep -nE '^### Step|^## Step'`) is `1·1b·2·2b·2c·3·4·5·5b·5c·6·7·8·8b` — **there is no + Step 6c**. `:72` ("If heatmap (option 3): Run the heatmap CLI command and jump to **Step 6c**") points a + reader nowhere. The real heatmap handler is **Step 2c** (`:106`, immediately below the monthly Step 2b), + which itself "jump[s] to Step 7" (`:114`). The two sibling branches are correct (monthly `:71`→2b; + weekly→inline), so heatmap — one of three top-level report types in Step 2 — is the lone misroute. +- **Severity:** a provably-wrong cross-reference on a **primary menu branch** (top-level report-type + selection, not a deep-dive). Recoverable (`:72` also says "Run the heatmap CLI command," and 2c sits + right under 2b, so a capable agent recovers by proximity) — hence MAJOR, not BLOCKER — but it is the + batch's one load-bearing navigation defect. +- **disposition:** retarget `:72` from "Step 6c" to "Step 2c". + +### MINOR — Step 8b markdown export under-declares its write tool (`report.md:429-431` vs `:9-15`) +- **Verified by main:** Step 8b (reached via Step 8 option 4, "Export report as markdown file") instructs + "Format the data using this template and **write to file**" / "Save to: …`-report.md`" (`:429-431`). + Frontmatter `allowed-tools` (`:9-15`) = Bash/Read/Glob/AskUserQuestion/Task — **no `Write`**. This is the + one genuine *under*-declaration this batch (the real-risk class). Held at MINOR by two mitigations: the + step is an optional deep-dive (not the primary path), and the declared `Bash` can satisfy the write via a + heredoc/`cat >`. Sibling `ab-test.md:12-19` declares `Write` for the same `.md`-save, so the omission + reads as an oversight/parity break. +- **disposition:** add `Write` to `allowed-tools` (parity with ab-test) or rephrase Step 8b to write via the + declared `Bash`. + +### SUGGESTION — `allowed-tools` over-declares `Glob` (`:12`); listing uses Bash `ls` (`:30,:197`). Correctness-lens. See Recurring note. + +--- + +## analyze.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 1 SUGGESTION) + +Class: **analytics-adjacent (read-only diagnostic; no CLI)**. Intent delivered: diagnosis + recovery plan +from reference files + AskUserQuestion (Steps Load-Context·1-8). Cross-refs resolve +(`subagent_type: linkedin-studio:analytics-interpreter` `:41` → agent ✓, `Task` declared `:12`; +`/linkedin:profile` `:188` → `commands/profile.md` ✓). Degradation present (analytics delegation is +conditional `:41` with a self-report fallback; functions with zero data). No saves/dwell claims → nothing to +contradict. No "thought leadership". + +### MINOR — two non-reconciling severity scales in one command (`analyze.md:155-178` vs `:227-231`) +- **Verified by main (correctness-lens; intent-lens silent — lens gap):** Step 5 grades reach drop on four + percentage bands (`<25` / `25-50` / `50-75` / `75%+`, `:155-178`). Step 7's timeline table (`:227-231`) + uses three rows on a *different* axis — "Moderate (link/off-topic)", "Moderate (partial reach loss)", + "Severe (sharp reach loss)". The two scales share no common key, so a user holding a Step 5 result (e.g. + "Down 50-75% → algorithmic suppression likely") cannot map it to a Step 7 timeline row. Advisory/usability + inconsistency, low-confidence; nothing breaks. +- **disposition:** cross-label the Step 7 rows to the Step 5 bands, or state explicitly that they are + independent axes. + +### SUGGESTION — the `:41` existence check ("If imported analytics data exists `…/analytics/`") implies Glob/Bash, neither declared (`allowed-tools` `:9-13` = Read/AskUserQuestion/Task). Softer than report's Write gap: no explicit tool call is written at `:41` (it's a conditional prose phrase) and the real data access is delegated to `analytics-interpreter` via `Task`. Correctness-lens. + +--- + +## audit.md — VERDICT: ALLOW (0 BLOCKER/MAJOR/MINOR · 1 SUGGESTION) + +Class: **analytics-adjacent (read-only strategy auditor; no CLI)**. Intent delivered: audit report (Step 7 +template) + action items (Step 8). Steps `0·1·2·3·4·5·5.5·6·7·8` sequential. Routing resolves +(`/linkedin:strategy` → `commands/strategy.md` ✓, `/linkedin:profile` ✓; no `subagent_type`, `Task` +correctly absent from `allowed-tools`). Degradation present (Step 0 checks for analytics data + asks for +screenshots/metrics; milestone block self-skips with no data `:140`). **Correctness cross-check (both +lenses):** the state fields the milestone block reads — `follower_count`, `monthly_growth`, +`growth_rate_needed` (`:105,:109,:128`) — all exist in `config/state-file.template.md:23,26,28`, so it reads +real fields. No saves/dwell claims. No "thought leadership". + +### SUGGESTION — `allowed-tools` over-declares `Grep` (`:11`); Step 0 uses Read/Glob, no grep call in the body. Both lenses (convergence). See Recurring note. + +--- + +## ab-test.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 2 SUGGESTION) + +Class: **analytics (experiment manager; manual metric entry, no CLI)**. Intent delivered: primary artifact +is the test-plan markdown written to `analytics/ab-tests/[name].md` (Step 2a.8) + running comparison / +analysis (2b/2c). Step inventory `0·1·2a(.1-.8)·2b·2c·2d·2e·3` consistent; Step 1's 6 intents map to 2a-2e + +option 6 self-handles (`:57`). Post-count arithmetic coherent ("3 per variant / 6 total" `:127`; 6-row +execution table `:155-162`; "X of 6" `:255`). `allowed-tools` (`:12-19` +Read/Glob/Write/Bash/AskUserQuestion/Task) — Read/Write/Bash/AskUserQuestion/Task(→`content-optimizer` `:119` +✓) all invoked. Degradation present (Error Handling: No-Tests-Directory, Incomplete-Data, Missing-Analytics, +Corrupted-files `:472-493`). Statistical honesty notably correct — 2c.4/Confidence-Level (`:311,:320-331`) +explicitly demotes small-sample results to "directional, not significant." No saves/dwell claims. No +"thought leadership". + +### MINOR — manual engagement-rate formula excludes clicks, diverging from the canonical `engagementRate` (`ab-test.md:236`) +- **Verified by main (both lenses converged):** `:236` computes ER as + `(reactions + comments + reposts) / impressions * 100` — clicks excluded (and 2b.3 `:228-234` never + collects clicks). The CLI's `engagementRate` includes clicks: `totalEngagement = reactions + comments + + shares + clicks` (`csv-parser.ts:205-208`). Step 2c.3 (`:282-288`) cross-references the A/B numbers against + the CLI weekly reports, where the two rates will not match. +- **Severity:** internally consistent within a single test (same formula on both variants, and A/B is + variant-relative), so the verdict-within-a-test is unaffected → MINOR, latent on the cross-reference path. +- **disposition:** add a one-line "clicks excluded by design" caveat, or align the manual formula to the + canonical definition. Fold into the sibling-drift reconciliation note. + +### SUGGESTION — bare agent name in a user-facing suggestion (`ab-test.md:468`): "use the `content-optimizer` agent" surfaces a bare name where the canonical form (correctly used at `:119`) is the namespaced `subagent_type`. Cosmetic. Intent-lens. + +### SUGGESTION — `allowed-tools` over-declares `Glob` (`:14`); scans use Bash `ls` (`:37,:209`). 2b.4/2c.5 append/update are edit-shaped but the declared `Write` covers them via full rewrite (no `Edit` gap). Correctness-lens. See Recurring note. + +--- + +## measure.md — VERDICT: ALLOW (0 findings — clean) + +Class: **front-door router (delegate-only)**. Intent delivered **and structurally enforced** (both lenses +converged): `allowed-tools` (`:10-13`) = `Glob` + `AskUserQuestion` only — no Bash/Read/Task — so the +command *cannot* run analysis itself; it identifies intent (Step 1, 5 intents `:30-34`) and routes (Step 2 +table `:41-47`, one row per intent). All five routes — `/linkedin:import`, `/linkedin:report`, +`/linkedin:analyze`, `/linkedin:audit`, `/linkedin:ab-test` — resolve to existing command files. Degradation +present (Step 0 glob optional, "Do not block on it"; order-note `:49-50` routes to import first when nothing +imported). No dangling branch, no analysis logic to drift, no saves/dwell claims, no "thought leadership". +The thinnest, cleanest surface in the batch — the delegate-only contract is enforced by the tool whitelist, +not just asserted. + +--- + +## Gate decision — R4 COMPLETE (6 surfaces) + +| Surface | Verdict | BLOCKER | MAJOR | MINOR | SUGGESTION | +|---|---|---|---|---|---| +| import | ALLOW | 0 | 0 | 1 | 1 | +| report | REWORK | 0 | 1 | 1 | 1 | +| analyze | ALLOW | 0 | 0 | 1 | 1 | +| audit | ALLOW | 0 | 0 | 0 | 1 | +| ab-test | ALLOW | 0 | 0 | 1 | 2 | +| measure | ALLOW | 0 | 0 | 0 | 0 | +| **TOTAL** | **REWORK** | **0** | **1** | **4** | **6** | + +**Batch verdict: REWORK** — 1 of 6 surfaces (report) carries **1 MAJOR**: the heatmap report type routes to +a nonexistent "Step 6c" (real handler Step 2c) — a provably-wrong cross-reference on a primary menu branch. +**0 BLOCKER anywhere.** The other five are ALLOW (measure notably clean — its delegate-only contract is +enforced by the `allowed-tools` whitelist, not merely asserted). + +**Analytics-class predicate verdict: PASS.** The saves/dwell honesty contract holds on every surface that +touches the metric — saves framed as manual/count-only/no-API and never folded into `engagementRate`, dwell +called unmeasurable, neither claimed as imported; `parseOptionalCount` semantics (`csv-parser.ts:71`) and the +`getAnalyticsRoot` seam described accurately wherever quoted. Graceful degradation present on all six. This +was the batch-specific axis and it is clean. + +**Independence verdict:** two convergences (import Step 6a flags; ab-test ER-omits-clicks) + three +divergences resolved by main's grounding in **both directions** — intent over-rated import's Step 6a +(MAJOR→MINOR on grounding that the executable path delegates), while correctness uniquely caught report's +Step 6c misroute (MAJOR) and analyze's twin severity scales (MINOR) that intent's lens never traced. The +two-lens method earned its keep again: had only the intent-lens run, report would have shipped ALLOW with a +broken primary branch; had only the correctness-lens run, import would have over-escalated to REWORK. + +**Systemic findings now span R2a+R2b+R3+R4:** clipboard `printf` (R2a, 10 files — **R4 adds none**) · +component scaffold (R2b, 3 files — **R4 adds none**) · bare reference paths (R2b+R3+R4 — R4 adds 7 +lowest-impact pointer/prose sites in analyze/report, not executable). **New this batch:** (a) `allowed-tools` +over-declaration now spans R3+R4 (~7 surfaces, SUGGESTION-class, harmless) with one true *under*-declaration +(report Step 8b `Write`, MINOR); (b) a small **sibling interface/metric-definition drift** cluster (import's +stale `trends` flags + ab-test's clicks-excluded ER) — reconcile once against the CLI SSOT. Cold review +**finds**; it changes no code. Each fix is its own operator-gated decision. Recommended consolidated-fix +order unchanged: (1) clipboard `printf` [R2a, 10 files, highest blast radius], (2) component scaffold [3 +files], (3) bare reference paths [grep-driven], then the per-surface items — now including **report Step 6c +misroute (the one R4 MAJOR)**, report Step 8b `Write`, the sibling-drift reconciliation (import 6a + +ab-test ER), analyze severity scales, and the over-declaration trims. Local-only (hardening-class), pushed +per the 2026-06-30 operator delegation (public catalog, no secrets). + +**Cumulative cold-review coverage: 23/29** (review.md S1=4 · R2a=5 · R2b=4 · R3=4 · R4=6). Remaining: **R5 +(Grow+Router)** — strategy · competitive · monetize · outreach · profile · linkedin (6 surfaces) → 29/29. diff --git a/docs/hardening/review-R5.md b/docs/hardening/review-R5.md new file mode 100644 index 0000000..28d8dec --- /dev/null +++ b/docs/hardening/review-R5.md @@ -0,0 +1,258 @@ +--- +type: cold-review +batch: R5 +journey: "Grow + Router — growth/authority surfaces + the command router (FINAL round)" +scope: "FROZEN committed files at HEAD 4109fe7 (clean tree; post-hardening cold pass)" +method: "2 independent cold Opus reviewers for the round (intent + correctness), each covering all 6 surfaces, no cross-feed; every mechanical claim tool-grounded (anti-fabrication mandate); reviewers carry NO drafting-session context. Divergences re-grounded by main before registration." +surfaces: [strategy, competitive, monetize, outreach, profile, linkedin] +reviewers: + - "intent-lens (conformance: intent delivery + cross-ref resolution + class predicates + graceful degradation + thought-leadership terminology ban)" + - "correctness-lens (internal consistency + bound-vs-canonical + step/phase arithmetic + allowed-tools completeness + dead-ref / executable-path checks)" +class: "guided/stateful (strategy·competitive·monetize·outreach·profile — primary artifact produced · subagent targets resolve · graceful degradation) + routing (linkedin — every emitted /linkedin:Y resolves)" +status: "COMPLETE — all 6 surfaces reviewed (strategy, competitive, monetize, outreach, profile, linkedin). R5 completes 29/29 cold-review coverage." +verdict: MINOR +counts: { BLOCKER: 0, MAJOR: 0, MINOR: 3, SUGGESTION: 2 } +--- + +# Cold review — R5 (Grow + Router) · FINAL round → 29/29 coverage + +Independent post-hoc cold review of the 6 remaining surfaces — the five Grow-journey +guided/stateful commands (`strategy`, `competitive`, `monetize`, `outreach`, `profile`) plus the +`linkedin` router — on the FROZEN committed files (HEAD `4109fe7`). Mirrors the S1 `review.md` + +R2a + R2b + R3 + R4 model (the cold-review method that did **not** fabricate): read-and-show before +assert, every `file:line` tool-confirmed, reviewers carry no drafting-session context. The +per-command interactive gate (`log.md`) already passed these; this pass adds the **independent** +axis that gate never had. **R5 completes the sweep: 23/29 + 6 = 29/29 cold-review coverage.** + +**Resolution integrity — PASS across all 6 surfaces (the headline R5 result).** Both blind lenses +independently confirmed, target-by-target against their own `ls`/`test -f`: +- **2/2** `subagent_type: linkedin-studio:X` refs resolve — `strategy-advisor` (`strategy.md:153`), + `network-builder` (`outreach.md:171`). +- **28/28** unique `/linkedin:Y` route tokens resolve to `commands/Y.md` (router + cross-command + suggestions). +- **11/11** router-suggested agents (named in `linkedin.md`) resolve to `agents/*.md`. +- **2/2** helper-script invocations resolve: `outreach.md`'s `state-updater.mjs --record-outreach` + (flags `--date/--track/--partner/--stage/--next/--due` match `state-updater.mjs:394-411` + byte-for-byte) and `linkedin.md`'s `queue-manager.mjs` import (`queueUpcoming`/`queueOverdue`/ + `queueFormatSummary` exist at `queue-manager.mjs:52,94,112`). +- **0 under-declared tools** — every body-invoked tool (Task/Read/Write/Edit/Bash/WebSearch/ + AskUserQuestion) is in the surface's `allowed-tools`; only harmless `Glob`/`Grep` over-declarations. +- **0 dead executable Read/Bash targets** — every `references/*`, `${CLAUDE_PLUGIN_ROOT}/skills/…`, + and script path on an executable path verified present. + +**No broken invocation, no missing primary artifact, no failing gate, no runtime-breaking +contradiction anywhere in R5 → 0 MAJOR, verdict MINOR (advisory, not REWORK).** This is the +cleanest batch of the sweep. + +**Independence cross-check — both outcomes recurred (the case for two lenses, again):** +1. **Convergence:** both blind lenses independently surfaced the same `monetize.md` description↔body + scope mismatch and the same `thought leader` terminology cluster — high confidence these are real. +2. **Divergence (re-grounded by main):** + - **Terminology severity** — intent-lens called the `thought leader` hits MINOR (it owns the + conformity predicate; memory `no-thought-leadership-phrase` makes this a *standing plugin rule*, + not a nicety), correctness-lens called them SUGGESTION (no runtime break). **Main ruling: + MINOR** — a direct violation of an explicit plugin terminology rule in user-facing strings is a + conformity defect; it is not MAJOR (no runtime break). + - **`monetize` scope mismatch severity** — intent-lens SUGGESTION ("description under-claims + body"), correctness-lens MINOR ("description contradicts its own 0-1K Stage 1"). **Main ruling: + MINOR** — it is a genuine self-contradiction about the command's scope, not a mere under-claim. + - **Lens-unique:** correctness-lens alone caught the `monetize` Audience-Size scorecard + arithmetic; intent-lens alone caught the bare-relative-path robustness gap. Both re-grounded by + main below and kept. + +--- + +## strategy.md — VERDICT: MINOR + +- **Intent delivered:** yes. Phase auto-detect from state (`:32-44`), phase strategy + delegation + to `strategy-advisor` (`:153`), trajectory overlay (`:250-284`), authority building Phase 2+ + (`:286-419`), stall points (Step 4), 90-day plan (Step 5), metrics (Step 6). +- **Resolution:** all resolve — `subagent_type: linkedin-studio:strategy-advisor` (`:153`) → + `agents/strategy-advisor.md` ✓; routes `/linkedin:profile` (`:292,:360`) ✓; `Task` declared. +- **Class predicate (guided/stateful):** growth-plan artifact produced ✓; subagent resolves ✓; + graceful degradation present ✓ (`follower_count` 0/missing handled `:42`; "If no milestone data: + Skip this step" `:284`; authority skipped in Phase 0-1 `:290`). +- **Arithmetic (correctness-lens, grounded):** 5 phases (0–4) consistent between Step 0.5 + auto-detect ranges and Step 2 headers; step numbering monotonic (0.5,1,2,3,3.5,3.6,4,5,6), no + gaps/dupes. PASS. +- **allowed-tools:** declared {Read, Glob, Grep, AskUserQuestion, Task}; under-declared {} ✓; + over {Glob, Grep} (harmless). +- **Findings:** + 1. **[MINOR]** `strategy.md:371` "Engaging with other thought leaders" — `thought leader` + terminology-ban hit (off-primary checklist label). Part of the cross-cutting cluster. + 2. **[MINOR]** bare relative paths for all file loads; **0×** `${CLAUDE_PLUGIN_ROOT}` (grounded + `grep -c` = 0, vs 1–10× in competitive/monetize/outreach/linkedin) → `Read` resolves against + cwd, not plugin root. Has `Glob` in allowed-tools as a fallback so it degrades, not fatal. + Folds into systemic finding #3 (bare ref-paths). + +## competitive.md — VERDICT: MINOR + +- **Intent delivered:** yes. Competitor analysis template (Step 2), landscape map (Step 3), + gap/opportunity matrix (Step 4), differentiation plan (Step 5), inspired takeaways (Step 6), + ethics note. +- **Resolution:** fully self-contained — **no** `subagent_type`, **no** `/linkedin:` routes; + `allowed-tools` (Read/Glob/WebSearch/AskUserQuestion) correctly omits `Task`. ✓ +- **Class predicate (guided/stateful):** competitive-analysis artifact produced ✓; no subagents to + resolve ✓; graceful degradation ✓ (Step 1 user-input/WebSearch-driven, runs with no state). +- **Arithmetic:** Steps 0–6 linear/monotonic; no stated totals to miscount. PASS. +- **allowed-tools:** declared {Read, Glob, WebSearch, AskUserQuestion}; under {} ✓; over {Glob}. +- **Findings:** + 1. **[MINOR]** `thought leader(s)` appears **4×** — including the frontmatter `description` + (`:4`, **user-facing**, shows in command listings) and the opening promise (`:17`), plus + `:31,:34`. **Worst terminology offender of the six.** Conformity defect, no runtime break. + +## monetize.md — VERDICT: MINOR + +- **Intent delivered:** yes. All 8 steps present: scorecard (1), stage strategy (2), lead-magnet + blueprint (3), funnel calendar (4), CTA + A/B variants (5), Featured optimization (6), revenue + model (7), tracking dashboard (8). +- **Resolution:** all resolve — no `subagent_type`; routes `/linkedin:post` + `/linkedin:pipeline` + (`:367`) both exist. ✓ +- **Class predicate (guided/stateful):** monetization-plan artifact produced ✓; no subagents ✓; + graceful degradation ✓ ("Stage 1: Visibility (0-1K followers)" `:92` serves brand-new users). +- **Gating honesty:** does NOT hard-gate; serves 0-1K. The real state-read gate is the router's + soft prepend (`linkedin.md:175`, `<1000` → prepend, continue anyway) — consistent with outreach. +- **Findings:** + 1. **[MINOR]** **description↔body scope self-contradiction** — `monetize.md:6` "Works from 1K+ + followers" contradicts its own Stage 1 "Visibility (**0-1K** followers, score 0-30)" (`:92`, + `:81`) and the router's "they work at any follower count" (`linkedin.md:126`). The body + genuinely covers sub-1K; the description misstates scope. Off-primary (a frontmatter string) + but user-facing. **Strongest non-terminology R5 finding.** Fix: align `:6` to "any follower + count (compounds at 1K+)". + 2. **[MINOR]** `thought leaders` in the frontmatter `description` (`:4`, user-facing). Part of the + cross-cutting cluster. + 3. **[SUGGESTION]** Audience-Size scorecard arithmetic (`:51-56`): sub-items +5/+10/+15/+5/+5 sum + to **+40** against a **`/25`** cap if read additively, whereas the other three categories each + sum to exactly 25 (e.g. Engagement Quality 5+5+10+3+2=25). The follower tiers (1K/5K/10K) are + clearly intended mutually-exclusive (one tier) but aren't marked as such → a literal additive + read overflows. AI-interpreted (not machine-summed) so intent is recoverable; cosmetic, but it + feeds the band that selects the stage. Fix: mark the three follower tiers "(pick one)". + +## outreach.md — VERDICT: PASS + +- **Intent delivered:** yes, thoroughly. Two-track (collab + speaking) orchestrator with a + Capability Checklist mapping every predecessor function to a step (`:31-62`) + 10 steps + state + persistence. +- **Resolution:** all resolve — `subagent_type: linkedin-studio:network-builder` (`:171`) ✓; + routes `/linkedin:strategy` (`:112`), `/linkedin:firsthour`+`/linkedin:outreach` (`:1078`), + `/linkedin:calendar` (`:1097`) all exist ✓; **Bash executable path sound** — Step 8c's + `state-updater.mjs --record-outreach` (`:1084-1092`) matches the script's handler + (`state-updater.mjs:294,:394-411`) byte-for-byte. ✓ +- **Class predicate (guided/stateful):** outreach-plan + persisted pipeline produced ✓; subagent + + script resolve ✓; graceful degradation ✓ (Step 2a "Not ready: <3 met → build foundation first" + + recommend `/linkedin:strategy` `:112`). +- **Arithmetic (correctness-lens, grounded):** "12 collab formats" → 12 (`FORMAT 1–12`); "4 talk + templates" → A–D; "5 phases" → PHASE 1–5; scorecards 4×/25=/100 and 5×/5=/25 check out; step + numbering monotonic. PASS. +- **allowed-tools:** declared {Read, Glob, WebSearch, AskUserQuestion, Task, Bash}; under {} ✓; + over {Glob}. +- **Gating honesty:** "1K+ followers" self-report (`:100,:143`) consistent with router's `~1K` soft + gate (`linkedin.md:120,:176`) and monetize's 1K. ✓ +- **Findings:** + 1. **[SUGGESTION]** `thought leader` inside a WebSearch query template (`:229`, + `"[your niche] linkedin thought leader"`). It is a *search string* targeting how others + self-label (intentional — to find such profiles), so the **lowest-priority** instance of the + terminology cluster — but the literal string is still in the plugin. + +## profile.md — VERDICT: PASS + +- **Intent delivered:** yes. Relevance-model context (`:20-44`), Profile SEO + per-section keyword + targets (`:46-79`), 7-section audit walkthrough (`:82-199`), profile-content alignment check + (`:200-212`), prioritized action plan (`:214-231`), alignment test (`:232-238`). +- **Resolution:** self-contained — no `subagent_type`, no `/linkedin:` routes; `allowed-tools` + Read/AskUserQuestion (tightest frontmatter of the six). ✓ +- **Class predicate (guided/stateful + topic-relevance-audit):** the topic-relevance audit **is** + actually performed (the body *is* that audit) ✓; artifact (audit + action plan) produced ✓; + graceful degradation ✓ (every step AskUserQuestion-driven, runs with zero state). Notable + verification discipline: explicitly refuses to fabricate a scoring breakdown (`:28,:32-36,:179`). +- **Arithmetic:** Sections 1–7 monotonic; profile-field limits (headline 220, About 2,600) are + LinkedIn field limits, NOT post hook/length bounds — no canonical contradiction. PASS. +- **Terminology — IMPORTANT NON-VIOLATION:** the two `thought leader` hits (`:79,:101`) are + **legitimate negative examples** — the command explicitly tells the user to AVOID the phrase + (`:79` lists it with "guru"/"ninja" as keyword-wasters; `:101` is a "Weak example"). Both lenses + agree; main confirmed by reading both lines. **profile.md models the correct behavior.** +- **Findings:** none of defect class. + 1. **[Note — systemic #3]** bare relative paths; **0×** `${CLAUDE_PLUGIN_ROOT}` and no `Glob` + fallback → most-exposed instance of the bare-path robustness item. BUT correctness-lens + verified all three referenced files exist (`test -f` OK), so this is a cwd-robustness concern, + not a dead ref. Folds into systemic #3; profile.md is otherwise the cleanest surface of R5. + +## linkedin.md (router) — VERDICT: MINOR + +- **Intent delivered:** yes. Status line (`:19-29`), upcoming/overdue posts via queue (`:31-57`), + five-journey menu with front-doors (`:59-127`), gating rule (`:122-127`), interactive menu + (`:129-146`), direct-routing table (`:156-198`). +- **Resolution (routing class):** **all 28 unique route tokens resolve** to `commands/*.md` + (verified token-by-token vs `ls commands/`); all 11 suggested agents resolve; `queue-manager.mjs` + node call (`:35-42`) uses exports that all exist (`:52,:94,:112`). **The router advertises nothing + that doesn't exist.** ✓ Correctly has no `Task` (delegate-only). +- **Class predicate (routing):** every route resolves ✓; graceful degradation ✓ (missing state + "No LinkedIn state tracked yet" `:28`; empty queue `:57`; follower segment only if + `follower_count > 0` `:30`). +- **Gating honesty/consistency:** `:175-176` give monetize + outreach the **same** soft state-read + gate (`<1000` → prepend, continue anyway); `:122-127` document the soft-gate design honestly + ("they work at any follower count… competitive is **not** gated"). Consistent with both command + bodies. ✓ +- **Findings:** + 1. **[MINOR]** `linkedin.md:118` "Competitive analysis of other thought leaders" — `thought + leader` terminology-ban hit in the **user-facing** routing-menu table. Part of the cluster. + +--- + +## Summary table + +| Surface | Verdict | MAJOR | MINOR | SUGGESTION | +|---|---|---|---|---| +| strategy.md | MINOR | 0 | 2 | 0 | +| competitive.md | MINOR | 0 | 1 | 0 | +| monetize.md | MINOR | 0 | 2 | 1 | +| outreach.md | PASS | 0 | 0 | 1 | +| profile.md | PASS | 0 | 0 | 0 (1 systemic-#3 note) | +| linkedin.md | MINOR | 0 | 1 | 0 | +| **R5 total** | **MINOR** | **0** | **3 distinct** | **2 distinct** | + +(Per-surface MINOR counts include shared cross-cutting findings; the 3 *distinct* R5 MINOR findings +are: the terminology cluster, the monetize scope contradiction, and the bare-path robustness item.) + +## Cross-cutting findings (for the consolidated fix-pass) + +1. **[MINOR · NEW R5 systemic] `thought leader` terminology cluster — 5 surfaces, 9 instances.** + competitive `:4`(description, user-facing)/`:17`/`:31`/`:34` · monetize `:4`(description, + user-facing) · linkedin `:118`(menu, user-facing) · strategy `:371`(checklist) · outreach + `:229`(WebSearch string, lowest priority). **profile `:79,:101` are NON-violations** (correct + negative examples — do not "fix" them). Memory `no-thought-leadership-phrase` confirms this is a + standing plugin terminology rule. **Recommended fix:** one terminology sweep replacing the + user-facing instances first ("thought leaders" → "creators"/"experts"/"voices in your niche"), + leaving profile's avoid-list intact and the outreach search-string as lowest priority. No runtime + risk — the buzzword gate scopes to post content, not command markdown. + +2. **[MINOR · per-flate] `monetize.md:6` description↔body scope self-contradiction.** "Works from + 1K+ followers" vs the body's own 0-1K Stage 1 (`:92`) and the router's "any follower count" + (`linkedin.md:126`). Fix: align the description to the body's actual sub-1K-onward range. + +3. **[MINOR · folds into existing systemic #3] bare ref-paths — strategy + profile (0× + `${CLAUDE_PLUGIN_ROOT}`).** profile most exposed (no `Glob` fallback). Refs verified to exist, so + cwd-robustness not a dead ref. Reconcile in the repo-wide path-style pass already scoped by + systemic #3 (R3/R4 found the same pattern in batch/pipeline/firsthour/analyze/report). + +4. **[SUGGESTION · per-flate] `monetize.md:51-56` Audience-Size scorecard sums to +40 vs `/25` + cap.** Follower tiers intended mutually-exclusive but unmarked. Fix: annotate "(pick one)". + +**R5 adds NO new ★ systemic finding to the existing three** (clipboard, scaffold-band, bare-paths) — +the only genuinely new cross-cutting item is the terminology cluster, which is a conformity sweep, +not a code-behavior defect. + +## Verification + +- Spot-checks re-grounded by main (this file): `grep -rniE 'thought.?leader'` over the 6 (9 hits, + classifications above); `sed -n '1,8p'`/`'92p'`/`'48,64p' monetize.md` (scope + scorecard); + `grep -c CLAUDE_PLUGIN_ROOT` per surface (strategy 0, competitive 4, monetize 10, outreach 10, + profile 0, linkedin 1). +- `bash scripts/test-runner.sh` → see STATE telling (expected 138 passed / 0 failed, floor 123) — + unchanged by this review (review is read-only; no command edits). + +## Sweep status after R5 + +**29/29 cold-review coverage reached** (S1 `review.md` + R2a + R2b + R3 + R4 + R5). The independent +cold-review phase is COMPLETE; the v1.0.0 review blocker is lifted. Remaining v1.0.0 work: the +consolidated fix-pass (operator-gated — review FINDS, fix is a separate decision) and the GUI. diff --git a/docs/ingestion-guard/plan.md b/docs/ingestion-guard/plan.md new file mode 100644 index 0000000..acee541 --- /dev/null +++ b/docs/ingestion-guard/plan.md @@ -0,0 +1,209 @@ +# Ingestion-guard integration plan — when & where to wire `llm-ingestion-guard` + +> **Status: `planned` (2026-07-16).** Plan only — nothing wired yet, per the adoption brief's +> "don't implement now" instruction. This document is the repo's durable answer to *when* and *where* a +> write-time ingestion guard earns its place at our persist gates. +> +> **Guard:** `llm-ingestion-guard` `v0.2` (alpha, public API may change). Write-time sibling of query-time +> chatbot guardrails; hardens *untrusted content flowing through an LLM step into a persisted, +> downstream-read store*. Python, stdlib-only core, 3.10+. +> +> **Adoption brief (authoritative, self-contained):** +> `https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security/raw/branch/main/docs/ADOPTION-BRIEF.md` +> (§7 planning checklist, §3 the 8-step contract, §4 the OKF adapter, §8 honest limitations). +> +> Grounded against `882f6ee` by two independent read-only surveys (ingest surface + persist seams), +> 2026-07-16. Every seam below carries a `file:line` anchor. + +## 1. Decision + +**Status = `planned`.** The plugin has live untrusted-ingest paths (a react-to-URL command, an auto-fetching +trend agent, an external-research newsletter fan-out), and it persists their output into stores a downstream +agent later reads as trusted context. That satisfies the brief's §7 decisive condition. Nothing is wired. +The guard is registered here as a **dependency to add before the first *automated* external-origin ingest +path goes fully live** — and one such path (trends → session re-injection, §5.1) is arguably already live, +so it is the first integration candidate, not a distant one. + +We do **not** implement now. The brief is explicit ("kun planlegg og oppdater planene"), and wiring is gated +on an unresolved interop decision (§7). + +## 2. What the guard is (and is not) for us + +It is **not** a query-time guardrail between the user and the model. It is a **write gate**: the last place +the provenance of a piece of content is still known before it is committed to a store that a *later* agent +reads as trusted. For this plugin that store is the second-brain (`brain/profile.md`, `ingest/published/`), +the trends store, the specifics-bank, the post queue, and the state file's `## Recent Posts` — all of which +are re-surfaced into future model context. A poisoned concept committed at write time is read back later with +its origin forgotten; the write gate is the only place to catch it. + +## 3. Ingest-surface analysis (untrusted vs first-party) + +Scored against the brief's §7 checklist. "Downstream trusted reader" = a command/agent/hook that later reads +the store **as context**, which is what turns a write into a poisoning surface. + +| # | Path | Origin | Untrusted? | Persist target | Downstream trusted reader | Live? | +|---|------|--------|-----------|----------------|---------------------------|-------| +| 1 | **trend-spotter agent** (`agents/trend-spotter.md`) → trends CLI `capture` (`scripts/trends/src/cli.ts:294`) | Auto-fetched web/vendor/regulator content (WebSearch/WebFetch + research MCPs) | **YES — external** | `trends/trends.json` — `source`/`title`/`url`/`summary` stored **verbatim** (`scripts/trends/src/item.ts:24-48`) | **`session-start.mjs:38-78` auto-reinjects into the next session's context — no human in the loop** | **LIVE** | +| 2 | **`brain ingest` / `scanInbox`** (`scripts/brain/src/ingest.ts:172-192,:247`) | User's own published posts, dropped into `ingest/inbox/` (manual) | Origin first-party today; **untrusted-*capable*** (drop-zone accepts any file; SB-S4 connector would automate it) | `ingest/published/<id>.md` | `voice-trainer` gold source (`agents/voice-trainer.md:136,144`); `brain consolidate` → `brain/profile.md`; `assemble`/`reconcile` | LIVE (manual) | +| 3 | **`/linkedin:newsletter`** research fan-out (`commands/newsletter.md:461-475`) | Open-web research agents (WebSearch/WebFetch) | **YES — external** | specifics-bank `ekstern` bindings (`scripts/specifics-bank/src/kilder.ts:51-52`) + `NN-kilder.md` + `queue.json` | Edition prose + sources ledger; queue readers | LIVE | +| 4 | **`/linkedin:react`, `:post`, `:pipeline`, `:batch`** URL ingest (`commands/react.md:52`, `post.md:62`, `pipeline.md:13`, `batch.md:12`) | External URL (news, blog, YouTube, social threads) via WebFetch | **YES — external** | react/post: clipboard + state `## Recent Posts` **metadata only** (raw content not persisted); pipeline/batch: draft files + `queue.json` | state `## Recent Posts` re-injected (`user-prompt-context.mjs:109-117`, `session-start.mjs:341`); queue readers | LIVE | +| 5 | **`/linkedin:import`** CSV (`scripts/analytics/src/parsers/csv-parser.ts`) | User's own LinkedIn analytics CSV export | First-party origin; **container-layer** parse surface | `analytics/posts/*.json` (`storage.ts:143-160`) | `report`/`audit`/`analyze`, `analytics-interpreter`, `brain assemble` | LIVE | +| 6 | **`/linkedin:competitive`, `:outreach`** (`commands/competitive.md:33`, `outreach.md:223`) | WebSearch of competitor/partner/event content | YES — external | **Nothing durable** (inline report only) | — (no persist) | LIVE | +| 7 | Received **third-party OKF bundle** | External bundle | YES — external | *does not exist* | — | **FUTURE / not built** | +| 8 | `setup`, `onboarding`, `first-post`, `quick`, `specifics-bank`, voice-samples | User's own typed/pasted content | No — first-party | state file / voice-samples / specifics-bank | content commands | LIVE | + +## 4. Where the guard applies — and where it deliberately does not + +**Applies (wire here):** the untrusted boundaries — rows 1–5. Trust follows the data's *origin*, not the +insertion channel (brief §7): a manual paste of an external article (`/linkedin:react`) is still external. + +**Does not apply (out of scope by design):** +- **First-party authoring** (row 8): onboarding, `setup`, typed post ideas, `specifics-bank` (human-only), + voice-samples, the user's own profile edits. The guard's threat model does not target trusted-author + in-place edits. +- **Fetch-but-no-persist** (row 6): `competitive`/`outreach` fetch external content but write nothing + durable — there is no downstream-trusted store to poison, so the *write-time* guard has no seam. (Their + risk is query-time, a different tool's job.) +- **Row 2 today** is first-party by origin (the user's own posts). It becomes an untrusted boundary the + moment SB-S4 (the EU/EEA DMA connector) or any received-bundle path automates the inbox — see §9. + +## 5. Integration points (persist gates) + minimal wiring + +**Key structural finding:** every external fetch in this plugin goes through the *model's* WebFetch/WebSearch/ +MCP tools — there is **no raw-HTTP `your_model()` seam in plugin code**. The fetch → transform → persist +pipeline is: *model tool call → agent reasoning → deterministic CLI write*. So the guard's classic two-bookend +model (`prepare_input` → `your_model` → `screen_output`) only **half-maps**: `screen_output` +(scan-before-persist) wires in cleanly at the deterministic CLI write points; `prepare_input` (sanitize+fence +before the model) has no clean code seam because the fetch and transform happen *inside the model's turn* +(§6). + +**Coverage gap to know:** `hooks/scripts/content-gatekeeper.mjs` is the only `PreToolUse(Write|Edit)` +choke-point, but it inspects the file **path, never the content bytes**, and only fires for the **Write/Edit +tool** on drafts/assets paths. The four durable trusted-context stores below are all written via +**Bash-invoked `node`/`tsx`**, so they **bypass the gate entirely**. A byte-level guard must wire at the CLI +write points, not solely at `content-gatekeeper`. + +Ranked by automated-reinjection risk (highest first): + +### 5.1 trends `capture` — the one live automated poison→reinject loop *(priority 1)* +- **Seam:** before `store.ts` persists to `trends/trends.json`, in `scripts/trends/src/cli.ts` `capture`. +- **Wiring:** `screen_output(item.title + "\n" + item.summary, PRESET_USER_UPLOAD)`; on + `Disposition.FAIL_SECURE`, route the item into the CLI's existing `errors[]` channel + (`cli.ts:294-320`) instead of persisting — a drop, not a crash. +- **Why first:** external content, stored verbatim, auto-surfaced back into future model context by + `session-start.mjs` with no human gate. This is the sharpest write→trusted-read loop in the plugin. + +### 5.2 brain `ingest` / `scanInbox` — the voice/profile gold seam *(priority 2)* +- **Seam:** `writePublished(rec)` in `scripts/brain/src/ingest.ts:172-192`; especially the `scanInbox` + (`:247`) path that reads user-dropped `ingest/inbox/*.md`. +- **Wiring:** `screen_output(body, ...)` before write; `FAIL_SECURE` → do not promote to + `ingest/published/`, log to a rejects sidecar. Once a bundle-shaped receive lands, switch to + `okf.import_bundle` (§8). +- **Why:** `ingest/published/` is the ranked-#1 gold source for `voice-trainer` and feeds `profile.md` via + consolidation — the highest-trust downstream read in the plugin. Note the existing `provenance=published` + guard here is an **anti-model-collapse** control (authorship axis), **not** an anti-injection control + (origin axis) — the two are orthogonal; this seam has the former, not the latter. + +### 5.3 newsletter research → specifics-bank `ekstern` bindings *(priority 3)* +- **Seam:** before an external research finding persists as an `ekstern` binding + (`scripts/specifics-bank/src/kilder.ts:51-52`). +- **Wiring:** `screen_output` on the claim text + source; `FAIL_SECURE` → quarantine, surface to the operator + in the fact-check sweep the newsletter pipeline already runs. + +### 5.4 analytics CSV import — the container-layer gate *(priority 4)* +- **Seam:** `saveBatch` in `scripts/analytics/src/utils/storage.ts:143-160` (or in `parseLinkedInCSV`). +- **Wiring:** row-content scan (CSV formula-injection `= + - @`, active-content) complementing the existing + **filename-only** sanitization (`sanitizeDate/Id/Week/Month` + `verifyPathWithinDirectory`, + `storage.ts:110-137`). Lower priority: origin is first-party and the JSON is never re-emitted to a + spreadsheet, so the practical blast radius is small — but there is currently **no** row-content + sanitization layer, so it is a real (if narrow) gap. + +## 6. The `prepare_input` caveat (honest scope of what we can wire) + +The input-side bookend (`sanitize` + fence before the model, contract steps 1–2) has **no clean code seam** +here: the untrusted content is fetched by the model's WebFetch tool and transformed by the drafting agent +*within the same model turn*, so plugin code never holds the raw input to wrap. Partial mitigations exist — +a sanitize pass on fetched text inside the URL commands' prompts, or reading `tool_input.content` in +`content-gatekeeper` for the Write-tool draft path — but neither is the clean `prepare_input(untrusted)` +call the library assumes. What we **can** wire cleanly and fully is `screen_output` at the persist gates (§5). + +Partly, the contract's *real* security already holds structurally: the drafting agents largely reason over +fetched text (close to "tool-less transform"), and the persist step is a deterministic CLI ("output as data, +parsed to a schema"). The lexicon/entropy scan is defense-in-depth on top of that, not the wall. + +## 7. Python ↔ Node interop — the real integration cost + +The guard is **Python** (stdlib, 3.10+). This plugin's runtime is **Node ESM `.mjs` hooks (deliberately +zero-npm-dep) + TypeScript engine via `tsx`**. The only Python in the repo is one **build-time** script +(`hooks/scripts/compile-hooks.py`), never on a data path. So wiring the guard means crossing a subprocess +boundary. Options: + +| Option | Shape | Trade-off | +|--------|-------|-----------| +| **(a) `spawnSync('python3', …)` at the engine CLI write points** *(recommended)* | The `scripts/{trends,brain,specifics-bank,analytics}` CLIs (already `tsx`, not the zero-dep hook hot path) shell out to a `python -m llm_ingestion_guard` scan | Adds a `python3` + `pip install llm-ingestion-guard` runtime dependency to the *engine layer only*; keeps the zero-dep Node **hooks** untouched. Cleanest fit. | +| (b) Port minimal `sanitize` + `scan_output` to a `.mjs` twin | Reimplement in Node | Defeats the point of adopting a *maintained* guard; the coverage matrix (126/126) would not apply to the port. Rejected unless a hard no-Python constraint appears. | +| (c) Bash step inside the command, not the hook | Command invokes the scan before the CLI write | Non-deterministic (depends on the agent running the step); weaker than a code-enforced gate. | + +**Blocker to resolve first:** whether a `python3` + one-package runtime dependency is acceptable given the +plugin's zero-dep design value. This is the gate on any wiring work. + +## 8. OKF `import_bundle` — future / conditional + +The plugin is **export-only** toward OKF: its brain *emits* OKF-compatible form (`type:` + per-level +`index.md` + root `okf_version`, landed 2026-06-26, `docs/okf-convergence-brief.md`). There is **no +`import_bundle` and no third-party-bundle receive path** anywhere in the repo, and inbox auto-classify/convert +is explicitly deferred (brief §11). So the guard's `okf.import_bundle(bundle, origin=EXTERNAL, +channel=AUTOMATIC)` adapter has **no seam today**. + +It becomes relevant if/when either lands: **(i)** SB-S4 — the EU/EEA DMA portability connector auto-feeding +`ingest/inbox/`; or **(ii)** a cross-plugin shared retrieval skill (a *separate standalone plugin*, per the +convergence brief §8) that merges *other* plugins' brains. At that point, wire `okf.import_bundle` at +`scanInbox` with `allow_reserved` chosen per channel (received bundle → `True`; materialised individual +uploads → `False`, per brief §4). + +## 9. When — roadmap triggers + +1. **Now:** `planned`. No wiring. (This doc + the STATE.md marker.) +2. **First wiring candidate — trends `screen_output` (§5.1):** the trends→session-reinjection loop is already + live, so per the brief's "include it before the first untrusted ingest path goes live," this is the + earliest concrete target once §7 is resolved. +3. **Hard trigger (not optional) — before SB-S4 or any received-bundle path:** the EU/EEA DMA connector, or a + cross-plugin shared skill, turns `ingest/inbox/` from "user's own manual paste" into an automated + external-origin ingress. Wire §5.2 (and §8's `import_bundle`) **before** that path goes live — this is the + brief's "when, not if" moment. +4. **Opportunistic:** §5.3 (newsletter) and §5.4 (CSV container-layer) can ride whichever hardening session + touches those CLIs. + +Sequencing note: none of this is on the current `docs/plan-2026-07/` roadmap (N1–N32). It is a new, +security-scoped work item to slot in after §7 is decided — most naturally as its own hardening slice, not by +displacing the agreed N-plan. + +## 10. Honest limitations (carried from brief §8 — a green scan is not "safe") + +- **Semantic / factual poisoning is invisible** to lexicon + entropy — a plausible-but-wrong concept (wrong + metric, wrong runbook step) carries no suspicious token and passes clean. **Highest impact for a + second-brain.** Our existing anti-sycophancy / evidence-threshold / keep-both-timestamped stance in the + consolidation loop is the human-in-the-loop mitigation; the deterministic guard does not judge semantics. +- **Dormant / broken-link injection:** a link to a not-yet-existing target passes a write-time scan; payload + planted later. Relevant to the brain's cross-links. +- **A document that *describes* attacks is a false positive** — security notes documenting injection payloads + trip carrier-strip. Matters if the plugin ever ingests security content. +- **Text-only, extracted-text-only** — no file parsing in the core; extract text first, scan with high-untrust + upload provenance. + +## 11. Existing write-time precedents the guard complements (not replaces) + +The plugin already has three narrow write-gate defenses; the guard generalizes the class rather than +duplicating them: +- **`state-updater.mjs`** uses a replacement *function* (not string) on every section-append to neutralize + `$&`/`` $` ``/`$'`/`$$`/`$n` from `$`-bearing user topics (`:14-25,:117-125`) — defends the regex mechanics, + not content semantics. +- **`analytics/storage.ts`** sanitizes filenames + `verifyPathWithinDirectory` (`:110-137`) — path-traversal + defense, not row content. +- **brain `provenance=published`** — model-collapse guard (authorship), orthogonal to injection (origin). + +## 12. Out of scope for this plan + +No guard code, no `pip install`, no CLI wiring, no hook changes. This is the *when/where* map and the +dependency registration only, per the adoption brief. Implementation is a separate, operator-approved work +item gated on §7. diff --git a/docs/okf-convergence-brief.md b/docs/okf-convergence-brief.md new file mode 100644 index 0000000..828be74 --- /dev/null +++ b/docs/okf-convergence-brief.md @@ -0,0 +1,190 @@ +# Brief — Cross-plugin second-brain convergence on OKF-compatible form + +> Created 2026-06-26. **Reference-design brief — not an implementation order.** Captures the operator-locked +> direction for converging three plugins' user-owned second brains onto one shared, interoperable form, +> with **linkedin-studio's brain as the reference design** and **Google OKF as a thin interop layer only**. +> Cross-cutting: most rollout lands in sibling repos and requires its own per-repo go (see §8). State-of-play +> in `STATE.md`. Companion design docs (read alongside): `okr/docs/okf-second-brain-note-2026-06.md`, +> `ms-ai-architect/docs/okf-second-brain-brief-2026-06.md`. + +## 1. Locked decision (operator, 2026-06-26) + +Converge on **the user's own context** (not the plugins' domain reference files), driven by **interop** — +**not** standard-adoption for its own sake. + +- **linkedin-studio's brain is the reference design** — the most mature of the three (provenance-weighted + learning, episodic/semantic split, evidence-threshold promotion). The siblings rise toward its maturity; + it is **not** levelled down to bare OKF. +- **OKF is the thin interop veneer** — add `type:` + per-level `index.md`; keep all rich fields as extension + keys (OKF consumers MUST preserve unknown keys). No capability is sacrificed. +- **Staged:** ship a shared **spec/convention first**; build a shared **skill only if measured divergence + justifies it** (okr's retrieval already works — see §4). + +## 2. Premise corrections (verified — these overturn the old STATE/memory framing) + +The pre-existing framing ("greenfield shared ingest skill; inbox→classify→convert→emit OKF; mdcode is the +key tool") rested on three premises that ground-truth checks **disproved**. Verified against the live +`GoogleCloudPlatform/knowledge-catalog` repo (research agent, 2026-06-26, file+URL log retained) and the +sibling repos. + +1. **`mdcode` is NOT an OKF tool.** It is a **Google Cloud Dataplex** git-sync tool whose on-disk `kb` + markdown carries a *different* frontmatter schema (`id`/`resource.name`/`createTime`/`links`) than OKF + (`type`/`title`/`description`/`tags`/`timestamp`). They are not interchangeable. Do **not** plan `kcmd` + to emit or sync OKF bundles. +2. **"OKF has no ingest" is true of the *format*, not the *repo*.** The repo ships an OKF *producer* + (`okf/src/reference_agent`, BigQuery+web→OKF) — but it reads a BigQuery dataset + seed URLs, **not** a + document folder, and is Gemini/GCP-bound. The genuinely reusable, GCP-free parts are the **SPEC**, the + **emit/serialize/validate** core (`OKFDocument`), and the `index.md` synthesis. +3. **Classify/convert of arbitrary documents is exactly what the repo provides *nothing* for** — those + stages are 100% build-yourself. And — decisive — **the sibling design docs never asked for them.** Both + frame the work as *OKF as the storage format for a user-owned second-brain wiki* + a **retrieval skill** + + a **maintenance mechanism**, with ingest being light ("onboarding writes OKF-conformant"), not + auto-classification. + +## 3. Landscape — the three consumers have already diverged + +| Plugin | Second-brain status | Maturity | +|---|---|---| +| **okr** | **Built.** `scripts/okf-index.mjs` + `okf-check.mjs` (conformance checker) + `lib/frontmatter.mjs` + skill `okr-second-brain-search` **v1.6.0** ("OKF-compatible markdown wiki") + tests + fixtures (`okf-minimal/`, `okf-realistic/`) + `inject-okr-context.mjs`. | Structured + retrieval (built) | +| **ms-ai-architect** | **Designed, not built.** `docs/okf-second-brain-brief-2026-06.md` (operator-confirmed) + `ref-kb-direction-note` + `ref-kb-workflow-plan`. No retrieval skill yet. | Designed | +| **linkedin-studio** | **Built, richer non-OKF schema.** `brain/` hub + `ingest/{inbox,published}` + `journal/` (episodic) + two-layer `profile.md` (semantic), provenance-weighting, evidence-threshold promotion, temporal validity. Engine: `scripts/brain/`. | Provenance-weighted learning system (most mature) | + +**Reading of the siblings' own docs:** they chose OKF because their second brains lived in ad-hoc `org/*.md` +**with no retrieval mechanism** — for them OKF (really: *structured markdown + a retrieval skill*) was an +upgrade from nothing. linkedin-studio is already past that point. So the convergence is "siblings rise to +the reference," not "everyone adopts a new format." + +## 4. okr already supplies the reference checker + +`okr/scripts/okf-check.mjs` implements **exactly the minimal contract** this brief recommends, and is the +de-facto reference implementation to align the shared spec with: + +- **Only `type:` is required** on a concept file (`.md` except `index.md`); ≥1 file without `type` → fail. +- Recommended fields (`resource`/`title`/`description`/`timestamp`) → **warnings, not errors**. +- Root `index.md` carries an `okf_version` marker, echoed for human comparison — **no auto-fetch** (hooks + are no-network). + +This means okr has **both** a writer and a checker in production. The shared artifact should generalize +okr's checker semantics, not reinvent them. (Reading okr's code is fine; **writing** okr is a separate go.) + +## 5. OKF v0.1 — verified core contract + +Source: `github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md` (v0.1, 12 June 2026, +"a starting point, not a finished standard"). + +- **Bundle** = a directory tree of markdown files, **one concept per file**. **Concept ID** = file path + minus `.md`. +- **Frontmatter:** required `type` (free string); recommended `title`, `description`, `resource` + (canonical source URI), `tags`, `timestamp`. **Consumers MUST preserve unknown keys and tolerate unknown + `type` values.** (Note: the Google *reference producer* is stricter than the spec — it also requires + `title`/`description`/`timestamp`. Build to the spec; supply the rest where cheap.) +- **Reserved filenames:** `index.md` (directory enumeration, **no frontmatter**, progressive disclosure) + and `log.md` (change log). Optional `okf_version` lives in the bundle-root `index.md`. +- **Cross-links:** plain markdown links (bundle-relative `/...` or relative); relation type is conveyed by + prose. **Consumers MUST tolerate broken links.** +- **Permissiveness is the whole point for us:** OKF is a *minimal superset-friendly* contract. Conforming + costs `type` + `index.md`; our rich fields ride along untouched as extension keys. + +## 6. The deliverable — "OKF-compatible second-brain form" + +A spec (document, not code) that all three plugins' user-data conforms to: + +1. **Minimal contract:** every concept file carries `type:`; each directory level has an `index.md`; + bundle-root `index.md` carries `okf_version`. Recommended fields where cheap. (= okr's `okf-check` + semantics, generalized.) +2. **Rich fields survive as extension keys.** linkedin-studio's brain keeps `provenance`, `first_seen`, + `last_seen`, `evidence_count`, `status`, episodic/semantic distinction — all as extra frontmatter keys + OKF must preserve. The model-collapse guard (`provenance=published` only) is unaffected. +3. **Mapping for our brain** (illustrative; verify writers in §10): + - `brain/index.md` → bundle-root index + `okf_version`. + - `brain/profile.md`, `operations.md`, `journal/*.md`, tributary summaries → concept files; each gains a + `type` (e.g. `Profile`, `Operations`, `JournalEntry`, `TributarySummary`) + retains its existing rich + frontmatter. + - `ingest/inbox/` stays the **manual drop-zone** (already exists) — the "inbox folder" mechanism, with + no heavy auto-classifier built now. + +## 7. Staged plan + +- **Stage 1 — Shared spec/convention (cheap, delivers interop).** Author "OKF-compatible second-brain form" + as a cross-cutting document; align it with okr's `okf-check`. Each plugin's user-data conforms; one + reader can traverse all three. **This alone meets the interop goal.** +- **Stage 2 — Measure divergence.** Do the per-plugin retrieval skills (okr's built one; architect's + planned one; linkedin-studio's in-context reads) diverge enough to hurt? Only a *measured* yes justifies + Stage 3 (operator anti-pattern: "ambitious initiatives where a config tweak suffices"). +- **Stage 3 — Conditional shared skill.** If justified: extract/generalize okr's working + `second-brain-search` into one home (see §9), with a discovery convention for where each plugin's brain + lives. + +## 8. Home decisions + +- **The spec** is cross-cutting → **catalog/marketplace level** (owned by no single plugin). +- **A future shared skill** (Stage 3 only) → a **standalone marketplace plugin** (own repo, release-tagged, + catalog-pinned), installable alongside the others, serving **consumer (a) — the user's own context — + directly**. Rejected alternatives: duplicate-per-plugin (drift risk); user-level `~/.claude/skills/` + (unversioned, outside the catalog). + +## 9. Per-repo scope boundaries (each its own explicit go) + +| Repo | This initiative's work | Status | +|---|---|---| +| **linkedin-studio** (here) | (1) Be the reference design (mostly exists in `docs/second-brain/architecture.md`). (2) Make our own brain emit OKF-compatible form (`type` + per-level `index.md` + root `okf_version`) without dropping rich fields. | **In scope — (2) ✅ LANDED 2026-06-26** | + +> **Stage-1 outcome (2026-06-26).** Brain writers now emit OKF-compatible form: `serializeProfile` +> leads with `type: Profile` frontmatter (constant → round-trip-safe), `operations.md` seed leads with +> `type: Operations`, `brain/index.md` carries an `okf_version: 0.1` marker, and `brain/journal/index.md` +> is scaffolded (per-level index). **Premise refinement (verified):** the brain is *deliberately* +> YAML-free with a byte-exact round-trip invariant on `ingest/published/*.md` (SC2) that a frontmatter +> block would break — so the OKF concept-bundle is scoped to **`brain/` only**; the round-trip-critical +> **`ingest/` tributary is excluded** and pointed to from the hub index. We **emit** frontmatter, adding +> no YAML parser. 5 new tests (`tests/okf-conform.test.ts`); full brain suite **132/132**; cross-tool +> proof — `okr/scripts/okf-check.mjs` validates `brain/` (exit 0). **Finish (same day):** the cheap +> recommended fields `title`/`description` added to the concept frontmatter (`timestamp`/`resource` stay +> out — a timestamp would break the pure serializer, `resource` is N/A for an internal concept); and the +> transient `brain/pending-diff.md` now carries `type: PendingDiff` so the bundle passes `okf-check` even +> mid-propose (re-verified exit 0 with a pending-diff present). Brain suite **134/134**. +| **okr** | Optional form-conformance alignment (already has writer + checker). | **Separate go** | +| **ms-ai-architect** | Build its retrieval skill against the shared spec. | **Separate go** | +| **catalog** | Host the shared spec. | **Separate go** (catalog only via `release-plugin.mjs`) | +| **new standalone plugin** | Stage-3 shared skill, if justified. | **Separate go** | + +Per scope-guard + "never write in other repos without explicit instruction": this session touches +**linkedin-studio only**. + +## 10. Key assumptions + tests (plan-quality mandate) + +| Assumption | Test (before relying on it) | +|---|---| +| OKF preserves unknown keys → our rich brain fields survive conformance | **✅ Verified:** `okf-check.mjs` exits 0 on `brain/`; `profile.md` round-trips (`parseProfile` skips the frontmatter, `parse ∘ serialize` identity holds). | +| Our brain is already near-OKF (conformance is a small writer change) | **⚠️ Refined → verified:** brain is *deliberately* YAML-free and `ingest/published` is round-trip-critical, so a literal frontmatter target conflicts there → bundle scoped to `brain/`, `ingest/` excluded (tributary). 4 surgical writer touchpoints (3 scaffold seeds + `serializeProfile`); we EMIT frontmatter, add no parser. | +| okr's `okf-check` semantics generalize as the shared conformance contract | Diff okr's contract (only-`type`-required, recommended=warnings, `okf_version` echo) against OKF SPEC §9 conformance → confirm it is a faithful, slightly-laxer subset. | +| A shared skill is *not yet* justified | Stage-2 measurement, deferred — do not build Stage 3 before it. | + +## 11. Open choices (resolve in `/trekbrief` or measurement, not now) + +- **Retrieval mechanism:** native Grep/Glob/Read (skill instruction "search the wiki first, open only + relevant") vs. a dedicated fileskb MCP server. Both sibling docs lean **native** (Claude Code's + Grep/Glob/Read already cover OKF's list/search/read). Genuine doubt → "build both, measure" candidate. +- **Degree of OKF formalism:** full v0.1 conformance vs. "OKF-compatible form" (frontmatter + `index.md` + only). Lean to the lightest that yields smart retrieval. +- **Inbox auto-classify/convert:** **defer.** OKF gives nothing for it; the manual inbox seam already + exists. Build only on demonstrated need. +- **Discovery convention:** how a shared skill finds each plugin's brain root. +- **OKF version-bump tracking:** how to catch v0.1 → later without manual polling (hooks are no-network). + +## 12. Success criterion (operator, inherited from both sibling tracks) + +Measured against **user value** (does the plugin retrieve the right personal/org context in chat and +commands?) + **maintenance reliability** — **not** against formal OKF conformance for its own sake. + +## 13. References + +- OKF SPEC v0.1: `github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md` +- Sibling design docs: `okr/docs/okf-second-brain-note-2026-06.md`, + `ms-ai-architect/docs/okf-second-brain-brief-2026-06.md`, + `ms-ai-architect/docs/ref-kb-direction-note-2026-06.md` +- okr reference implementation: `okr/scripts/okf-check.mjs`, `okr/scripts/okf-index.mjs`, + `okr/skills/okr-second-brain-search/SKILL.md` +- Our reference design: `docs/second-brain/architecture.md`; engine `scripts/brain/` +- Verified-OKF research log (files+URLs fetched on `main`): retained in session transcript, 2026-06-26 +- Memory: `google-okf-open-knowledge-format`, `profile-evolution-second-brain`, + `plugin-vs-maskinrommet-division`, `plugin-is-domain-general` diff --git a/docs/okf-ingestion/plan.md b/docs/okf-ingestion/plan.md new file mode 100644 index 0000000..b9e80cc --- /dev/null +++ b/docs/okf-ingestion/plan.md @@ -0,0 +1,341 @@ +# OKF-ingestion plan — mapping this repo against `llm-ingestion-okf` (phase 4) + +> **Status: `planned` (2026-07-20).** Survey + requirements only — nothing wired, nothing built. The +> library's Node half does not exist yet (its phase 4), so there is nothing to adopt today. This document +> is the repo's durable answer to *what door A or door B must support before this plugin can adopt*, and +> *what must never move into the shared library*. +> +> **Library:** `llm-ingestion-okf` v0.3.1 — Python 3.10+, zero runtime deps, **door A only** +> (manifest → connector → deterministic materialization of `ingest-{id}.md` → index generation, no model +> calls). Doors B (inbox) and C (external bundle import) are phase 2; the Node half is phase 4. +> **Spec:** `ingest-spec.md`, owned by `portfolio-optimiser-commons` — changes go via commons, never locally. +> **Security boundary:** `llm-ingestion-guard` (repo `llm-ingestion-pipeline-security`) owns all security. +> Door A is **ungated** — it calls no guard function before writing to disk (verified: no guard import or +> call site anywhere in the library's `src/`; `dependencies = []`). "Security is delegated" does **not** +> mean "safe by default"; gating is the caller's responsibility. +> +> **Separate, do not merge:** `docs/ingestion-guard/plan.md` (`planned`) is this repo's *security* wiring +> plan. It shares persist points with this document by necessity, but is a distinct decision with a +> distinct dependency. §7 states the boundary. + +## 1. Executive answer + +**Nothing here is a clean door-A fit today, and the reason is not the one the library expects.** + +The library's open finding F1 is *"door A has no free-text connector"*. That is real for us, but it is not +our blocker. Our blocker is one level deeper: + +> **Door A reaches sources with deterministic code. This plugin does not fetch anything with code.** + +Every external input in this repo arrives through a *model turn* — the `trend-spotter` agent's +WebSearch/WebFetch/MCP calls, `/linkedin:react`'s WebFetch of an operator-supplied URL. There is no raw +HTTP fetch anywhere in plugin code (verified across `scripts/`). Door A's three connectors (`file`, `sql`, +`http`) all assume code can go get the bytes. For our largest and least-trusted surface, code cannot. + +So the requirement we contribute to F1 is not *"add a free-text connector"* — it is **"add a door that +accepts an already-fetched, model-produced payload and materializes it deterministically."** That door +would immediately fit `trends capture`, which is already exactly this shape (stdin JSON envelope → +validate → deterministic store write). Details in §4. + +Adoption verdict: **`planned`, conditional.** Adopting door A as it stands would mean converting typed, +incrementally-accreting stores into rewrite-on-run markdown documents — a downgrade. We adopt when §4's +four requirements land, not before. + +## 2. Our sources — tabular vs free-text + +| Source | On disk | Shape | Trust boundary | Door-A fit today | +|---|---|---|---|---| +| **Analytics CSV export** → batches | `exports/*.csv` → `posts/<date>-<id>.json` | **Tabular** (header row + rows) | First-party (operator's own LinkedIn export), but an unvalidated parse surface | **Closest fit.** Satisfies `read_csv`'s header requirement. But see §3 — the output shape is wrong. | +| **Trend capture** | `trends/trends.json` (schema v4) | JSON envelope; `title`/`url`/`summary` are **verbatim free text** | **External**, model-mediated (web search/fetch via agent) | **No connector exists.** Not `http` — the fetch already happened, in a model turn. | +| **Morning brief** | `trends/morning-brief/<date>.md` | Structured md, carries external strings forward | Machine-rendered from store; re-injected by SessionStart hook | Destination, not a source. | +| **Brain inbox → published** | `ingest/inbox/*.md` → `ingest/published/<id>.md` | **Free text**, byte-exact body | Operator-dropped today; drop-zone has no origin control | **Excluded by design** — see §5. | +| **Brain profile** | `brain/profile.md` | Line grammar (6 constrained tokens + one-line value) | Machine-folded, operator-gated (`--apply --confirm`) | Destination. Already an OKF-conformant bundle (Stage 1, 2026-06-26). | +| **Specifics-bank** | `specifics-bank/specifics-bank.json` | JSON + verbatim free-text `content` | Operator-written by invariant (never AI-generated lived experience) | Destination. | +| **Contract-gate** | — writes nothing | Reads local operator files only | Local | Not applicable. | + +**Summary:** one genuinely tabular *source* (analytics CSV). One free-text external source that is not +reachable by any connector shape the library has (trend capture). One free-text local source that is +excluded by a fixed decision (published posts). Everything else is a destination store, not an ingestion +input. + +## 3. Where door A's contract collides with ours + +Four structural mismatches, each verified against the library's code. These are not preferences — each one +would break an invariant we hold today. + +**(1) Fixed 7-key frontmatter, no extension keys.** `materialize.py` emits exactly `type`, `title`, +`source_system`, `source_query`, `ingested_at`, `ingest_manifest`, `generated` — in fixed order, all values +collapsed to single lines. Our records carry domain fields that must survive: `provenance`, `status`, +`first_seen`, `last_seen`, `evidence_count` (brain), `score`, `pillar`, `topics`, `surfaced` (trends). +Note the tension with OKF the *format*, which explicitly requires consumers to preserve unknown keys — the +library's materializer is stricter than the spec it implements. Our Stage-1 OKF conformance work +(`docs/okf-convergence-brief.md`) depends on rich fields riding along as extension keys; door A has no +mechanism for that. + +> **Addition, 2026-07-20 (trinn E).** Two runs of `okr/scripts/okf-check.mjs` against a fresh +> `brain init` scaffold sharpen this into a concrete argument, and correct one of our own claims: +> +> - The reference checker's own constant is named `RECOMMENDED`, not `REQUIRED` (`okf-check.mjs:21`). +> A bundle missing `resource`/`timestamp` gets **warnings and exit 0**. The de-facto conformance +> tool already treats every §5 key beyond `type` as advisory — so a mandatory ordered prefix would +> turn a green bundle red without a byte changing. Argument fed to commons' D1. +> - **Correction:** trinn C claimed our `type` vocabulary is five values. It is three — +> `Profile` (`scripts/brain/src/profile.ts:48`), `Operations` (`scaffold.ts:56`), +> `PendingDiff` (`cli.ts:252`). `JournalEntry`/`TributarySummary` exist only as examples in +> `docs/okf-convergence-brief.md:101`, never in code. +> - **Correction:** we assented to a closed `type` vocabulary without checking its contents. +> `okf-check.mjs --strict-ingest` **exits 1** on our conformant bundle — `okr/lib/okf-vocab.mjs:11-21` +> is nine okr-domain values, and the safe default (`Dokument`) collapses all three of our types into +> one, erasing the distinction `okf-conform.test.ts` rests on. Our assent now carries a condition: +> an absent vocabulary must mean *do not snap*, and the vocabulary is per-bundle, never spec-global. + +**(2) Rewrite-on-run ownership vs incremental accretion.** Door A owns files via `generated: true` + +`ingest_manifest`, then **deletes every stamped file and rewrites the set** each run. Our stores accrete: +dedupe by content id, topic-union on re-capture, last-wins score, collision-suffix on body divergence, +compare-then-skip on scaffold. A door-A run against our data would be destructive by design. + +**(3) Operator-chosen slug ids vs content-addressed ids.** Library id = the manifest's `extraction.id` +(a hand-written slug, `[a-z0-9][a-z0-9-]*`), with provenance carried by a manifest stamp +(`{stem}@{sha256(manifest bytes)[:16]}`). Ours = `sha256(content)[:12]` — the id *is* the dedupe key, which +is why re-capturing the same trend or re-ingesting the same post is idempotent for free. A slug-keyed +model cannot express "same content, seen twice." + +**(4) Table rendering destroys free text.** `render_table` escapes `\` then `|`, then collapses CRLF/CR/LF +**to a single space**. Any multi-line body loses its line structure. This is fatal for post bodies (§5) and +lossy for trend summaries. + +## 4. What door A / door B must support before we can adopt — the concrete asks + +Ordered by how much they unblock. (1) is the one that decides F1 for us. + +**R1 — A model-mediated door ("already-fetched payload").** Accept a caller-supplied, schema-validated +payload (stdin JSON envelope or in-process record array) instead of a connector-fetched one, and +materialize it with the same determinism, same stamping, same idempotence as door A. The manifest would +declare shape and destination but not a fetch. This is the door that fits how an agentic plugin actually +ingests, and we have a working precedent to donate: `trends capture` is this contract already +(`normalizeItem` → "the one schema downstream never branches on" → deterministic store write). +**Why this over a free-text connector:** a `read_text`/`read_markdown` connector would serve repos whose +free text sits in a folder. Ours sits in a model's tool result. Both are needed; they are not the same ask, +and solving only the folder case leaves us exactly where we are. +*Security note:* this door is precisely where untrusted, model-touched bytes cross into a persisted store, +so it is where a guard call belongs. That wiring is `docs/ingestion-guard/plan.md`'s decision, not this +document's — but the door must at minimum expose a seam for it rather than writing straight through. + +**R2 — Extension keys preserved through materialization.** Let a record carry arbitrary additional +frontmatter keys, emitted after the 7 reserved ones, order-stable, never coerced. Without this, every +domain field we have is lost on the way through the library, and OKF's own "preserve unknown keys" rule is +violated by the tool that writes OKF. + +**R3 — Incremental/upsert materialization mode.** A mode where a run merges into an existing bundle keyed +by record id — add new, update changed, leave untouched what this run did not see — instead of +delete-all-stamped-then-rewrite. Door A's index maintenance already does exactly this kind of careful +merge for `index.md` (managed lines refreshed, curated lines preserved byte-verbatim); the ask is to extend +that discipline from the index to the records. + +**R4 — Content-addressed record ids as a first-class option.** Allow `id = hash(content)` rather than a +manifest slug, so dedupe and idempotence come from the data. Our three stores (brain, trends, +specifics-bank) all independently converged on `sha256(...)[:12]`; the pattern is not LinkedIn-specific. + +**R5 (phase 4, Node half) — contract parity details.** Stable string error codes on the error object +(the Python half's `exc.code` discipline, mirrored so Node consumers assert on `err.code`, never on message +text); zero runtime dependencies; ESM with `node:` prefixes; and the golden-fixture set shared across both +halves so byte-identity is the test, not the promise. + +> **Correction, 2026-07-20 (trinn D-review).** An earlier revision of this section claimed we "already meet +> the zero-dep/ESM/LF conventions". Verified against the code, two parts of that were false: +> +> - **We have no stable error codes.** None of the four CLIs set a machine-readable code; every failure is a +> free-text `Error` plus a numeric `process.exit` (`scripts/brain/src/ingest.ts:99,:113`, +> `scripts/trends/src/cli.ts:125,:255`). `err.code` is an ask *of* the library, not a discipline we mirror. +> - **"Exactly one trailing newline" is not asserted here either.** Nothing tests it, and +> `serializePublishedRecord` deliberately omits it (`scripts/brain/src/ingest.ts:71`) because the record +> must end on the verbatim body or the byte-exact round-trip (SC2, §5) breaks. +> +> **Second correction, same day, narrowing the one above.** The trinn D-review first read this as a +> conflict with `claude-playlist-corpus`, who asked the library to own frame normalization (LF-only, one +> blank line, trailing newline). It is not a conflict, and the check should have come first: our +> newline-free serializer governs `ingest/published/`, which is **excluded from the OKF bundle by design** +> (§5, `scripts/brain/tests/okf-conform.test.ts:22-27`). The files `write_concept` would actually write for +> us — the `brain/` bundle — get their trailing newline from our own serializer +> (`scripts/brain/src/profile.ts:65`). On the concept-writing path we agree with them. What survives is +> narrower: `write_concept` is also being asked to serve as a *general* verbatim writer callable outside a +> bundle (`ms-ai-architect`, `po-claude`), so frame normalization should be a documented parameter rather +> than an unconditional guarantee. The fixture ask applies to that general writer, not to the concept corpus. +> +> Zero-dep/ESM does hold (sole exception: `scripts/analytics` depends on `csv-parse`). + +## 5. What must never move here + +**`ingest/published/` provenance grammar stays plugin-local — fixed decision, and it has a technical +floor.** This is not merely a scope preference: + +- The store holds a **byte-exact round-trip invariant** (`parse ∘ serialize = identity`, SC2) on the + verbatim post body. Door A's table renderer collapses newlines to spaces (§3.4); its frontmatter + collapses whitespace runs. Either would break the invariant on contact. +- The record id is `mintContentId(verbatim body)` — deliberately **un-normalized**, so two + structurally-different posts never collide and the write path never silently drops a differing body. +- The grammar is deliberately YAML-free (a fixed 5-line header + `\n---\n` sentinel), which is also why our + own Stage-1 OKF conformance work scoped the concept-bundle to `brain/` and **excluded the `ingest/` + tributary** — verified 2026-06-26, `okf-check.mjs` exit 0 on `brain/`. +- `provenance=published` carries **model-collapse-guard semantics**: the voice/profile learning surface + learns from human-published content only, never from `ai-draft`. The *form* is generic; the *guarantee* + is domain policy and belongs where the policy is enforced. + +We are happy to describe the interface (this section is that description). We are not planning to hand it +over. + +**Also staying — LinkedIn domain logic:** trend scoring weights and mode SSOT +(`references/trend-scoring-modes.md`); brief ranking, pillar logic and Norwegian rendering; the +analytics↔post join heuristic (title-prefix matching with the 110-char hook rule behind `PREFIX_FLOOR`); +CSV column fuzzing against LinkedIn's export column names, the `engagementRate` formula and `saves` +semantics; the specifics taxonomy and its verification rules; contract-gate's writing-contract rules. + +**Candidates for sharing (bundle mechanics, if the Node half wants them):** content-addressed id minting; +dedupe + tag-union upsert; migrate-on-load `schemaVersion` stamping; collision-safe idempotent writes +(compare-then-skip, collision-suffix); path-traversal-safe filename resolution (our `storage.ts` twin of +the library's fail-closed `safe_resolve`); managed-line index maintenance. Several of these modules already +declare themselves generic by architecture. Note we carry the data-root resolver in **five** inlined copies +(four `scripts/*` packages + a zero-dep hooks twin, synchrony guarded by test) — deliberate, since the +*path convention* is plugin-local even where the *idiom* is not; a shared library should take the idiom and +leave the path. + +## 6. Bundle inventory and placement + +Operator decision, 2026-07-20: **user-owned OKF bundles live outside the plugin/repo tree and are only +referenced from it.** Plugin-generated bundles that are part of the plugin's own delivery stay. + +**We already comply — the migration happened in M0 (v0.6.0), before the decision existed.** Every +user-owned artifact resolves through `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/` +(`references/data-path-convention.md`), with an idempotent session-start migration. Nothing user-owned has +lived in the repo tree since. + +| Artifact | Path | OKF bundle? | Class | +|---|---|---|---| +| **Brain** | `<data-dir>/brain/` | **Yes** — the repo's only OKF bundle (`type` + per-level `index.md` + root `okf_version: 0.1`; `okf-check.mjs` exit 0, 2026-06-26) | **User-owned** — see boundary case below | +| Published-post tributary | `<data-dir>/ingest/{inbox,published}/` | No — deliberately excluded (§5) | User-owned | +| Trend store | `<data-dir>/trends/trends.json` | No — a single JSON file, not a markdown bundle | User-owned | +| Morning briefs | `<data-dir>/trends/morning-brief/<date>.md` | No — dated briefs with frontmatter, no `index.md`, not concept files | User-owned | +| Specifics-bank | `<data-dir>/specifics-bank/specifics-bank.json` | No | User-owned | +| Analytics batches | `<analytics-root>/posts/*.json` | No | User-owned | +| Reference docs (28) | `references/*.md` **in repo** | No — no `type`, no `index.md` | **Plugin-owned**, ships with the plugin, correctly in-tree | + +**Nothing breaks on migration, because there is nothing left to migrate.** The one caveat is historical: +pre-M0 installs kept data in-tree, and the session-start migration already handles that path. A second +caveat worth naming: `assets/drafts/queue.json` and `assets/analytics/` remain in-tree as gitignored +scratch — they are not bundles, but they are the last in-tree paths that hold user bytes, and they should +be revisited if the decision is ever tightened to "no user bytes in-tree at all." + +### The boundary case — and it says the taxonomy's axis is wrong + +The decision's table discriminates on **who writes**: plugin writes → plugin-owned → lives in the repo. +Our brain is **written by the plugin** (`consolidate`/fold, operator-gated at `--apply --confirm`) and +never hand-authored. By that table it is plugin-owned and belongs in the repo. **That conclusion is +exactly wrong** — it is the single most personal artifact we hold, it must survive plugin upgrade and +uninstall, and the repo is publicly distributed. + +The discriminator that gives the right answer everywhere in our inventory is **whose lifecycle the content +follows**, not whose hand writes the bytes. Our brain is machine-written and user-owned, and that +combination is not a rare corner — it is what any learning system produces. We suggest the axis be +lifecycle/ownership, with authorship as a non-determinative attribute. Otherwise every plugin that +*derives* user knowledge lands on the wrong side of a table it read correctly. + +## 7. Boundary against the guard plan + +`docs/ingestion-guard/plan.md` identifies four persist gates: trends `capture`, brain +`writePublished`/`scanInbox`, newsletter→`ekstern` bindings, analytics `saveBatch`. Two of those (trends +capture, analytics import) reappear here as adoption candidates — unavoidably, since they are the same +boundary viewed from two sides. + +The split: **the guard plan decides whether bytes are safe to persist; this plan decides what writes them +and in what shape.** They share a dependency in one place only — R1's seam is where a guard call would +land — and that is noted as a seam requirement, not a security decision. Neither plan is a prerequisite for +the other's approval. The guard plan's own interop blocker (Python↔Node) is tracked there. + +## 8. Open questions for the library owners + +1. Does R1 (model-mediated payload door) belong to door B, or is it a fourth door? It is neither an inbox + scan nor an external bundle import. +2. Is R2 (extension keys) a spec change via commons, or a materializer relaxation within the current spec? + OKF v0.1 already mandates preserving unknown keys, which suggests the latter. +3. ~~For phase 4: is the Node half expected to reach parity with door A only, or with whatever doors exist + when it starts?~~ **Answered 2026-07-20 (trinn E, felles §6): phase 4 delivers the layer-1 primitives, + not `materialize_bundle` parity** — no Node repo in the set runs door A (verified here: the only Python + in this repo is `hooks/scripts/compile-hooks.py`, a build-time script never on a data path). Our register + status therefore stays **`planned`**, not `blocked on phase 4 scope`. One follow-up outstanding: layer 1 + grew after the question was asked (`read_concept`, `navigate_bundle`, `routeLevel`), so we asked whether + phase 4 tracks the set as it stands or as it evolves — the reader is decision-relevant for us, since our + dependency is `serialize ∘ parse = identity`, not the writer alone. + +## 9. References + +- Library: `https://git.fromaitochitta.com/open/llm-ingestion-okf` +- Guard: `https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security` +- Spec owner: `portfolio-optimiser-commons/ingest-spec.md` +- This repo: `docs/okf-convergence-brief.md` (Stage-1 OKF conformance, `brain/` bundle), + `docs/ingestion-guard/plan.md` (separate, `planned`), `docs/second-brain/architecture.md` + +## 10. Trinn E — consolidated positions (preserved before postbox teardown, 2026-07-21) + +The OKF adoption round closed at trinn F (consensus). Our trinn E answer lived in the interim +postbox (`~/repos/_okf-interim/svar/linkedin-studio.md`), which is now torn down. Its durable +substance is preserved here; everything below was verified against code on 2026-07-20. The +technical requirements (R1–R5) already live in §4; this section captures the round-positional and +follow-up material that existed only in the postbox. + +**Our §7 register row (status + position).** +- Status stays **`planned`**, not `blocked on phase 4 scope` — verified: the only Python in this + repo is `hooks/scripts/compile-hooks.py`, a build-time script never on a data path; we do not + run door A and would gain nothing from parity. +- We do **not** need door-A stamping (`generated` / `ingest_manifest`). The negative requirement + holds: inject nothing, normalize nothing. +- Unforgeability must live **outside the file** — our content-address `mintContentId` (byte-exact + sha256, `scripts/brain/src/id.ts:53-55`) is unforgeable without the writer forming an opinion + about the frontmatter. If the writer inspects the string to enforce a stamp, passthrough (R2) is + gone in the same move. +- §5 must tolerate extension keys (R2, unchanged); the mandatory reserved-key prefix is limited to + **stamped, extracted** concepts, not concepts as such. + +**D1 (frontmatter/stamp) — we are affected, not owner.** The ratified D1 **matches our ask**: +enforcement moved to `check_bundle` (per-file outcome); the door-C writer rejects the *complete* +ownership stamp (`generated:true` **and** `ingest_manifest` together), not the five names, so +round-trip for legitimate door C is preserved; the mandatory prefix binds stamped (door-A) files +alone. We accept commons' point that passthrough makes §5's key contract unenforceable at the +writer by construction — that is the right outcome, since the writer cannot make the +stamped-vs-curated distinction without reading what it promised not to read. + +**D3 (`partial`) — accepted with one caveat, now resolved.** We accepted the definition and flagged +that "comparable path" was undefined (it decided our value outright: `brain/` writing through the +library while trend-store / specifics-bank stay standalone JSON — `scripts/trends/src/store.ts:126-141`). +The ratified definition (trinn F) is: comparable path = would have gone through the same door +(A/B/C). Under that, our JSON/CSV/domain-store paths are not comparable and do not force us +`partial`. **Our status today is `planned`, not `partial`** — zero production paths go through the +library; we do not emit `partial` (now a transitory value carrying owner + next step) until it is +ratified. + +**Durable technical additions (would otherwise be lost with the postbox).** +- **Empty vocabulary must mean "don't snap", not "snap to default".** Verified: + `node okr/scripts/okf-check.mjs <brain> --strict-ingest` collapses our three real types + (`Profile` / `Operations` / `PendingDiff`) to `DEFAULT_TYPE='Dokument'` + (`okr/lib/okf-vocab.mjs:26`), erasing the distinction our conformance test rests on + (`scripts/brain/tests/okf-conform.test.ts:15-20`). Vocabulary is per-bundle, never spec-global. + Ask: absence of an injected vocab list is a legal state (= "don't snap"), not "snap all to default". +- **Supporting evidence, and it is not ours:** `okf-check.mjs:21` names the constant `RECOMMENDED`, + not `REQUIRED` — the de-facto checker all nine repos read already treats §5 keys beyond `type` as + warnings, not requirements. Making the prefix mandatory would turn a green bundle red with zero + byte change, contradicting the tool that defined conformance in practice during the round. +- **`type` vocabulary is three values, not five** (corrects trinn C §5): only `Profile` + (`scripts/brain/src/profile.ts:48-50`), `Operations` (`scaffold.ts:56-58`), `PendingDiff` + (`cli.ts:252`) are emitted by code; `JournalEntry` / `TributarySummary` exist only as "e.g." in a + design note (`docs/okf-convergence-brief.md:101`), never in code. +- **Index `preserve-unowned` boundary.** We have a fourth index mode — create-if-absent, no managed + lines, whole file hand-editable (`scripts/brain/src/scaffold.ts:33-36,126-141`). Concrete + requirement for eventual adoption: `write_index(mode="preserve-unowned")` against an existing file + with no managed lines must leave it **byte-untouched**, not normalize the frame "while it is there + anyway" — else we lose a file the operator hand-writes. +- **Layer 1 = the evolving set, not a frozen snapshot** (also §8 item 3). Since §5 added + `read_concept`, `navigate_bundle`, and `routeLevel` after Q3 was asked, "the layer-1 primitives" + must read as the set as it stands, not a snapshot: our invariant is `serialize ∘ parse = identity` + (`scripts/brain/tests/ingest.test.ts:102`), and a Node half with a writer but no reader gives us + half the invariant. Derived (not a new ask): the shared golden fixtures for `resolve_link` must + cover all four orthogonal axes across runtime — our R5 stated the fixture requirement for the + writer's frame alone, which is now too narrow. diff --git a/docs/research-engine/brief-re-r3a.md b/docs/research-engine/brief-re-r3a.md new file mode 100644 index 0000000..2bdb7b0 --- /dev/null +++ b/docs/research-engine/brief-re-r3a.md @@ -0,0 +1,357 @@ +# Brief — RE-R3a: persist the relevance score + rank the morning brief on it + +> **Slice:** RE-R3a (research-engine rung-2, R3 slice 1 — research-*deepening*). R3 ("deepen the +> research engine") is an **arc** of 5 open hulls (substrate §1: autonomous trigger · freshness-as-seen-log · +> relevance/saturation/status scoring · brief history+diff · A1–A4 fan-out). R3a takes the first: the +> **relevance** half of hull 5 (and the remainder of hull 3 — "the store schema lacks fields a brief ranks +> on"). It persists the composite relevance score the `trend-spotter` agent ALREADY computes, onto the store +> record, and makes `rankForBrief` order on it. +> **Predecessor:** RE-R1 (`score.ts`, B2 triage-scorer — built, tested, deterministic) + RE-R2a (`capture` +> bridge + `publishedAt`, schema v1→v2) + RE-R2b (`brief.ts` dated artifact + surfacing). R2b explicitly +> deferred this in its §4: *"the B2 triage scorer stays out of the brief path — its output isn't persisted on +> records yet — that's R3."* R3a is exactly that R3 step. +> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (3) (schema fields a brief ranks on: +> relevance/...) + (5) (relevance scoring) + §B2 ("scoring/filtering as a gate before expensive work — the +> output is the rank key") + §A2 ("curate/score before synthesis — the writer sees ranked material"). +> **TDD-order:** RED tests land before code — but as **two phases** (light-Voyage BLOCKER fold): the +> store/brief/cli tests are true logic-RED against the pre-edit code (they build fixtures inline, import no new +> symbol); the score/item tests reference not-yet-existing `score.ts` exports, so under Node16 ESM a missing +> named import throws at module-load (not on assertion) — they are RED against **non-throwing stubs** landed as +> the first GREEN-prep sub-step. See plan Step 1. + +## 1. Operator decision context (2026-06-24) + +The research engine is **Tier-1** (operator, 2026-06-23): *"hele min arbeidsflyt hviler på at jeg får en jevn +strøm av gode forslag til tema å skrive om."* RE-R2 made the stream **visible** (a dated morning brief surfaced +at session-start). R2b ranks that brief on **pillar-overlap + recency only** — a coarse proxy for "good topic +to write about." The actual relevance judgment (audience pull, timing, angle potential, authority, depth) lives +in the five 1–10 dimension scores the `trend-spotter` agent produces in Step 2 and pipes to the `score` CLI — +and is then **thrown away** before the trend reaches the store (Step 4.5 builds a *separate*, score-free capture +batch). R3a stops discarding it: persist the composite + band on the record, and rank the brief on composite +first. **The slice the operator chose** ("scoring inn i briefen", 2026-06-24) — the highest-leverage next step +on the core need (better-ordered suggestions), built on already-shipped-but-dormant code (`score.ts` is tested +and unused on records). The bigger R3 arcs (autonomous trigger / seen-log / saturation+status / A1–A4 fan-out) +stay later slices. + +**Go-gate resolutions — CONFIRMED (operator "Go", 2026-06-24; baked into the plan):** **D1** persist the +**4-field** `TrendScore { mode, dimensions, composite, priority }` (composite+priority to rank/display, mode to +disambiguate the instrument, dimensions for audit + lossless re-weight). **D2** composite is the **primary +within-bucket sort** (buckets still assigned by overlap+freshness; composite orders *inside* a bucket). **D3** +score is **first-sight** (set on add, never updated on re-capture — matches the store's provenance discipline; +re-score-on-recapture pairs with the R3b seen-log/status slice). **D4** ship persist+rank as **one** slice (the +operator named the visible payoff; splitting would land an invisible schema-only cut like R2a). + +## 2. The gap — grounded in code + +- **The score the agent computes never reaches the store.** `trend-spotter.md` scores each candidate's five + dimensions and pipes them to the `score` CLI (`agents/trend-spotter.md:134-140`), which returns + `{composite, band}` per candidate (`score.ts:110-122` `triage`). But Step 4.5's capture batch + (`agents/trend-spotter.md:291-298`) is **built separately and carries no score** — `source/title/url/topics/ + publishedAt/summary` only. `TrendItem` (`item.ts:22-39`) and `TrendRecord` (`types.ts:26-48`) have **no score + field**. The relevance judgment is recomputed for the digest and discarded for persistence. +- **`score.ts` is built, tested, deterministic — and unconsumed on records.** It exports `composite()` + (`score.ts:77-88`) and `band()` (`score.ts:91-97`) as pure functions, pinned to the SSOT + (`references/trend-scoring-modes.md`, by `score.test.ts:12-30` weights + the band-string assertions). Nothing + persists their output. `TrendRecord`'s own doc-comment anticipates the field: *"can gain fields (relevance + score, first-mover timing, status) in a later slice"* (`types.ts:21-23`). +- **The brief ranks on a proxy.** `rankForBrief` sorts each bucket `overlap desc → effectiveDate desc → + title asc → url asc` (`brief.ts:94-104`). Overlap (a hard pillar count) is *part* of what the composite + already weights (Pillar Fit 30 %, `trend-scoring-modes.md:43`), but the composite also captures audience/ + timing/angle/authority — signal the brief currently can't see. `brief.ts`'s own header already names this as + the next slice: *"A persisted relevance/saturation score … (R3)"* (`brief.ts:11-14`). + +## 3. Scope — what is IN (RE-R3a) + +### S-score — `scripts/trends/src/score.ts` (EDIT) +- **`export interface TrendScore { mode: ScoreMode; dimensions: DimensionScores; composite: number; priority: + Priority }`** — the persist-ready envelope. Lives in `score.ts` (the score domain owns it); `types.ts` imports + it (one-way: `score.ts` imports nothing — verified leaf, `:1-17` — so no cycle). +- **`export function requiredDimensions(mode: ScoreMode): string[]`** — `Object.keys(WEIGHTS[mode])` + (`score.ts:37-40`). **Contract: ordered** — the keys come back in the SSOT weight-literal order (kortform + `["pillar","audience","timing","angle","authority"]`, long-form `["pillar","depth","angle","authority", + "currency"]`, `score.ts:20-35`); SC1 deep-equals that ordered array, and `score.test` pins the order so a + silent SSOT reorder fails loudly. `normalizeItem` consumes it as a **set** (membership), which is order-safe + either way. `WEIGHTS` stays private; the keys are exposed via this function. +- **`export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore`** — composes the + existing pure functions: `const c = composite(dimensions, mode); return { mode, dimensions, composite: c, + priority: band(c).priority }`. **No new arithmetic** — `composite()`+`band()` stay the single owners (SSOT + discipline). It throws (via `composite`, `score.ts:83`) on an out-of-range dimension — that is its + **contract**, exercised directly by SC1/SC2; on the capture path it is unreachable because `normalizeItem` + pre-validates (below). + +### S-types — `scripts/trends/src/types.ts` (EDIT) +- `import type { TrendScore } from "./score.js";` +- `TrendRecord` gains **`score?: TrendScore;`** (optional — pre-R3a records simply lack it; the `add` manual + path and unscored adopters omit it). Doc-comment updated to mark `score` as the now-realized field the + `:21-23` note anticipated. +- **`SCHEMA_VERSION = 2 → 3`** (`types.ts:62`). The bump is the only schema signal; the record shape change is + additive-optional, so the migration is the version-stamp alone (below). + +### S-store — `scripts/trends/src/store.ts` (EDIT) +- `TrendInput` (`store.ts:26-35`) gains **`score?: TrendScore;`** (imported from `score.js`). +- `addTrend` (`store.ts:120-140`): on a **new** record, persist `score` first-sight via the existing + conditional-spread idiom (`...(input.score !== undefined ? { score: input.score } : {})`, mirroring + `publishedAt`/`summary` `:134,136`). On a **duplicate**, score is **NOT** updated (D3 — first-sight, like + `source`/`capturedAt`/first `publishedAt`); topics still union (`:124-126`, unchanged). `AddResult` is + unchanged (no new flag). +- `loadStore` migrate comment (`:79-84`): extend to *"v1→v2→v3 are all purely additive-optional (an old record + is already a valid record that simply lacks the optional field), so the migration is the version stamp alone — + records pass through untouched."* **No code change** to the migration logic (`Math.max(onDisk, SCHEMA_VERSION)` + `:87` already does v2→v3 correctly; `saveStore` `JSON.stringify` `:95` preserves the `score` field — no field + stripping); only `SCHEMA_VERSION` (in `types.ts`) and the comment move. + +### S-item — `scripts/trends/src/item.ts` (EDIT) +- `TrendItem` (`item.ts:22-39`) gains **`score?: { mode: ScoreMode; dimensions: DimensionScores };`** — the + ingress envelope carries the agent's *judgment* (five scores + mode), **not** a precomputed composite (the + store computes it, so the composite has one owner). `import type { ScoreMode, DimensionScores } from + "./score.js"` + `import { requiredDimensions } from "./score.js"`. +- `normalizeItem` (`:86-119`): if `r.score` present, **validate structurally** (returns a structured error into + `errors[]`, never throws — the existing discipline, like the `publishedAt` ISO check `:99-106`): `score` is a + **non-array** object; `mode ∈ {kortform, long-form}`; `dimensions` is a **non-array** object; **each key in + `requiredDimensions(mode)` is present and a number in [1,10]**. On any failure → `errors.push("invalid score: + …")`. On success carry the **validated** `score = { mode, dimensions }` forward (the validated dimensions + object, not raw `r.score.dimensions`). Absent/null/invalid → key omitted. This guarantees the *capture path* + (`cli.ts:246-254`: `normalizeItems` → `itemToInput`) never reaches `composite` with bad dims. +- `itemToInput` (`:129-139`): if `item.score` present → add `score: scoreEnvelope(item.score.mode, + item.score.dimensions)` to the returned `TrendInput` (conditional spread, key omitted when absent). The + item→store bridge is the natural place to turn judgment into the persisted envelope. `itemToInput` is a public + function: called directly (e.g. in a test) with unvalidated dims it **throws by contract** (defense-in-depth); + the no-throw guarantee is a property of the *capture path*, not of `itemToInput` in isolation (§5). + +### S-brief — `scripts/trends/src/brief.ts` (EDIT) +- `rankForBrief` (`:72-114`): **composite becomes the primary within-bucket sort key** (D2). The comparator + (`:94-98`) gains a leading term: + `(b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) || <existing overlap desc → effectiveDate + desc → title asc → url asc>`. **Sentinel `-1`, not `-Infinity`** — composite is a weighted sum of [1,10] + dims so it is always ≥ 1.0 (min = 1×Σweights = 1.0, verified); `-1` sorts every unscored record below every + scored one and subtracts cleanly (`-Infinity - -Infinity = NaN` would corrupt the comparator). **Buckets are + UNCHANGED** — assignment stays `overlap≥2 & fresh` / `overlap==1 & fresh` / `!fresh` (`:100-104`); composite + only re-orders *within* a bucket. Total order preserved: the `(title,url)` pair is unique per store (it is the + dedupe id, `store.ts:66-68`), so the final `url asc` tie-break makes the order insertion-independent even for + equal composites. +- `renderBrief` (`:152-191`): surface the band **and mode** where a record is scored (so a reader can tell a + kortform "High" from a long-form "High" — the two are different instruments). **Pinned line shapes:** + - Top-entry meta line (`renderTopEntry`, `:135`), scored: + `- Kilde: <source> · Publisert: <date> (<age>d) · <priority> (<mode>) · Pillarer: <matched>` + (the `· <priority> (<mode>)` token sits between `(<age>d)` and `· Pillarer`); **unscored: unchanged** (no + token). + - Bullet line (`renderBulletEntry`, `:144`), scored: + `- **<title>** — «<matched>» · <date> (<age>d) · <priority> (<mode>) · 🔗 <url>` + (token **before** `· 🔗`); **unscored: unchanged**. + - The `ranking:` frontmatter descriptor (`:160`) → the **exact** string + `composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays <N>` + (pinned verbatim; `brief.test` asserts it byte-for-byte). +- `briefSummary` (`:122-130`): the top mention names the **band only** (mode stays a body-entry detail to keep + the one-line headline clean) — fresh>0 with a **scored** top → `… Topp: «<title>» (<pillar> · <priority> · + <age>d).`; fresh>0 with an **unscored** top → `… Topp: «<title>» (<pillar> · <age>d).` (no token). **Still one + line, no `"`, no `\n`** — the `extractYaml` contract (`brief.ts:118-120`) holds; the band strings + (`Immediate`/`High`/…) are bare words. +- `BRIEF_SCHEMA_VERSION` stays **1** (no frontmatter *field* added/removed; the surfacing hook still reads + `date`+`summary`; only the `ranking:` descriptor *string* and body content change). Bumping is an Open Q (§8), + not required for correctness. + +### S-cli — `scripts/trends/src/cli.ts` (EDIT, doc-only behavior) +- The `capture` branch (`:243-269`) folds via `itemToInput` (`:254`) — so once `item.ts` threads `score`, + capture **automatically** persists it with **no logic change**. Update only the header doc-comment + (`:15-21`) to note capture now persists an optional relevance score. The `score` CLI (`:218-241`, the digest + path) and the `add` manual path (`:123-147`, score-free) are unchanged. *(Capture's `{added, merged, + duplicates, errors}` tally is left unchanged — a `scored` count is an Open-Q nice-to-have, §8.)* + +### Wiring (D-default — WIRE, mirrors R2a/R2b Open Q#1) +- `agents/trend-spotter.md` (EDIT): Step 4.5's capture batch (`:291-298`) gains a per-item **`"score": {"mode": + "kortform", "dimensions": {"pillar": N, "audience": N, "timing": N, "angle": N, "authority": N}}`** — the same + five judgment scores the agent computed in Step 2 (`:134`), carried into capture so the store persists them and + the brief ranks on them. Prose explains the carry ("don't discard the Step-2 scores — fold them into the + capture batch"). Mode defaults `kortform`; `long-form` when invoked from `/linkedin:newsletter` (long-form + dims `pillar/depth/angle/authority/currency`). Domain-general (dimensions are the rubric's, pillars are the + user's config; no vendor/sector tokens). Keep the "skip silently if no deps" escape hatch. **Verified + non-vacuous:** `agents/trend-spotter.md` does NOT currently contain the literal `"dimensions"`, so the + Section 16j grep passes only after the wire is added. +- `scripts/trends/README.md` (EDIT): document the item `score` field (judgment in), the persisted `TrendScore` + (composite/priority out), and that the brief now ranks on composite. +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (`:701`, currently 104) to the `tests N` line + reported after Steps 1–6, **append** `+ RE-R3a: score +N` to the inline breakdown comment (`:701`). Add + **Section 16j** ("Trends Score Wiring", RE-R3a) **after Section 16i's closing `echo ""` (~`:1171`), before the + Section 18 block (`:1173`)** (16i is the last 16x before anti-erosion; file order 17→16g→16h→16i→18, + `:947/:1014/:1078/:1122/:1173`). Mirror 16i's shape: **unconditional**, deps-absent-safe `grep -qF` + a + non-vacuity self-test emitting **one** pass/fail (so the count is exact) — (1) self-test; (2) `export + interface TrendScore` in `score.ts`; (3) `score?: TrendScore` in `types.ts`; (4) `"dimensions"` in + `agents/trend-spotter.md`; (5) `score?.composite` in `brief.ts`. **5 unconditional emitters → bump + `ASSERT_BASELINE_FLOOR` 94 → exactly 99** (`:1193`; "live recount" is the safety net, but the expected value + is the pinned 94 + 5 = 99). Update the header-enumeration **prose chain** by inserting the 16j clause between + the 16i clause (`:46-49`) and the Section-18 clause (`:49`), preserving sentence flow. + +## 4. Non-goals — what is OUT (deferred) + +- **Re-score on re-capture** (refresh the score when a trend is re-seen) — **R3b**. R3a is first-sight only + (D3). Re-score pairs naturally with the seen-log/status slice (the Timing dimension decays, so a refresh is a + real improvement — but it expands `addTrend`'s mutation surface and wants the status/lifecycle model alongside). +- **Mode-segmented / mode-normalized ranking** — OUT. R3a ranks **all** records by composite regardless of mode; + a kortform composite and a long-form composite are different instruments (different dimensions, + `trend-scoring-modes.md:50,68`), so the ranking is **mode-blind by design for R3a**. This is acceptable because + (a) almost all records are `kortform` (the default), and (b) the body entry line **shows the mode** (`<priority> + (<mode>)`) so the operator can see when two adjacent entries were scored on different instruments. A + mode-segmented brief (separate sections per mode) or a `--mode` filter is a later refinement. +- **Saturation / status (acted/skipped) / first-mover-as-a-field** (the rest of hull 5) — **R3b+**. R3a does the + **relevance** half of hull 5 only. +- **Autonomous nightly trigger** (cron/launchd, hull 1) — **R3 later**. No scheduler enters the repo. +- **Freshness as a persisted seen-log / dedup-vs-seen (B4)** — **R3 later**. +- **Brief history surfacing / diff ("yesterday vs today", hull 7)** — **R3 later**. +- **Research-deepening A1–A4** (plan → isolated parallel workers → gap loop → curate), adapter sub-agents, MCP + fetch fan-out — **R3 later** (the big slice). +- **A new `score` field in the `add` manual CLI path** — OUT. `add` stays the raw, score-free manual path; only + the normalizing `capture` path carries scores. +- **`BRIEF_SCHEMA_VERSION` bump** — OUT by default (no frontmatter field changes); Open Q#5. +- **New source file / new agent / new command** — none. R3a is all edits to the six existing `src/*.ts` + + one agent + README + gate. Counts stay 27/19/29. + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** the failing tests land **BEFORE** the implementation. `store`/`brief`/`cli` + tests are true logic-RED against the pre-edit code (inline fixtures, no new import). `score`/`item` tests + reference new `score.ts` exports → under Node16 ESM a missing named import throws at module-load, so they are + RED against **non-throwing stubs** landed first (the stubs return wrong-but-present values; the value + assertions then fail). The plan records the RED proof in two phases (Step 1); it does NOT claim a single + "all five fail on assertion before any code" run. +- **One composite owner:** `composite()` + `band()` (`score.ts`) stay the sole arithmetic; `scoreEnvelope` + *composes* them, never re-derives. The agent supplies judgment, the code computes the composite (SSOT + discipline, `score.test.ts:12-30` pins the weights/bands). +- **Purity:** `scoreEnvelope`/`requiredDimensions`/`rankForBrief`/`renderBrief` touch no fs, no clock, no env, + no AI. All fs stays at the CLI edge. +- **No throw on the capture path (not "everywhere"):** `normalizeItem` fully validates the score before + `itemToInput`, so the capture loop (`cli.ts:246-258`) never reaches `composite` with bad dims and never + crashes (a bad score → `errors[]`). `itemToInput`/`scoreEnvelope`/`composite` called **directly** with bad + dims throw by contract — that is the defense-in-depth boundary, asserted (SC2), not a leak. +- **Determinism:** same `(store, pillars, today, freshDays)` → byte-identical brief (the composite sort is a + total order via the unique `(title,url)` final tie-break; `-1` sentinel for unscored is deterministic). +- **Lossless additive migration (both directions):** a v2 store loads as v3 with records **untouched** (no + `score` invented); round-trip writes `schemaVersion: 3`; a v3 store is idempotent; a v3 store's new optional + `score` field **survives a load+resave** (no field stripping, `JSON.stringify` `store.ts:95`). Mirrors the R2a + v1→v2 proof (`store.test.ts:403-476`) + a new field-preservation case. +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only and **never shells out to tsx** + (analytics fresh-clone-crash invariant) — R3a touches neither the hook nor the frontmatter schema, so the + zero-tsx surfacing is unchanged. (No hook test added; the existing hook suite must still pass untouched as a + regression sanity.) +- **Domain-general:** Section 17 de-niche stays green; the `trend-spotter.md` edit carries the rubric's + dimension names + the user's pillars, **no vendor/sector tokens**. +- **No SSOT change:** `references/trend-scoring-modes.md` (weights/bands/actions) untouched; `score.ts` mirrors + it exactly as today. +- **No store-query change:** `queryByTopic`/`history`/`newestCaptureDate` untouched; the brief recomputes + overlap as before (`queryByTopic` NOT refactored). +- **Pathguard:** all edits are to **existing** files (no new `.mjs` under `hooks/scripts/`; no new `.ts` — + R3a adds *no* source file). `.gitignore` already covers `scripts/trends/{node_modules,build}`. +- **Counts** (refs/agents/commands 27/19/29) unchanged. **Recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (score envelope)** — `requiredDimensions("kortform")` **deep-equals (ordered)** `["pillar","audience", + "timing","angle","authority"]`; `requiredDimensions("long-form")` deep-equals `["pillar","depth","angle", + "authority","currency"]` (the `WEIGHTS` literal order, `score.ts:20-35`), and `score.test` pins the order so a + SSOT reorder fails. `scoreEnvelope("kortform", {pillar:8,audience:7,timing:9,angle:6,authority:5})` returns + `{ mode:"kortform", dimensions:<the five>, composite: composite(dims,"kortform"), priority: band(composite). + priority }` — composite/priority equal the existing functions' output byte-for-byte (one owner); a bad + dimension makes `scoreEnvelope` throw (via `composite`). +- **SC2 (item validation + bridge + the throw contract)** — `normalizeItem` on an item with a valid `score` + carries the **validated** dims; with a bad `mode`, a missing dimension, a dimension out of [1,10], a non-object + `score`, or an **array** `dimensions` → `{ ok:false, errors:["invalid score: …"] }` (structured, **never + throws**); absent `score` → key omitted. `itemToInput(validItemWithScore, capturedAt)` returns a `TrendInput` + whose `score` is `scoreEnvelope(mode, dimensions)` (composite/priority computed); without a score → no `score` + key; **`itemToInput` called directly with an out-of-range dim throws** (the defense-in-depth contract). +- **SC3 (first-sight persist)** — `addTrend(store, inputWithScore)` on a **new** title+url persists `score` on + the record; re-`addTrend` of the same title+url with a **different** score does **NOT** change the stored + score (first-sight, D3) while topics still union; an input **without** a score adds a score-free record. +- **SC4 (migration v2→v3, both directions)** — a `schemaVersion:2` store with records lacking `score` loads as + **v3**, records intact, **no `score` invented**; round-trip `loadStore→saveStore` writes `schemaVersion:3`; a + v3 store with `score` on records loads idempotent; **a v3 store's `score` field survives load+resave** (field + preservation of a new optional field — not covered by the mirrored v1→v2 block). Mirrors + `store.test.ts:403-476`, **retitled `(RE-R3a / score v2→v3)` with every `schemaVersion` assertion literal + flipped `2`→`3`.** +- **SC5 (brief ranks on composite)** — within a bucket, `rankForBrief` orders **composite desc** first + (a composite-9 record ahead of a composite-6 record at the **same overlap**); an **unscored** record sorts + **after** every scored record in its bucket (the `-1` sentinel) and then by the existing keys; buckets are + unchanged (still overlap+freshness); the order is a **total order** (same-title/diff-url, both unscored → + fixed by `url asc`); same input → byte-identical brief (determinism). +- **SC6 (render surfaces band + mode)** — `renderBrief` emits the **full pinned line shapes** (§3): a scored + top-entry shows `· <priority> (<mode>)` between `(<age>d)` and `· Pillarer`; a scored bullet shows `· + <priority> (<mode>)` before `· 🔗`; an **unscored** entry renders the **unchanged** line (no token) — both + asserted as **full lines, not substrings**. `briefSummary` names the band (no mode) on a scored top, omits the + token on an unscored top, and stays one line with no `"`/`\n` **even when the top title contains a guillemet/ + quote** (the only new code path touching the summary). The `ranking:` frontmatter descriptor equals the pinned + string verbatim. A store whose only fresh match is a **single-pillar unscored** record → `briefSummary` renders + with no `· <priority>` token, one line. +- **SC7 (CLI persists score end-to-end)** — `echo '[{…,"score":{"mode":"kortform","dimensions":{…valid…}}}]' + | … capture --store <tmp>` then `… list --store <tmp> --json` shows the record carrying a `score` with the + computed composite/priority; a batch with one **bad** score → that item in `errors[]`, the valid ones added, + **exit 0** (the run isn't failed). +- **SC8 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the + bumped `TRENDS_TESTS_FLOOR`; new **Section 16j** green (`TrendScore` in `score.ts`, `score?: TrendScore` in + `types.ts`, `"dimensions"` in `trend-spotter.md`, `score?.composite` in `brief.ts`, non-vacuity self-test); + `ASSERT_BASELINE_FLOOR` = **99** (94 + 5); Section 17 de-niche green; counts 27/19/29. + +## 7. Verification + +**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; new Section 16j +self-test + greps pass; `ASSERT_BASELINE_FLOOR` = 99; Section 17 de-niche green; ref/agent/command counts +unchanged. **Regression sanity:** `node --test hooks/scripts/__tests__/` → still green untouched (R3a touches no +hook; adds no hook test). +**Behavioural (manual):** +1. `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d ago>", + "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}, + {"source":"tavily","title":"B","url":"https://e/b","topics":["ai","gov"],"publishedAt":"<~2d ago>", + "score":{"mode":"kortform","dimensions":{"pillar":6,"audience":5,"timing":6,"angle":5,"authority":5}}}]' + | node --import tsx src/cli.ts capture --store /tmp/r3a.json` — both overlap-2 & fresh, A scored higher. +2. `node --import tsx src/cli.ts list --store /tmp/r3a.json --json` → confirm both records carry `score` + with computed composite/priority. +3. `node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r3a.json --out /tmp/r3a-brief --json` → + confirm **A precedes B** in `topMatches` (higher composite, same overlap+freshness), the entry line shows + `· <priority> (kortform)`, and the `summary` names A with its band. +4. Append a bad-score item (`"timing":99`) to the batch and re-`capture` → confirm it lands in `errors[]`, + the valid items still added, exit 0. + +## 8. Open questions for the go-gate — RESOLVED + +D1–D4 confirmed by the operator ("Go", 2026-06-24): **D1** 4-field `TrendScore`; **D2** composite primary within +bucket; **D3** first-sight; **D4** one slice (data-then-visible commit order within it). Two residual cosmetics, +both baked to the recommended default: +- **D5 — `BRIEF_SCHEMA_VERSION` 1→2?** No (no frontmatter field added/removed; the hook reads only + `date`+`summary`). Re-open only if the artifact should self-announce the ranking change. +- **D6 — mode in the per-entry render?** YES (folded from plan-critic #3): the body entry shows `<priority> + (<mode>)`; the summary shows the band only. This makes the mode-blind ranking honest (the reader can see the + instrument). + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED** +(every SC1–SC8 traces to a step; zero creep; all §4 non-goals held; counts 27/19/29 verified live; "no new +source file" verified — exactly 6 `src/*.ts` + 5 `tests/*.test.ts`, all edited, none added; 0 findings). +**brief-reviewer: PROCEED_WITH_RISKS** (all four load-bearing claims — score.ts-is-a-leaf/no-cycle, composite ≥ +1.0, version-stamp-only migration, single-owner arithmetic — verified TRUE; 6 MINOR). **plan-critic: REVISE** +(1 BLOCKER, 4 MAJOR, 4 MINOR). All findings folded; see `plan-re-r3a.md §Plan-critic — folded` for per-step +resolution. Headlines: + +- **[BLOCKER, folded]** the "all five test files fail on assertion after Step 1" RED claim is false for + `score`/`item` under Node16 ESM (a missing named import throws at module-load, not on assertion). → RED is now + **explicitly two-phase**: logic-RED for `store`/`brief`/`cli` against pre-edit code; stub-first then + assertion-RED for `score`/`item` (§5; plan Step 1; the header blockquote). +- **[MAJOR, folded]** the no-throw guarantee was overstated ("unreachable" — but `itemToInput` is public and + throws on direct bad-dim calls). → reworded **path-specific** (no throw on the capture path; direct calls throw + by contract); SC2 asserts both (§5, §6). +- **[MAJOR, folded]** mode-mixing was waved away and "mode shown per entry" contradicted the render spec (which + only showed priority). → the render now shows `<priority> (<mode>)` per body entry (D6); §4 states mode-blind + ranking is accepted for R3a with the mode visible; SC6 asserts the full line incl. mode. +- **[MAJOR, folded]** `requiredDimensions` order contract was ambiguous (SC1 hard-coded arrays vs membership + use). → pinned **ordered** (SC1 deep-equals the SSOT-order array; `score.test` pins order; `normalizeItem` uses + membership) (§3 S-score, SC1). +- **[MAJOR, folded]** `ASSERT_BASELINE_FLOOR` "~99" was not pinned. → pinned **99** (94 + 5 unconditional 16j + emitters; self-test emits one pass/fail like 16i) (§3 wiring, SC8). +- **[MINOR, folded]** SC4 ref `:403-471` stale + pointed at v2 assertions → `:403-476` + "flip every + `schemaVersion` literal 2→3" note (SC4). **[MINOR, folded]** R1 SSOT-pin cite was the doc-comment → now + `score.test.ts:12-30` (§2, §5; plan R1). **[MINOR, folded]** bullet `· <priority>` placement was substring-only + → full pinned line shape, priority+mode before `🔗`, asserted as a full line (§3, SC6). **[MINOR, folded]** + three diverging `ranking:` descriptor strings → one verbatim target, asserted byte-for-byte (§3, SC6). **[MINOR, + folded]** unscored single-match-top summary path untested → added as an SC6 case. **[MINOR, folded]** + `normalizeItem` non-array object case understated → "non-array" added to both object checks + SC2. **[MINOR, + folded]** header-chain line-ref tightened to the 16i clause `:46-49` / Section-18 `:49`. **[MINOR, folded]** R9 + DAG now lists the three new one-way `score.ts ←` edges. **[MINOR, folded]** SC4 forward-compat / + score-survives-round-trip added. **[MINOR, folded]** SC6 quote-safety regression (scored top title with a + guillemet) added. diff --git a/docs/research-engine/brief-re-r3b.md b/docs/research-engine/brief-re-r3b.md new file mode 100644 index 0000000..694ab03 --- /dev/null +++ b/docs/research-engine/brief-re-r3b.md @@ -0,0 +1,416 @@ +# Brief — RE-R3b: trend lifecycle — re-score on re-capture · status (acted/skipped) · seen-log + +> **Slice:** RE-R3b (research-engine rung-2, R3 slice 2 — the **lifecycle** slice: what happens to a trend +> AFTER first capture). R3 ("deepen the research engine") is an **arc** of 5 open hulls (substrate §1). R3a took +> the **relevance** half of hull 5 (persist the score, rank on it). R3b takes the rest of the *lifecycle* of a +> trend: **(i) re-score on re-capture** (R3a's explicit deferral — hull 3 remainder), **(ii) a status lifecycle** +> `new`/`acted`/`skipped` (hull 5), and **(iii) a seen-log** — `surfacedCount`/`lastSurfacedAt` accumulated on +> each record as the temporal foundation slices (c)+(b) build on (hull 5, B4 dedup-state). +> **Predecessor:** RE-R3a (`score?: TrendScore` persisted first-sight; `rankForBrief` orders on composite; +> `renderBrief` surfaces band+mode) + RE-R2b (`brief.ts` dated artifact + surfacing) + RE-R2a (`capture` bridge). +> R3a §4 deferred this exactly: *"Re-score on re-capture … R3b. R3a is first-sight only (D3). Re-score pairs +> naturally with the seen-log/status slice."* — R3b is that paired slice. +> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (5) (status/lifecycle: acted/skipped) + +> remainder of (3) (status as a schema field) + §B4 (*"freshness window + dedup-state (append-only seen-log → +> don't re-surface the same item)"*). The freshness window already exists (`freshDays`, R2b); R3b adds the +> dedup-state (status as the hard dedup; surfacedCount as the soft signal). +> **TDD-order:** RED before code, **two phases** (light-Voyage BLOCKER fold, inherited from R3a): the re-score + +> migration parts of `store.test`, all of `brief.test`, and `cli.test` are true logic-RED against the pre-edit +> code (inline fixtures / behaviour change / subprocess — no new import); the `setStatus`/`markSurfaced`/ +> `effectiveStatus` tests reference not-yet-existing `store.ts` exports, so under Node16 ESM a missing named +> import throws at module-load (not on assertion) — they are RED against **non-throwing stubs** landed first. See +> plan Step 1. + +## 1. Operator decision context (2026-06-25) + +The research engine is **Tier-1** (operator, 2026-06-23). R1→R3a built the deterministic spine: item-schema + +triage scorer (R1) → capture bridge (R2a) → dated morning brief + surfacing (R2b) → persisted relevance score + +composite ranking (R3a). What the spine still lacks is **memory of a trend's life after first sight**: the score +is frozen at first capture even as timing decays; a trend the operator already wrote about (or deliberately +passed on) **re-tops tomorrow's brief unchanged**; and nothing records that a trend has been *surfaced* N times +without action. The morning brief is meant to be a **work queue**, but today it is amnesiac — it cannot tell a +fresh unhandled signal from one the operator dealt with yesterday. + +R3b closes that gap with the **lifecycle layer** the operator chose as slice (a) of the full-R3 build-out +(2026-06-24, *"ALLE gjenstående R3-slices … i rekkefølge (a) → (c) → (b) → (d) → (e)"*). It is **the fundament +for everything temporal**: the autonomous trigger (c) must *never automate a loop that re-surfaces handled +items* — so it depends on (a)'s status+seen-log; saturation/first-mover (b) is *only meaningful with accumulated +seen-data* — which (a) starts accumulating. R3b is deliberately first in the sequence: correctness of the +lifecycle model before any automation reads it. + +**Architectural decisions — CONFIRMED (operator, AskUserQuestion 2026-06-25; baked into the plan):** +- **A1 — seen-log form = on-record + the brief records surfacing.** Three new optional fields on `TrendRecord` + (`status`, `surfacedCount`, `lastSurfacedAt`); the `brief` CLI, **after** the pure `rankForBrief` computes the + ranking, records surfacing on the rendered trends and re-saves the store. `rankForBrief` stays **pure** + (mutation only at the CLI edge). The store stays the **single source of truth** — no separate `seen-items.md`. + A `--no-mark` flag gives a side-effect-free dry run. *(This is exactly what slice (c) will automate and slice + (b) will read.)* +- **A2 — re-score on re-capture = last-score-wins.** On a duplicate capture carrying a fresh `score`, the stored + `score` is **replaced** by the freshly-computed envelope (composite re-derived by the one owner, + `composite()`+`band()`). `score` becomes **the one deliberately-mutable field**; provenance (`source`, + `capturedAt`, first `publishedAt`) stays first-sight. A re-score **does NOT reset status** — an `acted`/`skipped` + decision sticks. *(Rationale: the Timing dimension decays, so the newer judgment — even a lower one — is the + truer one; monotone "only if higher" would freeze stale optimism.)* +- **A3 — acted/skipped are EXCLUDED from the brief.** `rankForBrief` drops every record whose effective status + is not `new` from all three buckets — the brief is a work queue, not an archive. Full history stays available + via `list`/`query`. + +## 2. The gap — grounded in code + +- **The score is frozen at first sight, even as timing decays.** `addTrend`'s duplicate branch + (`store.ts:127-131`) unions topics and returns — it **never touches `score`** (R3a's D3, first-sight only). + The capture path already carries a fresh score on every re-capture (`item.ts:192` `itemToInput` → + `scoreEnvelope`; `cli.ts:257` folds it through `addTrend`), so the fresh judgment **reaches `addTrend` and is + silently discarded** for any trend already in the store. A trend re-polled a week later still ranks on its + week-old Timing score. +- **A handled trend re-tops the brief unchanged.** `rankForBrief` (`brief.ts:82-92`) iterates **every** store + record, dropping only off-pillar ones (`overlap === 0`, `:89`). There is no notion of "I already wrote about + this" — an `acted` trend with a high composite re-sorts to the top of `topMatches` tomorrow exactly as it did + today. `TrendRecord` has **no `status` field** (`types.ts:29-59`); the doc-comment anticipates it: *"can gain + fields (…, status) in a later slice"* (`types.ts:22`). +- **Nothing records that a trend has been surfaced.** The brief is a **pure read** (`brief.ts:1-15`: *"No fs, no + clock, no AI"*); generating it leaves no trace on the store. There is no `surfacedCount`/`lastSurfacedAt` — + so a future autonomous loop (slice c) has **no way to know** a trend was already shown, and saturation (slice b) + has **no accumulated signal** to read. B4's dedup-state (`docs/research-engine-concepts.local.md:63`) does not + exist yet. +- **The CLI has no lifecycle verbs.** `cli.ts` exposes `add`/`query`/`list`/`status`/`normalize`/`score`/ + `capture`/`brief` (`:5-13`) — all capture/read. There is **no way for the operator to mark** a trend `acted` + or `skipped`. + +## 3. Scope — what is IN (RE-R3b) + +### S-types — `scripts/trends/src/types.ts` (EDIT) +- **`export type TrendStatus = "new" | "acted" | "skipped";`** — the lifecycle states. +- `TrendRecord` gains **three optional fields** (all absent on pre-R3b records, all additive): + - **`status?: TrendStatus;`** — lifecycle. **Absent ⇒ `"new"`** (back-compat); set only by `act`/`skip`/`reset`, + **never on capture** (a freshly-captured trend is implicitly `new`). + - **`surfacedCount?: number;`** — the seen-log count: how many distinct days this trend has appeared in a + generated brief. **Absent ⇒ 0.** Incremented (per-day-idempotent) by the `brief` CLI. + - **`lastSurfacedAt?: string;`** — ISO date of the most recent surfacing. **Absent ⇒ never.** The per-day + idempotency key (re-running today's brief does not re-increment). +- Doc-comment: mark `status`/`surfacedCount`/`lastSurfacedAt` as the now-realized lifecycle fields the `:22` + note anticipated. +- **`SCHEMA_VERSION = 3 → 4`** (`types.ts:73`). Additive-optional; the migration is the version-stamp alone + (below), identical to v1→v2→v3. + +### S-store — `scripts/trends/src/store.ts` (EDIT) +- **`export function effectiveStatus(t: TrendRecord): TrendStatus`** — `return t.status ?? "new";`. The single + reader of the absent-⇒-new convention (pure; consumed by `addTrend` audit, `brief`, and the CLI). Imports + `TrendStatus` from `./types.js` (type-only). +- **Re-score in `addTrend`'s duplicate branch (`:127-131`, A2):** after the topic union, if `input.score !== + undefined` **and it differs from `existing.score`** (compared via `JSON.stringify` — the envelope is built in a + fixed key order by `scoreEnvelope`, so the compare is stable), set `existing.score = input.score` and mark the + record changed. `AddResult.merged` is **broadened** to *"the existing record was mutated — topics unioned and/or + score refreshed"*; `merged` is true iff **either** changed (a re-capture with an identical score → `merged:false`, + no false-positive). `status`/`surfacedCount`/`lastSurfacedAt` are **NOT touched** on re-capture (A2: re-score + doesn't reset status; surfacing is the brief's job, not capture's). The **new-record** branch (`:132-144`) is + unchanged — a new record omits all three lifecycle fields (status absent ⇒ new; never surfaced; no input.status + exists on the capture path). +- **`export function setStatus(store: TrendStore, id: string, status: TrendStatus): { store: TrendStore; found: + boolean }`** — find the record by `id`; if absent return `{ store, found: false }` (no throw); else set + `t.status = status` (set **explicitly**, including `"new"` for a `reset`) and return `{ store, found: true }`. + Mutates in place + returns the same store (the `addTrend` idiom). Pure (no fs). +- **`export function markSurfaced(store: TrendStore, ids: string[], today: string): { store: TrendStore; marked: + number }`** — for each record whose `id` is in `ids` **and** whose `lastSurfacedAt !== today` (per-day + idempotent), set `surfacedCount = (surfacedCount ?? 0) + 1` and `lastSurfacedAt = today`; count it. Records + already surfaced today, or not in `ids`, are untouched. Pure (no fs; `today` injected by the caller, like + `capturedAt`). Returns the count actually incremented. +- `AddResult` keeps its **2-flag shape** `{ store, added, merged }` (no new flag — `merged` is broadened, not + joined). `TrendInput` is **unchanged** (no `status`/`surfaced*` input — lifecycle is set post-capture, not + ingested). +- `loadStore` migrate comment (`:82-88`): extend the enumeration to *"v1→v2→v3→v4 are all purely + additive-optional"*. **No code change** (`Math.max(onDisk, SCHEMA_VERSION)` `:91` already stamps v4; + `saveStore` `JSON.stringify` `:99` preserves the three new fields). Only `SCHEMA_VERSION` (in `types.ts`) and + the comment move. + +### S-brief — `scripts/trends/src/brief.ts` (EDIT) +- **`rankForBrief` excludes handled trends (A3):** in the entry loop (`:82-92`), add **`if (effectiveStatus(trend) + !== "new") continue;`** immediately before the `overlap === 0` check (so acted/skipped never enter any bucket). + Import `effectiveStatus` from `./store.js` (brief.ts already imports `defaultStorePath` from there — `:19`; the + edge stays one-way, no cycle). `totals.trends` **still counts the full inventory** (`store.trends.length`, + `:116`) — honest "of N in store"; `totals.matched`/`fresh` naturally reflect the post-filter `entries`. +- **`renderBrief`/`renderTopEntry`/`renderBulletEntry` surface the trend `id` + a surfaced marker** (so the + operator can act on an entry, and a re-surfaced item is honest). **Pinned line shapes:** + - A shared **`surfacedToken(e)`** helper (mirrors `scoreToken`, `:142-145`): ` · sett <surfacedCount>x` when + `surfacedCount >= 2`, else `""` (only a genuinely re-surfaced item is flagged; this is a saturation **hint**, + not the saturation **scoring** of slice b). **Semantic (folded — plan-critic #3): the count is PRIOR-DAY** — + the brief renders from `surfacedCount` **before** the CLI records today's surfacing (the mutation runs after + `renderBrief`), so `· sett Nx` means *"shown on N prior distinct days"* (today's appearance is recorded but + not yet counted in this render). The `>= 2` floor therefore means "already shown on ≥2 earlier days". This is + documented in the README + asserted by a unit test that sets `surfacedCount` directly (the cross-day behaviour + is exercised by behavioural step §7). + - Top-entry meta line (`renderTopEntry`, `:150`): append **` · \`<id>\``** at the end (after `Pillarer: …`), + and `surfacedToken(e)` after the `scoreToken`: + `- Kilde: <source> · Publisert: <date> (<age>d)<scoreToken><surfacedToken> · Pillarer: <matched> · \`<id>\`` + - Bullet line (`renderBulletEntry`, `:159`): append **` · \`<id>\``** at the end (after `🔗 <url>`), with + `surfacedToken` after `scoreToken`: + `- **<title>** — «<matched>» · <date> (<age>d)<scoreToken><surfacedToken> · 🔗 <url> · \`<id>\`` + - The id is rendered in backticks so it is copy-paste-ready for `act --id <id>` / `skip --id <id>`. +- **`export function surfacedIds(ranking: BriefRanking): string[]`** — the ids of the entries `renderBrief` + **actually shows**: `topMatches ∪ singleMatches ∪ olderMatched.slice(0, 5)` (mirrors the `:199` `.slice(0, 5)` + older cap), mapped to `e.trend.id`. The CLI feeds this to `markSurfaced` so the seen-log records exactly what + the operator saw. Pure. +- **`ranking:` frontmatter descriptor (`:175`)** → the **exact** string + `composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays <N>; excludes + acted/skipped` (pinned verbatim; `brief.test` asserts byte-for-byte). The trailing `; excludes acted/skipped` + is the only descriptor change. +- `briefSummary` (`:129-139`) is **unchanged** (the headline still names the top fresh match's band + age; status + exclusion happens upstream in the ranking, so the summary already reflects only `new` trends). `BRIEF_SCHEMA_ + VERSION` stays **1** (no frontmatter *field* added/removed — `date`/`summary`/`store`/`ranking`/`schemaVersion` + unchanged; only the `ranking:` *string* and body content change; the surfacing hook still reads `date`+`summary`). + +### S-cli — `scripts/trends/src/cli.ts` (EDIT) +- **`act` / `skip` / `reset` subcommands** (set lifecycle status by id): + - `act --id <id> [--store <path>]` → `setStatus(store, id, "acted")`; `skip …` → `"skipped"`; `reset …` → + `"new"`. Each: load → setStatus → if `found` save + print `Marked <id> <status>` (exit 0); if **not found** + print `error: no trend with id: <id>` to stderr + **exit 2**. A missing/`true` `--id` → `usage('<cmd> needs + --id <id>')` (exit 2). **Exit-code contract broadened (folded — plan-critic #2):** a not-found id is exit 2, + which the existing contract documents as "usage error". Update the header doc-comment (`cli.ts:33`) to read + *"0 on success, 2 on usage error or a not-found id (act/skip/reset)"* — a wrong `--id` value is an + argument-class error, distinct from `capture`'s data-stream items (which stay in `errors[]`, never the exit + code). A new exit code is **not** introduced (the CLI keeps its two codes). +- **`brief` records surfacing (A1):** **hoist the load** (folded — plan-critic #1 / brief-reviewer #1): replace + the inline `rankForBrief(loadStore(storePath), …)` (`cli.ts:286`) with **`const store = loadStore(storePath); + const ranking = rankForBrief(store, pillars, day, { freshDays });`** — `cli.ts:286` does **not** currently bind + a `store` variable (verified), so the surfacing edit needs this hoist or it references an undefined identifier. + Then after `writeFileSync(path, md, …)` (`:290`), **unless `--no-mark`**: `markSurfaced(store, surfacedIds + (ranking), day)` then `saveStore(storePath, store)` — the **hoisted `store`** holds the full inventory, so + acted/skipped records (filtered from the ranking but still in the store) are preserved on resave; the `.md` is + rendered from the pure `ranking` **before** the mutation. `const mark = flags["no-mark"] !== "true";` (a bare + `--no-mark` → `"true"` → mark off). The `--json` output gains a **`marked`** count (trends whose seen-log this + run incremented; `0` when `--no-mark`). `rankForBrief`/`renderBrief` are untouched — the mutation is purely at + the edge. +- **`capture` tally comment (`cli.ts:251-252`)** (folded — plan-critic #4): the broadened `AddResult.merged` + (topics ∪ score-refresh) makes the existing comment *"a fold is … `merged` (existing gained topics)"* stale → + update it to *"`merged` (existing gained topics and/or a refreshed score)"*. No tally-logic change (the loop + already counts `res.merged`). +- **Usage + header doc:** add the three new verbs + `[--no-mark]` to the `usage()` block (`:82-91`) and the + header synopsis (`:5-13`); a one-line header note that `act`/`skip`/`reset` set a trend's lifecycle status, the + brief excludes handled trends and records surfacing, and re-capture refreshes the score. + +### Wiring (D-default — WIRE, mirrors R3a) +- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): Step 4.5 already emits the per-item `score` (R3a); + re-score is **automatic** (capture re-folds an existing trend with a fresh score → `addTrend` now refreshes it), + so **no batch-shape change**. Add one prose line: re-capturing a known trend now **refreshes** its relevance + score (timing decays), and the operator marks trends `acted`/`skipped` via the CLI so the brief stops + re-surfacing handled work. Domain-general (no vendor/sector tokens). *(If a Section-16k grep targets the agent, + it must be verified non-vacuous first; the recommended 16k greps target src files only — see gate below.)* +- `scripts/trends/README.md` (EDIT): document the status lifecycle (`new`/`acted`/`skipped` + `act`/`skip`/`reset`), + the seen-log (`surfacedCount`/`lastSurfacedAt`, per-day idempotent, brief-recorded), re-score-on-recapture + (last-wins), and the brief's exclude-handled behaviour + `--no-mark`. +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (`:705`, currently 146) to the `tests N` line + reported after Steps 1–6, **append** `+ RE-R3b: lifecycle +N` to the inline breakdown comment. Add + **Section 16k** ("Trends Lifecycle Wiring", RE-R3b) **after Section 16j's closing block, before Section 18** + (16j is the last 16x before the anti-erosion Section 18; preserve that order). Mirror 16j's shape: + **unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail). Recommended + **6 emitters** (all on tracked src — no `tsx`): (1) self-test; (2) `export type TrendStatus` in `types.ts`; + (3) `surfacedCount` in `types.ts` (seen-log field); (4) `export function markSurfaced` in `store.ts` (seen-log + writer); (5) `effectiveStatus` in `brief.ts` (the brief excludes handled); (6) `command === "act"` in `cli.ts` + (the lifecycle verb). **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 99 → exactly 105** (`:1259`; + "live recount" is the safety net; the expected value is the pinned 99 + 6 = 105). Update the header-enumeration + prose chain by inserting the 16k clause between the 16j clause and the Section-18 clause. + +## 4. Non-goals — what is OUT (deferred) + +- **Saturation scoring / first-mover-as-a-field** (the quantitative *use* of `surfacedCount`) — **slice (b)**. + R3b **accumulates** the seen-log and shows a minimal `· sett Nx` hint, but it does **not** compute a saturation + score, decay the composite by surfacings, or add a first-mover field. (b) reads R3b's accumulated data. +- **Autonomous nightly trigger** (cron/launchd, headless entry — hull 1+6) — **slice (c)**. R3b adds no scheduler; + it builds the lifecycle (c) will safely automate. +- **Brief history surfacing / diff** ("what's new since yesterday" — hull 7) — **slice (d)**. The seen-log records + *that* a trend was surfaced; the cross-brief **diff** is (d). R3b's `· sett Nx` is a per-record count, not a + day-over-day diff. +- **Research-deepening A1–A4** (plan → isolated workers → gap loop → curate) — **slice (e)**, behind the post-(d) + re-evaluation gate. +- **Mode-segmented ranking / `--mode` filter** — still OUT (R3a non-goal, unchanged). +- **Re-score semantics other than last-wins** (monotone / timing-only refresh) — OUT (A2 chose last-wins). +- **A `status`/`surfaced*` input on the capture/`add` path** — OUT. Lifecycle is set **post-capture** by + `act`/`skip`/`reset`; capture never ingests a status. `TrendInput` is unchanged. +- **`act`/`skip` by title/url** (deriving the id) — OUT for R3b; `--id` only (the id is shown in the brief + + `list --json`). A title/url alias is a later ergonomic nice-to-have. +- **Auto-acting on publish** (wiring `act` into `/linkedin:post` / the post-tracking flow) — OUT. R3b ships the + CLI verbs; auto-marking from the content commands is a separate plugin-surface slice. +- **`BRIEF_SCHEMA_VERSION` bump** — OUT (no frontmatter field changes); Open Q. +- **New source file / new agent / new command** — none. R3b is edits to **four** existing `src/*.ts` (`types`, + `store`, `brief`, `cli`) + their tests + one agent (prose) + README + gate. `score.ts` + `item.ts` are + **untouched** (re-score reuses the R3a capture path). Counts stay 27/19/29. + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — true logic-RED for + the re-score + migration parts of `store.test` (existing `addTrend`/`loadStore`, inline fixtures), all of + `brief.test` (behaviour change to existing `rankForBrief`/`renderBrief`), and `cli.test` (subprocess: `act`/`skip` + print a usage/unknown-command error today → assertion-RED). Phase B — `setStatus`/`markSurfaced`/`effectiveStatus` + reference new `store.ts` exports → land non-throwing stubs first (Node16 ESM throws a missing named import at + module-load), then record value-assertion RED against the stubs. The plan does **not** claim a single + "everything fails before any code" run. +- **`rankForBrief` stays pure (A1):** no fs, no clock, no env, no AI, **no store mutation**. The status filter is + a pure read of `effectiveStatus`. The seen-log **write** lives only in the `brief` CLI edge (after the pure + ranking), guarded by `--no-mark`. `markSurfaced`/`setStatus`/`effectiveStatus`/`surfacedIds` are all pure. +- **One composite owner (unchanged):** re-score reuses the **already-built** capture path + (`itemToInput`→`scoreEnvelope`→`composite`+`band`); R3b adds **no new arithmetic** and does not touch `score.ts`. +- **Provenance discipline (A2):** `source`, `capturedAt`, and the first `publishedAt` stay **first-sight**; only + `score` is mutable on re-capture; `status`/`surfacedCount`/`lastSurfacedAt` are mutated only by their own + owners (`setStatus`/`markSurfaced`), never by `addTrend`. +- **Per-day-idempotent surfacing:** running `brief` twice on the same `today` increments `surfacedCount` **at most + once** (`markSurfaced` skips records whose `lastSurfacedAt === today`). Re-generating today's brief is a no-op on + the seen-log. *(This is the determinism guarantee for the autonomous loop: an idempotent daily mark.)* +- **No false-merge on re-capture:** a re-capture with a **byte-identical** score → `merged:false` (the + `JSON.stringify` compare); only a genuine topic-union or score-change flips `merged`. +- **Determinism (brief):** same `(store, pillars, today, freshDays)` → byte-identical `renderBrief` output (the + status filter + `surfacedToken` + id are deterministic reads of the store; the composite sort total order from + R3a holds). The CLI's surfacing mutation is **outside** the pure render. +- **Lossless additive migration (both directions):** a v3 store loads as v4 with records **untouched** (no + `status`/`surfaced*` invented); round-trip writes `schemaVersion: 4`; a v4 store is idempotent; the three new + optional fields **survive a load+resave**. Mirrors the R3a v2→v3 proof (`store.test.ts`, `(RE-R3a / score + v2→v3)` block) with the literals flipped `3`→`4`. +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only and **never shells out to tsx**. + R3b touches neither the hook nor the frontmatter schema (`BRIEF_SCHEMA_VERSION` stays 1), so surfacing is + unchanged. The existing hook suite must still pass untouched (regression sanity; R3b adds no hook test). +- **Domain-general:** Section 17 de-niche stays green; the `trend-spotter.md` prose carries only generic + lifecycle wording (`acted`/`skipped`/"refresh the score"), no vendor/sector tokens. +- **No SSOT change:** `references/trend-scoring-modes.md` untouched (R3b changes no scoring math). +- **No store-query change:** `queryByTopic`/`history`/`newestCaptureDate` untouched. *(The CLI `status` + subcommand — the staleness reader — is unrelated to the new `TrendStatus` lifecycle type; the name collision is + pre-existing and not reconciled here.)* +- **Pathguard:** all edits are to **existing** files (no new `.mjs` under `hooks/scripts/`; no new `.ts` — R3b + adds no source file). `.gitignore` already covers `scripts/trends/{node_modules,build}`. +- **Counts** (refs/agents/commands 27/19/29) unchanged. **Recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (status field + effectiveStatus + setStatus)** — `effectiveStatus({…no status})` is `"new"`; + `effectiveStatus({…status:"acted"})` is `"acted"`. `setStatus(store, id, "skipped")` on a present id sets the + record's `status` and returns `{ found:true }`; on an absent id returns `{ found:false }` (no throw, store + unchanged); a `reset` sets `status:"new"` explicitly. +- **SC2 (re-score last-wins, no false-merge, status/provenance untouched)** — `addTrend(store, dupInput)` where + `dupInput` has the same title+url and a **different** `score` → the stored `score` is **replaced**, `merged:true`, + `added:false`, topics still unioned, and `source`/`capturedAt`/`publishedAt`/`status`/`surfacedCount` are + **unchanged**. A re-capture with a **byte-identical** score (and no new topics) → `merged:false`. A duplicate + with **no** `score` → stored score unchanged. A re-capture of an **acted** trend with a new score → score + updated, **status stays `acted`**. **At the CLI edge (folded — plan-critic #4):** a `capture` of a scored item, + then a `capture` of the same title+url with a **changed** score → the second `capture --json` reports + `merged:1`, and `list --json` shows the **updated** composite (a subprocess test, not only the manual §7 step). +- **SC3 (markSurfaced + per-day idempotency)** — `markSurfaced(store, [idA, idC], "2026-06-25")` increments + `surfacedCount` (absent⇒0→1) and sets `lastSurfacedAt:"2026-06-25"` on A and C only (B untouched), returns + `marked:2`; a second `markSurfaced` with the **same `today`** → `marked:0`, counts unchanged; a third with a + **later** `today` → increments again, `lastSurfacedAt` advances; an id not in the store is silently skipped. +- **SC4 (migration v3→v4, both directions)** — a `schemaVersion:3` store with records lacking the lifecycle + fields loads as **v4**, records intact, **no field invented**; round-trip `loadStore→saveStore` writes + `schemaVersion:4`; a v4 store with lifecycle fields loads idempotent; **the three new fields survive + load+resave** (byte-identical). Mirrors the R3a `(RE-R3a / score v2→v3)` block, retitled `(RE-R3b / lifecycle + v3→v4)`, every `schemaVersion` literal flipped `3`→`4`. +- **SC5 (brief excludes acted/skipped)** — given a store with `new`, `acted`, and `skipped` records all matching + pillars + fresh: `rankForBrief` places **only** the `new` ones in `topMatches`/`singleMatches`/`olderMatched`; + `totals.trends` still equals the **full** store count; a store whose only matches are `acted`/`skipped` → + empty buckets + the `briefSummary` "no fresh signals" line; the order among the surviving `new` records is the + R3a composite total order (unchanged). +- **SC6 (brief render: id + surfaced marker + descriptor)** — `renderBrief` emits the **full pinned line shapes** + (§3): a top entry ends with `· \`<id>\`` (after `Pillarer: …`); a bullet ends with `· \`<id>\`` (after `🔗 + <url>`); a record with `surfacedCount >= 2` shows `· sett <N>x` (after the score token), one with + `surfacedCount` 0/1/absent shows **no** surfaced token — both asserted as **full lines**. The `ranking:` + descriptor equals the pinned string ending `; excludes acted/skipped` verbatim. `surfacedIds(ranking)` returns + exactly the ids of `topMatches ∪ singleMatches ∪ olderMatched.slice(0,5)`. Two `renderBrief` calls on the same + input are byte-identical. +- **SC7 (CLI act/skip/reset)** — `act --id <id> --store <tmp>` then `list --store <tmp> --json` shows the record + with `status:"acted"`; `skip` → `"skipped"`; `reset` → `"new"`; an **unknown** id → stderr error + **exit 2**, + store unchanged; a missing `--id` → usage + exit 2. +- **SC8 (CLI brief marks surfaced + --no-mark + exclusion end-to-end)** — `brief --pillars … --store <tmp>` on a + store with fresh matches → the written `.md` **omits** any acted/skipped record; a following `list --store <tmp> + --json` shows the surfaced trends with `surfacedCount:1` + today's `lastSurfacedAt`, and the `--json` output + carries `marked:<n>`; a **second** `brief` the same day → `marked:0`, counts unchanged (idempotent); + `brief --no-mark --store <tmp>` on a fresh store → `marked:0`, **no `surfacedCount` written** (store's trends + unchanged save for nothing). +- **SC9 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the bumped + `TRENDS_TESTS_FLOOR`; new **Section 16k** green (`TrendStatus` + `surfacedCount` in `types.ts`, `markSurfaced` + in `store.ts`, `effectiveStatus` in `brief.ts`, `command === "act"` in `cli.ts`, non-vacuity self-test); + `ASSERT_BASELINE_FLOOR` = **105** (99 + 6); Section 17 de-niche green; counts 27/19/29; the hook suite still + green untouched. + +## 7. Verification + +**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; Section 16k self-test + +greps pass; `ASSERT_BASELINE_FLOOR` = 105; Section 17 de-niche green; ref/agent/command counts unchanged. +**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3b touches no +hook; adds no hook test). +**Behavioural (manual):** +1. `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d ago>", + "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}]' + | node --import tsx src/cli.ts capture --store /tmp/r3b.json` — adds A. +2. Re-`capture` A with a **lower** timing (`"timing":3`) → `list --store /tmp/r3b.json --json` shows A's + composite **dropped** (re-score last-wins); the capture tally reports `merged:1`. +3. `node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r3b.json --out /tmp/r3b-brief --json` → + confirm `marked:1`; `list --json` shows A with `surfacedCount:1` + today's `lastSurfacedAt`; the entry line + shows `· \`<id>\``. +4. Re-run the **same** `brief` → `marked:0` (idempotent); `surfacedCount` still 1. +5. `node --import tsx src/cli.ts act --id <A's id> --store /tmp/r3b.json` → re-run `brief` → A is **absent** from + the written `.md`; the summary reports no fresh signals (if A was the only match). +6. `node --import tsx src/cli.ts reset --id <A's id> --store /tmp/r3b.json` → A reappears in the brief. +7. `brief --no-mark` on a fresh store → `marked:0`, `surfacedCount` not written. + +## 8. Open questions for the go-gate + +Three architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-25): **A1** on-record seen-log, +the `brief` CLI records surfacing (`rankForBrief` pure, `--no-mark` dry-run); **A2** re-score last-wins (score the +one mutable field; status not reset); **A3** acted/skipped excluded from the brief. Residual decisions, all baked +to the recommended default — confirm or redirect with "Go": +- **D1 — status values `new`/`acted`/`skipped`, absent⇒new (omit on add)?** YES (rec). A 3-state lifecycle; a + freshly-captured trend is implicitly `new` (field omitted); `reset` sets `"new"` explicitly. Re-open only if a + `published`/`drafted` distinction is wanted (the plugin tracks posts elsewhere — kept out of the trend store). +- **D2 — `AddResult.merged` broadened (topics ∪ score-refresh), no new flag?** YES (rec). Keeps the 2-flag shape; + the capture tally's "N merged" honestly means "N existing records updated". Re-open only if `rescored` must be + counted **separately** from topic-merges in the CLI tally. +- **D3 — include `reset` (un-skip → new)?** YES (rec). Symmetric + cheap; the operator changes their mind. Drop + only to keep the verb set to two. +- **D4 — show the trend `id` in brief entries?** YES (rec). The status feature is **inoperable** otherwise — the + operator needs the id to `act`/`skip`. Shown in backticks for copy-paste. Alternative: omit, and require + `list --json` to find ids (clunky). +- **D5 — minimal `· sett Nx` marker when `surfacedCount >= 2`?** YES (rec). Keeps the seen-log **honest/visible** + in R3b (otherwise it is an invisible schema-only accumulation — the anti-pattern R3a warned of) without + straying into (b)'s saturation scoring or (d)'s diff. The `>= 2` floor means a first/second sighting is silent. + Drop only if any visible surfaced signal should wait for (b). +- **D6 — `act`/`skip` identify by `--id` only?** YES (rec). Store-native; the id is shown in the brief + + `list --json`. A title/url alias is a deferred nice-to-have. +- **D7 — which entries count as "surfaced"?** The entries `renderBrief` **actually shows**: `topMatches ∪ + singleMatches ∪ olderMatched.slice(0,5)` (rec). Matches what the operator saw; the older-bucket cap mirrors the + render's `.slice(0,5)`. +- **D8 — `BRIEF_SCHEMA_VERSION` 1→2?** NO (rec). No frontmatter field added/removed (the hook reads only + `date`+`summary`). Re-open only if the artifact should self-announce the exclude-handled change. +- **D9 — commit split?** Single code commit (rec) — R3b's lifecycle (re-score/status/seen-log) is tightly + coupled; the R3a data-then-visible split would land an invisible cut. Docs commit first, then one code commit. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED** +(every SC1–SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held; counts 27/19/29 verified live; +`score.ts`/`item.ts`-untouched verified — `itemToInput` already builds the envelope on every capture incl. +re-capture; A1/A2/A3 consistent across every step; the R3a-block reconcile is a necessary prerequisite, not creep; +2 MINOR plan line-cite nits). **brief-reviewer: PROCEED_WITH_RISKS** (all nine load-bearing claims verified TRUE — +incl. the v3→v4 reconcile complete for **every** breaking literal, enumerated; 1 MEDIUM + 3 LOW). **plan-critic: +PROCEED_WITH_RISKS (78/B)** (the two-phase RED, the atomic bump+reconcile, the `merged` broadening's +non-regression, the `surfacedIds` formula, and the gate arithmetic all verified correct; 1 MAJOR + 5 MINOR). +All findings folded; see `plan-re-r3b.md §Plan-critic — folded` for per-finding resolution. Headlines: + +- **[MAJOR/MEDIUM, folded — both reviewers] the `brief` CLI's `store` binding does not exist.** `cli.ts:286` + inlines `rankForBrief(loadStore(storePath), …)` — there is no `const store`, so the `markSurfaced(store, …)` / + `saveStore(storePath, store)` edit referenced an undefined identifier. → §3 S-cli + plan Step 5 now **hoist** + `const store = loadStore(storePath)` and pass it to `rankForBrief`; R5 wording corrected. +- **[MINOR, folded — plan-critic #2] not-found id → exit 2 contradicted the documented exit-code contract.** → + the `cli.ts:33` doc-comment is **broadened** to *"2 on usage error or a not-found id (act/skip/reset)"* (a wrong + `--id` is an argument-class error, distinct from `capture`'s data items); no third exit code introduced (§3 S-cli). +- **[MINOR, folded — plan-critic #3] `· sett Nx` off-by-one.** Render precedes the surfacing mutation, so the + token reflects the **prior-day** count. → the **prior-day semantic** is now stated explicitly (§3 S-brief + the + README): `· sett Nx` = "shown on N prior distinct days". +- **[MINOR, folded — plan-critic #4] `capture` tally comment stale + the re-score CLI tally untested.** → §3 S-cli + updates the `cli.ts:251-252` comment (`merged` = topics ∪ score-refresh); SC2 adds a subprocess assertion that a + re-captured changed-score item reports `merged:1` with the updated composite. +- **[MINOR, folded — plan-critic #5] Step 2 used `TrendStatus` before Step 3 defined it.** → the plan is + reordered: Step 2 adds the `TrendStatus` type + the three fields to `types.ts` **first** (then the `store.ts` + functions); Step 3 isolates the atomic `SCHEMA_VERSION` bump + the R3a-block reconcile. +- **[LOW, folded — brief-reviewer #4] forward-debt: the new R3b migration block hard-coded `4`** (perpetuating the + reconcile-cycle this slice pays for R3a). → the new block's **target + idempotent** assertions commit against + `SCHEMA_VERSION` (the hard-`4` is the Step-1 RED device only; the v3 **input** fixtures stay literal `3`), + breaking the cycle so R3c won't pay it. +- **[LOW, folded] cosmetic literal/title drift** — `store.test.ts:571`/`:598` titles + `:570` comment flipped to + "the current version"; `cli.test.ts:247`'s inert `schemaVersion:2` fixture added to the scope-fence enumeration; + the two plan line-cites corrected to `~:1235` (after 16j's block) / `:1237` (Section 18 header). diff --git a/docs/research-engine/brief-re-r3c.md b/docs/research-engine/brief-re-r3c.md new file mode 100644 index 0000000..0f78ae5 --- /dev/null +++ b/docs/research-engine/brief-re-r3c.md @@ -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. diff --git a/docs/research-engine/brief-re-r3d.md b/docs/research-engine/brief-re-r3d.md new file mode 100644 index 0000000..47bf7f1 --- /dev/null +++ b/docs/research-engine/brief-re-r3d.md @@ -0,0 +1,401 @@ +# Brief — RE-R3d: temporal overlay — first-mover + saturation (R3 slice b) + +> **Slice:** RE-R3d (research-engine rung-2, R3 slice **(b)** in the operator's `(a)→(c)→(b)→(d)→(e)` +> sequence — the doc is numbered r3d by creation order, the concept is slice (b)). It closes the **rest of +> hull #3** (substrate §1): the store-schema fields a brief *ranks on* were `relevance` (✅ R3a) · `status` +> (✅ R3b) · **first-mover** · **saturation** · `angle`. `angle` is **already** a scored dimension +> (`score.dimensions.angle`, weight 15 %/20 % — `score.ts:24`/`:32`), folded into the R3a composite; so the +> rest of #3 is exactly **saturation + first-mover**, confirming §5's framing. +> **Predecessor:** RE-R3a (persisted relevance composite — the PRIMARY within-bucket rank key, `brief.ts:102`) +> + RE-R3b (the seen-log: per-day-idempotent `surfacedCount`/`lastSurfacedAt`, `types.ts:71-78`, whose comment +> names this slice — *"the temporal foundation slices (c)+(b) read"*) + RE-R3c (the autonomous trigger that +> makes `surfacedCount` **accumulate day-over-day without the operator running anything** — the dependency that +> makes saturation a real signal, the load-bearing reason (b) follows (c)). +> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (3) (*"store-schema mangler felt en brief +> rangerer på (relevance/first-mover/saturation/angle/status)"*) + §B4 (*"freshness-vindu … + dedup-state +> (append-only seen-log → ikke re-overflate samme sak)"* — the seen-log IS the saturation substrate). R3d turns +> the seen-log + the publish/capture dates into a **live temporal overlay** the brief ranks on. +> **The core decision (operator-confirmed, AskUserQuestion 2026-06-26 — baked):** +> - **SB1 — derived at brief time, NOT a stored field.** first-mover/saturation are pure functions of fields the +> store ALREADY persists (`publishedAt`/`capturedAt` → age; `surfacedCount` → repeat-exposure) + the injected +> `today` — exactly like `ageDays`/`effectiveDate` are derived on `BriefEntry`, never stored. **No +> `SCHEMA_VERSION` bump (stays 4); no `BRIEF_SCHEMA_VERSION` bump (stays 1).** The signal recomputes every run, +> so it can never go stale (a stored field would, as `surfacedCount` grows). Respects the discipline that an +> *avledbar* value is not persisted (`ageDays` is derived; `score` is persisted only because it is a frozen +> model judgment — `types.ts:54-64`). +> - **SB2 — refine recency WITHIN the composite tier; do not override it.** The R3a relevance composite stays +> the PRIMARY sort key (`brief.ts:103`). The temporal signal enters `cmp` as a NEW key **after** pillar-overlap, +> **before** `effectiveDate` — a richer recency class than the raw date it sits in front of. A +> saturated-but-higher-composite trend still outranks a fresh-but-lower one (composite dominates across tiers); +> the overlay only re-orders *within* the same (composite, overlap) tier. Honors *"felt en brief rangerer på"* +> without shadowing the SSOT-pinned relevance score. +> **TDD-order (two-phase RED, light-Voyage discipline, inherited from R3c):** Phase A — `temporalSignal` is a NEW +> named export of the EXISTING `brief.ts`; under Node16 ESM a missing named import throws at module-load (every +> `brief.test.ts` test would error, not assert), so land a **non-throwing stub** (`temporalSignal → {tier:"fresh", +> …}`, `BriefEntry.temporal` populated by it) FIRST, then record value-assertion RED against it (the stub returns +> "fresh" always → the first-mover/saturated/ordering/token assertions fail on values, true assertion-RED). Phase +> B — the CLI flag tests are value-RED against the existing `brief` handler (the new flags are silently ignored by +> `parseFlags` today → tuned-threshold behaviour is unchanged → RED). See plan Step 1. + +## 1. Operator decision context (2026-06-26) + +The research engine is **Tier-1** (operator, 2026-06-23). R1→R3c built the deterministic spine, the trend's life +after capture, **and** the autonomy that runs it: item-schema + triage (R1) → capture bridge (R2a) → dated +morning brief + surfacing (R2b) → persisted relevance + composite ranking (R3a) → status lifecycle + seen-log + +re-score (R3b) → autonomous trigger + headless entry (R3c). The brief now **regenerates itself every morning** +(R3c), so `surfacedCount` accumulates day-over-day on its own — but the brief still treats a trend the operator +has seen surfaced for five straight days **identically** to one captured an hour ago, as long as their frozen +relevance composites tie. The temporal axis the seen-log records is **logged but unread by the ranking**. + +R3d closes the **rest of hull #3** — the **first-mover** and **saturation** signals — which the operator chose as +slice **(b)** 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** R3c for a load-bearing reason: saturation reads `surfacedCount`, and +`surfacedCount` only accumulates *autonomously* because R3c now fires the brief daily. (c) makes (b) meaningful; +without the daily trigger, the seen-log would only grow on the days the operator happened to ask for a brief. + +**The concrete value — keeping a frozen composite honest over time.** R3a *froze* the relevance composite at first +capture (`types.ts:54-59`: *"first-sight, never updated on re-capture"*). A trend captured fresh scores +`timing` 9 ("you'd be among first") → a high composite → "Immediate" → sorts top (`brief.ts:103`). Six days and +five surfacings later it is stale and over-exposed, yet the **frozen** composite still says "Immediate" and still +sorts top. R3d is the **live temporal overlay** that demotes it — *without mutating the stored composite* (which +stays the SSOT-pinned, re-score-on-re-capture value R3b owns). `surfacedToken` (`brief.ts:154`) is already a +*hint* of this — the `· sett Nx` marker explicitly labelled *"Not the saturation SCORING of slice (b)"*. R3d +promotes that placeholder hint into the real, ranked signal. + +## 2. The gap — grounded in code + +- **The seen-log is written but never ranked on (hull 3, saturation).** R3b records `surfacedCount`/ + `lastSurfacedAt` per trend (`store.ts:187-202`, `types.ts:71-78`) and renders a display hint (`brief.ts:154-157`, + the `· sett Nx` token at `>=2`), but `rankForBrief`'s comparator (`brief.ts:102-107`) **never reads it** — sort + order is `composite → overlap → effectiveDate → title → url`. A trend surfaced ten times sorts identically to + one surfaced zero times at the same composite+overlap. +- **Recency is read coarsely (hull 3, first-mover).** The comparator's only temporal key is `effectiveDate` + (`brief.ts:105`) — a raw date tiebreaker. There is no notion of *"this is genuinely fresh AND uncrowded — you'd + be first"* vs *"this is just the most recent of a stale set"*. The capture-time `timing` dimension + (`score.ts:23`, kortform 20 %) captures a first-mover *judgment*, but it is **frozen at capture** (R3a) — it + cannot reflect that the same trend is no longer fresh a week later. +- **`angle` is already covered (hull 3, no work needed).** Hull #3 lists `angle` among the rank fields, but + `angle` is one of the five scored dimensions (`score.ts:24` kortform 15 %, `:32` long-form 20 %), persisted in + `score.dimensions` (R3a) and already folded into the composite the brief ranks on. No separate field is needed; + §5's *"saturation + first-mover (resten av #3)"* is exact. +- **The brief's `ranking:` frontmatter would misrepresent itself.** The descriptor line (`brief.ts:187`) states + the exact sort; once the overlay enters `cmp`, that line must name the new key or the artifact lies about its + own ordering. + +## 3. Scope — what is IN (RE-R3d) + +**Zero new source/test files** (the two tracked slice docs aside). R3d is pure EDITs: the derived signal lives beside `ageDaysBetween` in `brief.ts` (the module +that already owns the derived-temporal ranking logic — surgical, no new module to wire into the gate), its unit +tests in `brief.test.ts`, its CLI flags in `cli.ts`/`cli.test.ts`, plus the wiring docs + gate. + +### S-signal — `scripts/trends/src/brief.ts` (EDIT) — the derived temporal overlay + +- **`export type TemporalTier = "first-mover" | "neutral" | "warming" | "saturated"`** — the four ordinal classes + of the temporal axis (best → worst opportunity). **`neutral`** (the draft called this "fresh" — renamed: a tier + named "fresh" collided with the `freshDays` bucketing concept AND mislabelled a 30-day-old-but-unsurfaced trend + as "fresh"; `neutral` is honestly "no exposure signal, no recency edge" — folded, all three reviewers). +- **`export interface TemporalSignal { tier: TemporalTier; firstMover: boolean; surfacings: number; rank: number }`** + — `firstMover` = recent AND unsurfaced; `surfacings` = prior-day surfacings (`surfacedCount ?? 0`, the + self-exposure level); `rank` = the descending sort integer (`first-mover` 3 > `neutral` 2 > `warming` 1 > + `saturated` 0). +- **`export function temporalSignal(ageDays: number, surfacedCount: number | undefined, opts: { firstMoverDays: + number; saturationAt: number }): TemporalSignal`** — pure (no clock/fs/env; takes the already-computed + `ageDays` + the persisted `surfacedCount` + the injected thresholds). Logic: + - `surfacings = surfacedCount ?? 0`; `const at = Math.max(1, opts.saturationAt)` — a **defensive clamp**: a + direct caller (the function is a public export the gate greps for) passing `saturationAt 0` would otherwise + make `surfacings >= 0` always true → every non-first-mover trend "saturated". The CLI also guards `< 1`, but + the pure function must not trust its caller (folded — plan-critic m3). + - `firstMover = ageDays >= 0 && ageDays <= firstMoverDays && surfacings === 0` — recent AND never surfaced on a + prior day. The **`ageDays >= 0`** guard keeps a *future*-dated trend (data glitch) OUT of the "act now, you're + early" headline (folded — plan-critic m5). The window maps to the SSOT "<24-72h, you would be among first" + band, evaluated on the LIVE date rather than frozen at capture. + - tier: `first-mover` if `firstMover`; else `saturated` if `surfacings >= at`; else `warming` if `surfacings >= + 1`; else `neutral`. (`firstMover` requires `surfacings === 0`; `saturated`/`warming` both require `surfacings + >= 1` — so first-mover can collide with neither; the four branches are disjoint and total.) + - `rank` derived from tier as above. +- **`RankOptions` gains two knobs** (`brief.ts:51-54`): `firstMoverDays?: number` (default **2**) + + `saturationAt?: number` (default **3** — the existing `· sett Nx` hint fires at `>=2`, so default 3 keeps + `surfacedCount 2` a "warming · sett 2x" FYI and escalates `>=3` to "saturated"). Defaults documented like + `freshDays`'s default 7. +- **`BriefEntry` gains `temporal: TemporalSignal`** (`brief.ts:26-36`) — populated in `rankForBrief` right after + `ageDays` is computed: `temporalSignal(ageDays, trend.surfacedCount, { firstMoverDays, saturationAt })`. +- **`rankForBrief` threads the two new opts** (`brief.ts:78`): `const firstMoverDays = opts.firstMoverDays ?? 2; + const saturationAt = opts.saturationAt ?? 3;`. The bucketing (`isFresh`/`freshDays`) is **unchanged** — + saturation does NOT move a trend between top/single/older buckets; it only re-orders *within* a bucket via the + comparator (a soft signal, not a filter — staleness still owns the older bucket via `freshDays`). **Note** the + intended in-bucket effect this exposes (folded — plan-critic m4): inside `olderMatched`, a `neutral` + (unsurfaced) trend ranks *above* a `warming`/`saturated` one — "you have not been shown this stale item yet" + ranks above "you have seen and skipped this stale item N times." That is the saturation signal working, not a + bug; the `neutral` (not "fresh") name keeps it honest. +- **`cmp` gains the temporal key (SB2)** (`brief.ts:102-107`): insert `b.temporal.rank - a.temporal.rank` **after** + `b.overlap - a.overlap` and **before** `b.effectiveDate.localeCompare(a.effectiveDate)`. Composite stays + PRIMARY; the chain stays a total order (`rank` is an integer; ties fall through to the unchanged + `effectiveDate → title → url` tail, whose `(title,url)` pair is the unique id). +- **`temporalToken` replaces `surfacedToken`** (`brief.ts:154-157`): the R3b placeholder hint is promoted to the + ranked signal's badge — `first-mover` → `· 🥇 først ute`; `saturated` → `· 🔁 mettet (Nx)`; `warming` → + `· sett Nx` **only when `surfacings >= 2`** (this **exactly preserves the R3b ≥2 badge contract**: the live + `surfacedToken` fires only at `c >= 2`, `brief.ts:156`, and `brief.test.ts:408` pins `!md.includes("sett 1x")`; + the warming *tier* still demotes a surfaced-once trend in `rank`, but its *badge* stays suppressed at 1 — folded, + all three reviewers: the draft's "preserves the hint" was inaccurate because warming covers `>= 1`; gating the + badge at `>= 2` makes it true); `neutral` → `""`. Used in both `renderTopEntry` (`brief.ts:162`) and + `renderBulletEntry` (`brief.ts:171`), replacing the `surfacedToken(e)` call. (The `scoreToken` is untouched.) +- **`briefSummary` carries the first-mover marker** (`brief.ts:131-141`): when the top entry is a first-mover, + append `· 🥇 først ute` inside the headline parens (`(${pillar}${band} · 🥇 først ute · ${top.ageDays}d)`) — + the one-line surfacing the SessionStart hook shows then says *"act now, you're early"*. The marker carries **no + double-quote and no newline**, so the hook's `^summary: *"?([^"\n]*)"?` regex still captures it whole + (`brief.ts:128-130` invariant preserved). +- **`renderBrief`'s `ranking:` descriptor names the new key** (`brief.ts:187`): *"composite desc, then + pillar-overlap desc, **then temporal (first-mover↑/saturated↓)**, then publishedAt desc (capturedAt fallback); + freshDays N; excludes acted/skipped"* — so the artifact self-documents its true sort. + +### S-cli — `scripts/trends/src/cli.ts` (EDIT) — two `brief` threshold flags + +- **`brief … [--first-mover-days N] [--saturation-at N]`** (mirror `--fresh-days`, `cli.ts:312-317`): + `--first-mover-days` parses a **non-negative** integer (default 2; bad → `usage` exit 2); `--saturation-at` + parses a **positive** integer (default 3; `< 1` → `usage` exit 2, since `saturationAt 0` would mark every + surfaced trend saturated). Both passed into `rankForBrief(store, pillars, day, { freshDays, firstMoverDays, + saturationAt })` (`cli.ts:323`). +- **Usage + header synopsis** (`cli.ts:14`, `:110`): add the two optional flags to the `brief` line + a one-line + header note that the brief applies a derived temporal overlay (first-mover↑/saturated↓) at rank time. +- **`schedule` is untouched** — the nightly run uses the **defaults** (2/3). Baking tunable thresholds into the + schedule artifact is OUT for R3d (keeps `schedule.ts`/`run-daily.sh` and the R3c tests untouched — no + regression surface). + +### Wiring (D-default — WIRE, mirrors R3a/R3b/R3c) + +- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): one line — the morning brief now applies a **live + temporal overlay** at rank time (first-mover ranked up, repeatedly-surfaced/saturated ranked down), **derived** + from the publish/capture dates + the seen-log — no new capture step; the agent's polling/capture path is + unchanged. Domain-general (no vendor/sector token). +- `references/trend-scoring-modes.md` (SSOT, EDIT — **one-line note only**): under "Consumers", note that the + morning brief applies a *brief-time* temporal overlay (first-mover/saturation, RE-R3d) as a + **within-composite-tier** ranking refinement, and that **this does not change the capture-time weights** above. + The five-dimension capture-scoring math is **untouched** (R3d changes no weight, no band, no formula) — the note + prevents the SSOT from being misread as the *whole* ranking story (verifiseringsplikt; honesty). +- `scripts/trends/README.md` (EDIT): add a `## Temporal overlay (RE-R3d)` section between the R3c scheduler + section (`README.md:135-154`) and `## Tests` (`:155`): the first-mover/saturation definitions, the + **derived-not-stored** boundary (no schema bump), the `--first-mover-days`/`--saturation-at` flags + defaults, + the `cmp` integration (composite stays primary), and the badge set. +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (**:713**, currently **192**) to the `tests N` line + reported after the suite runs, **append** `+ RE-R3d: brief +N, cli +N (temporal overlay)` to the inline + breakdown comment. Add **Section 16m** ("Trends Temporal Overlay", RE-R3d) **between Section 16l's closing + `echo ""` (`:1374`) and the Section 18 header (`:1376`)** (anti-erosion must stay last). Mirror 16l's shape: + **unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail). + **6 emitters** (all on tracked source, no `tsx`, all literals **ASCII** — test-runner.sh must stay ASCII-only, + so the badge sentinels grep the ASCII tier/flag literals, never the emoji): (1) self-test; (2) `export function + temporalSignal` in `brief.ts`; (3) the cmp key `b.temporal.rank` in `brief.ts`; (4) the tier literal + `"first-mover"` in `brief.ts`; (5) the flag key `first-mover-days` in `cli.ts`; (6) the flag key `saturation-at` + in `cli.ts`. **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 111 → exactly 117** (**:1403**; "live + recount" is the safety net; the expected value is the pinned 111 + 6). Insert the 16m clause into the + **header-enumeration prose chain (`:57-62`)** before "…the assertion-count anti-erosion floor (SC6) in Section + 18", and **append the R3d (→117) narration** to the Section-18 floor-history comment (`:1376-1402`, which ends + "= 111"). + +## 4. Non-goals — what is OUT (deferred) + +- **A stored `saturation`/`firstMover` field + schema bump** — OUT (SB1). The signals are **derived** each run; + persisting an *avledbar* value would go stale as `surfacedCount` grows and would violate the + `ageDays`-is-derived discipline. `SCHEMA_VERSION` stays **4**; `types.ts` is **untouched**. +- **Market/external saturation** (how crowded the topic is *across the web* — how many others have posted) — OUT, + needs external polling = **slice (e)** (the AI fan-out). R3d's saturation is **self-surfacing** only: a proxy + for *"you keep seeing this and not acting → the window is closing for you"*, derived from OUR seen-log. The + brief is honest about which it measures (the README + the badge wording say "seen N×", not "covered N× online"). +- **Mutating / re-scoring the relevance composite** — OUT. The R3a composite stays frozen + PRIMARY (SB2). R3d + adds a SEPARATE sort key; it never recomputes, decays, or overwrites `score.composite`. The displayed composite + is always the stored value. +- **Saturation as a filter (auto-dropping / hiding saturated trends)** — OUT. Saturation **demotes within a + bucket**; it never removes a trend. Dropping is owned by `status` (acted/skipped, R3b) and the `freshDays` + staleness window (the older bucket); R3d's overlay is a soft *re-ordering* signal, not a gate. +- **Tunable thresholds in the scheduled run** — OUT, and a **known limitation** (not merely a later nicety — + folded — plan-critic m6 / brief-reviewer). `brief` gets `--first-mover-days`/`--saturation-at` for an + interactive/tuned run; the **nightly `schedule` run is the primary saturation consumer** (it is what makes + `surfacedCount` accumulate, §1) yet is locked to the **defaults** (2/3) — the operator cannot retune the signal + *where it actually fires* without re-running `schedule`. Accepted for R3d to keep `schedule.ts`/`run-daily.sh` + + the R3c tests untouched (no regression surface); baking the two flags into the schedule artifact is a small, + clearly-scoped follow-up. +- **Brief history / day-over-day diff** ("what changed since yesterday" — hull 7) — **slice (d)**. +- **A new module / new test file** — none. The signal lives in `brief.ts`; its tests in `brief.test.ts`. (No + pathguard surface either — all EDITs.) +- **New agent / new command / new reference doc** — none. R3d EDITs `brief.ts` + `cli.ts` + their tests + one + agent (prose) + the SSOT (one note) + README + gate. Counts stay **29/19/27**. `BRIEF_SCHEMA_VERSION` stays + **1** (the frontmatter *fields* are unchanged — only the `ranking:` descriptor string + body badge tokens + change; R3a/R3b added tokens without bumping it either — `brief.ts:23`). + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — `temporalSignal` is a + new named export of the existing `brief.ts`; Node16 ESM throws a missing named import at module-load, so land a + non-throwing **stub** first (returns `{tier:"fresh",firstMover:false,surfacings:0,rank:2}`; `BriefEntry.temporal` + populated by it), THEN record value-assertion RED (the stub's constant "fresh" fails the first-mover/saturated/ + ordering/token assertions). Phase B — the CLI flag tests are value-RED against the existing handler (the new + flags are silently ignored today). The plan does **not** claim a single "everything fails before any code" run. +- **`temporalSignal` is pure** (no clock, no fs, no env, no AI): every input is injected (`ageDays` already + computed, `surfacedCount` from the record, thresholds from the CLI edge). Mirrors `ageDaysBetween`/`renderBrief` + purity. Same inputs → same output. +- **Determinism of the brief:** given `(store, pillars, today, freshDays, firstMoverDays, saturationAt)` the + rendered `.md` is **byte-identical** (R2b/R3a/R3b/R3c proved the byte-determinism of the rest; the overlay adds + only a pure, injected-threshold sort key + deterministic tokens). +- **Composite stays PRIMARY + frozen (SB2 + R3a):** `cmp`'s first key is unchanged (`b.trend.score?.composite ?? + -1`); the temporal key sits *after* overlap. A saturated higher-composite trend still outranks a fresh + lower-composite one — the overlay re-orders only WITHIN a (composite, overlap) tier. The stored + `score.composite` is never read for mutation, only for sorting. +- **No schema / SSOT-math change:** `types.ts` untouched (`SCHEMA_VERSION` 4); `brief.ts`'s + `BRIEF_SCHEMA_VERSION` stays 1; `score.ts`/`store.ts`/`item.ts`/`schedule.ts` untouched. The SSOT + (`trend-scoring-modes.md`) gets a **one-line consumer note** only — **no weight, band, or formula changes** (the + capture-scoring instrument is unchanged; the overlay is a separate brief-time layer). +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only (`session-start.mjs` per R3c brief + §2). The summary's new `· 🥇 først ute` marker carries no double-quote/newline, so the extractYaml regex still + captures it whole. R3d touches neither the hook nor the frontmatter field set; the hook suite must still pass + untouched (regression sanity; R3d adds no hook test). +- **ASCII-only gate literals:** `scripts/test-runner.sh` must stay ASCII (a multibyte char crashes bash 3.2 under + `set -u`). The Section-16m sentinels grep the **ASCII** tier/flag literals (`"first-mover"`, `first-mover-days`, + `saturation-at`, `b.temporal.rank`, `export function temporalSignal`) — **never** the emoji badges (which live + only in `brief.ts` source + rendered output, asserted by the TS tests, not by the shell gate). +- **Domain-general:** no hard-coded user/repo path, no vendor/sector token in any edit. The tier labels + + Norwegian badge wording (`først ute`, `mettet`, `sett Nx`) are domain-general UI copy (the brief's existing + language); pillars/topics remain config. Section 17 de-niche stays green. +- **Pathguard:** R3d adds **no new files** — every change is an EDIT of an existing file (write-allowed). (No + `.mjs`-under-`hooks/scripts/` surface, no new `scripts/` file.) +- **Counts** (refs/agents/commands 27/19/29) unchanged — **recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (first-mover detection)** — `temporalSignal(ageDays, surfacedCount, {firstMoverDays:2, saturationAt:3})`: + `(1, 0)`, `(1, undefined)`, `(2, 0)`, `(0, 0)` → `{tier:"first-mover", firstMover:true, rank:3}`; `(3, 0)` (past + the window) → NOT first-mover (`tier:"neutral", rank:2`); `(1, 1)` (recent but already surfaced) → NOT + first-mover (`tier:"warming", rank:1`); **`(-1, 0)`** (future publishedAt) → NOT first-mover (`tier:"neutral"` — + the `ageDays >= 0` guard). Pure: same inputs → same output. +- **SC2 (saturation grading + clamp)** — same thresholds: `(5, 3)` and `(5, 4)` → `{tier:"saturated", rank:0}`; + `(5, 2)` and `(5, 1)` → `{tier:"warming", rank:1}`; `(5, 0)` → `{tier:"neutral", rank:2}`. `surfacedCount === + saturationAt` is saturated (inclusive `>=`). **Defensive clamp:** `temporalSignal(5, 5, {…, saturationAt:0})` + classifies via the clamped `at=1` (NOT "every trend saturated") — the function does not trust an out-of-range + threshold. +- **SC3 (ranking — overlay re-orders within tier, composite dominates, temporal↔date DISAGREE)** — the fixture + **forces the temporal key and `effectiveDate` to disagree**, so the test is genuinely RED in Phase A and the new + key is what decides (folded — plan-critic M1: `surfacedCount` correlates with age, so a naive "first-mover vs + saturated" fixture would already be ordered correctly by the existing `effectiveDate`-desc key — a vacuous test). + Three entries, **same** overlap: **A** = `neutral` (surfaced 0, **older** date, e.g. 5d), composite 7.0; **B** = + `warming` (surfaced 2, **newer** date, e.g. 1d), composite 7.0; **Z** = `saturated` (surfaced 4), composite + **8.5**. Expected order **`[Z, A, B]`**: Z first (higher composite — PRIMARY, SB2); then A **above** B even + though B is newer — the temporal key (`neutral` rank 2 > `warming` rank 1) overrides the `effectiveDate`-desc + tiebreaker that would have put the newer B first. In Phase A (stub, constant rank) the order is `[Z, B, A]` + (effectiveDate decides A vs B) → RED. `cmp` remains a total order. +- **SC4 (render badges + the ≥2 badge boundary)** — `renderBrief`/the bullet path: a first-mover entry contains + `· 🥇 først ute`; a saturated entry (`surfacedCount 3`, default `saturationAt 3`) contains `· 🔁 mettet (3x)`; a + warming entry with `surfacedCount 2` contains `· sett 2x`; a warming entry with **`surfacedCount 1` contains NO + badge** (the preserved R3b ≥2 contract — `brief.test.ts:408` stays green, unchanged); a `neutral` entry contains + **none** of the three. The R3b `· sett 3x` assertion (`brief.test.ts:407`) is **updated** to `· 🔁 mettet (3x)` + (its surfacedCount-3 trend is now saturated). **Also updated** (folded — brief-reviewer MEDIUM-2): the two tests + pinning the `ranking:` descriptor verbatim (`brief.test.ts:325-331` and the regex `:410-416`) gain the new + `then temporal (first-mover↑/saturated↓), ` segment; and the `effectiveDate`-isolation test (`brief.test.ts: + 96-102`) is **re-based** so both entries share a temporal tier (both `neutral`) — else the new key, not + `effectiveDate`, would silently decide it (coverage erosion — folded — brief-reviewer MEDIUM-3). +- **SC5 (summary first-mover marker)** — when the top entry is a first-mover, `briefSummary` (and the frontmatter + `summary:` line) contains `· 🥇 først ute` inside the headline parens; when it is not, the marker is absent. The + summary contains **no** `"` and **no** `\n` (the hook-regex invariant). +- **SC6 (CLI flags)** — `brief --pillars ai --first-mover-days 1 --saturation-at 2 …` changes the tiers vs the + defaults (a 2-day-old trend is first-mover at default 2 but `fresh` at `--first-mover-days 1`; `surfacedCount 2` + is `warming` at default 3 but `saturated` at `--saturation-at 2`); absent flags use defaults 2/3; + `--first-mover-days -1` / `--first-mover-days x` / `--saturation-at 0` / `--saturation-at x` → `usage` exit 2. +- **SC7 (determinism)** — two `brief` runs with the same `(store, pillars, today, freshDays, firstMoverDays, + saturationAt)` → byte-identical `.md`. +- **SC8 (no schema / no score mutation)** — `SCHEMA_VERSION` 4; `BRIEF_SCHEMA_VERSION` 1; `types.ts` untouched; a + `brief` run does **not** change any record's `score.composite` (assert the store's scores are unchanged after a + brief, only `surfacedCount`/`lastSurfacedAt` move — the existing R3b behaviour). +- **SC9 (purity)** — `temporalSignal` reads no clock/fs/env; a property check over a grid of `(ageDays, + surfacedCount)` gives stable, threshold-consistent tiers (first-mover ⊆ recent∧unsurfaced; saturated ⇔ + `surfacings >= saturationAt`). +- **SC10 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the + bumped `TRENDS_TESTS_FLOOR`; new **Section 16m** green (the six ASCII sentinels + non-vacuity self-test); + `ASSERT_BASELINE_FLOOR` = **117** (111 + 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 16m self-test + +greps pass; `ASSERT_BASELINE_FLOOR` = 117; Section 17 de-niche green; ref/agent/command counts unchanged. +**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3d touches no +hook). The R3c suite (`schedule.test.ts`/`run-daily.test.ts`) still green untouched (`schedule.ts`/`run-daily.sh` +not edited). +**Behavioural (manual):** +1. Seed a store with three trends sharing topics/pillars: one fresh+unsurfaced (`publishedAt` ~1d ago, + `surfacedCount` absent), one warming (`surfacedCount` 2), one saturated (`surfacedCount` 4) — same composite. +2. `node --import tsx src/cli.ts brief --pillars ai,gov --out /tmp/r3d-mb --store /tmp/r3d.json` → inspect the + `.md`: the fresh+unsurfaced entry sorts first with `· 🥇 først ute`; the saturated one sorts last with `· 🔁 + mettet (4x)`; the `ranking:` descriptor names the temporal key. +3. Re-run with `--first-mover-days 0 --saturation-at 2` → the first-mover badge disappears (0-day window) and the + `surfacedCount 2` entry escalates to `mettet (2x)`. +4. `--first-mover-days x` → exit 2 (`usage`); fs untouched. +5. Confirm the seeded records' `score.composite` values are unchanged after the brief (only `surfacedCount`/ + `lastSurfacedAt` advance) — the overlay never mutates the relevance score. + +## 8. Open questions for the go-gate + +Two architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-26): **SB1** derived-at-brief +(no schema bump); **SB2** refine recency within the composite tier (composite stays primary). Residual decisions, +all baked to the recommended default — confirm or redirect with "Go": +- **D1 — `firstMoverDays` default `2`?** YES (rec). The tight end of the SSOT "<24-72h, you would be among first" + band; `--first-mover-days N` tunes it. Re-open only for a different default (e.g. 3 = the full 72h "early" + band). +- **D2 — `saturationAt` default `3`?** YES (rec). The existing `· sett Nx` hint fires at `>=2`, so default 3 + keeps `surfacedCount 2` an FYI ("warming · sett 2x") and escalates `>=3` to "saturated". `--saturation-at N` + tunes it. Re-open only for a different default. +- **D3 — four tiers (`first-mover`/`fresh`/`warming`/`saturated`)?** YES (rec). A first-mover top, a fresh + baseline, a warming FYI (preserves the R3b `sett Nx` hint), a saturated demotion. Drop only to collapse + warming into fresh (a 3-tier model) or to add a fifth class. +- **D4 — temporal key sits AFTER overlap, BEFORE effectiveDate in `cmp`?** YES (rec, = SB2). Composite then + overlap stay primary; the overlay is the coarse recency class, `effectiveDate` the fine tiebreaker beneath it. + Re-open only to move the key (e.g. before overlap — stronger overlay). +- **D5 — saturation NEVER moves a trend between top/single/older buckets (soft re-order only)?** YES (rec). A + soft signal; bucketing stays `overlap`+`freshDays`. Re-open only to let a saturated trend drop a bucket. +- **D6 — badges `🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` (warming) / none (fresh)?** YES (rec). Promotes the + R3b `sett Nx` hint into a graded set. Re-open for different wording/emoji (the gate sentinels are ASCII, so + emoji changes are test-only). +- **D7 — summary line carries `· 🥇 først ute` when the top is a first-mover?** YES (rec). The one-line surfacing + then signals "act now, you're early". Drop only to keep the summary minimal (no marker). +- **D8 — add a one-line overlay note to the SSOT (`trend-scoring-modes.md`)?** YES (rec). Honest cross-reference + so the SSOT is not misread as the whole ranking story; **no** weight/formula change. Drop only to document the + overlay solely in `brief.ts` + README + the brief's `ranking:` descriptor. +- **D9 — `schedule` untouched (nightly run uses default thresholds)?** YES (rec). Keeps `schedule.ts`/ + `run-daily.sh` + the R3c tests untouched (no regression surface). Re-open only to bake `--first-mover-days`/ + `--saturation-at` into the schedule artifact now. +- **D10 — commit split?** Docs commit first, then **one** code commit (rec) — the overlay (signal + flags + + wiring) is one coherent feature. Re-open only for a signal-then-wiring split. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. They **converged on the same two +defects** (the strongest signal): **scope-guardian: MIXED** (0 hard creep, both confirmed decisions honored, +every SC1–SC10 traces to a step; 1 MAJOR + 1 line-cite + discretionary MINORs). **brief-reviewer: +PROCEED_WITH_RISKS** (all seven RED-premise/correctness claims HOLD; the risk is GREEN-completeness — the plan +listed 1 of 4 breaking test assertions; 1 MAJOR + 2 MEDIUM + 2 LOW). **plan-critic: APPROVE_WITH_NOTES, 78/B** (the +floor arithmetic, line-cites, grep sentinels, cmp total-order, and two-phase-RED structure all verified correct; +2 MAJOR + 4 MINOR). **All findings folded** (per-finding resolution in `plan-re-r3d.md §Plan-critic — folded`). +Headlines: + +- **[MAJOR, folded — all three] the warming badge fired at `surfacings >= 1`, but the live R3b `surfacedToken` + fires only at `>= 2`** (`brief.ts:156`), and `brief.test.ts:408` pins `!md.includes("sett 1x")`. The draft's + "preserves the R3b hint" was false (it broadened `>=2` to `>=1`). → `temporalToken`'s warming badge is gated at + **`surfacings >= 2`** (R3b contract preserved exactly; `:408` stays green); the warming *tier* still demotes + surfaced-once in `rank`. SC4 gains the `surfacedCount 1 → no badge` boundary. +- **[MAJOR, folded — plan-critic M1 / brief-reviewer MEDIUM-3] the ordering test was not genuinely RED + vacuous.** + `surfacedCount` correlates with age, so a "first-mover vs saturated" fixture is *already* ordered by the existing + `effectiveDate`-desc key — Phase A would be GREEN and GREEN proves nothing. → SC3's fixture now **forces + temporal↔date disagreement** (older-`neutral` A vs newer-`warming` B at equal composite; the temporal key, not + the date, must decide A>B). The coverage-eroded `effectiveDate`-isolation test (`:96-102`) is re-based to a + shared tier. +- **[MEDIUM, folded — brief-reviewer MEDIUM-2] the `ranking:` descriptor change breaks two more pinned tests** + (`brief.test.ts:325-331` + the regex `:410-416`). → the test inventory (SC4 + plan Step 1) now enumerates **all + four** touch points, not one. +- **[MINOR, folded — plan-critic m3] `temporalSignal` was undefensive against `saturationAt < 1`.** → a + `Math.max(1, saturationAt)` clamp inside the pure function (the CLI guard is not enough — the function is a + public, gate-grepped export). SC2 gains a clamp case. +- **[MINOR, folded — plan-critic m4] the "fresh" tier name was a misnomer** (a 30-day-old unsurfaced trend is not + "fresh"; collides with `freshDays`). → renamed **`neutral`** ("no exposure signal"); the in-bucket effect + (unsurfaced ranks above seen-and-skipped within `olderMatched`) is documented as intended. +- **[MINOR, folded — plan-critic m5] a future `publishedAt` (ageDays < 0) became a first-mover** "act now" + headline. → the `ageDays >= 0` guard excludes it (it falls to `neutral`). SC1 gains the `(-1, 0)` case. +- **[MINOR, folded — plan-critic m6 / brief-reviewer] the nightly run (the primary saturation consumer) is locked + to default thresholds.** → reframed in §4 as a **known limitation**, not a "later nicety." +- **[LOW, folded — all three] long-form `angle` weight cite `:34` → `:32`** (`:34` is `currency`; the substance — + angle is a scored dimension in both modes — holds). diff --git a/docs/research-engine/brief-re-r3e.md b/docs/research-engine/brief-re-r3e.md new file mode 100644 index 0000000..a115d69 --- /dev/null +++ b/docs/research-engine/brief-re-r3e.md @@ -0,0 +1,430 @@ +# Brief — RE-R3e: brief history + day-over-day diff (R3 slice d) + +> **Slice:** RE-R3e (research-engine rung-2, R3 slice **(d)** in the operator's `(a)→(c)→(b)→(d)→(e)` +> sequence — the doc is numbered r3e by creation order, the concept is slice (d)). It closes **hull #7** +> (substrate §1 hull list: *"ingen brief-historikk"*): the dated morning brief already writes one Markdown +> file per day (`morning-brief/YYYY-MM-DD.md`), but the file is **prose-only** — nothing records *which* +> trends a brief showed in a machine-readable form, and no run reads yesterday's brief, so the engine cannot +> answer the one question a daily motor exists to answer: **"what is new since I last looked?"** +> **Predecessor:** RE-R2b (the dated brief artifact + `surfacedIds(ranking)` — the exact set a brief shows: +> `brief.ts:305`) + RE-R3b (the per-day-idempotent seen-log `surfacedCount`/`lastSurfacedAt`, which already +> records *that* a trend was surfaced but not *with which cohort*) + RE-R3c (the autonomous trigger that makes +> the dated files **accumulate day-over-day on their own** — the dependency that makes a day-over-day diff a real +> signal, not a once-in-a-while comparison) + RE-R3d (the temporal overlay — the within-brief recency/saturation +> class the diff is orthogonal to). +> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull **#7** (*"ingen brief-historikk"*) + §B3 +> (*"Dated-digest som flat plain-text-artefakt … diffbar, grep-bar, lenkbar … Senere sesjon laster «gårsdagens +> brief» trivielt"* — the dated file is **explicitly designed to be diffed**, R3e is the diff B3 anticipated) + +> §B4 (*"append-only seen-log → ikke re-overflate samme sak"* — R3e is the per-cohort complement: not "have I +> seen this ever" but "was this in the PRIOR brief"). +> **The core decisions (operator-confirmed, AskUserQuestion 2026-06-26 — baked):** +> - **SD1 — persist membership in the brief's own frontmatter, NOT a sidecar.** Each brief writes a single +> `surfaced: <id-csv>` line into its YAML frontmatter — the ids it actually showed (`surfacedIds(ranking)`). +> This keeps **one self-describing, grep-bar artifact** per day (B3), mirrors the existing `store: { … }` +> frontmatter idiom, and is **hook-safe** (the SessionStart `extractYaml` is `^summary:`-anchored and +> line-scoped — a new `surfaced:` line cannot perturb it). The diff reads the prior brief's `surfaced:` line +> via one pure regex. **No sidecar `.json`; no second artifact.** `BRIEF_SCHEMA_VERSION` bumps **1 → 2** (the +> frontmatter gained a field — the first bump since R2b; the store's `SCHEMA_VERSION` stays **4**). +> - **SD2 — `added` with titles + `dropped` as a count; `brief.ts` stays store-free.** The diff is the symmetric +> set difference of today's surfaced ids against the prior brief's: **added** (in today, not prior — the +> headline "what's new", rendered with titles resolved from the ranking the brief already holds), **carried** +> (in both), **dropped** (in prior, not today). `added` is the value; `dropped`/`carried` render as a one-line +> tally (counts). The dropped ids are **not** resolved against the store for an acted/skipped/aged *reason* — +> that would require injecting store records into the render and is the one explicit follow-up (§4). The render +> needs **only the ranking** it already has → `brief.ts` stays pure (no store, no fs). The framing is **honest**: +> "ikke vist i dag" (not shown today), never "resolved" (which the count cannot prove). +> **TDD-order (two-phase RED, light-Voyage discipline, inherited from R3c/R3d):** Phase A — `diffSurfaced`, +> `parseSurfacedFrontmatter`, `selectPriorBriefFile` (+ the `BriefDiff` type) are NEW named exports of the +> EXISTING `brief.ts`; under Node16 ESM a missing named import throws at module-load (every `brief.test.ts` test +> would error, not assert), so land **non-throwing stubs** FIRST (`diffSurfaced → {priorDate:null,added:[], +> carried:[],dropped:[]}`, `parseSurfacedFrontmatter → []`, `selectPriorBriefFile → null`; `renderBrief` gains an +> optional `diff` param it ignores in the stub), then record value-assertion RED against them (the stubs' constant +> returns fail the diff/parse/select/section/marker assertions — true assertion-RED). Phase B — the CLI two-day +> diff test is value-RED against the existing `brief` handler (today it writes no `surfaced:` line, reads no prior +> brief, and its `--json` carries no `diff` key → the day-2 diff assertions fail). See plan Step 1. + +## 1. Operator decision context (2026-06-26) + +The research engine is **Tier-1** (operator, 2026-06-23). R1→R3d built the deterministic spine, the trend's life +after capture, the autonomy that runs it, and the within-brief temporal overlay: item-schema + triage (R1) → +capture bridge (R2a) → dated morning brief + surfacing (R2b) → persisted relevance + composite ranking (R3a) → +status lifecycle + seen-log + re-score (R3b) → autonomous trigger + headless entry (R3c) → temporal overlay +(first-mover + saturation, R3d). The brief now **regenerates itself every morning** (R3c) and ranks each trend by +its frozen relevance composite, its pillar overlap, and a live first-mover/saturation class (R3d). But every +morning's brief is a **standalone snapshot**: it cannot say *"these three are new since yesterday; the two you +saw yesterday are gone."* The accumulated dated files are a pile of snapshots, not a **history with a diff**. + +R3e closes **hull #7** — the **brief history + day-over-day diff** — which the operator chose as slice **(d)** 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** R3c for a load-bearing reason: a day-over-day diff is only meaningful when a brief is +**produced every day on its own** — R3c's nightly trigger is what makes "yesterday's brief" reliably exist. (c) +makes (d) a real signal; without the daily trigger, "since last brief" could mean "since whenever the operator +last happened to ask." + +**The concrete value — turning a pile of snapshots into a feed.** A daily motor's job is to surface the *delta*: +the operator does not want to re-read the full ranked list every morning and diff it in their head — they want +the engine to say **"3 nye siden i går"** at the top of the brief (and on the one-line SessionStart surfacing, +for free). R3e makes the dated file a genuine **history rung**: each brief records what it showed +(`surfaced:` frontmatter), and the next brief reads the most recent prior one and renders **"Nytt siden sist."** +This is the smallest honest step from *"a brief is written daily"* (R3c) to *"the brief tells me what changed"* +(the point of a feed) — and it is exactly the diffable dated-digest §B3 said the artifact was designed to be. + +## 2. The gap — grounded in code + +- **The dated brief is prose-only; membership is not machine-readable.** `cli.ts:341-343` writes + `morning-brief/${day}.md` from `renderBrief(ranking)`; the body embeds each trend's `id` inside a rendered + bullet (`brief.ts:242`, `:251` — `` `${e.trend.id}` ``), but there is **no structured record** of *the set a + brief showed*. `surfacedIds(ranking)` (`brief.ts:305`) computes that set and feeds it to `markSurfaced` + (`cli.ts:348`), but it is **never persisted to the artifact** — so a later run that wants "what did yesterday's + brief show" would have to scrape prose. The seen-log (`surfacedCount`/`lastSurfacedAt`, R3b) records *that* and + *how many days* a trend was surfaced, but **not which cohort it appeared with** — it cannot reconstruct + "yesterday's brief contained {A, B, C}." +- **No run reads a prior brief.** The only consumer of the dated files is the SessionStart hook's + `latestMorningBrief` (`session-start.mjs:60-77`), which reads the **single newest** file's `date`+`summary` and + surfaces it verbatim. Nothing reads the **second-newest** to compare. There is no diff, anywhere. +- **"New since last" is not derivable from the store alone.** The seen-log gives "never surfaced ever" + (`surfacedCount` absent ⇒ a first-ever sighting) — but that is **not** "new since the last brief": a trend + surfaced once three days ago, absent from yesterday's brief, reappearing today is *new to yesterday's reader* + yet has `surfacedCount 1` (not 0). Only a **per-brief membership record** (the prior `surfaced:` set) answers + "was this in the immediately-prior brief," and only it can compute **dropped** (in the prior cohort, gone + today) — which the store cannot express at all. This is precisely the gap §B3's "diffbar … dated-digest" and + §1 hull #7 name. +- **The brief's own summary cannot signal a delta.** `briefSummary` (`brief.ts:204-216`) describes today's top + match in isolation; the SessionStart surfacing (`session-start.mjs:534-536`) shows that line verbatim. There is + no "N nye siden sist" the operator could see *without opening the file* — the one number a feed leads with. + +## 3. Scope — what is IN (RE-R3e) + +**Zero new source/test files** (the two tracked slice docs aside). R3e is pure EDITs: the diff lives beside +`surfacedIds` in `brief.ts` (the module that already owns the brief's pure read logic — surgical, no new module +to wire into the gate), its unit tests in `brief.test.ts`, its CLI wiring (prior-file discovery) in +`cli.ts`/`cli.test.ts`, plus the wiring docs + gate. + +### S-history — `scripts/trends/src/brief.ts` (EDIT) — the pure diff + the persisted membership + +- **`BRIEF_SCHEMA_VERSION` bumps 1 → 2** (`brief.ts:23`) — the frontmatter gained the `surfaced:` field. (The + store's `SCHEMA_VERSION` is untouched at **4** — R3e adds **no store field**; the membership lives in the + artifact, the diff is derived.) +- **`export interface BriefDiff { priorDate: string | null; added: string[]; carried: string[]; dropped: string[] }`** + — `priorDate` = the date of the brief diffed against (`null` ⇒ no prior brief, i.e. the first ever / a fresh + data dir); `added`/`carried`/`dropped` = the three partitions of the set difference, each **order-stable** + (added/carried preserve today's `surfacedIds` order; dropped preserves the prior set's order). +- **`export function diffSurfaced(currentIds: string[], priorIds: string[], priorDate: string | null): BriefDiff`** + — pure (no clock/fs/env; both id lists + the prior date are injected by the CLI edge). `added` = `currentIds` + not in `priorIds`; `carried` = `currentIds` in `priorIds`; `dropped` = `priorIds` not in `currentIds`. Uses a + `Set` for membership; preserves input order in the output arrays. When `priorIds` is empty (first brief), + `added === currentIds` and `dropped === []`. +- **`export function parseSurfacedFrontmatter(md: string): string[]`** — pure; extracts the `surfaced:` value + from a brief's full text via a single line-anchored regex (mirrors the hook's `extractYaml` idiom: + `/^surfaced: *([^\n]*)/m`), splits on `,`, trims, drops empties. Returns `[]` when the line is **absent, blank, + or malformed** (a pre-R3e brief, or a hand-edited file) — degrades to "empty prior," never throws. Real ids are + comma-free hex (`store.ts:69-72`), so the CSV is unambiguous. +- **`export function selectPriorBriefFile(filenames: string[], today: string): string | null`** — pure; from a + directory listing, returns the **lexicographically greatest** filename matching `^\d{4}-\d{2}-\d{2}\.md$` whose + date is **strictly less than** `today` (ISO dates sort lexicographically, so string compare = date compare), + else `null`. Mirrors the hook's `latestMorningBrief` filter+sort (`session-start.mjs:63-66`) but **excludes + today and any future-dated file** — so a same-day re-run diffs against the true previous day, not its own + just-written file (the byte-determinism guarantee, SC8). +- **`renderBrief` gains an optional `diff` param** (`brief.ts:259`): `renderBrief(ranking: BriefRanking, diff?: + BriefDiff)`. Two additive emissions: + - **Frontmatter `surfaced:` line** — `surfaced: ${surfacedIds(ranking).join(",")}` inserted **before** + `schemaVersion:` (always emitted, even for an empty store → `surfaced: ` blank; this is the record the *next* + day's diff reads, independent of whether *today* had a prior). `schemaVersion:` now renders **2**. + - **A `## 🆕 Nytt siden sist` section** (the header gains ` (<prior-date>)` when a prior brief exists, per SC9), + placed **after the intro line and before `## 🎯 Topp-treff`** (the + delta leads, then the full ranked list). Branches (all deterministic): + - **no diff arg / `priorDate === null` with added** → `_Første brief — alt nedenfor er nytt._` + - **`priorDate === null` with no added** (empty first brief) → `_Første brief._` + - **`priorDate !== null`, `added` non-empty** → one bullet per added id, its entry resolved from the ranking + (title + matched pillars + date + link + id, reusing the bullet idiom), then a tally line + `_${carried.length} båret over, ${dropped.length} ikke vist i dag._` + - **`priorDate !== null`, `added` empty** → `_Ingenting nytt siden ${priorDate}._` (+ the same tally line) + - When `diff` is omitted (a bare `renderBrief(ranking)` call, e.g. a unit test that does not exercise the diff), + it defaults to the empty diff (`{priorDate:null,added:[],carried:[],dropped:[]}`) → the **`_Første brief._`** + section branch (`priorDate===null`, `added` empty). The `surfaced:` frontmatter line is **independent of the + diff** — always `surfacedIds(ranking).join(",")` (blank **only** for an empty store), so a non-empty ranking + still emits its real surfaced ids. (This keeps existing single-arg call sites compiling and semantically valid.) +- **`briefSummary` gains an optional `diff` param** (`brief.ts:204`): `briefSummary(ranking, diff?)`. When `diff` + is present, `priorDate !== null`, and `added.length > 0`, it appends ` ${added.length} nye siden sist.` to the + one-line headline — so the **SessionStart hook surfaces the delta for free** (it already shows the `summary:` + line verbatim; no hook edit). The marker is suppressed on the first brief (`priorDate === null`) and when + nothing is new (no noise). It carries **no double-quote and no newline** (the `^summary: *"?([^"\n]*)"?` + hook-regex invariant, `brief.ts:200-203`). `renderBrief` passes its `diff` through to `briefSummary` so the + frontmatter `summary:` and the `--json summary` agree. + +### S-cli — `scripts/trends/src/cli.ts` (EDIT) — prior-brief discovery + the diff in `--json` + +- **The `brief` handler discovers the prior brief and computes the diff** (between the ranking at `cli.ts:339` + and the render at `:340`): `readdirSync(outDir)` (guarded by `existsSync` — a first run has no dir) → + `selectPriorBriefFile(files, day)` → if found, `readFileSync` it and `parseSurfacedFrontmatter` → build + `diffSurfaced(surfacedIds(ranking), priorIds, priorDate)`; on any fs error, degrade to the empty-prior diff + (`priorDate: null`). Pass the diff into `renderBrief(ranking, diff)`. Adds `readdirSync` to the existing + `node:fs` import (`cli.ts:51`). +- **`--json` gains a `diff` object** (`cli.ts:352`): `diff: { priorDate, added: added.length, carried: + carried.length, dropped: dropped.length }` — counts, not id lists (the headless `run-daily.sh` collapses + `--json` to one cron-log line). The non-JSON console line (`cli.ts:355`) appends `, N nye siden sist` when + `added > 0 && priorDate !== null`. +- **`--no-mark` is unchanged in meaning** — it still governs only the **store** seen-log write (`cli.ts:347-349`). + The artifact's `surfaced:` frontmatter records what the brief showed **regardless** of `--no-mark` (it is a + property of the rendered brief, not of the store mutation). No new flag. + +### Wiring (D-default — WIRE, mirrors R3a/R3b/R3c/R3d) + +- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): one line — the morning brief now records which trends + it showed (frontmatter `surfaced:`) and renders a **day-over-day diff** ("Nytt siden sist") against the most + recent prior brief — no new capture step; the agent's polling/capture path is unchanged. Domain-general (no + vendor/sector token). +- `scripts/trends/README.md` (EDIT): add a `## Brief history + diff (RE-R3e)` section between the R3d temporal- + overlay section and `## Tests`: the `surfaced:` frontmatter record, the `selectPriorBriefFile` prior-discovery + (strict `< today`, same-day re-run determinism), the `diffSurfaced` partitions, the section + the summary + marker, and the `BRIEF_SCHEMA_VERSION 1→2` boundary (artifact-only; store `SCHEMA_VERSION` stays 4). +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (**live `:716`**, currently **216**) to the `tests N` + line reported after the suite runs — **recounted live**, **append** `+ RE-R3e: brief +N, cli +N (brief history + + diff)` to the inline breakdown comment. Add **Section 16n** ("Trends Brief History / Diff", RE-R3e) **between + Section 16m's closing `echo ""` and the Section 18 header** (anti-erosion must stay last). Mirror 16m's shape: + **unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail). **6 + emitters** (all on tracked source, no `tsx`, all literals **ASCII** — the section header emoji `🆕` is **never** + grepped; the shell stays ASCII-clean for bash 3.2 `set -u`): (1) self-test; (2) `export function diffSurfaced` + in `brief.ts`; (3) `parseSurfacedFrontmatter` in `brief.ts`; (4) the section header literal `Nytt siden sist` + in `brief.ts`; (5) `selectPriorBriefFile` in `cli.ts` (the diff wiring); (6) the frontmatter emit `surfaced: ` + in `brief.ts`. **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 117 → exactly 123** (**live `:1473`**; + "live recount" is the safety net; the expected value is the pinned 117 + 6). Insert the 16n clause into the + **header-enumeration prose chain (`:53-64`)** before "…the assertion-count anti-erosion floor (SC6) in Section + 18," and **append the R3e (→123) narration** to the Section-18 floor-history comment (which ends "= 117"). + +## 4. Non-goals — what is OUT (deferred) + +- **A sidecar `.json` membership manifest** — OUT (SD1). Membership lives in the brief's own frontmatter + (`surfaced:`), keeping one self-describing artifact (B3). No second file per day. +- **`dropped` resolved to an acted/skipped/aged *reason*** — OUT (SD2), and the **one explicit follow-up**. + `dropped` renders as a **count** ("N ikke vist i dag"). Labelling *why* each dropped id left (acted/skipped via + `status`, or aged past `freshDays`) would require injecting the store records into the render — `brief.ts` would + no longer be store-free. Honest framing for R3e: "ikke vist i dag," never "resolved." A small, clearly-scoped + follow-up (CLI resolves dropped ids → `{title, status}` and passes them to a richer render) if the loop-closing + signal proves worth the coupling. +- **A browsable history INDEX file** (e.g. a rolling `history.md` of all past briefs) — OUT. The dated files + + the `surfaced:` frontmatter **are** the history (grep-bar, lenkbar — B3); an index is a presentation nicety, not + a capability gap. +- **A SessionStart hook change to render the diff** — OUT. The "N nye siden sist" marker rides the **existing** + `summary:` surfacing (`session-start.mjs:534-536`) — no hook edit, no hook test, no new frontmatter field the + hook must learn. (The hook still reads only `date`+`summary`.) +- **`schedule` / `run-daily.sh` changes** — OUT. The nightly run calls `brief` (`run-daily.sh:33`), which now + computes the diff internally → the scheduled brief gets "Nytt siden sist" **automatically**, with **no** + scheduler edit (no R3c regression surface). +- **A new store field / schema bump / store mutation for the diff** — OUT. `SCHEMA_VERSION` stays **4**; + `types.ts`/`store.ts` are **untouched**. The membership is an artifact property; the diff is derived at the CLI + edge. +- **"New" defined as first-ever-sighting (`surfacedCount === 0`)** — OUT (rejected as less correct). R3e's "new" + is **relative to the immediately-prior brief** (artifact diff), which also flags a trend *re-emerging* after a + gap — the honest meaning of "siden sist." (`surfacedCount` stays the R3d saturation input, a different + question.) +- **Diffing against an arbitrary historical brief (`--since <date>`)** — OUT. R3e diffs against the **most recent + prior** brief only (the "since last" a daily feed needs). An arbitrary baseline is a later nicety. +- **A new module / new test file** — none. The diff lives in `brief.ts`; its tests in `brief.test.ts`. (No + pathguard surface — all EDITs.) +- **New agent / new command / new reference doc** — none. R3e EDITs `brief.ts` + `cli.ts` + their tests + one + agent (prose) + README + gate. Counts stay **29/19/27**. (The SSOT `trend-scoring-modes.md` is **not** touched — + the diff is not a scoring concern; scope fence.) + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — the new named exports + (`diffSurfaced`/`parseSurfacedFrontmatter`/`selectPriorBriefFile`/`BriefDiff`) need non-throwing **stubs** first + (Node16 ESM throws a missing named import at module-load), THEN value-assertion RED against the constant stubs. + Phase B — the CLI two-day diff test is value-RED against the existing handler (no `surfaced:` write, no prior + read, no `diff` in `--json` today). The plan does **not** claim a single "everything fails before any code" run. +- **`brief.ts` stays pure** (no clock, no fs, no env, no AI): `diffSurfaced`/`parseSurfacedFrontmatter`/ + `selectPriorBriefFile` all take strings/arrays and return values — the directory read + file read live in + `cli.ts` (the edge), exactly like `today`/`pillars` are injected. The module's "No fs, no clock, no AI, no + network" header claim is preserved. +- **Determinism of the brief:** given `(store, pillars, today, freshDays, firstMoverDays, saturationAt, diff)` the + rendered `.md` is **byte-identical** (the diff is now an injected input, like `today`). Critically, a **same-day + re-run is byte-identical**: `selectPriorBriefFile` excludes `${today}.md` (strict `<`), so the re-run diffs + against the same previous day's brief and re-writes the same `surfaced:` line (R3c SC7 preserved). +- **`surfaced:` records the shown set, `--no-mark`-independent:** the frontmatter line is `surfacedIds(ranking)` + joined — what the brief *showed* — regardless of whether the store seen-log was written (`--no-mark` governs the + store mutation only). The artifact is always self-consistent. +- **Frozen composite + temporal overlay untouched (R3a + R3d):** R3e adds **no** `cmp` key and changes **no** + ranking — `rankForBrief` is unchanged. The diff is a **post-ranking, render-time** layer over the same surfaced + set. `score.composite` is never read for mutation; the R3d `temporal` overlay is orthogonal (it orders within + the brief; the diff compares across briefs). +- **Schema boundary:** `BRIEF_SCHEMA_VERSION` bumps **1 → 2** (the artifact's frontmatter gained `surfaced:`); + the store's `SCHEMA_VERSION` stays **4**; `types.ts`/`store.ts`/`score.ts`/`item.ts`/`schedule.ts`/ + `run-daily.sh` are **untouched**. +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only (`session-start.mjs:60-77`). The new + `surfaced:` frontmatter line is `^surfaced:`-keyed (the `^summary:`-anchored, line-scoped `extractYaml` cannot + match it), and the `summary:` marker carries no `"`/`\n` — so the regex still captures the summary whole. R3e + touches neither the hook nor the field set the hook reads; the hook suite must still pass untouched (regression + sanity; R3e adds no hook test). +- **ASCII-only gate literals:** `scripts/test-runner.sh` must stay ASCII (a multibyte char crashes bash 3.2 under + `set -u`). The Section-16n sentinels grep the **ASCII** literals (`export function diffSurfaced`, + `parseSurfacedFrontmatter`, `Nytt siden sist`, `selectPriorBriefFile`, `surfaced: `) — **never** the `🆕` emoji + (which lives only in `brief.ts` source + rendered output, asserted by the TS tests, not by the shell gate). +- **Domain-general:** no hard-coded user/repo path, no vendor/sector token in any edit. The section header + + Norwegian copy (`Nytt siden sist`, `båret over`, `ikke vist i dag`, `nye siden sist`, `Første brief`) are + domain-general UI copy (the brief's existing language); pillars/topics remain config. Section 17 de-niche stays + green. +- **Pathguard:** R3e adds **no new files** — every change is an EDIT of an existing file (write-allowed). (No + `.mjs`-under-`hooks/scripts/` surface, no new `scripts/` file.) +- **Counts** (refs/agents/commands 27/19/29) unchanged — **recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (diffSurfaced — partitions + order + empty prior)** — `diffSurfaced(["a","b","c"], ["b","c","d"], "2026- + 06-25")` → `{priorDate:"2026-06-25", added:["a"], carried:["b","c"], dropped:["d"]}` (added/carried in current + order, dropped in prior order). `diffSurfaced(["a","b"], [], null)` → `{priorDate:null, added:["a","b"], + carried:[], dropped:[]}` (empty prior ⇒ everything added). Pure: same inputs → same output. The three + partitions are **mutually disjoint** — `Set` membership is binary (an id is in `priorIds` or not), so each id + lands in exactly one of added/carried and dropped is disjoint from both; within each list, order and any + duplicates **mirror the input** (`surfacedIds` yields **distinct** ids in production, so within-list dups never + arise — the cross-partition exclusivity is the real invariant, not within-list dedup). +- **SC2 (parseSurfacedFrontmatter — read + degrade)** — parses `surfaced: 1a2b,3c4d,5e6f` (in a full frontmatter + block) → `["1a2b","3c4d","5e6f"]`; a **blank** `surfaced: ` → `[]`; an **absent** `surfaced:` line (a pre-R3e + brief) → `[]`; whitespace around ids is trimmed; the `summary:`/`store:`/`date:` lines are **not** mismatched + (line-anchored). Never throws on malformed input. +- **SC3 (selectPriorBriefFile — strict-prior selection)** — from `["2026-06-24.md","2026-06-25.md","2026-06-26.md", + "README.md","2026-06-30.md"]` with `today="2026-06-26"` → `"2026-06-25.md"` (greatest `< today`; **excludes** + today `2026-06-26.md` and the future `2026-06-30.md`; ignores the non-dated `README.md`). Empty list, or no file + `< today`, → `null`. +- **SC4 (frontmatter `surfaced:` line + round-trip)** — `renderBrief(ranking, diff)` emits exactly one + `^surfaced: <csv>$` line, equal to `surfacedIds(ranking).join(",")`, positioned before `schemaVersion: 2`; an + empty-store brief emits `surfaced: ` (blank); `parseSurfacedFrontmatter(renderBrief(r, d))` round-trips to + `surfacedIds(r)`. `schemaVersion:` renders `2`. +- **SC5 (Nytt siden sist section — all four branches)** — the rendered body contains `## 🆕 Nytt siden sist`; + with `priorDate:null` + added → `Første brief — alt nedenfor er nytt`; with `priorDate:null` + no added (empty + store) → `Første brief.`; with a prior + `added` → one bullet per added entry (its **title** present, resolved + from the ranking) + `N båret over, M ikke vist i dag`; with a prior + no added → `Ingenting nytt siden <date>` + + the tally. The section precedes `## 🎯 Topp-treff`. +- **SC6 (summary delta marker)** — `briefSummary(ranking, diff)` with `priorDate !== null` and `added.length > 0` + ends with ` ${added.length} nye siden sist.`; with `priorDate:null` (first brief) or `added.length === 0`, the + marker is **absent** (and `briefSummary(ranking)` with no diff === `briefSummary(ranking, emptyDiff)` — no + marker, so the existing `frontmatter summary === briefSummary(r)` test stays green). The summary contains **no** + `"` and **no** `\n`. +- **SC7 (schema boundary)** — `BRIEF_SCHEMA_VERSION === 2`; `SCHEMA_VERSION === 4`; `types.ts` untouched; a + `brief` run does **not** change any record's `score.composite` (only `surfacedCount`/`lastSurfacedAt` move — the + existing R3b behaviour, since `rankForBrief`/`markSurfaced` are unchanged). +- **SC8 (determinism, incl. same-day re-run)** — two `brief` renders with the same `(store, pillars, today, + opts, diff)` → byte-identical `.md`. End-to-end via the CLI: running `brief` **twice on the same day** (the + second after the first wrote `${day}.md`) → byte-identical files, because `selectPriorBriefFile` excludes the + same-day file and picks the same prior day. +- **SC9 (CLI diff wiring — two-day sequence)** — write a day-1 brief (records `surfaced:` for its cohort), then a + day-2 brief over a store with one **new** trend: the day-2 `.md` `## 🆕 Nytt siden sist (<day-1>)` section lists + the new trend, the day-2 `--json` carries `diff: { priorDate:<day-1>, added:≥1, carried:…, dropped:… }`, and the + console line appends `N nye siden sist`. A **first** run (empty dir) → `diff.priorDate === null`. A **custom + `--out`** isolates discovery to that dir (the diff reads prior briefs only from `outDir`). +- **SC10 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the + bumped `TRENDS_TESTS_FLOOR`; new **Section 16n** green (the six ASCII sentinels + non-vacuity self-test); + `ASSERT_BASELINE_FLOOR` = **123** (117 + 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 16n self-test + +greps pass; `ASSERT_BASELINE_FLOOR` = 123; Section 17 de-niche green; ref/agent/command counts unchanged. +**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3e touches no +hook). The R3c suite (`schedule.test.ts`/`run-daily.test.ts`) still green untouched (`schedule.ts`/`run-daily.sh` +not edited). +**Behavioural (manual):** +1. `D=/tmp/r3e-mb-$$; S=/tmp/r3e-$$.json` (unique dir, no `rm`). Seed a store with two on-pillar trends and run a + day-1 brief: `node --import tsx src/cli.ts brief --pillars ai,gov --out "$D" --store "$S"` → inspect the `.md`: + the frontmatter carries `surfaced: <ids>` and `schemaVersion: 2`; the `## 🆕 Nytt siden sist` section says + `Første brief — alt nedenfor er nytt`. +2. `capture` a third on-pillar trend into the same store, then run a day-2 brief **with a later `today`** (seed via + a second dated file is not possible — use `--out "$D"` so day-1's `${day}.md` is the prior; on a real next-day + run the date advances): inspect the new `.md` → `## 🆕 Nytt siden sist (<prior-date>)` lists the new trend with + its title, then `N båret over, M ikke vist i dag`; the `--json` shows `diff.added ≥ 1`. +3. Re-run the **same-day** brief → the written `.md` is **byte-identical** (`diff` against the same prior file; + `surfaced:` re-written identically) — `cmp` the two files. +4. Confirm the seeded records' `score.composite` values are unchanged after the briefs (only `surfacedCount`/ + `lastSurfacedAt` advance) — the diff never mutates the store ranking. +5. Confirm a pre-R3e brief (no `surfaced:` line) as the prior → `parseSurfacedFrontmatter` returns `[]` → every + trend reads as `added` (graceful degrade, no crash). + +## 8. Open questions for the go-gate + +Two architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-26): **SD1** persist membership +in the brief's frontmatter (no sidecar); **SD2** `added` with titles + `dropped` as a count (`brief.ts` +store-free). Residual decisions, all baked to the recommended default — confirm or redirect with "Go": +- **D1 — "new" = relative to the immediately-prior brief (artifact diff), not first-ever (`surfacedCount 0`)?** + YES (rec). It is the honest meaning of "siden sist" and catches re-emergence; it is also what unlocks `dropped`. + Re-open only to redefine "new" as first-ever. +- **D2 — diff against the most recent prior brief only (no `--since` baseline)?** YES (rec). The "since last" a + daily feed needs. Re-open only to add an arbitrary historical baseline. +- **D3 — `## 🆕 Nytt siden sist` placed before `## 🎯 Topp-treff` (delta leads)?** YES (rec). The one thing a feed + leads with. Re-open only to place it after the ranked list (appendix) or omit the header on a first brief. +- **D4 — summary marker ` N nye siden sist.` (suppressed on first brief / when nothing new)?** YES (rec). The + delta the SessionStart hook surfaces for free, with no hook edit. Drop only to keep the summary minimal. +- **D5 — `surfaced:` frontmatter always emitted (incl. `--no-mark`, incl. empty store → blank)?** YES (rec). It is + the record the *next* diff reads; gating it on `--no-mark` or non-empty would silently break tomorrow's diff. + Re-open only to gate it. +- **D6 — `dropped`/`carried` render as a one-line count (no titles); `dropped` framed "ikke vist i dag"?** YES + (rec, = SD2). Keeps `brief.ts` store-free; honest (a count cannot prove "resolved"). Re-open only to pull the + reason-labeled follow-up into R3e now. +- **D7 — `BRIEF_SCHEMA_VERSION` 1 → 2; store `SCHEMA_VERSION` stays 4?** YES (rec). The frontmatter gained a + field (the first artifact-schema change since R2b); no store field. Re-open only to add a store field instead. +- **D8 — README gets the R3e section; `trend-spotter.md` gets one prose line; the SSOT is NOT touched?** YES + (rec). The diff is not a scoring concern — touching `trend-scoring-modes.md` would be scope creep. Re-open only + to add an SSOT note. +- **D9 — commit split?** Docs commit first, then **one** code commit (rec) — the diff (helpers + render + CLI + wiring + gate) is one coherent feature. Re-open only for a helpers-then-wiring split. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran COLD on this brief + the plan against live `scripts/trends/` code (scope-guardian, +brief-reviewer, plan-critic — the R3c/R3d discipline). **Verdicts:** scope-guardian **MIXED** · brief-reviewer +**PROCEED_WITH_RISKS** · plan-critic **REWORK (0.88)**. They **converged on 2 MAJOR** (both re-verified against +live code before folding) + 4 MINOR. Every line-cite, the floors, the regex/lex/hook safety, and the §3 scope +fence were **confirmed correct** by all three and left untouched. + +**MAJOR-1 — a hard schema literal breaks at the 1→2 bump (the §6/Step-1 "no existing assertion breaks" scoping +missed it).** `tests/brief.test.ts:574` is `assert.equal(BRIEF_SCHEMA_VERSION, 1)` — a **hard literal**, not the +constant-tracking RegExp at `:163` (`new RegExp("\\nschemaVersion: " + BRIEF_SCHEMA_VERSION + "\\n")` auto-tracks). +It lives in the `rankForBrief — no schema/score mutation` block (`:568-577`), **outside** the frontmatter tests +§6/plan-Step-1 enumerated, so the "every frontmatter assertion auto-tracks" claim did not cover it. **Resolution:** +the GREEN schema bump (`BRIEF_SCHEMA_VERSION = 2`) must **also flip `:574` → `, 2)`** in the same step (plan +Step 3). `:575` (`assert.equal(SCHEMA_VERSION, 4)`, the store schema) is untouched. Folded into plan Step 3 + R1 + +Step 1's enumeration. (Swept live: `:574` is the *only* hard `BRIEF_SCHEMA_VERSION` literal in the suite; +`cli.test.ts:102` asserts the **store** `persisted.schemaVersion === SCHEMA_VERSION` (4) — unrelated, stays green.) + +**MAJOR-2 — the `--json summary` would diverge from the file frontmatter on day-2 (the "one source" invariant).** +The file's frontmatter `summary:` is built inside `renderBrief` (`brief.ts:265`), which Step 3 routes through +`briefSummary(ranking, diff)` → on day-2 it carries the ` N nye siden sist.` marker. But the CLI's `--json` +`summary` field reads a **separate** `const summary = briefSummary(ranking)` (`cli.ts:350`, comment `// SAME source +the frontmatter carries`) that Step 4 left unthreaded → no marker. `cli.test.ts:268` asserts +`fileFrontmatter.summary === json.summary` ("one source") → would **break** on day-2. **Resolution:** Step 4 +changes `cli.ts:350` to `briefSummary(ranking, diff)` (the `diff` is in scope — Step 4 computes it between the +ranking at `:339` and the render at `:340`). **Safe on day-1:** `priorDate === null` ⇒ the marker is suppressed ⇒ +byte-identical to today's string. Folded into plan Step 4 + the files-touched table. + +**MINOR (folded):** +- **M1 — `BriefDiff` is an interface (type-only export).** Anywhere it is referenced as a type (tests or `cli.ts`), + import it with **`import type { BriefDiff }`**, never a value import — under Node16 ESM + tsx a type-only export + is stripped from the emitted JS, so a value-import named binding fails to resolve at **module-load** (the same + Phase-A hazard as a missing named import). The plan's Step-4 code does **not** annotate `: BriefDiff` (it infers + from `diffSurfaced`'s return) and the SC tests pass object literals — so in practice no `BriefDiff` import is + needed; the rule is the guardrail if one is added. Folded into plan Step 2/4. +- **M2 — SC9's prior brief is a real renamed brief, not a hand-fixture (rename-real-write).** Replace the "pre-write + a `<prior-date>.md` fixture carrying a `surfaced:` line" mechanism (plan Step 4 note / Phase B / Step 7) with: + run `brief` once (writes `${today}.md` with a genuine `surfaced:` line), `mv ${today}.md → 2026-06-20.md` (a fixed + past date) **in the same `--out`**, then run `brief` again. This (a) closes the write→read loop **clock-free** (no + `today()` manipulation), and (b) **guarantees the prior `surfaced:` ids are real store ids** (they came from a + real run), so `carried` is non-trivial and `added` is *exactly* the newly-captured trend — a hand-fixture risks an + id mismatch that makes everything read as added/dropped (a weaker, possibly-vacuous test). Folded into plan Step 4 + note, Phase B, Step 7, SC9. +- **M3 — SC1 "repeated id" wording.** `diffSurfaced` uses `currentIds.filter(...)`/`priorIds.filter(...)`, which + **preserve** within-list duplicates — so "not double-counted" is wrong as within-list dedup. What the `Set` + membership actually guarantees is **cross-partition disjointness** (added/carried/dropped are mutually exclusive). + Reworded in §6 SC1 + plan Step 1/verification. (Production `surfacedIds` yields distinct ids, so within-list dups + never arise.) +- **M4 — §3 "empty `surfaced:`" self-contradiction.** The bare `renderBrief(ranking)` default-diff prose said it + yields "an empty `surfaced:` reflecting the ranking" — contradictory: `surfaced:` is always + `surfacedIds(ranking).join(",")` (**non-empty** for a non-empty ranking; blank only for an empty store), + independent of the diff; the default empty diff only drives the **`_Første brief._`** section branch. Reworded in + §3 (the `renderBrief` default-diff bullet). + +**Confirmed correct by all three — left untouched:** every line-cite (`brief.ts:23/204/265/305`, +`cli.ts:339/340/350/352`, `session-start.mjs:60-77/534-536`); the floors (`TRENDS_TESTS_FLOOR` 216 @ live `:716`, +`ASSERT_BASELINE_FLOOR` 117 @ live `:1473` → 123 = 117 + 6 unconditional 16n emitters); Section 16m is the last +trends section (16n sits between its `echo ""` and Section 18); the 6 ASCII sentinels are non-vacuous; the +`/^surfaced: *([^\n]*)/m` regex, the ISO-lex compare, the `^surfaced:` ≠ `^summary:` hook-safety, the `--json` +shape, and the same-day-determinism strict `<`. diff --git a/docs/research-engine/plan-re-r3a.md b/docs/research-engine/plan-re-r3a.md new file mode 100644 index 0000000..7a393ae --- /dev/null +++ b/docs/research-engine/plan-re-r3a.md @@ -0,0 +1,321 @@ +# Plan — RE-R3a: persist the relevance score + rank the morning brief on it + +> **Brief:** `docs/research-engine/brief-re-r3a.md`. **Slice:** RE-R3a (research-engine rung-2 — R3 slice 1, +> research-deepening: the relevance half of hull 5 + the hull-3 schema remainder). +> **TDD-order (two-phase RED — light-Voyage BLOCKER fold):** Step 1 records RED in two phases — +> **(A)** true logic-RED for `store`/`brief`/`cli` against the pre-edit code (inline fixtures, no new import); +> **(B)** for `score`/`item`, land non-throwing stubs for the new `score.ts` exports first (Node16 ESM throws a +> missing named import at module-load, not on assertion), then record the value-assertion RED against the stubs. +> Then GREEN: S-score envelope → S-types + S-store (first-sight persist + v2→v3 migrate) → S-item (validate + +> bridge) → S-brief (composite-sort + render band+mode) → S-cli (doc-only) → wire `trend-spotter.md` + README → +> gate floors + Section 16j → behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Go-gate decisions (confirmed "Go" 2026-06-24):** D1 4-field `TrendScore` · D2 composite primary within +> bucket · D3 first-sight · D4 one slice (data-then-visible commit order) · D6 mode shown in per-entry render. +> **Light-Voyage hardened:** scope-guardian ALIGNED (0) / brief-reviewer PROCEED_WITH_RISKS (6 MINOR) / +> plan-critic REVISE (1 BLOCKER, 4 MAJOR, 4 MINOR) — all folded (see §Plan-critic — folded). + +## Goal + +Stop discarding the relevance judgment the `trend-spotter` agent already computes. Persist a 4-field +`TrendScore { mode, dimensions, composite, priority }` on the store record (schema v2→v3, additive lossless +migrate — the R2a pattern), computed deterministically from the agent's five judgment scores by the already-built +`score.ts` (`composite`+`band`, one owner). Then make `rankForBrief` order each bucket on composite first, and +`renderBrief` surface the band + mode. No re-score-on-recapture, no saturation/status, no scheduler, no new +source file — those stay later R3 slices. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/score.ts` | **EDIT** — `TrendScore` interface + `requiredDimensions(mode)` (ordered) + `scoreEnvelope(mode, dimensions)` (composes the existing `composite`+`band`, no new arithmetic; throws on bad dim by contract) | SC1 | +| `scripts/trends/src/types.ts` | **EDIT** — `import type { TrendScore }`; `TrendRecord.score?: TrendScore`; `SCHEMA_VERSION` 2→3; doc-comment | SC4 | +| `scripts/trends/src/store.ts` | **EDIT** — `TrendInput.score?: TrendScore`; `addTrend` persists `score` first-sight on add (conditional spread), duplicate does NOT update; migrate comment v1→v2→v3 (logic unchanged) | SC3, SC4 | +| `scripts/trends/src/item.ts` | **EDIT** — `TrendItem.score?: {mode,dimensions}`; `normalizeItem` validates (non-array `score`/`dimensions`; mode; the mode's five dims in [1,10]) → structured error, carries validated dims; `itemToInput` carries `scoreEnvelope(...)` (throws by contract on direct bad dims) | SC2 | +| `scripts/trends/src/brief.ts` | **EDIT** — `rankForBrief` comparator: composite primary (`?? -1`), buckets unchanged; `renderBrief`/`renderBulletEntry` surface `· <priority> (<mode>)` (full pinned shapes); `briefSummary` band only; exact `ranking:` descriptor | SC5, SC6 | +| `scripts/trends/src/cli.ts` | **EDIT (doc-only behavior)** — capture persists score automatically via `itemToInput` (no logic change); header doc-comment note | SC7 | +| `scripts/trends/tests/score.test.ts` | **EDIT** — `requiredDimensions` (both modes, ordered + order pinned) + `scoreEnvelope` (composite/priority = existing funcs; bad dim throws) | SC1 | +| `scripts/trends/tests/item.test.ts` | **EDIT** — score validation (valid carried/validated dims; bad mode/missing/out-of-range/non-array/array-dims → structured error, no throw) + `itemToInput` envelope + `itemToInput` direct bad-dim throws | SC2 | +| `scripts/trends/tests/store.test.ts` | **EDIT** — first-sight persist (new persists; duplicate keeps first score, topics union; score-free add) + v2→v3 migration (lossless/idempotent + score-survives-round-trip, mirrors `:403-476` with `2`→`3`) | SC3, SC4 | +| `scripts/trends/tests/brief.test.ts` | **EDIT** — composite-primary within bucket; unscored last (`-1`); total order; full render lines (`· <priority> (<mode>)` scored / unchanged unscored); summary band; unscored single-match-top summary; quote-safe summary; exact `ranking:` string; determinism | SC5, SC6 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — capture batch with score → record carries computed composite/priority (read back via `list --json`); bad score → `errors[]`, valid added, exit 0 | SC7 | +| `agents/trend-spotter.md` | **EDIT** — Step 4.5 capture batch carries per-item `"score":{"mode":"kortform","dimensions":{…}}`; prose ("carry the Step-2 scores"); domain-general; contains literal `"dimensions"` (currently absent → grep non-vacuous) | SC8 | +| `scripts/trends/README.md` | **EDIT** — item `score` field (judgment in) + persisted `TrendScore` (out) + brief ranks on composite | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` 104→recount + breakdown comment (`:701`); NEW unconditional **Section 16j** (after 16i `~:1171` / before 18 `:1173`); `ASSERT_BASELINE_FLOOR` 94→**99**; header-enumeration chain (16i clause `:46-49`, Section-18 clause `:49`) | SC8 | +| `docs/research-engine/{brief,plan}-re-r3a.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 99, schema v3, gate total). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `references/trend-scoring-modes.md` (SSOT weights/bands unchanged) · the +SessionStart hook + its tests (R3a touches no hook; no frontmatter-schema change; no new hook test) · +`queryByTopic`/`history`/`newestCaptureDate` (store query unchanged) · the `score` CLI digest path + the `add` +manual path (`cli.ts`) · `config/*` · no new `.ts`/`.mjs` file · `agents/*` count (19) · `commands/*` (29) · +`references/*` (27) · `.gitignore` (trends lines present). + +## Step 1 — (RED, two phases) failing tests across score/item/store/brief/cli + +RED discipline (R2a/R2b) + the light-Voyage BLOCKER fold: a missing **named** import throws at module-load under +Node16 ESM (`package.json:8` `node --import tsx --test`; `tsconfig.json` `module: Node16`), so `score`/`item` +(which reference new `score.ts` exports) cannot be assertion-RED before those exports exist. Split the RED proof: + +**Phase A — true logic-RED against the pre-edit code** (`store`/`brief`/`cli` build fixtures inline; `TrendStore`/ +`TrendRecord` are `import type`, erased by tsx; they import no new runtime symbol): +- `store.test.ts`: `addTrend(emptyStore, inputWithScore)` → record has `score`; a second `addTrend` (same + title+url, **different** score) → stored score **unchanged**, `added:false`, topics unioned; score-free add → + score-free record. **Migration:** a `schemaVersion:2` store with score-less records → `loadStore` gives + `schemaVersion:3`, records intact, `"score" in record === false` (not invented); round-trip writes + `schemaVersion:3`; a v3 store with `score` is idempotent; **a v3 store's `score` survives load+resave** + (read back, re-save, re-read — `score` byte-identical). *(Mirror the RE-R2a block `store.test.ts:403-476`, + retitled `(RE-R3a / score v2→v3)`, every `schemaVersion` assertion literal flipped `2`→`3`.)* +- `brief.test.ts`: two **same-overlap, same-freshness** records, composites 9 vs 6 → the 9 sorts first in its + bucket; a **scored** vs **unscored** same-bucket pair → scored first (the `-1` sentinel); a same-title/diff-url + **both-unscored** pair → fixed by `url asc` (total order intact); `renderBrief` for a scored top entry contains + the **full line** `… (<age>d) · <priority> (<mode>) · Pillarer: …`, for an unscored one the **unchanged** line + (no token) — assert full lines, not substrings; a scored bullet contains `… (<age>d) · <priority> (<mode>) · + 🔗 …`; `briefSummary` names the band on a scored top, omits it on an unscored top, and is one line with no `"` + **even when the top title contains a guillemet/quote**; a store whose only fresh match is a **single-pillar + unscored** record → summary with no `· <priority>` token; the `ranking:` line equals the pinned string + verbatim; two `renderBrief` calls byte-identical. +- `cli.test.ts`: a `capture` batch (subprocess, `--store` temp) with a valid per-item `score` → a following + `list --store <tmp> --json` shows the record's `score` with the computed composite/priority; a batch with one + bad score (`timing:99`) → JSON `errors[]` non-empty, the valid items added, **exit 0**. + +**Phase B — stub-first, then value-assertion RED** (`score`/`item` reference new exports): +- Land **non-throwing stubs** in `score.ts` so the imports resolve: `requiredDimensions → []`; `scoreEnvelope → + { mode, dimensions, composite: 0, priority: "Skip" }`. (These are the wrong-value stubs Step 2 replaces.) +- `score.test.ts`: `requiredDimensions("kortform")`/`("long-form")` deep-equal the two **ordered** five-key + lists (and a guard that the order matches `Object.keys(KORTFORM_WEIGHTS)` so a SSOT reorder fails); + `scoreEnvelope(mode, dims)` returns `{mode, dimensions, composite, priority}` with composite/priority **equal + to `composite(dims,mode)` / `band(...).priority`** (assert against the existing functions — not hard-coded — + so they share one owner); a bad dim makes `scoreEnvelope` throw (it calls `composite`). Fails on assertion + against the stubs (`[] ≠ expected`, `composite 0 ≠ real`). +- `item.test.ts`: a valid `score` → `normalizeItem` `ok:true` carrying the **validated** dims; bad `mode` / a + missing dimension / a dim `0` or `11` / a non-object `score` / an **array** `dimensions` → `ok:false` with an + `invalid score` error (**does not throw**); absent `score` → key omitted. `itemToInput(validItemWithScore, + "2026-06-24")` → `score` equals `scoreEnvelope(mode,dims)`; without score → no `score`; **`itemToInput` with an + out-of-range dim throws** (the defense-in-depth contract). Fails on assertion against the stubs. + +**RED proof (record in commit, two phases):** Phase A — `(cd scripts/trends && npm test)` before any src edit → +the `store`/`brief`/`cli` new cases fail on **assertion** (logic-RED), not module-not-found. Phase B — after the +non-throwing stubs land, the `score`/`item` cases fail on **value assertion** against the stubs. The plan does +**not** claim a single "all five fail before any code" run. + +## Step 2 — (GREEN) `score.ts` envelope + +In `scripts/trends/src/score.ts`: +- `export interface TrendScore { mode: ScoreMode; dimensions: DimensionScores; composite: number; priority: + Priority }`. +- `export function requiredDimensions(mode: ScoreMode): string[] { return Object.keys(WEIGHTS[mode]); }` — + **ordered** (insertion order of the SSOT weight literal); `score.test` pins the order. +- `export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore { const c = + composite(dimensions, mode); return { mode, dimensions, composite: c, priority: band(c).priority }; }` — + composes the existing pure functions; **no new arithmetic**; throws via `composite` on a bad dim (its + contract). Replace the Phase-B stubs. Make `score.test` green. + +## Step 3 — (GREEN) `types.ts` + `store.ts` (schema v3 + first-sight persist) + +- `types.ts`: `import type { TrendScore } from "./score.js";`; add `score?: TrendScore;` to `TrendRecord` + (doc-comment marks it as the realized `:21-23` field); `SCHEMA_VERSION = 3`. +- `store.ts`: add `score?: TrendScore;` to `TrendInput` (`import type { TrendScore } from "./score.js"`); + in `addTrend`'s new-record branch add `...(input.score !== undefined ? { score: input.score } : {})` (after the + `summary` spread `:136`); the **duplicate** branch is unchanged (topics union only — score is first-sight, + D3). Extend the `loadStore` migrate comment to *"v1→v2→v3 all additive-optional … the migration is the version + stamp alone"* — **no logic change** (`Math.max(onDisk, SCHEMA_VERSION)` `:87` already handles v3; `saveStore` + `JSON.stringify` `:95` preserves `score`). Make `store.test` green (first-sight + v2→v3 + survives-round-trip). + +## Step 4 — (GREEN) `item.ts` (validate + bridge) + +- `TrendItem` gains `score?: { mode: ScoreMode; dimensions: DimensionScores };` (`import type { ScoreMode, + DimensionScores } from "./score.js"` + `import { requiredDimensions } from "./score.js"`). +- `normalizeItem`: after the `publishedAt` validation (`:99-106`), if `r.score !== undefined && r.score !== + null`, validate: `score` is a **non-array** object; `mode` ∈ `{kortform, long-form}`; `dimensions` is a + **non-array** object; **for each key in `requiredDimensions(mode)`**, the value is a number in [1,10]. On any + failure `errors.push("invalid score: <reason>")`; on success build `score = { mode, dimensions }` from the + **validated** values (not raw `r.score.dimensions`). Carried into the returned `TrendItem` via conditional + spread (key omitted when absent/invalid). **Never throws** — structured errors only (the `publishedAt` + discipline). +- `itemToInput`: add `...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, + item.score.dimensions) } : {})` (`import { scoreEnvelope } from "./score.js"`). On the **capture path** the + dims are pre-validated by `normalizeItem`, so `scoreEnvelope`→`composite` cannot throw there; called + **directly** with bad dims it throws by contract (defense-in-depth — SC2 asserts it). Make `item.test` green. + +## Step 5 — (GREEN) `brief.ts` (composite sort + render band+mode) + +- `rankForBrief` comparator (`:94-98`): prepend `(b.trend.score?.composite ?? -1) - (a.trend.score?.composite + ?? -1) ||` before the existing `b.overlap - a.overlap || …`. **Sentinel `-1`** (composite ≥ 1.0 always; `-1` + sorts unscored last and subtracts cleanly). Buckets (`:100-104`) and totals unchanged. +- `renderTopEntry` (`:132-141`): when `e.trend.score` is present, insert `· ${e.trend.score.priority} + (${e.trend.score.mode})` into the meta line **between `(${e.ageDays}d)` and `· Pillarer`** (unscored: + unchanged). `renderBulletEntry` (`:143-145`): when scored, insert `· ${score.priority} (${score.mode})` + **before `· 🔗`** (unscored: unchanged). Both shapes asserted as **full lines** in `brief.test`. +- `briefSummary` (`:122-130`): in the fresh>0 branch, when `top.trend.score` is present include `· + ${top.trend.score.priority}` in the top mention (**band only — no mode** to keep the one-line headline clean): + `… Topp: «<title>» (<pillar> · <priority> · <age>d).`; when the top is unscored omit the token: + `… Topp: «<title>» (<pillar> · <age>d).` Keep it one line, no `"`/`\n`. +- `renderBrief`'s `ranking:` line (`:160`) → the **exact pinned** string + `composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays + ${ranking.freshDays}` (asserted byte-for-byte). `BRIEF_SCHEMA_VERSION` stays 1. Make `brief.test` green. + +## Step 6 — (GREEN) `cli.ts` (doc-only) + `cli.test` + +`capture` (`:243-269`) already folds through `itemToInput` (`:254`), which now carries `score` → capture +persists it with **no logic change**. Update only the header doc-comment (`:15-21`): note `capture` persists an +optional relevance score computed from the item's judgment scores. Make `cli.test`'s capture-persists-score + +bad-score-in-errors cases green. *(No `today()` exact-value assertions in `cli.test` — the wall clock is read at +the edge; composite/priority are deterministic and asserted on the read-back record.)* + +## Step 7 — wire `trend-spotter.md` (Open Q#1 default = WIRE) + README + +In `agents/trend-spotter.md` Step 4.5 (`:291-298`), extend each capture-batch item with **`"score": {"mode": +"kortform", "dimensions": {"pillar": N, "audience": N, "timing": N, "angle": N, "authority": N}}`** — the same +five judgment scores computed in Step 2 (`:134`). Add prose: don't discard the Step-2 scores; fold them into the +capture batch so the store persists the relevance assessment and the morning brief ranks on it. Mode defaults +`kortform`; `long-form` when invoked from `/linkedin:newsletter` (the long-form dims are +`pillar/depth/angle/authority/currency`, `trend-scoring-modes.md:59-65`). The replacement prose **must contain +the literal `"dimensions"`** (Section 16j `grep -qF`; verified absent today → non-vacuous). Keep the "skip +silently if no deps" escape hatch + domain-general phrasing (Section 17). Update `scripts/trends/README.md`: the +item `score` field (judgment in), the persisted `TrendScore` (composite/priority out), and that the brief now +ranks on composite. + +## Step 8 — gate: floors + new unconditional Section 16j + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (`:701`, currently 104) to the **`tests N` line** reported by `(cd scripts/trends && + npm test)` after Steps 1–6 — recounted live, NOT additive-guessed. Stays **inside** the `if [ -x …/tsx ]` + deps guard. **Append** `+ RE-R3a: score +N` to the inline breakdown comment (`:701`) so it can't drift. +- Add **Section 16j** ("Trends Score Wiring", RE-R3a), mirroring Section 16i's shape (`:1122-1171`). **Placement + (verified):** file order is 17→16g→16h→16i→18 (`:947/:1014/:1078/:1122/:1173`), so **16i is the last section + before Section 18** — insert 16j **after 16i's closing `fi`/`echo ""` (~`:1171`), before the Section 18 block + (`:1173`)** (anti-erosion Section 18 must stay last so it counts every prior check). Five **unconditional**, + deps-absent-safe checks (pure `grep -qF`/self-test, no `tsx`), the self-test emitting **one** pass/fail like + 16i: + (1) a non-vacuity self-test (a probe carrying `score?.composite` accepted, one without rejected); + (2) `grep -qF 'export interface TrendScore' scripts/trends/src/score.ts`; + (3) `grep -qF 'score?: TrendScore' scripts/trends/src/types.ts`; + (4) `grep -qF '"dimensions"' agents/trend-spotter.md` (the capture batch carries the judgment); + (5) `grep -qF 'score?.composite' scripts/trends/src/brief.ts` (the brief ranks on it — payoff wired, not + merely doc'd). +- Bump `ASSERT_BASELINE_FLOOR` (`:1193`, currently 94) → **exactly 99** (94 + the 5 new unconditional 16j + emitters; the self-test emits one pass/fail like 16i, so 99 is deterministic — "live recount" is the safety + net, not a guess). Update the **header-enumeration prose chain** by inserting the 16j clause **between** the + 16i clause (`:46-49`) and the Section-18 clause (`:49`), preserving sentence flow (it's prose, not an append). +- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3a adds no hook test). It + must still pass untouched (`node --test hooks/scripts/__tests__/`) as a regression sanity at land. + +## Step 9 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then run brief §7's four behavioural steps (capture A>B with +scores, `list --json` to confirm persisted composite/priority, `brief --json` to confirm A precedes B + the band +in the summary + `· <priority> (kortform)` in the entry line, a bad-score item lands in `errors[]` with exit 0). +Run full `bash scripts/test-runner.sh` → `FAIL=0` (`ASSERT_BASELINE_FLOOR` 99); run `node --test +hooks/scripts/__tests__/` → still green (untouched regression). + +## Step 10 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 99, schema v3, +gate total). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3a.md` (no +suffix, tracked); **(2)** code commit — the six `src/*.ts` + five test files + `agents/trend-spotter.md` + +`scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]`. (Per D4, the code commit MAY be split +into a data commit [score/types/store/item/cli + their tests] and a visible commit [brief + its tests + agent +wire] if the R2a/R2b two-commit rhythm is preferred.) 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 | store/brief/cli new cases fail on assertion (logic-RED), not module-not-found | +| — | RED Phase B | `npm test` after non-throwing stubs | score/item cases fail on value assertion against stubs (`[] ≠ expected`, `composite 0 ≠ real`) | +| SC1 | score envelope | `npm test` (score.test) | `requiredDimensions` both modes ordered (order pinned); `scoreEnvelope` composite/priority = existing funcs; bad dim throws | +| SC2 | item validate + bridge + contract | `npm test` (item.test) | valid score carried (validated dims); bad mode/missing/out-of-range/non-array/array-dims → structured error (no throw); `itemToInput` envelope; direct bad-dim throws | +| SC3 | first-sight persist | `npm test` (store.test) | new persists score; duplicate keeps first score (topics union); score-free add works | +| SC4 | migration v2→v3 | `npm test` (store.test) | v2 loads as v3, records intact, no score invented; round-trip writes v3; v3 idempotent; score survives load+resave | +| SC5 | brief ranks on composite | `npm test` (brief.test) + manual | composite primary within bucket; unscored last (`-1`); total order; deterministic | +| SC6 | render band+mode | `npm test` (brief.test) | full lines `· <priority> (<mode>)` scored / unchanged unscored; summary band (no mode); unscored single-match summary; quote-safe; exact `ranking:` string | +| SC7 | CLI persists score | `npm test` (cli.test) + manual | capture batch w/ score → record carries composite/priority; bad score → `errors[]`, valid added, exit 0 | +| SC8 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | `FAIL=0`; trends ≥ floor; Section 16j green; `ASSERT_BASELINE_FLOOR`=99; Section 17; counts 27/19/29 | + +## Risks + +- **R1 — composite/band drift from the SSOT** (someone re-implements the math in `scoreEnvelope`). *Mitigated:* + `scoreEnvelope` *composes* `composite()`+`band()` (one owner); SC1 asserts equality against those functions; + `score.test.ts:12-30` (weights) + the band-string assertions already pin them to the SSOT. +- **R2 — a bad score crashes the capture loop.** *Mitigated:* `normalizeItem` fully validates the score (mode + + the mode's five dims in [1,10], non-array objects) → structured error into `errors[]`; on the capture path + `itemToInput`→`composite` is unreachable for bad dims; SC2 + SC7 assert no-throw + `errors[]` routing. + `itemToInput` called **directly** with bad dims throws by contract (SC2) — defense-in-depth, not a leak. +- **R3 — comparator NaN from the unscored sentinel** (`-Infinity - -Infinity`). *Mitigated:* sentinel is `-1` + (composite ≥ 1.0 = min 1×Σweights); subtracts cleanly; SC5 asserts the both-unscored total order holds. +- **R4 — losing the brief's determinism** (composite ties not fully broken). *Mitigated:* the new term is a + leading tie-break; the existing `overlap → effectiveDate → title → url` chain still gives a total order (the + `(title,url)` pair is the unique dedupe id, `store.ts:66-68`); SC5 asserts byte-identical output + the + same-title/diff-url case. +- **R5 — `extractYaml` mis-reads the `summary`** if the band token introduces a `"`/newline. *Mitigated:* the + band strings (`Immediate`/`High`/…) are bare words; the summary stays one line, no `"` — asserted in SC6 even + with a guillemet/quote in the top title; the surfacing hook is untouched. +- **R6 — migration not actually lossless** (a v2 record mutated on load, or a new field stripped on resave). + *Mitigated:* the migration is the version stamp alone (logic unchanged); `saveStore` `JSON.stringify` `:95` + strips nothing; SC4 mirrors the proven R2a `store.test:403-476` and adds a score-survives-round-trip case. +- **R7 — editing `trend-spotter.md` trips the de-niche guard.** *Mitigated:* Section 17 runs in the gate; the + added `score`/`dimensions` are the rubric's generic dimension names + the user's pillars, vendor/sector-free. +- **R8 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16j is pure + `grep`/self-test on tracked source (no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps guard. +- **R9 — import cycle.** *Mitigated:* `score.ts` imports nothing internal today (`:1-17`, verified leaf). R3a + adds three new **one-way** inbound edges to it — `score.ts ← types.ts` (`TrendScore` type), `score.ts ← + store.ts` (`TrendScore` type on `TrendInput`), `score.ts ← item.ts` (`ScoreMode`/`DimensionScores` types + + `requiredDimensions`/`scoreEnvelope` values). The DAG stays acyclic: `score.ts (leaf) ← {types, store, item, + brief, cli}`, since `score.ts` imports none of them back. +- **R10 — mode-mixing makes the ranking apples-to-oranges** (kortform vs long-form composites ranked together). + *Accepted for R3a, mitigated visibly:* almost all records are `kortform` (the default); the body entry line + **shows the mode** (`<priority> (<mode>)`, D6/SC6) so the operator can see when adjacent entries used different + instruments; a mode-segmented brief is a documented R3-later non-goal (brief §4). + +## Plan-critic — folded + +Three Opus reviewers ran, each verifying claims against live code: **scope-guardian ALIGNED** (0 findings; counts +27/19/29 + "no new source file" verified live); **brief-reviewer PROCEED_WITH_RISKS** (all four load-bearing +claims verified TRUE; 6 MINOR); **plan-critic REVISE** (1 BLOCKER, 4 MAJOR, 4 MINOR; score 72/C). Resolution, +each verified against live code: + +- **[BLOCKER — plan-critic] Step 1 RED-proof self-contradictory for score/item under ESM** (a missing named + import throws at module-load, not on assertion; the stub-first fix inverted the stated ordering). ✅ Step 1 is + now **explicitly two-phase**: Phase A true logic-RED for `store`/`brief`/`cli` against pre-edit code; Phase B + stub-first then value-assertion RED for `score`/`item`. The header blockquote + brief §5 + §TDD-order state it; + the "all five fail before any code" claim is removed. +- **[MAJOR — plan-critic] no-throw guarantee overstated** (`itemToInput` is public; direct bad-dim calls throw). + ✅ Step 4 + R2 + brief §5 reword it **path-specific** (no throw on the capture path because `normalizeItem` + gates it; direct calls throw by contract); SC2 adds a direct-throw assertion + a carries-validated-dims + assertion. +- **[MAJOR — plan-critic] mode-mixing waved away + "mode shown per entry" contradicted the render spec.** ✅ D6: + the render now shows `· <priority> (<mode>)` per body entry (Step 5); brief §4 adds a mode-blind-ranking + non-goal with the rationale (mode visible, mostly kortform); SC6 asserts the full line incl. mode; R10 added. +- **[MAJOR — plan-critic] `requiredDimensions` order contract ambiguous** (SC1 hard-coded arrays vs membership + use). ✅ pinned **ordered** (Step 2 + SC1 deep-equal the SSOT-order array; `score.test` pins the order; + `normalizeItem` consumes as a set). +- **[MAJOR — plan-critic] `ASSERT_BASELINE_FLOOR` "~99" not pinned.** ✅ pinned **99** (94 + 5 unconditional 16j + emitters; the self-test emits one pass/fail like 16i) — Step 8 + SC8 + brief §3. +- **[MINOR — brief-reviewer] SC4 ref `:403-471` stale + v2 assertions** → ✅ `:403-476` + "flip every + `schemaVersion` literal 2→3" note (SC4, Step 1). +- **[MINOR — brief-reviewer] R1 SSOT-pin cite was the doc-comment** (`score.ts:9-13`) → ✅ now `score.test.ts:12-30` + (R1, brief §2/§5). +- **[MINOR — brief-reviewer] bullet `· <priority>` placement substring-only** → ✅ full pinned line shape + (priority+mode before `🔗`), asserted as a full line (Step 5, SC6). +- **[MINOR — brief-reviewer] three diverging `ranking:` descriptor strings** → ✅ one verbatim target string, + asserted byte-for-byte (Step 5, SC6). +- **[MINOR — brief-reviewer] unscored single-match-top summary path untested** → ✅ added as a Phase-A + brief.test case + SC6. +- **[MINOR — brief-reviewer] `normalizeItem` non-array object case understated in the brief** → ✅ "non-array" + added to both `score` and `dimensions` object checks (Step 4, brief §3, SC2 array-dims case). +- **[MINOR — plan-critic] header-chain line-ref `:33-49` loose** → ✅ tightened to the 16i clause `:46-49` / + Section-18 clause `:49` (Step 8). +- **[MINOR — plan-critic] R9 DAG omitted the new `score.ts ←` edges** → ✅ R9 now lists all three one-way edges. +- **[MINOR — plan-critic] SC6 quote-safety regression with the new token** → ✅ SC6 asserts the summary stays + one-line/no-`"` with a scored top title containing a guillemet/quote. +- **[MINOR — plan-critic] SC4 forward-compat / score-survives-round-trip untested** → ✅ added to SC4 + Step 1 + store.test. +- **[plan-critic headless-readiness] N/A** — R3a executes **in-session, operator-driven** (driftsmodell), not as + a headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a/R2b had none either). + +**scope-guardian — ALIGNED:** every SC1–SC8 traces to a step; zero creep; all §4 non-goals held (no +re-score-on-recapture, no saturation/status/first-mover field, no scheduler, no seen-log, no brief-diff, no +A1–A4, no mode-filter, no `score` in the `add` path, no new source file/agent/command); counts 27/19/29 verified +live; exactly 6 `src/*.ts` + 5 `tests/*.test.ts`, all edited, none added. diff --git a/docs/research-engine/plan-re-r3b.md b/docs/research-engine/plan-re-r3b.md new file mode 100644 index 0000000..219c11b --- /dev/null +++ b/docs/research-engine/plan-re-r3b.md @@ -0,0 +1,384 @@ +# Plan — RE-R3b: trend lifecycle — re-score on re-capture · status (acted/skipped) · seen-log + +> **Brief:** `docs/research-engine/brief-re-r3b.md`. **Slice:** RE-R3b (research-engine rung-2 — R3 slice 2, the +> **lifecycle** slice: re-score-on-recapture + status `new`/`acted`/`skipped` + the `surfacedCount`/`lastSurfacedAt` +> seen-log). +> **TDD-order (two-phase RED — light-Voyage BLOCKER fold, inherited from R3a):** Step 1 records RED in two phases — +> **(A)** true logic-RED for the re-score + migration parts of `store.test` (existing `addTrend`/`loadStore`, +> inline fixtures), all of `brief.test` (behaviour change to existing `rankForBrief`/`renderBrief`), and `cli.test` +> (subprocess — `act`/`skip` are unknown commands today → assertion-RED); **(B)** for `setStatus`/`markSurfaced`/ +> `effectiveStatus` (new `store.ts` exports), land non-throwing stubs first (Node16 ESM throws a missing named +> import at module-load, not on assertion), then record value-assertion RED against the stubs. Then GREEN: +> S-store stubs→real (effectiveStatus + setStatus + markSurfaced + re-score) → S-types (3 fields + v3→v4) **+ the +> R3a-migration-block reconcile** → S-brief (status filter + id/marker render + surfacedIds + descriptor) → S-cli +> (act/skip/reset + brief-marks-surfaced + --no-mark) → wire `trend-spotter.md` (prose) + README → gate floors + +> Section 16k → behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-25):** A1 on-record seen-log, brief records +> surfacing (rankForBrief pure, `--no-mark`) · A2 re-score last-wins (score the one mutable field; status not +> reset) · A3 acted/skipped EXCLUDED from the brief. Go-gate D1–D9 baked to recommended defaults (brief §8). +> **Light-Voyage hardened:** scope-guardian ALIGNED (0 creep / 0 gaps; 2 MINOR line-cites) · brief-reviewer +> PROCEED_WITH_RISKS (all 9 load-bearing claims TRUE; 1 MEDIUM + 3 LOW) · plan-critic PROCEED_WITH_RISKS (78/B; +> 1 MAJOR + 5 MINOR) — all folded (see §Plan-critic — folded). The MAJOR (the `brief` CLI `store` binding) is +> fixed by the Step-5 hoist. + +## Goal + +Give a trend a **life after first capture**. (1) **Re-score on re-capture** — `addTrend`'s duplicate branch now +refreshes `score` (last-wins; timing decays), reusing the already-built capture path (no new arithmetic, `score.ts` +untouched). (2) **Status lifecycle** — `new`/`acted`/`skipped` on the record, set by new `act`/`skip`/`reset` CLI +verbs; the brief **excludes** handled trends. (3) **Seen-log** — `surfacedCount`/`lastSurfacedAt` accumulated +(per-day-idempotent) by the `brief` CLI after the pure ranking, the temporal foundation slices (c)+(b) build on. +Schema v3→v4 (additive lossless migrate — the R3a pattern, **plus** reconciling the R3a migration block's +hard-coded `3` literals that the bump would otherwise regress). No saturation scoring, no scheduler, no brief-diff, +no new source file — those stay later R3 slices. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/types.ts` | **EDIT** — `export type TrendStatus`; `TrendRecord` gains `status?`/`surfacedCount?`/`lastSurfacedAt?` (all optional); `SCHEMA_VERSION` 3→4; doc-comment | SC1, SC4 | +| `scripts/trends/src/store.ts` | **EDIT** — `effectiveStatus(t)` (absent⇒new); `setStatus(store,id,status)`; `markSurfaced(store,ids,today)` (per-day idempotent); `addTrend` duplicate branch re-scores (last-wins, `merged` broadened, no false-merge); migrate comment v1→…→v4 (logic unchanged) | SC1, SC2, SC3, SC4 | +| `scripts/trends/src/brief.ts` | **EDIT** — `rankForBrief` excludes `effectiveStatus !== "new"`; `renderTopEntry`/`renderBulletEntry` append `· \`<id>\`` + `surfacedToken` (`· sett Nx` when count≥2); `surfacedIds(ranking)`; `ranking:` descriptor gains `; excludes acted/skipped` | SC5, SC6 | +| `scripts/trends/src/cli.ts` | **EDIT** — `act`/`skip`/`reset --id` (setStatus, not-found→exit 2); `brief` **hoists `const store = loadStore(storePath)`** + records surfacing via `markSurfaced`+`saveStore` unless `--no-mark`, `--json` gains `marked`; exit-code doc-comment (`:33`) + capture tally comment (`:251-252`) broadened; usage + header doc | SC7, SC8 | +| `scripts/trends/tests/store.test.ts` | **EDIT** — effectiveStatus; setStatus (found/absent/reset); markSurfaced (increment/idempotent/skip); re-score (last-wins/no-false-merge/status+provenance untouched/no-score-noop); **NEW `(RE-R3b / lifecycle v3→v4)` migration block** + **reconcile the existing `(RE-R3a / score v2→v3)` block's `3` literals** | SC1–SC4 | +| `scripts/trends/tests/brief.test.ts` | **EDIT** — exclude acted/skipped (all buckets; totals.trends full; only-handled→empty); render full lines (`· \`<id>\``; `· sett Nx` at count≥2 / none below); `surfacedIds`; exact `ranking:` string; determinism | SC5, SC6 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — act/skip/reset (read back via `list --json`; unknown id→exit 2; missing --id→exit 2); brief marks surfaced (`surfacedCount:1`+`lastSurfacedAt`, `marked` in json); second-same-day idempotent (`marked:0`); `--no-mark` (no write); brief md omits acted/skipped | SC7, SC8 | +| `scripts/trends/src/item.ts` | **UNTOUCHED** — re-score reuses the existing `itemToInput`→`scoreEnvelope` bridge (R3a). Listed to assert it is *not* in scope. | — | +| `scripts/trends/src/score.ts` | **UNTOUCHED** — no scoring-math change. Listed to assert it is *not* in scope. | — | +| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: re-capture refreshes the score (timing decays); operator marks `acted`/`skipped` via the CLI so the brief stops re-surfacing handled work. No batch-shape change (score already emitted, R3a). Domain-general. | — | +| `scripts/trends/README.md` | **EDIT** — status lifecycle + verbs, seen-log (per-day idempotent, brief-recorded), re-score last-wins, brief exclude-handled + `--no-mark` | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` 146→recount + breakdown comment (`:705`); NEW unconditional **Section 16k** (after 16j's block `~:1235` / before Section 18 header `:1237`); `ASSERT_BASELINE_FLOOR` 99→**105**; header-enumeration chain | SC9 | +| `docs/research-engine/{brief,plan}-re-r3b.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 105, schema v4, gate total). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `references/trend-scoring-modes.md` (no scoring-math change) · `score.ts` + `item.ts` +(re-score reuses the R3a capture path) · the SessionStart hook + its tests (no hook change; no frontmatter-schema +change; `BRIEF_SCHEMA_VERSION` stays 1; no new hook test) · `queryByTopic`/`history`/`newestCaptureDate` (store +query unchanged) · the CLI `status` staleness reader (unrelated to the new `TrendStatus` type — the name collision +is pre-existing, not reconciled here) · `config/*` · no new `.ts`/`.mjs` file · `agents/*` count (19) · +`commands/*` (29) · `references/*` (27) · `.gitignore` (trends lines present) · the two inert `schemaVersion: 2` +fixture literals — `brief.test.ts:53` (`rankForBrief` ignores `schemaVersion`) AND `cli.test.ts:247` (the seed +store is re-stamped to current on capture/brief load; no test reads its on-disk version — `cli.test.ts:102` uses +the `SCHEMA_VERSION` constant) — both **left as-is** (folded — brief-reviewer #3: enumerated so "miss none" is +literally true). + +## Step 1 — (RED, two phases) failing tests across store/brief/cli + +Same RED discipline as R3a (two-phase, light-Voyage BLOCKER fold): a missing **named** import throws at +module-load under Node16 ESM, so the `store.test` cases that reference the new `setStatus`/`markSurfaced`/ +`effectiveStatus` exports cannot be assertion-RED before stubs exist. Split: + +**Phase A — true logic-RED against the pre-edit code** (uses existing `addTrend`/`loadStore`/`saveStore`/ +`rankForBrief`/`renderBrief`; `TrendStore`/`TrendRecord` are `import type`, erased by tsx; subprocess for cli): +- `store.test.ts` — **re-score** (uses existing `addTrend`): `addTrend(store, dupSameTitleUrlDifferentScore)` → + stored `score` **replaced**, `merged:true`, `added:false`, topics unioned, `source`/`capturedAt`/`publishedAt` + unchanged; a **byte-identical** re-score (no new topics) → `merged:false`; a duplicate with **no** `score` → + stored score unchanged; re-capture of a record with `status:"acted"` (constructed inline) → score updated, + `status` still `"acted"`, `surfacedCount` untouched. *(These fail against pre-edit `addTrend`, which never + touches `score` on a duplicate.)* +- `store.test.ts` — **NEW migration block `(RE-R3b / lifecycle v3→v4)`** (uses existing `loadStore`/`saveStore`, + **hard-coded `4`** so it is RED while `SCHEMA_VERSION` is still 3 — the genuine-RED device; the target+idempotent + assertions are switched to `SCHEMA_VERSION` at GREEN, Step 3, per brief-reviewer #4): a + `schemaVersion:3` store with score-bearing, lifecycle-field-less records → `loadStore` gives `schemaVersion:4`, + records intact, `"status" in record === false` (+ `surfacedCount`/`lastSurfacedAt` absent — none invented); + round-trip writes `schemaVersion:4`; a `schemaVersion:4` store with lifecycle fields loads **idempotent** + (stays 4, fields deep-equal); the three new fields **survive load→save→load**. +- `brief.test.ts` — **exclude** (uses existing `rankForBrief`): a store with `new` + `acted` + `skipped` matches + all fresh+on-pillar → only the `new` records appear in any bucket; `totals.trends` equals the **full** count + (incl. handled); a store whose only matches are `acted`/`skipped` → all buckets empty + the "no fresh signals" + `briefSummary`. **render** (uses existing `renderBrief`): a top entry's meta line ends `· \`<id>\`` (full line); + a bullet ends `· \`<id>\`` (full line); a record with `surfacedCount:3` shows `· sett 3x` (full line), one with + `surfacedCount:1`/absent shows **no** surfaced token (full line); `surfacedIds(ranking)` (new export — see + Phase B note) … **[moved to Phase B]**; the `ranking:` line equals the pinned string ending `; excludes + acted/skipped`; two `renderBrief` calls byte-identical. +- `cli.test.ts` — **act/skip/reset** (subprocess, `--store` temp): `act --id <id>` then `list --json` → record + `status:"acted"`; `skip` → `"skipped"`; `reset` → `"new"`; an **unknown** id → **exit 2** + store unchanged; a + missing `--id` → exit 2. **brief-marks-surfaced**: `brief --store <tmp>` on fresh matches → `list --json` shows + the surfaced trends `surfacedCount:1` + today's `lastSurfacedAt`, the `brief --json` carries `marked:<n>`; a + **second** same-day `brief` → `marked:0`, counts unchanged; `brief --no-mark` → `marked:0`, no `surfacedCount` + written; the brief `.md` **omits** an `acted` record. **re-score tally** (folded — plan-critic #4): `capture` a + scored item, then `capture` the same title+url with a **changed** score → the second `capture --json` reports + `merged:1` and `list --json` shows the **updated** composite. *(Fail today: `act`/`skip`/`reset` are unknown + commands → `usage` exit 2 but no status set; `brief` does not write `surfacedCount` / emit `marked`; a re-capture + with a changed score is a plain `duplicate` (score discarded), not `merged`.)* + +**Phase B — stub-first, then value-assertion RED** (`store.test`/`brief.test` reference new exports): +- Land **non-throwing stubs** so the imports resolve: in `store.ts` — `effectiveStatus → "new"` (constant), + `setStatus → { store, found:false }`, `markSurfaced → { store, marked:0 }`; in `brief.ts` — `surfacedIds → []`. + (Wrong-value stubs Step 2/4 replace.) +- `store.test.ts`: `effectiveStatus({…status:"acted"})` is `"acted"` (stub returns `"new"` → RED); + `setStatus(store, presentId, "skipped")` → `{ found:true }` and the record's status set (stub `found:false` → + RED); `markSurfaced(store,[idA],today)` increments + sets `lastSurfacedAt`, `marked:1`, and is per-day + idempotent on a re-call (stub `marked:0`, no mutation → RED). +- `brief.test.ts`: `surfacedIds(ranking)` deep-equals the ids of `topMatches ∪ singleMatches ∪ + olderMatched.slice(0,5)` (stub `[]` → RED). + +**RED proof (record in commit, two phases):** Phase A — `(cd scripts/trends && npm test)` before any src edit → +the re-score/migration/brief/cli new cases fail on **assertion** (logic-RED), not module-not-found. Phase B — +after the non-throwing stubs land, the `effectiveStatus`/`setStatus`/`markSurfaced`/`surfacedIds` cases fail on +**value assertion** against the stubs. The plan does **not** claim a single "everything fails before any code" run. + +## Step 2 — (GREEN) `types.ts` lifecycle type+fields, then `store.ts` functions + re-score + +**(Reordered — folded plan-critic #5: the type must exist before `store.ts` uses it.)** First, in +`scripts/trends/src/types.ts` — add **`export type TrendStatus = "new" | "acted" | "skipped";`** and the three +optional `TrendRecord` fields (`status?: TrendStatus`, `surfacedCount?: number`, `lastSurfacedAt?: string`) with +doc-comments marking them the realized lifecycle fields the `:22` note anticipated. **Do NOT bump `SCHEMA_VERSION` +here** — the bump lands in Step 3, atomic with the R3a-block reconcile (so the suite is never bumped-but-unreconciled). + +Then in `scripts/trends/src/store.ts` (replace the Phase-B stubs): +- `import type { TrendStatus } from "./types.js";` (type-only — no cycle; the DAG stays `score ← types ← store`, + acyclic). +- `export function effectiveStatus(t: TrendRecord): TrendStatus { return t.status ?? "new"; }`. +- `export function setStatus(store, id, status): { store: TrendStore; found: boolean }` — `const t = + store.trends.find((x) => x.id === id); if (!t) return { store, found: false }; t.status = status; return { + store, found: true };`. Sets `"new"` explicitly on a `reset`. +- `export function markSurfaced(store, ids: string[], today: string): { store: TrendStore; marked: number }` — + `const wanted = new Set(ids); let marked = 0; for (const t of store.trends) { if (!wanted.has(t.id)) continue; + if (t.lastSurfacedAt === today) continue; t.surfacedCount = (t.surfacedCount ?? 0) + 1; t.lastSurfacedAt = + today; marked++; } return { store, marked };`. Per-day idempotent. +- **Re-score in `addTrend`'s duplicate branch** (`:127-131`): after `existing.topics = topics;` compute + `let changed = topicsChanged;` (rename the `unionTopics` result) and add: + `if (input.score !== undefined && JSON.stringify(existing.score) !== JSON.stringify(input.score)) { + existing.score = input.score; changed = true; }`; `return { store, added: false, merged: changed };`. Broaden + the `AddResult.merged` doc-comment to *"the existing record was mutated — topics unioned and/or score + refreshed."* The new-record branch is unchanged. Make the Phase-A re-score + Phase-B lifecycle `store.test` + cases green. + +## Step 3 — (GREEN) `SCHEMA_VERSION` 3→4 bump — ATOMIC with the R3a-block reconcile + +The bump + every test it touches land in **one** step (verified-correct by plan-critic #2): the suite is never +bumped-but-unreconciled. *(At RED, Step 1, only the new R3b block was failing — hard-coded `4` while SCHEMA_VERSION +was still 3; the existing R3a block was green at `3===3`. This step flips SCHEMA_VERSION to 4, which would regress +the R3a block's `3` literals UNLESS reconciled here — hence atomic.)* + +- `types.ts`: **`SCHEMA_VERSION = 4`**. Extend the `store.ts` `loadStore` migrate comment to *"v1→v2→v3→v4 all + additive-optional … the migration is the version stamp alone"* (no logic change — `Math.max(onDisk, + SCHEMA_VERSION)` already stamps v4; `saveStore` preserves the new fields). +- **Reconcile the existing `(RE-R3a / score v2→v3)` block** (`store.test.ts:558-650`) — the bump to 4 regresses + its hard-coded `3` literals (a v3 store now migrates to **4**, so "v3 idempotent" is no longer true). The + brief-reviewer's full enumeration confirmed **exactly three** breaking assertions (`:588`/`:602`/`:628`); apply + the minimal, intent-preserving fix (align to the R2a block's `SCHEMA_VERSION` discipline, `:493`): + - `:588` `assert.equal(s.schemaVersion, 3, "v2 store must migrate to v3")` → `SCHEMA_VERSION` + message "must + migrate to the current version"; **flip the test title at `:571`** ("…loads stamped as **v3**" → "…to the + current version") and the **stale `:570` comment** ("…≠ 3"). + - `:602` `assert.equal(onDisk.schemaVersion, 3)` → `SCHEMA_VERSION`; **flip the test title at `:598`** ("…writes + **schemaVersion:3** to disk" → "…writes the current schemaVersion", mirroring the reconciled R2a twin `:502`). + - `:606` test **"a v3 store … loads idempotent"** → **retitle** "a v3 store migrates to the current version, + score preserved" and change `:628` `assert.equal(s.schemaVersion, 3)` → `SCHEMA_VERSION` (the + `assert.deepEqual(score, …)` stays — score survives the migration). *(The v4-**idempotent** guarantee now + lives in the new R3b block.)* + - `:638` test "a v3 store's score survives load→save→load" → **verified** it asserts only the `score` field + (`:663`, no version literal) — **no change**. +- **The new `(RE-R3b / lifecycle v3→v4)` block** (landed RED in Step 1 with hard-coded `4`): switch its **target + + idempotent** version assertions from hard-`4` to **`SCHEMA_VERSION`** (folded — brief-reviewer #4: now == 4 and + **future-proof**, so R3c's v4→v5 bump won't have to reconcile this block — breaking the cycle R3b pays for R3a). + The v3 **input** fixtures stay literal `3` (they are old-version inputs). *(The hard-`4` was only the genuine-RED + device for the Step-1 run; record that RED proof in the commit log.)* +- Make the new `(RE-R3b / lifecycle v3→v4)` block + the reconciled R3a block both green; the full suite green after + the bump (SC4). + +## Step 4 — (GREEN) `brief.ts` (exclude handled + id/marker render + surfacedIds) + +- `import { effectiveStatus } from "./store.js";` (brief.ts already imports `defaultStorePath` from there — one-way). +- `rankForBrief` entry loop (`:82-92`): add `if (effectiveStatus(trend) !== "new") continue;` immediately before + the `if (overlap === 0) continue;` (`:89`). `totals.trends` stays `store.trends.length` (`:116`). Buckets + + the composite total order (R3a) unchanged. +- `surfacedToken(e)` helper (after `scoreToken`, `:141-145`): `const c = e.trend.surfacedCount; return c && + c >= 2 ? ` · sett ${c}x` : "";`. **Prior-day semantic (folded — plan-critic #3):** the CLI records today's + surfacing **after** `renderBrief`, so `surfacedCount` here is the **prior-day** count — `· sett Nx` = "shown on + N prior distinct days" (today's appearance is recorded but not yet in this render). Document it in the README; + `brief.test` asserts the token by setting `surfacedCount` directly, the cross-day behaviour by §8. +- `renderTopEntry` (`:147-156`) meta line (`:150`): append `${surfacedToken(e)}` after `${scoreToken(e)}` and + ` · \`${e.trend.id}\`` at the very end (after `Pillarer: …`). +- `renderBulletEntry` (`:158-160`): append `${surfacedToken(e)}` after `${scoreToken(e)}` and ` · \`${e.trend.id}\`` + at the very end (after `🔗 ${e.trend.url}`). +- `export function surfacedIds(ranking: BriefRanking): string[]` — `return [...ranking.topMatches, + ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id);` (mirrors the render's + `:199` `.slice(0, 5)`). Replace the Phase-B stub. +- `renderBrief`'s `ranking:` line (`:175`) → append `; excludes acted/skipped` to the pinned string. `briefSummary` + + `BRIEF_SCHEMA_VERSION` unchanged. Make `brief.test` green. + +## Step 5 — (GREEN) `cli.ts` (act/skip/reset + brief marks surfaced + --no-mark) + +- Import `setStatus`, `markSurfaced` from `./store.js`; `surfacedIds` from `./brief.js`. +- A shared `setStatusCmd(status: TrendStatus)` inline helper (or three branches): read `flags.id`; if missing/`"true"` + → `usage('<cmd> needs --id <id>')`; `const store = loadStore(storePath); const res = setStatus(store, flags.id, + status); if (!res.found) { console.error(`error: no trend with id: ${flags.id}`); process.exit(2); } + saveStore(storePath, store); console.log(`Marked ${flags.id} ${status}`);`. Wire `command === "act"` → + `"acted"`, `"skip"` → `"skipped"`, `"reset"` → `"new"`. +- **Broaden the exit-code doc-comment** (folded — plan-critic #2): `cli.ts:33` *"2 on usage error (incl. + unparseable stdin / bad flag)"* → *"2 on usage error or a not-found id (act/skip/reset)"*. A wrong `--id` value + is an argument-class error (exit 2), distinct from `capture`'s data items (`cli.ts:31` — `errors[]`, never the + exit code). No third code introduced. +- `brief` branch (`:274-298`): **hoist the load** (folded — plan-critic #1 / brief-reviewer #1): `cli.ts:286` is + `const ranking = rankForBrief(loadStore(storePath), …)` — there is **no `store` variable** (verified). Replace + with `const store = loadStore(storePath); const ranking = rankForBrief(store, pillars, day, { freshDays });`. + Then after `writeFileSync(path, md, "utf8")` (`:290`), add: `const mark = flags["no-mark"] !== "true"; const + marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0; if (mark) saveStore(storePath, + store);` — the **hoisted `store`** holds the full inventory, so acted/skipped records (filtered from the + ranking, still in the store) are preserved on resave; the `.md` is rendered from the pure `ranking` **before** + the mutation. Add `marked` to the `--json` object (`:293`) and the human summary line. +- **Update the `capture` tally comment** (folded — plan-critic #4): `cli.ts:251-252` *"`merged` (existing gained + topics)"* → *"`merged` (existing gained topics and/or a refreshed score)"* (no tally-logic change). +- `usage()` (`:82-91`) + header synopsis (`:5-13`): add `act`/`skip`/`reset --id <id>` and `[--no-mark]`; a + one-line header note (lifecycle verbs set status; the brief excludes handled trends + records surfacing; + re-capture refreshes the score). Make `cli.test` green (incl. the re-captured-changed-score → `merged:1` tally + assertion, plan-critic #4). + +## Step 6 — wire `trend-spotter.md` (prose) + README + +In `agents/trend-spotter.md`: add **one prose line** (no batch-shape change — the per-item `score` is already +emitted, R3a): re-capturing a known trend now **refreshes** its relevance score (timing decays), and the operator +marks trends `acted`/`skipped` via the CLI (`act`/`skip --id`) so the morning brief stops re-surfacing handled +work. Domain-general (Section 17). Update `scripts/trends/README.md`: the status lifecycle (`new`/`acted`/`skipped` ++ `act`/`skip`/`reset`), the seen-log (`surfacedCount`/`lastSurfacedAt`, per-day idempotent, recorded by `brief`), +re-score-on-recapture (last-wins), and the brief's exclude-handled + `--no-mark` behaviour. + +## Step 7 — gate: floors + new unconditional Section 16k + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (`:705`, currently 146) to the **`tests N` line** reported by `(cd scripts/trends && + npm test)` after Steps 1–6 — recounted live, NOT additive-guessed. Stays **inside** the deps guard. **Append** + `+ RE-R3b: lifecycle +N` to the inline breakdown comment. +- Add **Section 16k** ("Trends Lifecycle Wiring", RE-R3b), mirroring Section 16j (header `:1177`, block runs + through `~:1235`). **Placement (verified live — scope-guardian / brief-reviewer):** 16j is the last section + before Section 18 (anti-erosion, header `:1237`) — insert 16k **after 16j's block (`~:1235`), before Section 18 + (`:1237`)** (anti-erosion must stay last so it counts every prior check). Six + **unconditional**, deps-absent-safe checks (pure `grep -qF`/self-test, no `tsx`), the self-test emitting **one** + pass/fail like 16j: + (1) a non-vacuity self-test (a probe carrying `effectiveStatus` accepted, one without rejected); + (2) `grep -qF 'export type TrendStatus' scripts/trends/src/types.ts`; + (3) `grep -qF 'surfacedCount' scripts/trends/src/types.ts` (the seen-log field); + (4) `grep -qF 'export function markSurfaced' scripts/trends/src/store.ts` (the seen-log writer); + (5) `grep -qF 'effectiveStatus' scripts/trends/src/brief.ts` (the brief excludes handled); + (6) `grep -qF 'command === "act"' scripts/trends/src/cli.ts` (the lifecycle verb). +- Bump `ASSERT_BASELINE_FLOOR` (`:1259`, currently 99) → **exactly 105** (99 + the 6 new unconditional 16k + emitters; the self-test emits one pass/fail like 16j, so 105 is deterministic — "live recount" is the safety + net, not a guess). Update the **header-enumeration prose chain** (`:49-53`) by inserting the 16k clause + **between** the 16j clause and the Section-18 clause, preserving sentence flow. +- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3b adds no hook test). It must + still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land. + +## Step 8 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then run brief §7's seven behavioural steps (capture A; re-capture +A with a lower timing → `list --json` shows the composite dropped + `merged:1`; `brief --json` → `marked:1`, +`list` shows `surfacedCount:1`+`lastSurfacedAt`, entry line shows `· \`<id>\``; re-run brief → `marked:0`; +`act --id <A>` → A absent from the brief md; `reset --id <A>` → A reappears; `--no-mark` → no surfacedCount +written). Run full `bash scripts/test-runner.sh` → `FAIL=0` (`ASSERT_BASELINE_FLOOR` 105); run `node --test +hooks/scripts/__tests__/*.test.mjs` → still green (untouched regression). + +## Step 9 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 105, schema v4, +gate total). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3b.md` (no +suffix, tracked); **(2)** code commit — the three `src/*.ts` (`types`/`store`/`brief`/`cli` — four) + three test +files + `agents/trend-spotter.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]` +(D9: single code commit — the lifecycle is tightly coupled). 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 | re-score/migration/brief/cli new cases fail on assertion (logic-RED), not module-not-found | +| — | RED Phase B | `npm test` after non-throwing stubs | effectiveStatus/setStatus/markSurfaced/surfacedIds fail on value assertion against stubs | +| SC1 | status + effectiveStatus + setStatus | `npm test` (store.test) | effectiveStatus absent⇒new; setStatus found/absent; reset⇒new | +| SC2 | re-score last-wins | `npm test` (store.test) | dup w/ diff score → replaced, merged:true, provenance+status+surfaced untouched; identical→merged:false; no-score→unchanged; acted stays acted | +| SC3 | markSurfaced + idempotency | `npm test` (store.test) | increments + sets lastSurfacedAt; same-day re-call marked:0; later day increments; unknown id skipped | +| SC4 | migration v3→v4 | `npm test` (store.test) | v3 loads as v4, intact, no field invented; round-trip writes v4; v4 idempotent; new fields survive; **R3a block reconciled (no regression)** | +| SC5 | brief excludes handled | `npm test` (brief.test) | only `new` in buckets; totals.trends full; only-handled→empty + no-fresh summary; surviving order = R3a total order | +| SC6 | render id + marker + descriptor | `npm test` (brief.test) | full lines `· \`<id>\``; `· sett Nx` at count≥2 / none below; surfacedIds set; exact `ranking:` ending `; excludes acted/skipped`; deterministic | +| SC7 | CLI act/skip/reset | `npm test` (cli.test) + manual | act→acted, skip→skipped, reset→new (via list --json); unknown id→exit 2; missing --id→exit 2 | +| SC8 | CLI brief marks + --no-mark + exclude | `npm test` (cli.test) + manual | surfacedCount:1 + lastSurfacedAt + marked in json; second same-day marked:0; --no-mark no write; md omits acted/skipped | +| SC9 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16k green; ASSERT_BASELINE_FLOOR=105; Section 17; counts 27/19/29; hook suite green | + +## Risks + +- **R1 — the v3→v4 bump silently regresses the R3a migration block** (its hard-coded `3` literals; "v3 + idempotent" is false after the bump). *Mitigated:* Step 3 **explicitly reconciles** the `(RE-R3a / score v2→v3)` + block (flip `3`→`SCHEMA_VERSION`, retitle the idempotent test to a forward-migration test); SC4 asserts the + full suite green after the bump. This is the load-bearing migration subtlety — caught by premise verification + before drafting, not after. +- **R2 — re-score corrupts provenance / status / the seen-log** (over-broad mutation in `addTrend`). *Mitigated:* + the duplicate branch touches **only** `topics` + `score`; `source`/`capturedAt`/`publishedAt`/`status`/ + `surfacedCount`/`lastSurfacedAt` are untouched; SC2 asserts each is preserved (incl. an acted-trend re-capture + keeping `status:"acted"`). +- **R3 — false-merge inflation** (a re-capture with an identical score reported as `merged`). *Mitigated:* the + `JSON.stringify(existing.score) !== JSON.stringify(input.score)` guard (the envelope is built in a fixed key + order by `scoreEnvelope`, so the compare is stable); SC2 asserts an identical re-score → `merged:false`. +- **R4 — non-idempotent surfacing** (re-running today's brief double-counts; the autonomous loop (c) would inflate + `surfacedCount`). *Mitigated:* `markSurfaced` skips records whose `lastSurfacedAt === today`; SC3 + SC8 assert a + same-day re-call → `marked:0`, counts unchanged. +- **R5 — the brief CLI's new store write loses data** (acted/skipped records dropped on resave, or the brief + written from a mutated ranking). *Mitigated:* `markSurfaced` mutates only the `surfacedCount`/`lastSurfacedAt` + of the **surfaced** ids on the **already-loaded full store**; `saveStore` writes the whole store (handled + records preserved); the `.md` is rendered from the pure `ranking` **before** the mutation; SC8 asserts the md + omits acted/skipped AND the store still contains them with surfacing recorded. +- **R6 — `rankForBrief` loses purity** (the status filter or surfacing leaking fs/mutation into the pure render). + *Mitigated:* the filter is a pure read of `effectiveStatus`; the seen-log **write** is only in the `brief` CLI + edge, guarded by `--no-mark`; SC5/SC6 assert deterministic, byte-identical render; `markSurfaced`/`setStatus`/ + `surfacedIds`/`effectiveStatus` are all pure (no fs). +- **R7 — losing the brief's determinism** (the id/marker tokens or the status filter perturbing the total order). + *Mitigated:* the id + `surfacedToken` are deterministic reads; the filter only removes records, preserving the + R3a composite total order on the survivors; SC5/SC6 assert byte-identical output. +- **R8 — editing `trend-spotter.md` trips the de-niche guard.** *Mitigated:* Section 17 runs in the gate; the + added prose is generic lifecycle wording (`acted`/`skipped`/"refresh the score"), vendor/sector-free. +- **R9 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16k is pure + `grep`/self-test on tracked source (no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps guard. +- **R10 — import cycle.** *Mitigated:* the new edges are one-way: `store.ts ← types.ts` (`TrendStatus` type into + `store`, via `types`), `brief.ts → store.ts` (`effectiveStatus` value — brief already imports store), `cli.ts → + {store,brief}` (already). `score.ts`/`item.ts` are untouched. The DAG stays acyclic: `score (leaf) ← types ← + store ← brief ← cli`. +- **R11 — the `status` name collision** (the CLI `status` staleness subcommand vs the new `TrendStatus` lifecycle). + *Accepted:* the subcommand reads store staleness (newest capture), the type is the per-record lifecycle — no + shared code; the collision is pre-existing and cosmetic; not reconciled here (documented non-goal). + +## Plan-critic — folded + +Three Opus reviewers ran, each verifying claims against live code: **scope-guardian ALIGNED** (0 creep, 0 gaps; +counts 27/19/29 + `score.ts`/`item.ts`-untouched + A1/A2/A3-consistency verified live; 2 MINOR plan line-cites); +**brief-reviewer PROCEED_WITH_RISKS** (all 9 load-bearing claims TRUE; the v3→v4 reconcile complete for every +breaking literal, enumerated; 1 MEDIUM + 3 LOW); **plan-critic PROCEED_WITH_RISKS (78/B)** (the two-phase RED, +the atomic bump+reconcile, the `merged` broadening's non-regression, the `surfacedIds` formula, and the gate +arithmetic all verified correct; 1 MAJOR + 5 MINOR). Resolution, each verified against live code: + +- **[MAJOR — plan-critic #1 / MEDIUM — brief-reviewer #1] the `brief` CLI's `store` binding does not exist.** + `cli.ts:286` inlines `rankForBrief(loadStore(storePath), …)` — no `const store`, so the `markSurfaced(store, + …)` / `saveStore(storePath, store)` edit referenced an undefined identifier (would not compile). ✅ Step 5 now + **hoists** `const store = loadStore(storePath); const ranking = rankForBrief(store, …);`; the brief §3 S-cli + + A1 wording + R5 corrected to the hoisted binding. +- **[MINOR — plan-critic #2] not-found id → exit 2 contradicted the documented exit-code contract** (`cli.ts:33` + "2 on usage error"; `cli.ts:31` data-conditions never via exit code). ✅ Step 5 **broadens the `:33` + doc-comment** to "2 on usage error or a not-found id (act/skip/reset)" (a wrong `--id` is an argument-class + error); no third code introduced. +- **[MINOR — plan-critic #3] `· sett Nx` off-by-one** (render precedes the surfacing mutation → the token reflects + the prior-day count). ✅ the **prior-day semantic** is now explicit (Step 4 + brief §3 + README): `· sett Nx` = + "shown on N prior distinct days"; the `brief.test` sets `surfacedCount` directly, §8 exercises the cross-day path. +- **[MINOR — plan-critic #4] `capture` tally comment stale + the re-score CLI tally untested.** ✅ Step 5 updates + the `cli.ts:251-252` comment (`merged` = topics ∪ score-refresh); Step 1 + SC2 add a subprocess assertion that a + re-captured changed-score item reports `merged:1` with the updated composite. +- **[MINOR — plan-critic #5] Step 2 used `TrendStatus` before Step 3 defined it.** ✅ **reordered**: Step 2 adds + the `TrendStatus` type + the three fields to `types.ts` first (then the `store.ts` functions); Step 3 isolates + the atomic `SCHEMA_VERSION` bump + the R3a reconcile. +- **[MINOR — plan-critic #6 / brief-reviewer LOW] stale R3a test titles + comment** (`store.test.ts:571`/`:598` + titles, `:570` comment still say "v3" after the bump). ✅ Step 3 flips both titles to "the current version" + + refreshes the comment, alongside the `:588`/`:602`/`:628` assertion flips. +- **[LOW — brief-reviewer #4] forward-debt: the new R3b migration block hard-coded `4`** (perpetuating the + reconcile-cycle). ✅ Step 3 commits the new block's **target + idempotent** assertions against `SCHEMA_VERSION` + (future-proof; the hard-`4` is the Step-1 RED device only); the v3 **input** fixtures stay literal `3`. +- **[LOW — brief-reviewer #3] `cli.test.ts:247` inert fixture not enumerated.** ✅ added to the scope-fence + enumeration alongside `brief.test.ts:53` ("miss none" now literally true). +- **[MINOR — scope-guardian] two plan line-cites** for the gate placement (`~:1191`/`:1262`). ✅ corrected to + `~:1235` (after 16j's block) / `:1237` (Section 18 header) in Step 7 + the Files-touched table. + +**scope-guardian — ALIGNED:** every SC1–SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held (no +saturation scoring, no scheduler, no brief-diff, no A1–A4, no status/surfaced input on capture, no act/skip-by-title, +no auto-act-on-publish, no new source/agent/command, `BRIEF_SCHEMA_VERSION` unchanged); counts 27/19/29 verified +live; `score.ts`/`item.ts` untouched verified (`itemToInput` already builds the envelope on every capture incl. +re-capture); the R3a-block reconcile is a necessary prerequisite the bump forces, not creep. + +**[plan-critic headless-readiness] N/A** — R3b executes **in-session, operator-driven** (driftsmodell), not as a +headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a/R2b/R3a had none either). diff --git a/docs/research-engine/plan-re-r3c.md b/docs/research-engine/plan-re-r3c.md new file mode 100644 index 0000000..1434b4b --- /dev/null +++ b/docs/research-engine/plan-re-r3c.md @@ -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. diff --git a/docs/research-engine/plan-re-r3d.md b/docs/research-engine/plan-re-r3d.md new file mode 100644 index 0000000..a6a3181 --- /dev/null +++ b/docs/research-engine/plan-re-r3d.md @@ -0,0 +1,335 @@ +# Plan — RE-R3d: temporal overlay — first-mover + saturation (R3 slice b) + +> **Brief:** `docs/research-engine/brief-re-r3d.md`. **Slice:** RE-R3d (research-engine rung-2 — R3 slice **(b)**: +> the live temporal overlay). Closes the rest of hull #3 — **first-mover** + **saturation** (relevance ✅ R3a; +> status ✅ R3b; `angle` already a scored dimension). **Zero new files** — pure EDITs. +> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)** +> `temporalSignal` is a NEW named export of the EXISTING `brief.ts`; Node16 ESM throws a missing named import at +> module-load (every `brief.test.ts` test would error, not assert), so land a **non-throwing stub** first +> (`temporalSignal → {tier:"fresh",…}` + the type exports + `BriefEntry.temporal` populated by the stub in +> `rankForBrief`), then record value-assertion RED against it (the constant "fresh" stub fails the +> first-mover/saturated/ordering/token assertions); **(B)** the CLI flag tests are value-RED against the existing +> `brief` handler (the new flags are silently ignored by `parseFlags` today → tuned-threshold behaviour unchanged). +> Then GREEN: real `temporalSignal` + `RankOptions` knobs + `cmp` key → render tokens + summary marker + descriptor +> → `cli.ts` flags → wire `trend-spotter.md` (prose) + SSOT note + README → gate floors + Section 16m → +> behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-26):** SB1 derived-at-brief (no schema bump) · SB2 +> refine recency within the composite tier (composite stays PRIMARY). Go-gate D1–D10 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 morning brief read the **temporal axis** R3b logs but the ranking ignores. Two derived signals, computed +**at brief time** from already-persisted fields (no new store field, no schema bump): **first-mover** (recent AND +never surfaced on a prior day — "you'd be early") ranks a trend up; **saturation** (surfaced on `>= saturationAt` +prior days — "you keep seeing this and not acting") ranks it down. The R3a relevance composite stays the PRIMARY +sort key (SB2); the overlay is a new `cmp` key **after** pillar-overlap, **before** `effectiveDate`, re-ordering +only WITHIN a (composite, overlap) tier. It also promotes the R3b `· sett Nx` display hint into a graded badge set +and surfaces a first-mover marker on the one-line summary. **No schema change** (`SCHEMA_VERSION` 4 / +`BRIEF_SCHEMA_VERSION` 1 untouched); **no new agent/command/reference/module/file**; `score.ts`/`store.ts`/ +`types.ts`/`item.ts`/`schedule.ts`/`run-daily.sh` untouched. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/brief.ts` | **EDIT** — add `TemporalTier`/`TemporalSignal` exports + pure `temporalSignal(ageDays, surfacedCount, {firstMoverDays, saturationAt})`; `RankOptions` gains `firstMoverDays?`(def 2)/`saturationAt?`(def 3); `BriefEntry` gains `temporal`; `rankForBrief` threads the opts + populates `temporal`; `cmp` gains `b.temporal.rank - a.temporal.rank` after overlap, before effectiveDate; `surfacedToken`→`temporalToken` (badges); `briefSummary` first-mover marker; `ranking:` descriptor names the new key | SC1–SC5, SC7, SC9 | +| `scripts/trends/src/cli.ts` | **EDIT** — `brief … [--first-mover-days N] [--saturation-at N]` (mirror `--fresh-days` at `:312-317`, into `rankForBrief` opts at `:323`); usage line (`:110`) + header synopsis (`:14`) note the overlay; **`schedule` untouched** (nightly uses defaults) | SC6 | +| `scripts/trends/tests/brief.test.ts` | **EDIT** — `temporalSignal` unit incl. clamp + future-date + neutral cases (SC1/SC2/SC9); **disagreement** ordering fixture (SC3); render badges (SC4) + **4 touched assertions**: `:407` `sett 3x`→`mettet (3x)`, `:408` unchanged (≥2 gate), `:325-331`+`:410-416` descriptor segment, `:96-102` re-based to a shared tier; summary marker (SC5); determinism (SC7); no-score-mutation (SC8) | SC1–SC5, SC7–SC9 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — subprocess: `brief --first-mover-days N --saturation-at N` changes tiers vs defaults; absent → defaults 2/3; `--first-mover-days -1`/`x`, `--saturation-at 0`/`x` → exit 2 | SC6 | +| `scripts/trends/src/types.ts` · `store.ts` · `score.ts` · `item.ts` · `schedule.ts` · `run-daily.sh` | **UNTOUCHED** — no data-shape/scoring/store/scheduler change. Listed to assert they are *not* in scope (`SCHEMA_VERSION` 4 held in `types.ts`; the R3c scheduler suite stays green untouched). | — | +| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: the brief now applies a live temporal overlay (first-mover↑/saturated↓) at rank time, derived from dates + the seen-log; capture path unchanged. Domain-general. | — | +| `references/trend-scoring-modes.md` | **EDIT (one-line note, "Consumers")** — the brief applies a within-composite-tier temporal overlay (RE-R3d); **no** weight/band/formula change. | — | +| `scripts/trends/README.md` | **EDIT** — new `## Temporal overlay (RE-R3d)` between the R3c scheduler section (`:135-154`) and `## Tests` (`:155`): first-mover/saturation, derived-not-stored, the flags + defaults, the `cmp` integration, the badges | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` (`:713`, 192)→recount + breakdown comment; NEW unconditional **Section 16m** between 16l's `echo ""` (`:1374`) and Section 18 (`:1376`); `ASSERT_BASELINE_FLOOR` (`:1403`) 111→**117**; header-enum chain (`:57-62`) + Section-18 floor-history narration (`:1376-1402`, ends "= 111") | SC10 | +| `docs/research-engine/{brief,plan}-re-r3d.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 117, gate total; schema unchanged v4; **correct the stale :709/:1329 line-cites to live :713/:1403**). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `types.ts`/`store.ts`/`score.ts`/`item.ts` (no data-shape/scoring change; +`SCHEMA_VERSION` 4) · `schedule.ts`/`run-daily.sh` + their tests (R3c untouched — the nightly run uses default +thresholds; no regression surface) · the SessionStart hook + its tests (R3d changes no frontmatter *field*; the +summary's new marker is regex-safe; no hook change/test) · `config/*` · `commands/*` (29) · `agents/*` count (19 +— `trend-spotter.md` is a prose EDIT) · `references/*` count (27 — `trend-scoring-modes.md` is an EDIT) · +`algorithm-signals-reference.md` (cited for grounding, not edited) · `.gitignore` (no new artifact) · +`BRIEF_SCHEMA_VERSION` (1) · `SCHEMA_VERSION` (4). + +## Step 1 — (RED, two phases) failing tests across brief/cli + +**Phase A — stub-first, then value-assertion RED** (`brief.test.ts` imports the new `brief.ts` exports): +- Land **non-throwing stubs** so the static imports resolve (Node16 ESM links named imports before any test runs): + in `brief.ts` — add `export type TemporalTier`, `export interface TemporalSignal`, and `export function + temporalSignal(): TemporalSignal { return { tier: "neutral", firstMover: false, surfacings: 0, rank: 2 }; }` (a + constant stub ignoring its args); add `temporal: TemporalSignal` to `BriefEntry` and populate it in `rankForBrief` + via the stub. The `cmp` key + `temporalToken` + the summary marker are **NOT** added yet (Steps 2–3). +- `brief.test.ts` (value-RED against the stub): + - **unit (SC1/SC2/SC9)** — `temporalSignal(1,0,{firstMoverDays:2,saturationAt:3})` expects + `{tier:"first-mover",rank:3}` (stub returns "neutral" → RED); `(5,3,…)`→`saturated`; `(5,2,…)`→`warming`; + `(5,0,…)`→`neutral`; `(3,0,…)`→`neutral` (past window); `(-1,0,…)`→`neutral` (future-date `>=0` guard); the + clamp case `(5,5,{…,saturationAt:0})`→`saturated` via `at=1`. + - **ordering (SC3) — DISAGREEMENT fixture** (folded — plan-critic M1: `surfacedCount` correlates with age, so a + naive first-mover-vs-saturated fixture is *already* ordered by the existing `effectiveDate`-desc key → GREEN in + Phase A, vacuous in GREEN). Three same-overlap entries: **A** `neutral` (surfaced 0, **older** date ≈5d, + composite 7.0); **B** `warming` (surfaced 2, **newer** date ≈1d, composite 7.0); **Z** `saturated` (surfaced 4, + composite **8.5**). Expect `topMatches.map(e=>e.trend.title) === [Z, A, B]`. Phase A (stub: constant rank, no + temporal key) yields `[Z, B, A]` (`effectiveDate`-desc decides A vs B) → RED; GREEN (Step 2 inserts + `temporal.rank`) flips A above the newer B, proving the key. + - **render (SC4)** — `renderBrief(...)` expects `· 🥇 først ute` on the first-mover entry, `· 🔁 mettet (3x)` on a + `surfacedCount 3` entry, `· sett 2x` on `surfacedCount 2`, **no badge** on `surfacedCount 1`, none on a + `neutral` entry (stub render still emits the old `surfacedToken` → RED). **Four existing assertions touched** + (folded — brief-reviewer MEDIUM-2/3): (1) `:407` `· sett 3x` → `· 🔁 mettet (3x)` (its surfacedCount-3 trend is + now saturated); (2) `:408` `!md.includes("sett 1x")` **stays unchanged** (the ≥2 badge gate keeps + surfacedCount-1 markerless); (3) `:325-331` + (4) the regex `:410-416` (the `ranking:` descriptor) gain `then + temporal (first-mover↑/saturated↓), `; and the `effectiveDate`-isolation test `:96-102` is **re-based** so both + entries share a `neutral` tier (else the new key, not `effectiveDate`, silently decides it — coverage erosion). + - **summary (SC5)** — `briefSummary(...)` with a first-mover top expects `· 🥇 først ute` in the headline (stub → + no marker → RED); assert the summary has no `"` and no `\n`. + - **no-score-mutation (SC8)** — capture a store's `score.composite` values, run `rankForBrief` (pure — no + mutation) and assert unchanged; assert `BRIEF_SCHEMA_VERSION === 1`, `SCHEMA_VERSION === 4`. + +**Phase B — subprocess value-RED against the existing CLI handler** (no new import; the flags are parsed-but-unused +today): +- `cli.test.ts` — `brief --pillars ai --first-mover-days 1 --saturation-at 2 --store <seeded> --out <tmp>`: + today `parseFlags` stores the flags but the `brief` handler ignores them → the rendered tiers match the defaults + → RED. **The tier badges live in the written `.md` body, not in `--json`** (which returns only + `{path,date,totals,summary,marked}`, `cli.ts:336`), so the assertion **`readFileSync(path)`** and checks the + badge (a `surfacedCount 2` trend renders `· 🔁 mettet (2x)` at `--saturation-at 2`, but `· sett 2x` at the + default) — folded — brief-reviewer LOW-5; the first-mover marker is also observable via the `--json` `summary`. + `--first-mover-days x` / `--saturation-at 0` today are ignored (exit 0) → RED against the expected `usage` exit 2. + +**RED proof (record in commit, two phases):** Phase A — after the non-throwing `temporalSignal` stub lands, +`(cd scripts/trends && npm test)` fails the unit/ordering/render/summary cases on **value** assertions against the +constant-"fresh" stub (not module-not-found). Phase B — the `cli.test` flag cases fail on value/exit assertions +against the flag-ignoring handler. The plan does **not** claim a single "everything fails before any code" run. + +## Step 2 — (GREEN) `brief.ts` — the real signal + ranking integration + +Replace the Phase-A stub with the real, pure implementation: +- `export type TemporalTier = "first-mover" | "fresh" | "warming" | "saturated";` +- `export interface TemporalSignal { tier: TemporalTier; firstMover: boolean; surfacings: number; rank: number; }` +- `export function temporalSignal(ageDays, surfacedCount, opts): TemporalSignal`: + ```ts + const surfacings = surfacedCount ?? 0; + const at = Math.max(1, opts.saturationAt); // defensive clamp (plan-critic m3) + const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0; // >=0 guard (m5) + const tier: TemporalTier = firstMover ? "first-mover" + : surfacings >= at ? "saturated" + : surfacings >= 1 ? "warming" : "neutral"; + const rank = tier === "first-mover" ? 3 : tier === "neutral" ? 2 : tier === "warming" ? 1 : 0; + return { tier, firstMover, surfacings, rank }; + ``` + Pure (no clock/fs/env). Branches disjoint + total (`firstMover` requires `surfacings === 0`; `saturated`/ + `warming` both require `surfacings >= 1`). +- `RankOptions` (`:51-54`) gains `firstMoverDays?: number` + `saturationAt?: number`. `rankForBrief` (`:78`) reads + `const firstMoverDays = opts.firstMoverDays ?? 2; const saturationAt = opts.saturationAt ?? 3;` and populates + `BriefEntry.temporal = temporalSignal(ageDays, trend.surfacedCount, { firstMoverDays, saturationAt })` at the + push site (`:93`). +- `cmp` (`:102-107`): insert `b.temporal.rank - a.temporal.rank ||` **after** `b.overlap - a.overlap ||` and + **before** `b.effectiveDate.localeCompare(a.effectiveDate)`. Composite stays the first key (SB2). The chain stays + a total order. The bucketing (`isFresh`/`freshDays`, `:109-113`) is **unchanged**. +Make the Phase-A unit + ordering cases green. + +## Step 3 — (GREEN) `brief.ts` — render badges + summary marker + descriptor + +- Replace `surfacedToken` (`:154-157`) with `temporalToken(e: BriefEntry): string`: + ```ts + const t = e.temporal; + if (t.tier === "first-mover") return " · 🥇 først ute"; + if (t.tier === "saturated") return ` · 🔁 mettet (${t.surfacings}x)`; + if (t.tier === "warming" && t.surfacings >= 2) return ` · sett ${t.surfacings}x`; // preserves the R3b ≥2 badge contract + return ""; // neutral, or warming with surfacings 1 + ``` + Replace the `surfacedToken(e)` call in `renderTopEntry` (`:162`) and `renderBulletEntry` (`:171`) with + `temporalToken(e)`. (`scoreToken` untouched.) +- `briefSummary` (`:131-141`): compute `const fm = top.temporal.firstMover ? " · 🥇 først ute" : "";` and emit + `(${pillar}${band}${fm} · ${top.ageDays}d)`. The marker carries no `"`/`\n` (hook-regex invariant, `:128-130`). +- `renderBrief`'s `ranking:` descriptor (`:187`): insert `then temporal (first-mover↑/saturated↓), ` between + `pillar-overlap desc, ` and `then publishedAt desc`. +Make the Phase-A render + summary cases green. + +## Step 4 — (GREEN) `cli.ts` — the two `brief` threshold flags + +After the `--fresh-days` block (`:313-317`), add (mirroring its idiom): +```ts +let firstMoverDays = 2; +if (flags["first-mover-days"] && flags["first-mover-days"] !== "true") { + const n = Number.parseInt(flags["first-mover-days"], 10); + if (Number.isNaN(n) || n < 0) usage("--first-mover-days must be a non-negative integer"); + firstMoverDays = n; +} +let saturationAt = 3; +if (flags["saturation-at"] && flags["saturation-at"] !== "true") { + const n = Number.parseInt(flags["saturation-at"], 10); + if (Number.isNaN(n) || n < 1) usage("--saturation-at must be a positive integer"); + saturationAt = n; +} +``` +Pass into the rank call (`:323`): `rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt })`. +Update the `brief` usage line (`:110`) + header synopsis (`:14`) to list the two flags + a one-line note that the +brief applies a derived temporal overlay (first-mover↑/saturated↓) at rank time. **No new exit code** (0/2). The +`schedule` branch (`:343-417`) is **untouched** (nightly run uses defaults). Make the Phase-B `cli.test` cases green. + +## Step 5 — wire `trend-spotter.md` (prose) + SSOT note + README + +- `agents/trend-spotter.md` — one prose line (no batch-shape change): the morning brief now applies a **live + temporal overlay** at rank time (first-mover ranked up, repeatedly-surfaced/saturated ranked down), **derived** + from the publish/capture dates + the seen-log — no new capture step; the polling/capture path is unchanged. + Domain-general (Section 17). +- `references/trend-scoring-modes.md` — under "Consumers", add ONE line: the morning brief applies a brief-time + temporal overlay (first-mover/saturation, RE-R3d) as a within-composite-tier ranking refinement; this does **not** + change the capture-time weights above. (No weight/band/formula edit.) +- `scripts/trends/README.md` — add `## Temporal overlay (RE-R3d)` between `:154` and `## Tests` (`:155`): the + first-mover/saturation definitions (self-surfacing, not market-coverage), the **derived-not-stored** boundary + (no schema bump), the `--first-mover-days`(2)/`--saturation-at`(3) flags, the `cmp` integration (composite stays + primary), the badge set (`🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` / none). + +## Step 6 — gate: floors + new unconditional Section 16m + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (`:713`, currently **192**) 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-R3d: brief +N, cli +N (temporal overlay)` to the inline breakdown comment. +- Add **Section 16m** ("Trends Temporal Overlay", RE-R3d), mirroring Section 16l (unconditional, deps-absent-safe, + pure `grep -qF`/self-test, no `tsx`, **all literals ASCII** — the badge emoji are NEVER grepped; the shell must + stay ASCII-clean for bash 3.2 `set -u`). **Placement (verified live):** between Section 16l's trailing `echo ""` + (`:1374`) and the Section 18 header (`:1376`) — anti-erosion stays last. Six **unconditional** checks, the + self-test emitting **one** pass/fail like 16l: + (1) a non-vacuity self-test (a probe carrying `temporalSignal` accepted, one without rejected); + (2) `grep -qF 'export function temporalSignal' scripts/trends/src/brief.ts`; + (3) `grep -qF 'b.temporal.rank' scripts/trends/src/brief.ts` (the cmp integration); + (4) `grep -qF '"first-mover"' scripts/trends/src/brief.ts` (the tier literal); + (5) `grep -qF 'first-mover-days' scripts/trends/src/cli.ts` (the flag); + (6) `grep -qF 'saturation-at' scripts/trends/src/cli.ts` (the flag). +- Bump `ASSERT_BASELINE_FLOOR` (**`:1403`**, currently **111**) → **exactly 117** (111 + the 6 new unconditional + 16m emitters; the self-test emits one pass/fail like 16l, so 117 is deterministic — "live recount" is the safety + net, not a guess). Insert the 16m clause into the **header-enumeration prose chain (`:57-62`)** before "…the + assertion-count anti-erosion floor (SC6) in Section 18", preserving sentence flow. **Append** the RE-R3d (→117) + narration to the **Section-18 floor-history comment** (`:1376-1402`, which ends "= 111"). +- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3d 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 five behavioural steps: seed a store with a +fresh+unsurfaced, a warming (`surfacedCount` 2), and a saturated (`surfacedCount` 4) trend at the **same** +composite; `brief --pillars … --out /tmp/r3d-mb --store /tmp/r3d.json` → the fresh+unsurfaced sorts first with +`· 🥇 først ute`, the saturated last with `· 🔁 mettet (4x)`, the `ranking:` descriptor names the temporal key; +re-run with `--first-mover-days 0 --saturation-at 2` → first-mover badge gone, `surfacedCount 2` escalates to +`mettet (2x)`; `--first-mover-days x` → exit 2; confirm the seeded `score.composite` values are unchanged after the +brief (only `surfacedCount`/`lastSurfacedAt` advance). Run full `bash scripts/test-runner.sh` → `FAIL=0` +(`ASSERT_BASELINE_FLOOR` 117, trends ≥ new floor, Section 16m green, Section 17 de-niche green, counts 29/19/27); +run `node --test hooks/scripts/__tests__/*.test.mjs` → still green (untouched regression); confirm `schedule.test`/ +`run-daily.test` still green (R3c untouched). + +## Step 8 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 117, gate total; +schema unchanged v4; **correct the stale :709/:1329 cites to live :713/:1403**). Commit order (house style): +**(1)** docs commit `docs/research-engine/{brief,plan}-re-r3d.md` (no suffix, tracked); **(2)** code commit — +`brief.ts` + `cli.ts` + `brief.test.ts` + `cli.test.ts` + `agents/trend-spotter.md` + +`references/trend-scoring-modes.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]` +(D10: single code commit — the overlay 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)` after the `temporalSignal` stub | unit/ordering/render/summary cases fail on **value** assertions vs the constant-"fresh" stub (not module-not-found) | +| — | RED Phase B | `npm test` (cli.test) before the flag impl | `--first-mover-days`/`--saturation-at` cases fail on value/exit vs the flag-ignoring handler | +| SC1 | first-mover detection | `npm test` (brief.test) | `(≤firstMoverDays, surfaced 0)` → first-mover; `(>window,0)`/`(≤window,≥1)` → not | +| SC2 | saturation grading | `npm test` (brief.test) | `surfacings ≥ saturationAt` → saturated; `1..at-1` → warming; `0` → fresh (inclusive `>=`) | +| SC3 | ranking — within-tier re-order, composite dominates (DISAGREE fixture) | `npm test` (brief.test) | `[Z(8.5,sat), A(7.0,neutral,older), B(7.0,warming,newer)]`; temporal key flips A above newer B; Phase A `[Z,B,A]`→RED; total order | +| SC4 | render badges + ≥2 boundary | `npm test` (brief.test) | `🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` (warming≥2) / none (warming 1, neutral); `:407` updated, `:408` stays green | +| SC5 | summary first-mover marker | `npm test` (brief.test) | first-mover top → `· 🥇 først ute` in headline; else absent; no `"`/`\n` | +| SC6 | CLI flags | `npm test` (cli.test) | flags change tiers vs defaults; defaults 2/3 when absent; bad values → exit 2 | +| SC7 | determinism | `npm test` (brief.test) | same `(store,pillars,today,freshDays,firstMoverDays,saturationAt)` → byte-identical `.md` | +| SC8 | no schema / no score mutation | `npm test` (brief.test) | `SCHEMA_VERSION` 4; `BRIEF_SCHEMA_VERSION` 1; `score.composite` unchanged after a brief | +| SC9 | purity | `npm test` (brief.test) | `temporalSignal` stable over a grid; first-mover ⊆ recent∧unsurfaced; saturated ⇔ `surfacings≥saturationAt` | +| SC10 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16m green; `ASSERT_BASELINE_FLOOR`=117; Section 17; counts 29/19/27; hook suite green | + +## Risks + +- **R1 — changing `cmp` re-orders existing brief output → silently breaks downstream expectations.** *Mitigated:* + composite stays the PRIMARY key (SB2) — the overlay only re-orders WITHIN a (composite, overlap) tier; the + bucketing is unchanged; SC3 pins the exact order; SC7 pins byte-determinism; the hook reads only date+summary + (unaffected). +- **R2 — the `surfacedToken`→`temporalToken` promotion breaks pinned R3b/descriptor assertions.** + *Mitigated/expected (folded — all three reviewers):* the warming badge is gated at `surfacings >= 2`, so + `brief.test.ts:408` (`!md.includes("sett 1x")`) stays green and the R3b ≥2 contract is preserved exactly; Step 1 + enumerates the **four** touched assertions (`:407` `sett 3x`→`mettet (3x)`; `:325-331`+`:410-416` descriptor) and + re-bases the coverage-eroded `:96-102`. `surfacedCount 2` still renders `· sett 2x`. +- **R3 — emoji in the gate crashes bash 3.2 `set -u`.** *Mitigated:* Section 16m greps ONLY ASCII literals + (`temporalSignal`, `b.temporal.rank`, `"first-mover"`, `first-mover-days`, `saturation-at`); the emoji live only + in `brief.ts` source + rendered output, asserted by the TS tests, never by the shell gate. +- **R4 — the summary marker breaks the SessionStart extractYaml regex.** *Mitigated:* `🥇 først ute` carries no + `"` and no `\n`; SC5 asserts the invariant; the hook suite is a land-time regression check. +- **R5 — saturation framing overclaims (reads as market-coverage).** *Accepted/honest:* R3d's saturation is + **self-surfacing** (our seen-log), a proxy for a closing/ignored window — NOT external coverage (that is slice + e, AI polling). The README + badge wording say "seen N×", not "covered online"; the brief §4 non-goal states the + boundary. No salesmanship. +- **R6 — first-mover default (2) too tight / saturationAt default (3) arbitrary.** *Mitigated:* both are + CLI-tunable (`--first-mover-days`/`--saturation-at`), documented as deliberate defaults (like `freshDays` 7), + grounded in the SSOT timing band (`<24-72h`) + the existing `sett Nx` `>=2` hint; D1/D2 are operator go-gate + knobs. +- **R7 — float/`-1` composite sentinel interaction with the new integer key.** *Mitigated:* the temporal key is a + separate `||` term (a small integer diff); it never touches the `score?.composite ?? -1` term; the comparator + stays a sum-free short-circuit chain (no NaN risk). +- **R8 — editing `trend-spotter.md` / the SSOT trips the de-niche guard (Section 17).** *Mitigated:* the added + prose + the SSOT note carry only generic overlay wording; pillars/topics stay config; Section 17 runs in the + gate. +- **R9 — gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16m is pure `grep`/self-test on + tracked source (`brief.ts` + `cli.ts`; no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps + guard. +- **R10 — STATE's pinned floor line-cites (`:709`/`:1329`) are stale (live `:713`/`:1403`).** *Mitigated:* caught + at brief time (the line numbers drifted when R3c added Section 16l + the floor-history narration); the plan cites + live values; Step 8 corrects STATE. +- **R11 — a temporal ordering test that passes WITHOUT the feature (false RED / vacuous GREEN).** *Mitigated + (folded — plan-critic M1):* `surfacedCount` correlates with age, so a naive first-mover-vs-saturated fixture is + already ordered by `effectiveDate`-desc. The SC3 fixture is built to force the temporal key and `effectiveDate` + to **disagree** (older-`neutral` A vs newer-`warming` B at equal composite), so it is RED in Phase A and the new + key is provably what decides in GREEN; the coverage-eroded `:96-102` is re-based to a shared tier. + +## Plan-critic — folded + +Three Opus reviewers ran on the brief + this plan, each verifying against live code; they **converged on the same +two defects**. Verdicts: **scope-guardian MIXED** (0 hard creep; both confirmed decisions honored; every SC traces +to a step; the floor/line cites verified live); **brief-reviewer PROCEED_WITH_RISKS** (all seven RED-premise/ +correctness claims HOLD; the gap was GREEN-completeness — 1 of 4 breaking assertions listed); **plan-critic +APPROVE_WITH_NOTES 78/B** (floor arithmetic, line-cites, grep sentinels, cmp total-order, two-phase-RED structure +all verified correct). Per-finding resolution (full headline list in `brief-re-r3d.md §9`): + +- **[MAJOR — all three] warming badge fired at `>=1`, but live `surfacedToken` fires at `>=2` (`brief.ts:156`) + + `brief.test.ts:408` pins `!sett 1x`.** ✅ Step 3 gates the warming badge at `surfacings >= 2` (R3b ≥2 contract + preserved exactly; `:408` unchanged); the warming *tier* still demotes in `rank`. SC4 gains the surfacedCount-1 + no-badge boundary. The "preserves the hint" wording is corrected. +- **[MAJOR — plan-critic M1 / brief-reviewer MEDIUM-3] the ordering test was not RED + vacuous** (`surfacedCount` + correlates with age → `effectiveDate`-desc already orders first-mover-vs-saturated). ✅ Step 1 SC3 fixture forces + temporal↔date **disagreement** (older-`neutral` A vs newer-`warming` B, equal composite; expect `[Z,A,B]`, Phase + A `[Z,B,A]`→RED); `:96-102` re-based to a shared tier (R11). +- **[MEDIUM — brief-reviewer MEDIUM-2] the `ranking:` descriptor change breaks `:325-331` + `:410-416`.** ✅ Step 1 + + the Files table enumerate all four touched assertions, not one. +- **[MINOR — plan-critic m3] `temporalSignal` undefensive vs `saturationAt < 1`.** ✅ Step 2 clamps `const at = + Math.max(1, opts.saturationAt)` inside the pure function (the CLI guard alone is insufficient — the function is a + public, gate-grepped export). SC2 clamp case added. +- **[MINOR — plan-critic m4] the "fresh" tier was a misnomer** (collides with `freshDays`; a 30-day unsurfaced + trend is not "fresh"). ✅ renamed **`neutral`** throughout (Steps 1–3, SCs); the gate sentinel greps `"first-mover"` + (unaffected). The in-bucket effect (unsurfaced ranks above seen-and-skipped within `olderMatched`) documented in + brief §3 as intended. +- **[MINOR — plan-critic m5] future `publishedAt` (ageDays < 0) became a first-mover "act now" headline.** ✅ Step 2 + adds the `ageDays >= 0` guard (future → `neutral`). SC1 gains the `(-1,0)` case. +- **[MINOR — plan-critic m6 / brief-reviewer] nightly run locked to default thresholds** (the primary saturation + consumer). ✅ reframed in brief §4 as a known limitation, not a "later nicety." +- **[LOW — brief-reviewer LOW-5] SC6 tier assertions can't read tiers from `--json`** (it omits the body badges). + ✅ Step 1 Phase B reads the written `.md` via `readFileSync(path)`; the first-mover marker is also in the `--json` + `summary`. +- **[LOW — all three] long-form `angle` cite `:34` → `:32`** (`:34` = `currency`; substance holds). ✅ brief §0/§2 + corrected. + +**Verified correct (no change needed):** the floor arithmetic (16m = 1 self-test + 5 greps = 6 → `ASSERT_BASELINE_ +FLOOR` 111→117), all live cites (`:713`/`:1403`/`:1374`/`:1376`/`:57-62`), the six ASCII grep sentinels match the +literals Steps 2–4 write, the `cmp` insertion preserves a total order with no NaN risk, and the two-phase RED +structurally avoids a module-load ERROR. **This slice is authored + landed in-session (driftsmodell), not as a +headless autonomous run, so per-step revert/halt clauses are not needed** (R1/R2/R3a/R3b had none either). diff --git a/docs/research-engine/plan-re-r3e.md b/docs/research-engine/plan-re-r3e.md new file mode 100644 index 0000000..00880b5 --- /dev/null +++ b/docs/research-engine/plan-re-r3e.md @@ -0,0 +1,401 @@ +# Plan — RE-R3e: brief history + day-over-day diff (R3 slice d) + +> **Brief:** `docs/research-engine/brief-re-r3e.md`. **Slice:** RE-R3e (research-engine rung-2 — R3 slice **(d)**: +> brief history + day-over-day diff). Closes hull **#7** (*"ingen brief-historikk"*) — each brief records the +> trends it showed (`surfaced:` frontmatter) and renders **"Nytt siden sist"** against the most recent prior +> brief. **Zero new files** — pure EDITs (the two tracked slice docs aside). +> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)** +> `diffSurfaced`/`parseSurfacedFrontmatter`/`selectPriorBriefFile` (+ `BriefDiff`) are NEW named exports of the +> EXISTING `brief.ts`; Node16 ESM throws a missing named import at module-load (every `brief.test.ts` test would +> error, not assert), so land **non-throwing stubs** first (constant returns; `renderBrief`/`briefSummary` gain an +> optional `diff` param ignored by the stub render), then record value-assertion RED against them (the constant +> stubs fail the diff/parse/select/section/marker assertions); **(B)** the CLI two-day diff test is value-RED +> against the existing `brief` handler (no `surfaced:` write, no prior read, no `diff` in `--json` today). +> Then GREEN: real `diffSurfaced`/`parseSurfacedFrontmatter`/`selectPriorBriefFile` → `surfaced:` frontmatter + +> `BRIEF_SCHEMA_VERSION` 1→2 → `## 🆕 Nytt siden sist` section → summary marker → `cli.ts` prior-discovery + +> `--json diff` → wire `trend-spotter.md` (prose) + README → gate floors + Section 16n → behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-26):** SD1 frontmatter `surfaced:` (no sidecar) · +> SD2 `added` w/ titles + `dropped` as a count (`brief.ts` store-free). 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 + +Turn the dated morning brief from a **standalone daily snapshot** into a **history rung with a day-over-day diff**. +Each brief persists the set of trend ids it showed into its YAML frontmatter (`surfaced: <id-csv>`), bumping +`BRIEF_SCHEMA_VERSION` 1→2 (the store's `SCHEMA_VERSION` stays 4 — **no store field**). The next brief discovers +the most recent **prior** dated file (strictly `< today`), parses its `surfaced:` line, and renders a +**`## 🆕 Nytt siden sist`** section — `added` (in today, not prior — the headline, with titles resolved from the +ranking), `carried`/`dropped` as a one-line count — plus a ` N nye siden sist.` marker on the one-line summary the +SessionStart hook already surfaces. The diff is a **pure, render-time** layer: `rankForBrief` and the R3a +composite / R3d temporal overlay are **unchanged**; `brief.ts` stays **store-free and fs-free** (the directory + +file reads live at the `cli.ts` edge, injected like `today`/`pillars`). **No new agent/command/reference/module/ +file**; `types.ts`/`store.ts`/`score.ts`/`item.ts`/`schedule.ts`/`run-daily.sh` + the hook untouched. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/brief.ts` | **EDIT** — `BRIEF_SCHEMA_VERSION` 1→2; add `BriefDiff` interface + pure `diffSurfaced(currentIds, priorIds, priorDate)` + `parseSurfacedFrontmatter(md)` + `selectPriorBriefFile(filenames, today)`; `renderBrief` gains optional `diff?` → emits `surfaced:` frontmatter line (before `schemaVersion:`) + the `## 🆕 Nytt siden sist` section (before Topp-treff); `briefSummary` gains optional `diff?` → ` N nye siden sist.` marker; `renderBrief` passes `diff` through to `briefSummary` | SC1–SC8 | +| `scripts/trends/src/cli.ts` | **EDIT** — `brief` handler: `readdirSync(outDir)` (existsSync-guarded) → `selectPriorBriefFile` → `readFileSync` + `parseSurfacedFrontmatter` → `diffSurfaced(surfacedIds(ranking), priorIds, priorDate)` → `renderBrief(ranking, diff)`; thread `diff` into the shared summary at `:350` (`briefSummary(ranking, diff)` — one-source, MAJOR-2); `--json` gains `diff:{priorDate,added,carried,dropped}` (counts); console line appends `, N nye siden sist`; add `readdirSync` to the `node:fs` import | SC9 | +| `scripts/trends/tests/brief.test.ts` | **EDIT** — `diffSurfaced` unit (SC1), `parseSurfacedFrontmatter` unit (SC2), `selectPriorBriefFile` unit (SC3), `surfaced:` frontmatter + round-trip (SC4), `## 🆕 Nytt siden sist` four branches (SC5), summary marker + the `briefSummary(r)===briefSummary(r,empty)` invariant (SC6), `BRIEF_SCHEMA_VERSION===2` (SC7), determinism-with-diff (SC8). **Stubs imported in Phase A.** No existing assertion breaks (verified §Step 1) | SC1–SC8 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — subprocess two-day sequence: day-1 brief writes `surfaced:`; day-2 over a +1-trend store → `Nytt siden sist (<day1>)` lists the new trend, `--json diff.added≥1`, console `N nye siden sist`; first run → `diff.priorDate===null`; custom `--out` isolates discovery | SC9 | +| `scripts/trends/src/types.ts` · `store.ts` · `score.ts` · `item.ts` · `schedule.ts` · `run-daily.sh` | **UNTOUCHED** — no data-shape/scoring/store/scheduler change (`SCHEMA_VERSION` 4 held in `types.ts`; the R3c scheduler suite + R3d ranking stay green untouched). Listed to assert they are *not* in scope. | — | +| `hooks/scripts/session-start.mjs` + its tests | **UNTOUCHED** — the `surfaced:` frontmatter line is `^surfaced:`-keyed (the `^summary:`-anchored `extractYaml` cannot match it); the summary marker is `"`/`\n`-free. No hook edit/test; the hook suite is a land-time regression check. | — | +| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: the brief now records its shown set + renders a day-over-day diff ("Nytt siden sist"); capture path unchanged. Domain-general. | — | +| `scripts/trends/README.md` | **EDIT** — new `## Brief history + diff (RE-R3e)` between the R3d temporal-overlay section and `## Tests`: `surfaced:` record, `selectPriorBriefFile` strict-prior + same-day determinism, `diffSurfaced` partitions, section + summary marker, `BRIEF_SCHEMA_VERSION 1→2` (artifact-only) | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` (live `:716`, 216)→recount + breakdown comment; NEW unconditional **Section 16n** between 16m's `echo ""` and Section 18; `ASSERT_BASELINE_FLOOR` (live `:1473`) 117→**123**; header-enum chain (`:53-64`) + Section-18 floor-history narration (ends "= 117") | SC10 | +| `docs/research-engine/{brief,plan}-re-r3e.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 123, gate total; `BRIEF_SCHEMA_VERSION` 1→2; store schema unchanged v4; correct stale line-cites to live `:716`/`:1473`). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `types.ts`/`store.ts`/`score.ts`/`item.ts` (no data-shape/scoring change; +`SCHEMA_VERSION` 4) · `schedule.ts`/`run-daily.sh` + their tests (R3c untouched — the nightly run gets the diff +internally; no scheduler edit) · the SessionStart hook + its tests (R3e adds no field the hook reads; `surfaced:` ++ marker are regex-safe) · `references/trend-scoring-modes.md` (the diff is not a scoring concern) · `config/*` · +`commands/*` (29) · `agents/*` count (19 — `trend-spotter.md` is a prose EDIT) · `references/*` count (27) · +`.gitignore` (no new artifact — the brief files already live under the gitignored data dir). `SCHEMA_VERSION` (4). + +## Step 1 — (RED, two phases) failing tests across brief/cli + +**Phase A — stub-first, then value-assertion RED** (`brief.test.ts` imports the new `brief.ts` exports): +- Land **non-throwing stubs** so the static imports resolve (Node16 ESM links named imports before any test runs): + in `brief.ts` — add `export interface BriefDiff`, and + - `export function diffSurfaced(): BriefDiff { return { priorDate: null, added: [], carried: [], dropped: [] }; }` + - `export function parseSurfacedFrontmatter(): string[] { return []; }` + - `export function selectPriorBriefFile(): string | null { return null; }` + (each a constant stub ignoring its args). Add an **optional** `diff?: BriefDiff` param to `renderBrief` **and** + `briefSummary`, **wired but inert** in the stub: `renderBrief` does NOT yet emit the `surfaced:` line or the + section; `briefSummary` does NOT yet emit the marker. (Keeps the static signatures stable for the RED tests + while the *behaviour* is still absent → value-RED, not type-RED.) `BRIEF_SCHEMA_VERSION` is still **1** in Phase + A (so the SC7 `=== 2` assertion is RED). +- `brief.test.ts` (value-RED against the stubs): + - **diffSurfaced (SC1)** — `diffSurfaced(["a","b","c"],["b","c","d"],"2026-06-25")` expects + `{priorDate:"2026-06-25",added:["a"],carried:["b","c"],dropped:["d"]}` (stub returns all-empty/null → RED); + `diffSurfaced(["a","b"],[],null)` expects `{priorDate:null,added:["a","b"],carried:[],dropped:[]}`; a repeated + id not double-counted. + - **parseSurfacedFrontmatter (SC2)** — a full frontmatter string with `surfaced: 1a2b,3c4d,5e6f` → `["1a2b", + "3c4d","5e6f"]` (stub `[]` → RED); blank `surfaced: ` → `[]`; absent line → `[]`; trims whitespace; does not + match `summary:`/`store:`. + - **selectPriorBriefFile (SC3)** — `(["2026-06-24.md","2026-06-25.md","2026-06-26.md","README.md","2026-06-30.md"], + "2026-06-26")` → `"2026-06-25.md"` (stub `null` → RED); empty list / none `< today` → `null`. + - **frontmatter `surfaced:` + round-trip (SC4)** — `renderBrief(r, diff)` includes `\nsurfaced: ` + the + `surfacedIds(r).join(",")` value, before `\nschemaVersion: 2\n`; `parseSurfacedFrontmatter(renderBrief(r,d)) + === surfacedIds(r)`; empty store → `surfaced: ` blank. Stub render emits neither the line nor `schemaVersion: + 2` → RED. + - **section (SC5)** — `renderBrief(r, diff)` contains `## 🆕 Nytt siden sist`; the four branches (first-brief+ + added → `Første brief — alt nedenfor er nytt`; empty first → `Første brief.`; prior+added → the added title + + `båret over` + `ikke vist i dag`; prior+no-added → `Ingenting nytt siden <date>`). Stub render omits the + section → RED. **Assert the section precedes `## 🎯 Topp-treff`** (index check). + - **summary marker (SC6)** — `briefSummary(r, {priorDate:"2026-06-25",added:["x"],carried:[],dropped:[]})` ends + with ` 1 nye siden sist.` (stub omits → RED); `briefSummary(r, {priorDate:null,…})` and `briefSummary(r, + {…,added:[]})` have no marker; **`briefSummary(r) === briefSummary(r, emptyDiff)`** (the invariant that keeps + the existing `:166-171` test green); no `"`/`\n`. + - **schema (SC7)** — `BRIEF_SCHEMA_VERSION === 2` (stub still 1 → RED); `SCHEMA_VERSION === 4`. + - **determinism (SC8)** — `renderBrief(r, d) === renderBrief(r, d)`; with a fixed `d`, stable bytes. +- **Existing assertions — one hard literal flips with the bump; the rest auto-track (verified live, MAJOR-1):** + the frontmatter tests are `:158-160` (`startsWith "---\n"` — unaffected), `:161-164` (`schemaVersion: ` built + from the **imported** `BRIEF_SCHEMA_VERSION` constant via RegExp `:163` — auto-tracks 1→2; `date:`/`store:` + unaffected), `:166-173` (`summary: === briefSummary(r)` — preserved by the SC6 invariant: with no prior the + marker is suppressed, so `briefSummary(r)===briefSummary(r,emptyDiff)`), the determinism pair `:182-184` (both + sides default to the empty diff → still equal), and the `ranking:` descriptor tests (**descriptor unchanged by + R3e** → unaffected). **The one break:** `:574` `assert.equal(BRIEF_SCHEMA_VERSION, 1)` — a **hard literal** in + the `rankForBrief — no schema/score mutation` block (`:568-577`), **outside** the frontmatter set, which the + GREEN bump must flip to `, 2)` in **Step 3** (`:575` `assert.equal(SCHEMA_VERSION, 4)` stays — store schema + untouched). Swept: `:574` is the *only* hard `BRIEF_SCHEMA_VERSION` literal in the suite. The new section is + **additive**, asserted only by new tests; **no test pins the intro→Topp-treff adjacency** (verified — all body + assertions are substring/`match`). + +**Phase B — subprocess value-RED against the existing CLI handler** (no new import; the handler ignores prior +briefs today): +- `cli.test.ts` — a **two-day** sequence sharing one `--out <tmp>` dir: (1) seed a store, run `brief --pillars + ai,gov --out <tmp> --store <s> --json` → today the written `.md` has **no `surfaced:` line** and the `--json` + has **no `diff` key** → RED against the day-1 assertions (`surfaced:` present, `--json.diff.priorDate === null`). + (2) `capture` one new on-pillar trend, then **rename** the day-1 `.md` to a fixed past date (`mv` it to + `2026-06-20.md` in the same `<tmp>` — the **rename-real-write** mechanism, M2) and run a second `brief` → against + the *existing* handler it reads no prior, renders no `Nytt siden sist (<date>)` section, `--json` has no `diff` → + RED. **Diff content lives in the written `.md` body**, so the assertion `readFileSync(path)` checks `## 🆕 Nytt + siden sist`; the `diff` counts are read from `--json`. + +**RED proof (record in commit, two phases):** Phase A — after the non-throwing stubs land, `(cd scripts/trends && +npm test)` fails the diff/parse/select/frontmatter/section/marker/schema cases on **value** assertions against the +constant stubs (not module-not-found). Phase B — the `cli.test` two-day cases fail on the missing `surfaced:`/ +`diff`/section. The plan does **not** claim a single "everything fails before any code" run. + +## Step 2 — (GREEN) `brief.ts` — the three pure helpers + +Replace the Phase-A stubs with the real, pure implementations (all no clock/fs/env): +- `export interface BriefDiff { priorDate: string | null; added: string[]; carried: string[]; dropped: string[]; }` +- `diffSurfaced(currentIds, priorIds, priorDate)`: + ```ts + const prior = new Set(priorIds); + const cur = new Set(currentIds); + return { + priorDate, + added: currentIds.filter((id) => !prior.has(id)), + carried: currentIds.filter((id) => prior.has(id)), + dropped: priorIds.filter((id) => !cur.has(id)), + }; + ``` + Order-stable (filters preserve input order); empty `priorIds` ⇒ `added===currentIds`, `dropped===[]`. +- `parseSurfacedFrontmatter(md)`: + ```ts + const m = md.match(/^surfaced: *([^\n]*)/m); + if (!m) return []; + return m[1].split(",").map((s) => s.trim()).filter((s) => s.length > 0); + ``` + Absent/blank/malformed → `[]` (mirrors the hook's `extractYaml` line-anchoring; never throws). +- `selectPriorBriefFile(filenames, today)`: + ```ts + const todayFile = `${today}.md`; + return ( + filenames + .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f) && f < todayFile) + .sort() + .pop() ?? null + ); + ``` + ISO dates sort lexicographically, so `f < todayFile` = date `< today` (strict — excludes today + future); + greatest remaining = the most recent prior. Mirrors `session-start.mjs:63-66`, minus today. +Make the Phase-A diff/parse/select cases green. + +## Step 3 — (GREEN) `brief.ts` — frontmatter `surfaced:` + schema bump + the section + the marker + +- **`BRIEF_SCHEMA_VERSION = 2`** (`brief.ts:23`) — **and flip the one hard test literal in the same step** + (MAJOR-1): `tests/brief.test.ts:574` `assert.equal(BRIEF_SCHEMA_VERSION, 1)` → `assert.equal(BRIEF_SCHEMA_VERSION, + 2)`. (The RegExp at `:163` and the new SC7 already track the constant; `:575` `assert.equal(SCHEMA_VERSION, 4)` + is untouched.) +- **`renderBrief(ranking, diff: BriefDiff = { priorDate: null, added: [], carried: [], dropped: [] })`** — the + default empty diff keeps single-arg call sites valid. Two additive emissions: + - In the frontmatter block (`brief.ts:263-269`), insert **before** the `schemaVersion:` line: + `lines.push(\`surfaced: ${surfacedIds(ranking).join(",")}\`);` (empty store ⇒ `surfaced: ` blank). The + `schemaVersion:` line now renders `2` via the bumped constant. + - After the intro line (`brief.ts:273-276`) and **before** `## 🎯 Topp-treff` (`:278`), emit the section: + ```ts + lines.push(diff.priorDate !== null ? `## 🆕 Nytt siden sist (${diff.priorDate})` : "## 🆕 Nytt siden sist"); + if (diff.priorDate === null) { + lines.push(diff.added.length > 0 ? "_Første brief — alt nedenfor er nytt._" : "_Første brief._", ""); + } else if (diff.added.length === 0) { + lines.push(`_Ingenting nytt siden ${diff.priorDate}._`, + `_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, ""); + } else { + const byId = new Map( + [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched].map((e) => [e.trend.id, e]), + ); + for (const id of diff.added) { + const e = byId.get(id); + if (e) lines.push(renderBulletEntry(e)); + } + lines.push(`_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, ""); + } + ``` + (`renderBulletEntry` is the existing bullet renderer — reused, no new format.) An added id always resolves + (added ⊆ surfacedIds ⊆ ranking entries); the `if (e)` guard keeps it total. + - Pass the diff through: `briefSummary(ranking, diff)` at the frontmatter `summary:` line (`brief.ts:265`). +- **`briefSummary(ranking, diff?: BriefDiff)`** (`brief.ts:204`): after building the headline, append the marker: + ```ts + const delta = diff && diff.priorDate !== null && diff.added.length > 0 + ? ` ${diff.added.length} nye siden sist.` : ""; + return `${...existing headline...}${delta}`; + ``` + Suppressed on the first brief / when nothing new; carries no `"`/`\n` (the existing summary already guarantees + this — the marker adds only digits + ASCII words + a period). The no-diff call (`briefSummary(ranking)`) yields + exactly the pre-R3e string (the SC6 invariant). +Make the Phase-A frontmatter/section/marker/schema cases green. + +## Step 4 — (GREEN) `cli.ts` — prior-brief discovery + the diff in `--json` + +- Add `readdirSync` to the `node:fs` import (`cli.ts:51`). +- In the `brief` handler, **between** the ranking (`cli.ts:339`) and the render (`:340`): + ```ts + const todayIds = surfacedIds(ranking); + let priorIds: string[] = []; + let priorDate: string | null = null; + try { + if (existsSync(outDir)) { + const priorFile = selectPriorBriefFile(readdirSync(outDir), day); + if (priorFile) { + priorIds = parseSurfacedFrontmatter(readFileSync(join(outDir, priorFile), "utf8")); + priorDate = priorFile.slice(0, 10); + } + } + } catch { priorIds = []; priorDate = null; } // unreadable prior ⇒ first-brief path + const diff = diffSurfaced(todayIds, priorIds, priorDate); + const md = renderBrief(ranking, diff); + ``` +- Import `diffSurfaced`, `parseSurfacedFrontmatter`, `selectPriorBriefFile` from `./brief.js` (`cli.ts:71`). (If + any code annotates `: BriefDiff`, import it via **`import type`** — it is an interface, stripped from emitted JS, + so a value-import fails at module-load (M1); the code above infers the type from `diffSurfaced`'s return, so no + `BriefDiff` import is actually needed.) +- **Thread the diff into the shared summary** (`cli.ts:350`, MAJOR-2): change `const summary = briefSummary(ranking)` + → `const summary = briefSummary(ranking, diff)`. The frontmatter `summary:` (built inside `renderBrief` → + `briefSummary(ranking, diff)`) and the `--json` `summary` (read from this var) must stay **one source** + (`cli.test.ts:268`); without this, day-2's file carries the ` N nye siden sist.` marker but `--json.summary` + would not. **Safe on day-1:** `priorDate === null` ⇒ marker suppressed ⇒ byte-identical to the pre-R3e string. +- `--json` (`cli.ts:352`): add `diff: { priorDate: diff.priorDate, added: diff.added.length, carried: + diff.carried.length, dropped: diff.dropped.length }`. +- The non-JSON console line (`cli.ts:355`): append `${diff.added.length > 0 && diff.priorDate !== null ? \`, ${diff.added.length} nye siden sist\` : ""}`. +- **No new flag, no new exit code.** **Note (Phase-B test mechanism — rename-real-write, M2):** `today()` is + wall-clock, so a same-process two-day sequence cannot advance the date. Rather than hand-author a `<prior>.md` + fixture (which risks an id mismatch — its `surfaced:` ids would not be real store ids, so every trend reads as + added/dropped, a weak/vacuous test), the cli.test **runs `brief` for real** (writing `${today}.md` with a genuine + `surfaced:` line = `surfacedIds(ranking)`), **renames** it to a fixed past date (`mv ${out}/${today}.md + ${out}/2026-06-20.md`), then runs `brief` again in the same `--out`. The second run discovers `2026-06-20.md` as + the strict-prior, parses its **real** ids, and diffs against today's cohort — proving discovery + parse + diff + clock-free, with `carried`/`added` that are *exactly* right (id-matched). Capture one new on-pillar trend between + the runs → that trend is the sole `added`. +Make the Phase-B cli cases green. + +## Step 5 — wire `trend-spotter.md` (prose) + README + +- `agents/trend-spotter.md` — one prose line (no batch-shape change): the morning brief now **records the trends + it showed** (frontmatter `surfaced:`) and renders a **day-over-day diff** ("Nytt siden sist") against the most + recent prior brief — no new capture step; the polling/capture path is unchanged. Domain-general (Section 17). +- `scripts/trends/README.md` — add `## Brief history + diff (RE-R3e)` between the R3d temporal-overlay section and + `## Tests`: the `surfaced:` frontmatter record (one self-describing artifact, `BRIEF_SCHEMA_VERSION 1→2`, + store `SCHEMA_VERSION` stays 4), `selectPriorBriefFile` strict-`< today` discovery (same-day re-run + determinism), the `diffSurfaced` partitions (added/carried/dropped), the `## 🆕 Nytt siden sist` section + (added with titles, carried/dropped as a count, "ikke vist i dag" framing) + the ` N nye siden sist.` summary + marker the SessionStart hook surfaces for free. + +## Step 6 — gate: floors + new unconditional Section 16n + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (live **`:716`**, currently **216**) 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-R3e: brief +N, cli +N (brief history + diff)` to the inline breakdown comment. +- Add **Section 16n** ("Trends Brief History / Diff", RE-R3e), mirroring Section 16m (unconditional, + deps-absent-safe, pure `grep -qF`/self-test, no `tsx`, **all literals ASCII** — the `🆕` emoji is NEVER grepped; + the shell stays ASCII-clean for bash 3.2 `set -u`). **Placement (verify live):** between Section 16m's trailing + `echo ""` and the Section 18 header — anti-erosion stays last. Six **unconditional** checks, the self-test + emitting **one** pass/fail like 16m: + (1) a non-vacuity self-test (a probe carrying `diffSurfaced` accepted, one without rejected); + (2) `grep -qF 'export function diffSurfaced' scripts/trends/src/brief.ts`; + (3) `grep -qF 'parseSurfacedFrontmatter' scripts/trends/src/brief.ts`; + (4) `grep -qF 'Nytt siden sist' scripts/trends/src/brief.ts` (the section header literal — ASCII portion only); + (5) `grep -qF 'selectPriorBriefFile' scripts/trends/src/cli.ts` (the diff wiring in the CLI); + (6) `grep -qF 'surfaced: ' scripts/trends/src/brief.ts` (the frontmatter emit). +- Bump `ASSERT_BASELINE_FLOOR` (live **`:1473`**, currently **117**) → **exactly 123** (117 + the 6 new + unconditional 16n emitters; the self-test emits one pass/fail like 16m, so 123 is deterministic — "live recount" + is the safety net, not a guess). Insert the 16n clause into the **header-enumeration prose chain (`:53-64`)** + before "…the assertion-count anti-erosion floor (SC6) in Section 18," preserving sentence flow. **Append** the + RE-R3e (→123) narration to the **Section-18 floor-history comment** (which ends "= 117"). +- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3e 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 five behavioural steps with a **unique tmp dir +(no `rm`)**: `D=/tmp/r3e-mb-$$; S=/tmp/r3e-$$.json`. Seed an on-pillar store; `brief --pillars … --out "$D" +--store "$S"` → frontmatter carries `surfaced: <ids>` + `schemaVersion: 2`, the section says `Første brief — alt +nedenfor er nytt`; **rename that real brief to a fixed past date** (`mv "$D/$(ls "$D")" "$D/2026-06-20.md"`), +`capture` a new on-pillar trend, and re-run `brief` → `## 🆕 Nytt siden sist (2026-06-20)` lists the added trend + +`N båret over, M ikke vist i dag`, `--json diff.added ≥ 1` (the **rename-real-write** path, M2 — the prior's ids +are real, so `carried`/`added` are id-exact); same-day re-run → `cmp` the two `${day}.md` byte-identical; confirm +`score.composite` unchanged after the briefs (only `surfacedCount`/`lastSurfacedAt` advance); strip the `surfaced:` +line from the renamed prior (a pre-R3e brief) and re-run → every trend reads as added (graceful degrade). Run +full `bash scripts/test-runner.sh` → `FAIL=0` (`ASSERT_BASELINE_FLOOR` 123, trends ≥ new floor, Section 16n green, +Section 17 de-niche green, counts 29/19/27); run `node --test hooks/scripts/__tests__/*.test.mjs` → still green +(untouched regression); confirm `schedule.test`/`run-daily.test` still green (R3c untouched). + +## Step 8 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 123, gate total; +`BRIEF_SCHEMA_VERSION` 1→2; store schema unchanged v4; **correct the stale `:713`/`:1403` cites to live +`:716`/`:1473`**). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3e.md` +(no suffix, tracked); **(2)** code commit — `brief.ts` + `cli.ts` + `brief.test.ts` + `cli.test.ts` + +`agents/trend-spotter.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]` (D9: single +code commit — the diff 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) — note +`BRIEF_SCHEMA_VERSION` 1→2 is the **artifact** schema, not the plugin version. + +## Verification (testable) + +| SC | Check | Command | Expected | +|---|---|---|---| +| — | RED Phase A | `(cd scripts/trends && npm test)` after the stubs | diff/parse/select/frontmatter/section/marker/schema cases fail on **value** assertions vs the constant stubs (not module-not-found) | +| — | RED Phase B | `npm test` (cli.test) before the wiring | the two-day cases fail on the missing `surfaced:`/`diff`/section | +| SC1 | diffSurfaced partitions | `npm test` (brief.test) | added/carried/dropped order-stable; empty prior ⇒ all added; repeated id once | +| SC2 | parseSurfacedFrontmatter | `npm test` (brief.test) | csv → ids; blank/absent/malformed → `[]`; line-anchored (no `summary:` mismatch) | +| SC3 | selectPriorBriefFile | `npm test` (brief.test) | greatest `< today`; excludes today + future; ignores non-dated; none → `null` | +| SC4 | `surfaced:` + round-trip | `npm test` (brief.test) | one `surfaced: <csv>` line = `surfacedIds(r).join(",")`, before `schemaVersion: 2`; round-trips; empty store → blank | +| SC5 | Nytt siden sist (4 branches) | `npm test` (brief.test) | `## 🆕 Nytt siden sist`; first-brief/empty/added/no-added branches; section precedes Topp-treff | +| SC6 | summary delta marker | `npm test` (brief.test) | ` N nye siden sist.` when prior+added; absent on first/no-added; `briefSummary(r)===briefSummary(r,empty)`; no `"`/`\n` | +| SC7 | schema boundary | `npm test` (brief.test) | `BRIEF_SCHEMA_VERSION` 2; `SCHEMA_VERSION` 4; no `score.composite` mutation after a brief | +| SC8 | determinism + same-day | `npm test` (brief.test) | same `(store,pillars,today,opts,diff)` → byte-identical; same-day re-run picks the same prior (strict `<`) | +| SC9 | CLI diff wiring | `npm test` (cli.test) | two-day (**rename-real-write**: real day-1 brief renamed to `2026-06-20.md`): `Nytt siden sist (<day1>)` lists the new trend; `--json diff` counts; first run `priorDate null`; `--out` isolates | +| SC10 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16n green; `ASSERT_BASELINE_FLOOR`=123; Section 17; counts 29/19/27; hook suite green | + +## Risks + +- **R1 — adding `surfaced:` / bumping the schema breaks pinned tests.** *Mitigated (verified live):* the + frontmatter `schemaVersion:` test uses the **imported** `BRIEF_SCHEMA_VERSION` constant (auto-tracks 1→2); the + `summary:`-equality test is preserved by the SC6 invariant (`briefSummary(r)===briefSummary(r,empty)`); + `startsWith "---\n"` and the `date:`/`ranking:` tests are unaffected. **The one hard break (MAJOR-1):** `:574` + `assert.equal(BRIEF_SCHEMA_VERSION, 1)` — a literal **outside** the frontmatter set — which Step 3 flips to + `, 2)` with the bump (`:575` `SCHEMA_VERSION === 4` stays). Step 1 enumerates every surviving assertion + this + one break. +- **R2 — inserting a section before Topp-treff breaks an ordering assertion.** *Mitigated (verified):* no existing + test pins the intro→Topp-treff adjacency (all body assertions are substring/`match`); the new section is + asserted only by new tests. SC5 pins the section-before-Topp index in the new suite. +- **R3 — the `surfaced:` CSV could collide with a comma in an id.** *Mitigated:* real ids are 12-hex + (`store.ts:69-72`) — comma-free; the join/split is unambiguous in production. The brief.test fixtures use + comma-free ids for the round-trip. (Test `mkTrend` ids are `title|url`; the round-trip unit uses clean ids.) +- **R4 — same-day re-run picks its own just-written file → self-diff (empty) → non-deterministic vs the first + run.** *Mitigated:* `selectPriorBriefFile` filters `f < ${today}.md` (strict), so the same-day file is excluded + and the re-run picks the same true-prior → byte-identical (SC8). This is the exact R3c SC7 guarantee, preserved. +- **R5 — a malformed / hand-edited / pre-R3e prior brief crashes the diff.** *Mitigated:* `parseSurfacedFrontmatter` + returns `[]` on absent/blank/malformed (never throws); the cli `try/catch` degrades any fs error to the + empty-prior (first-brief) path. SC2 + behavioural step 5 cover it. +- **R6 — the summary marker breaks the SessionStart `extractYaml` regex.** *Mitigated:* ` N nye siden sist.` is + digits + ASCII words + a period — no `"`, no `\n`; SC6 asserts the invariant; the hook suite is a land-time + regression check. The hook reads `date`+`summary` only; `surfaced:` is `^surfaced:`-keyed (the `^summary:` + regex cannot match it). +- **R7 — the `🆕` emoji in the gate crashes bash 3.2 `set -u`.** *Mitigated:* Section 16n greps ONLY ASCII + literals (`export function diffSurfaced`, `parseSurfacedFrontmatter`, `Nytt siden sist`, `selectPriorBriefFile`, + `surfaced: `); the emoji lives only in `brief.ts` source + rendered output, asserted by the TS tests. +- **R8 — `BRIEF_SCHEMA_VERSION` bump misread as a store-schema/plugin-version change.** *Mitigated:* it is the + **artifact** frontmatter version (`brief.ts:23`, distinct from the store's `SCHEMA_VERSION` — the comment says + so); store `SCHEMA_VERSION` stays 4; no plugin version bump (additive). README + §4 state the boundary. +- **R9 — the diff couples `brief.ts` to fs (directory read) → breaks the purity claim.** *Mitigated (SD2):* the + three new helpers are pure (string/array in, value out); the `readdirSync`/`readFileSync` live in `cli.ts` (the + edge), injected exactly like `today`/`pillars`. `brief.ts`'s "No fs" header claim holds. +- **R10 — STATE's pinned floor line-cites (`:713`/`:1403`) are stale (live `:716`/`:1473`).** *Mitigated:* caught + at brief time (the lines drifted when R3d added Section 16m + the floor-history narration); the plan cites live + values; Step 8 corrects STATE. +- **R11 — a diff test that passes WITHOUT the feature (vacuous GREEN).** *Mitigated:* SC1/SC2/SC3 are unit tests + of pure functions whose stubs return constants (true value-RED in Phase A); SC9's day-2 asserts a **specific** + added trend appears in `Nytt siden sist (<day1>)` AND the `--json diff.added` count — both absent in the + flag-ignoring handler (Phase B RED). No fixture is ordered-by-accident. +- **R12 — `--no-mark` desync: the artifact records `surfaced:` but the store seen-log is not written.** *Accepted/ + intended:* `surfaced:` is a property of the rendered brief (what it showed), `--no-mark` governs only the store + mutation. The next diff reads the **artifact**, so it is correct regardless of `--no-mark`. SC4 (frontmatter) + and the existing `--no-mark` test (`cli.test:392-398`) both hold. + +## Plan-critic — folded + +Three Opus reviewers (scope-guardian, brief-reviewer, plan-critic) ran COLD on the brief + this plan against live +`scripts/trends/`. **Verdicts:** scope-guardian **MIXED** · brief-reviewer **PROCEED_WITH_RISKS** · plan-critic +**REWORK (0.88)** — **converged on 2 MAJOR + 4 MINOR** (all re-verified against live code before folding; full +rationale in `brief-re-r3e.md §9`). + +- **MAJOR-1** — `tests/brief.test.ts:574` `assert.equal(BRIEF_SCHEMA_VERSION, 1)` is a **hard literal** (outside + the frontmatter set §Step-1 enumerated; the `:163` RegExp auto-tracks). **Folded:** Step 3 flips it to `, 2)` + with the constant bump; Step 1's enumeration + R1 now name it; `:575` (`SCHEMA_VERSION === 4`) stays. +- **MAJOR-2** — `cli.ts:350` `const summary = briefSummary(ranking)` was left unthreaded → day-2 `--json.summary` + would lose the marker the file's frontmatter carries, breaking the `cli.test.ts:268` "one source" invariant. + **Folded:** Step 4 changes it to `briefSummary(ranking, diff)` (safe day-1; `priorDate===null` suppresses the + marker). +- **M1 (MINOR)** — `BriefDiff` is type-only → `import type` if referenced (Step 2/4); the Step-4 code infers it, so + no import is actually needed. +- **M2 (MINOR)** — SC9 uses **rename-real-write** (run `brief`, `mv ${day}.md → 2026-06-20.md`, re-run), not a + hand-fixture → clock-free + id-exact prior. **Folded:** Step 4 note, Phase B, Step 7, SC9 row. +- **M3 (MINOR)** — SC1 "repeated id not double-counted" reworded → **cross-partition disjointness** (filters + preserve within-list dups; production ids are distinct). Brief §6 SC1 + Step 1. +- **M4 (MINOR)** — brief §3 "empty `surfaced:`" contradiction reworded (the `surfaced:` line is diff-independent = + `surfacedIds(ranking)`; the default empty diff only drives the `_Første brief._` section). Brief §3. + +**Confirmed correct (untouched):** all line-cites, the floors (216 @ `:716`; 117 → 123 @ `:1473`), Section 16m as +the last trends section, the 6 ASCII sentinels' non-vacuity, the regex/ISO-lex/hook-safety/`--json`-shape/same-day +strict-`<` — verified by all three. diff --git a/docs/second-brain/architecture.md b/docs/second-brain/architecture.md index 67adb0a..83afcb5 100644 --- a/docs/second-brain/architecture.md +++ b/docs/second-brain/architecture.md @@ -1,6 +1,6 @@ # Second Brain — Architecture Design -> **Status:** architecture **approved by operator 2026-06-23**. **SB-S0 (Foundation) + SB-S1 (Ingest + gold signal) + SB-S2 (Evolution loop) landed 2026-06-23** (`scripts/brain/`, 82 tests, gate-wired; ingest CLI + published-only invariant + operator-gated consolidation loop + session-start nudge); S3–S4 remain design-phase. +> **Status:** architecture **approved by operator 2026-06-23**. **SB-S0 (Foundation) + SB-S1 (Ingest + gold signal) + SB-S2 (Evolution loop) landed 2026-06-23** (`scripts/brain/`, 82 tests, gate-wired; ingest CLI + published-only invariant + operator-gated consolidation loop + session-start nudge). **SB-S3a–e landed 2026-06-24** (profile.md reader-wiring, supersede arm, cross-silo id-threading, operations.md ops centre, content-history retirement + read-side reconcile); only S4 (connector) remains design-phase. > **Boundary (confirmed 2026-06-23):** the **engine** (store schema · evolution loop · ingest seam) → **the plugin** (domain-general, shareable); the **user's data** (posts · articles · newsletters · plans · ideas) → the **per-user data dir** (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/`, survives reinstall); the **personal cockpit** (the operator's day-to-day operations centre) → **Maskinrommet** (a thin layer that reads/writes *through* the plugin's store, never a fork of the engine). > **Research inputs (three parallel threads, 2026-06-23):** `research/connector-egress.md` · `research/secondbrain-sota.md` · `research/silo-inventory.md`. diff --git a/hooks/scripts/__tests__/session-start-slots.test.mjs b/hooks/scripts/__tests__/session-start-slots.test.mjs new file mode 100644 index 0000000..b14485d --- /dev/null +++ b/hooks/scripts/__tests__/session-start-slots.test.mjs @@ -0,0 +1,181 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, copyFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// N13 (A1-10 / A1-11 surfacing): the week's picture — which editions are in +// flight and which publishing slots are still empty — must be readable at +// session start without opening a file. session-start is a procedural hook with +// no exports, so we run it as a subprocess with an isolated HOME + +// LINKEDIN_STUDIO_DATA and inspect the additionalContext. Pattern: +// session-start-trends-staleness.test.mjs subprocess harness. +const here = dirname(fileURLToPath(import.meta.url)); +const hookPath = join(here, '..', 'session-start.mjs'); +const PLUGIN_ROOT = join(here, '..', '..', '..'); +const STATE_TEMPLATE = join(PLUGIN_ROOT, 'config', 'state-file.template.md'); + +const WIP_HEADER = '## Editions in Flight'; +const SLOTS_HEADER = '## Publishing Slots'; +const NEXT_OPEN = 'Next open slot:'; + +const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; +const isoDaysAhead = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10); +const isoDaysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10); +const weekdayOf = (isoDate) => DAY_KEYS[new Date(`${isoDate}T12:00:00Z`).getUTCDay()]; + +// Seed an isolated data root, run the hook, return the injected context. +function runHook({ slots, queue, editions, trends } = {}) { + const home = mkdtempSync(join(tmpdir(), 'lis-sl-home-')); + const data = mkdtempSync(join(tmpdir(), 'lis-sl-data-')); + try { + // The status/reminders branch only runs when the state file exists; the + // template is the canonical valid state. + mkdirSync(join(home, '.claude'), { recursive: true }); + copyFileSync(STATE_TEMPLATE, join(home, '.claude', 'linkedin-studio.local.md')); + + if (slots) { + mkdirSync(join(data, 'profile'), { recursive: true }); + writeFileSync( + join(data, 'profile', 'publishing-slots.json'), + JSON.stringify({ schemaVersion: 1, slots }), + ); + } + if (queue) { + mkdirSync(join(data, 'drafts'), { recursive: true }); + writeFileSync(join(data, 'drafts', 'queue.json'), JSON.stringify({ version: 1, queue })); + } + if (editions) { + mkdirSync(join(data, 'editions'), { recursive: true }); + writeFileSync( + join(data, 'editions', 'register.json'), + JSON.stringify({ schemaVersion: 1, editions }), + ); + } + if (trends) { + mkdirSync(join(data, 'trends'), { recursive: true }); + writeFileSync(join(data, 'trends', 'trends.json'), JSON.stringify({ schemaVersion: 1, trends })); + } + + const stdout = execFileSync('node', [hookPath], { + input: '', + env: { ...process.env, HOME: home, USERPROFILE: home, LINKEDIN_STUDIO_DATA: data }, + encoding: 'utf-8', + }); + return JSON.parse(stdout).hookSpecificOutput.additionalContext; + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(data, { recursive: true, force: true }); + } +} + +const activeRow = (over = {}) => ({ + series: 'serien', + editionId: '05', + title: 'En utgave', + path: '/series/serien', + currentPhase: 'draft', + nextAction: 'Step 4 - full draft', + slot: null, + startedAt: new Date(Date.now() - 3 * 86400000).toISOString(), + updatedAt: new Date().toISOString(), + completedAt: null, + status: 'active', + ...over, +}); + +describe('session-start — editions in flight (N13/A1-11)', () => { + test('surfaces active editions with phase, next action and days in flight', () => { + const ctx = runHook({ editions: [activeRow()] }); + assert.ok(ctx.includes(WIP_HEADER), 'expected the in-flight roll-up'); + assert.ok(ctx.includes('serien #05'), 'expected the edition identity'); + assert.ok(ctx.includes('draft'), 'expected the current phase'); + assert.ok(ctx.includes('Step 4 - full draft'), 'expected the next action'); + assert.ok(/3d in flight/.test(ctx), 'expected measured days in flight'); + }); + + test('completed editions drop out — the view is work in progress, not history', () => { + const ctx = runHook({ + editions: [activeRow({ editionId: '04', status: 'complete', completedAt: new Date().toISOString() })], + }); + assert.ok(!ctx.includes(WIP_HEADER), 'a register with no active rows must not print the block'); + }); + + test('no register at all is silence, not an error', () => { + const ctx = runHook({}); + assert.ok(!ctx.includes(WIP_HEADER), 'an unused register must not print anything'); + }); +}); + +describe('session-start — publishing-slot vacancy (N13/A1-10)', () => { + test('names the next open slot when the grid has an uncovered day', () => { + const target = isoDaysAhead(3); + const ctx = runHook({ slots: [{ day: weekdayOf(target), time: '12:00', label: 'long-form' }] }); + assert.ok(ctx.includes(SLOTS_HEADER), 'expected the slots block'); + assert.ok(ctx.includes(NEXT_OPEN), 'expected the next open slot to be named'); + assert.ok(ctx.includes('long-form'), 'expected the operator label to carry through'); + }); + + test('an unconfigured grid says nothing at all — no default schedule is imposed', () => { + const ctx = runHook({ editions: [activeRow()] }); + assert.ok(!ctx.includes(SLOTS_HEADER), 'no slots config => no slots block'); + }); + + test('a queue entry on the slot date covers it', () => { + const today = new Date().toISOString().slice(0, 10); + const day = weekdayOf(today); + // A weekly slot has exactly two occurrences in a 14-day window from today. + const ctx = runHook({ + slots: [{ day, time: '08:30' }], + queue: [ + { id: 'a', scheduled_date: today, scheduled_time: '08:30', status: 'scheduled' }, + { id: 'b', scheduled_date: isoDaysAhead(7), scheduled_time: '08:30', status: 'scheduled' }, + ], + }); + assert.ok(ctx.includes(SLOTS_HEADER), 'a configured grid still reports'); + assert.ok(!ctx.includes(NEXT_OPEN), 'a fully covered horizon has no open slot to name'); + assert.ok(/are covered/.test(ctx), 'expected the all-covered confirmation'); + }); + + test('a scheduled long-form edition covers its claimed slot', () => { + const today = new Date().toISOString().slice(0, 10); + const day = weekdayOf(today); + const ctx = runHook({ + slots: [{ day, time: '08:30' }], + editions: [ + activeRow({ slot: `${today} 08:30` }), + activeRow({ editionId: '06', slot: `${isoDaysAhead(7)} 08:30` }), + ], + }); + assert.ok(!ctx.includes(NEXT_OPEN), 'register-claimed slots count as covered'); + }); + + test('an imminent empty slot (<=3 days) says how to fill it', () => { + const target = isoDaysAhead(2); + const ctx = runHook({ slots: [{ day: weekdayOf(target), time: '09:00' }] }); + assert.ok(/\/linkedin:newsletter/.test(ctx), 'expected the fill-it pointer for an imminent vacancy'); + }); +}); + +describe('session-start — N13 leaves the existing nudges intact', () => { + test('the trend-staleness (discovery) nudge still fires alongside the new blocks', () => { + const ctx = runHook({ + slots: [{ day: weekdayOf(isoDaysAhead(3)), time: '12:00' }], + editions: [activeRow()], + trends: [ + { + id: 't1', + title: 'A trend', + url: 'https://example.com/1', + source: 'tavily', + capturedAt: isoDaysAgo(10), + topics: ['x'], + }, + ], + }); + assert.ok(ctx.includes('Trend signals are'), 'the discovery nudge must survive N13'); + assert.ok(ctx.includes(SLOTS_HEADER) && ctx.includes(WIP_HEADER), 'the new blocks render too'); + }); +}); diff --git a/hooks/scripts/__tests__/slots.test.mjs b/hooks/scripts/__tests__/slots.test.mjs new file mode 100644 index 0000000..8679b40 --- /dev/null +++ b/hooks/scripts/__tests__/slots.test.mjs @@ -0,0 +1,313 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + SLOTS_SCHEMA_VERSION, + normalizeSlots, + readSlots, + slotOccurrences, + coverageByDate, + vacantSlots, + nextFreeSlot, + activeEditions, + defaultSlotsPath, + defaultRegisterPath, +} from '../slots.mjs'; + +// N13 (A1-10): the week's production grid — configured publishing slots, what +// covers them (queue + editions register) and what is still in flight. The core +// is pure (dates in as strings, no clock) so vacancy is testable without +// freezing time; only the thin readers touch the data dir. + +// Run `fn` with an isolated LINKEDIN_STUDIO_DATA root, seeded by `seed(dir)`. +function withDataRoot(seed, fn) { + const data = mkdtempSync(join(tmpdir(), 'lis-slots-')); + const prev = process.env.LINKEDIN_STUDIO_DATA; + process.env.LINKEDIN_STUDIO_DATA = data; + try { + if (seed) seed(data); + return fn(data); + } finally { + if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = prev; + rmSync(data, { recursive: true, force: true }); + } +} + +function seedSlots(data, config) { + mkdirSync(join(data, 'profile'), { recursive: true }); + writeFileSync(join(data, 'profile', 'publishing-slots.json'), JSON.stringify(config)); +} + +function seedQueue(data, entries) { + mkdirSync(join(data, 'drafts'), { recursive: true }); + writeFileSync(join(data, 'drafts', 'queue.json'), JSON.stringify({ version: 1, queue: entries })); +} + +function seedRegister(data, editions) { + mkdirSync(join(data, 'editions'), { recursive: true }); + writeFileSync(join(data, 'editions', 'register.json'), JSON.stringify({ schemaVersion: 1, editions })); +} + +const row = (over = {}) => ({ + series: 'serien', + editionId: '05', + title: 'En utgave', + path: '/series/serien', + currentPhase: 'draft', + nextAction: 'Step 4 — full draft', + slot: null, + startedAt: '2026-07-20T08:00:00.000Z', + updatedAt: '2026-07-22T08:00:00.000Z', + completedAt: null, + status: 'active', + ...over, +}); + +const queueEntry = (over = {}) => ({ + id: 'p1', + scheduled_date: '2026-07-28', + scheduled_time: '08:30', + status: 'scheduled', + ...over, +}); + +describe('slots — config normalization (N13/A1-10)', () => { + test('accepts full weekday names and abbreviations, any case', () => { + const { slots } = normalizeSlots({ + slots: [ + { day: 'Tuesday', time: '08:30' }, + { day: 'THU', time: '12:00', label: 'long-form' }, + ], + }); + assert.deepEqual( + slots, + [ + { day: 'tue', time: '08:30', label: '' }, + { day: 'thu', time: '12:00', label: 'long-form' }, + ], + ); + }); + + test('drops entries with an unknown day or a malformed time', () => { + const { slots } = normalizeSlots({ + slots: [ + { day: 'funday', time: '08:30' }, + { day: 'mon', time: '25:00' }, + { day: 'mon', time: '8:30' }, + { day: 'wed', time: '09:00' }, + ], + }); + assert.deepEqual(slots, [{ day: 'wed', time: '09:00', label: '' }]); + }); + + test('garbage in never throws — it yields an empty grid', () => { + for (const bad of [null, undefined, 42, 'nope', {}, { slots: 'x' }]) { + assert.deepEqual(normalizeSlots(bad).slots, [], `expected [] for ${JSON.stringify(bad)}`); + } + }); + + test('carries the timezone through as a label, defaulting to empty', () => { + assert.equal(normalizeSlots({ timezone: 'Europe/Oslo', slots: [] }).timezone, 'Europe/Oslo'); + assert.equal(normalizeSlots({ slots: [] }).timezone, ''); + }); + + test('exposes the schema version it reads', () => { + assert.equal(SLOTS_SCHEMA_VERSION, 1); + }); +}); + +describe('slots — occurrence expansion (N13/A1-10)', () => { + const grid = [ + { day: 'tue', time: '08:30', label: '' }, + { day: 'thu', time: '12:00', label: 'long-form' }, + ]; + + test('expands the weekly grid across the horizon, chronologically', () => { + // 2026-07-27 is a Monday. + const occ = slotOccurrences(grid, '2026-07-27', 14); + assert.deepEqual( + occ.map((o) => `${o.date} ${o.time}`), + ['2026-07-28 08:30', '2026-07-30 12:00', '2026-08-04 08:30', '2026-08-06 12:00'], + ); + assert.equal(occ[1].label, 'long-form'); + }); + + test('includes the start day itself — a slot today is still actionable', () => { + const occ = slotOccurrences(grid, '2026-07-28', 1); + assert.deepEqual(occ.map((o) => o.date), ['2026-07-28']); + }); + + test('two slots on the same day come out in time order', () => { + const occ = slotOccurrences( + [{ day: 'tue', time: '16:00', label: '' }, { day: 'tue', time: '08:30', label: '' }], + '2026-07-28', + 1, + ); + assert.deepEqual(occ.map((o) => o.time), ['08:30', '16:00']); + }); + + test('no slots, or a zero horizon, yields nothing', () => { + assert.deepEqual(slotOccurrences([], '2026-07-27', 14), []); + assert.deepEqual(slotOccurrences(grid, '2026-07-27', 0), []); + }); +}); + +describe('slots — coverage and vacancy (N13/A1-10)', () => { + test('a scheduled queue entry covers its date', () => { + const cover = coverageByDate([queueEntry()], []); + assert.equal(cover.get('2026-07-28'), 1); + }); + + test('a published entry still counts — the slot was used', () => { + const cover = coverageByDate([queueEntry({ status: 'published' })], []); + assert.equal(cover.get('2026-07-28'), 1); + }); + + test('a cancelled entry does not cover — that slot fell vacant again', () => { + const cover = coverageByDate([queueEntry({ status: 'cancelled' })], []); + assert.equal(cover.get('2026-07-28'), undefined); + }); + + test('an editions-register row covers the date of its slot, completed rows included', () => { + const cover = coverageByDate([], [ + row({ slot: '2026-07-30 12:00' }), + row({ editionId: '06', slot: '2026-08-06 12:00', status: 'complete', completedAt: '2026-08-01T00:00:00.000Z' }), + ]); + assert.equal(cover.get('2026-07-30'), 1); + assert.equal(cover.get('2026-08-06'), 1); + }); + + test('rows without a claimed slot cover nothing', () => { + assert.equal(coverageByDate([], [row({ slot: null })]).size, 0); + }); + + test('vacancy is counted per date — two slots one day need two posts', () => { + const occ = slotOccurrences( + [{ day: 'tue', time: '08:30', label: '' }, { day: 'tue', time: '16:00', label: '' }], + '2026-07-28', + 1, + ); + const oneCovered = vacantSlots(occ, coverageByDate([queueEntry()], [])); + assert.deepEqual(oneCovered.map((o) => o.time), ['16:00'], 'one post covers only the first slot that day'); + + const bothCovered = vacantSlots(occ, coverageByDate([queueEntry(), queueEntry({ id: 'p2' })], [])); + assert.deepEqual(bothCovered, []); + }); + + test('uncovered occurrences survive in chronological order', () => { + const occ = slotOccurrences([{ day: 'tue', time: '08:30', label: '' }], '2026-07-27', 14); + const vacant = vacantSlots(occ, coverageByDate([queueEntry()], [])); + assert.deepEqual(vacant.map((o) => o.date), ['2026-08-04']); + }); +}); + +describe('slots — data-dir readers (N13/A1-10)', () => { + test('readSlots reads the per-user config under profile/', () => { + withDataRoot( + (d) => seedSlots(d, { schemaVersion: 1, timezone: 'Europe/Oslo', slots: [{ day: 'thu', time: '12:00' }] }), + () => { + const { slots, timezone } = readSlots(); + assert.equal(timezone, 'Europe/Oslo'); + assert.deepEqual(slots, [{ day: 'thu', time: '12:00', label: '' }]); + }, + ); + }); + + test('an absent config is silence, not an error — the un-configured user is never nagged', () => { + withDataRoot(null, () => { + assert.deepEqual(readSlots().slots, []); + assert.equal(nextFreeSlot({ from: '2026-07-27', days: 14 }), null); + }); + }); + + test('a malformed config degrades to an empty grid instead of crashing session start', () => { + withDataRoot( + (d) => { + mkdirSync(join(d, 'profile'), { recursive: true }); + writeFileSync(join(d, 'profile', 'publishing-slots.json'), '{ not json'); + }, + () => assert.deepEqual(readSlots().slots, []), + ); + }); + + test('the config and register paths resolve under the per-user data root', () => { + withDataRoot(null, (d) => { + assert.equal(defaultSlotsPath(), join(d, 'profile', 'publishing-slots.json')); + assert.equal(defaultRegisterPath(), join(d, 'editions', 'register.json')); + }); + }); + + test('reading vacancy never creates the queue file — a read-only check writes nothing', () => { + withDataRoot( + (d) => seedSlots(d, { slots: [{ day: 'tue', time: '08:30' }] }), + (d) => { + nextFreeSlot({ from: '2026-07-27', days: 14 }); + assert.equal( + existsSync(join(d, 'drafts', 'queue.json')), + false, + 'the vacancy read must not materialize queue.json', + ); + }, + ); + }); +}); + +describe('slots — nextFreeSlot end to end (N13/A1-10)', () => { + test('returns the earliest uncovered slot, skipping what the queue and register cover', () => { + withDataRoot( + (d) => { + seedSlots(d, { slots: [{ day: 'tue', time: '08:30' }, { day: 'thu', time: '12:00', label: 'long-form' }] }); + seedQueue(d, [queueEntry()]); // covers Tue 2026-07-28 + seedRegister(d, [row({ slot: '2026-07-30 12:00' })]); // covers Thu 2026-07-30 + }, + () => { + const next = nextFreeSlot({ from: '2026-07-27', days: 14 }); + assert.equal(next.date, '2026-08-04'); + assert.equal(next.time, '08:30'); + }, + ); + }); + + test('null when every slot in the horizon is covered', () => { + withDataRoot( + (d) => { + seedSlots(d, { slots: [{ day: 'tue', time: '08:30' }] }); + seedQueue(d, [queueEntry()]); + }, + () => assert.equal(nextFreeSlot({ from: '2026-07-27', days: 7 }), null), + ); + }); +}); + +describe('slots — editions in flight (N13/A1-11 surfacing)', () => { + test('lists active rows only, oldest series first, with whole days in flight', () => { + withDataRoot( + (d) => + seedRegister(d, [ + row({ series: 'b-serien', editionId: '02' }), + row({ series: 'a-serien', editionId: '07', startedAt: '2026-07-24T00:00:00.000Z' }), + row({ series: 'a-serien', editionId: '01', status: 'complete', completedAt: '2026-07-21T00:00:00.000Z' }), + ]), + () => { + const rows = activeEditions({ now: '2026-07-27T00:00:00.000Z' }); + assert.deepEqual( + rows.map((r) => `${r.series}#${r.editionId}`), + ['a-serien#07', 'b-serien#02'], + 'complete rows drop out of the in-flight view', + ); + assert.equal(rows[0].daysInFlight, 3); + // Started 2026-07-20 08:00Z, now 2026-07-27 00:00Z = 6.67 days — floored, + // like every other elapsed-day report in the hook layer. + assert.equal(rows[1].daysInFlight, 6); + }, + ); + }); + + test('an absent register is an empty view, never a crash', () => { + withDataRoot(null, () => assert.deepEqual(activeEditions({ now: '2026-07-27T00:00:00.000Z' }), [])); + }); +}); diff --git a/hooks/scripts/__tests__/state-updater.test.mjs b/hooks/scripts/__tests__/state-updater.test.mjs index abf6f62..a3884c1 100644 --- a/hooks/scripts/__tests__/state-updater.test.mjs +++ b/hooks/scripts/__tests__/state-updater.test.mjs @@ -1,6 +1,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact } from '../state-updater.mjs'; +import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact, recordDoNext } from '../state-updater.mjs'; const SAMPLE_STATE = `--- last_post_date: "2026-04-05" @@ -284,41 +284,73 @@ describe('updatePostTracking', () => { }); }); +// Fixed synthetic "today" for prune tests: far from every hardcoded SAMPLE_STATE +// date, so fixture dates computed relative to it can never collide with them. +const FIXED_TODAY = new Date('2027-01-01T00:00:00Z'); + +// Days before FIXED_TODAY as YYYY-MM-DD. +function daysBeforeFixedToday(days) { + const d = new Date(FIXED_TODAY); + d.setDate(d.getDate() - days); + return d.toISOString().slice(0, 10); +} + +// Extract only the Recent Posts section body so assertions cannot accidentally +// match dates in the frontmatter or other sections. +function recentPostsSection(content) { + const match = content.match(/## Recent Posts\n([\s\S]*?)\n## /); + assert.ok(match, 'Recent Posts section present'); + return match[1]; +} + describe('pruneContentHistory', () => { - test('removes entries older than 90 days', () => { - const today = new Date(); - const old = new Date(today); - old.setDate(old.getDate() - 100); - const oldDate = old.toISOString().slice(0, 10); + test('prunes an old entry that sits BELOW a fresh entry (regression: /m flag truncated the capture to the first line)', () => { + // Production prepends new entries at the top, so old entries always sit + // BELOW fresh ones. With the /m flag, the $ alternative in the lookahead + // matched the end of the FIRST entry line, so only that line was captured + // and older entries below were never scanned -> never pruned. + const oldDate = daysBeforeFixedToday(400); + const freshDate = daysBeforeFixedToday(10); - const recent = new Date(today); - recent.setDate(recent.getDate() - 10); - const recentDate = recent.toISOString().slice(0, 10); - - const stateWithOld = SAMPLE_STATE.replace( - '## Recent Posts\n\n', - `## Recent Posts\n\n- [${oldDate}] "Old post..." (1000) - old topic\n- [${recentDate}] "Recent post..." (1200) - recent topic\n` + const state = SAMPLE_STATE.replace( + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + `## Recent Posts\n\n- [${freshDate}] "Fresh post..." (1200) - fresh topic\n- [${oldDate}] "Old post..." (1000) - old topic\n\n` ); - const result = pruneContentHistory(stateWithOld, 90); + const result = pruneContentHistory(state, 90, FIXED_TODAY); + assert.notEqual(result, null, 'an old entry below a fresh one must be pruned'); + assert.equal(result.pruned, 1); + const section = recentPostsSection(result.content); + assert.ok(!section.includes(oldDate), 'old entry pruned'); + assert.ok(section.includes(freshDate), 'fresh entry kept'); + }); + + test('removes entries older than 90 days', () => { + const oldDate = daysBeforeFixedToday(100); + const recentDate = daysBeforeFixedToday(10); + + const stateWithOld = SAMPLE_STATE.replace( + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + `## Recent Posts\n\n- [${oldDate}] "Old post..." (1000) - old topic\n- [${recentDate}] "Recent post..." (1200) - recent topic\n\n` + ); + + const result = pruneContentHistory(stateWithOld, 90, FIXED_TODAY); assert.notEqual(result, null); assert.equal(result.pruned, 1); - assert.ok(!result.content.includes(oldDate)); - assert.ok(result.content.includes(recentDate)); + const section = recentPostsSection(result.content); + assert.ok(!section.includes(oldDate)); + assert.ok(section.includes(recentDate)); }); test('preserves entries within 90 days', () => { - const today = new Date(); - const recent = new Date(today); - recent.setDate(recent.getDate() - 30); - const recentDate = recent.toISOString().slice(0, 10); + const recentDate = daysBeforeFixedToday(30); const stateWithRecent = SAMPLE_STATE.replace( - '## Recent Posts\n\n', - `## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n` + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + `## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n\n` ); - const result = pruneContentHistory(stateWithRecent, 90); + const result = pruneContentHistory(stateWithRecent, 90, FIXED_TODAY); assert.equal(result, null); // nothing to prune }); @@ -327,22 +359,19 @@ describe('pruneContentHistory', () => { /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, '## Recent Posts\n\n' ); - const result = pruneContentHistory(emptyRecent, 90); + const result = pruneContentHistory(emptyRecent, 90, FIXED_TODAY); assert.equal(result, null); }); test('handles custom maxAgeDays', () => { - const today = new Date(); - const old = new Date(today); - old.setDate(old.getDate() - 40); - const oldDate = old.toISOString().slice(0, 10); + const oldDate = daysBeforeFixedToday(40); const stateWithOld = SAMPLE_STATE.replace( - '## Recent Posts\n\n', - `## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n` + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + `## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n\n` ); - const result = pruneContentHistory(stateWithOld, 30); + const result = pruneContentHistory(stateWithOld, 30, FIXED_TODAY); assert.notEqual(result, null); assert.equal(result.pruned, 1); }); @@ -352,25 +381,23 @@ describe('pruneContentHistory', () => { // string search has no $1 group, but `$&` still expands to the whole matched // section and `$$` collapses to `$`, so a kept post like "$$ and $& budget" // corrupted state. The replacement function inserts newSection verbatim. - const today = new Date(); - const old = new Date(today); old.setDate(old.getDate() - 100); - const oldDate = old.toISOString().slice(0, 10); - const recent = new Date(today); recent.setDate(recent.getDate() - 10); - const recentDate = recent.toISOString().slice(0, 10); + const oldDate = daysBeforeFixedToday(100); + const recentDate = daysBeforeFixedToday(10); // Build the fixture with a replacement FUNCTION too — a string replacement here // would itself interpret the `$$`/`$&` we are trying to plant (the very bug under // test), corrupting the fixture before pruneContentHistory ever sees it. const stateWithMix = SAMPLE_STATE.replace( - '## Recent Posts\n\n', - () => `## Recent Posts\n\n- [${oldDate}] "Old..." (1000) - drop me\n- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget\n` + /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/, + () => `## Recent Posts\n\n- [${oldDate}] "Old..." (1000) - drop me\n- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget\n\n` ); - const result = pruneContentHistory(stateWithMix, 90); + const result = pruneContentHistory(stateWithMix, 90, FIXED_TODAY); assert.notEqual(result, null); assert.equal(result.pruned, 1); - assert.ok(!result.content.includes(oldDate), 'old entry pruned'); - assert.ok(result.content.includes(`- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget`), 'kept $-bearing entry survives verbatim'); + const section = recentPostsSection(result.content); + assert.ok(!section.includes(oldDate), 'old entry pruned'); + assert.ok(section.includes(`- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget`), 'kept $-bearing entry survives verbatim'); const headings = result.content.match(/^## Recent Posts$/gm) || []; assert.equal(headings.length, 1, 'section must not be duplicated by a $& expansion'); }); @@ -666,3 +693,218 @@ describe('recordOutreachContact', () => { assert.ok(result.content.includes('[2026-05-30 14:00]')); }); }); + +// Mirrors the ## Do-Next Directives block config/state-file.template.md ships +// (own fixture rather than an edit to TEMPLATE_STATE, so the recordOutreachContact +// "entry lands inside the last section" assertion keeps testing exactly what it +// tested before). Every template-initialized state file therefore hits the +// section-APPEND branch; SAMPLE_STATE (no section) exercises section-CREATE. +const DONEXT_TEMPLATE_STATE = `--- +last_post_date: "2026-04-05" +last_post_topic: "AI strategy" +follower_count: 850 +--- + +# LinkedIn Session State + +## Recent Posts + +- [2026-04-05] "AI governance is not about..." (1450) - AI strategy + +## Do-Next Directives + +<!-- Measurement -> next action. Written by /linkedin:report, /linkedin:analyze, /linkedin:ab-test (Adopt) and the 48h post-feedback-monitor; read by the create surfaces at Step 0. --> +<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer --> +`; + +describe('recordDoNext', () => { + // "Now" for the write tests: days — not months — after the 2026-05-3x record dates, + // so the age floor is NOT what those assertions end up measuring. (Reusing + // FIXED_TODAY here made the supersede test pass for the wrong reason: every row + // was already past the 60-day floor, so "replaced" and "expired" were + // indistinguishable.) The prune tests below deliberately use FIXED_TODAY with + // dates computed relative to it. + const DONEXT_TODAY = new Date('2026-06-10T00:00:00Z'); + const REPORT_OPTS = { + recordDate: '2026-05-30', + source: 'report', + directives: [ + { directive: 'Double down on AI-agent posts', evidence: 'weekly 2026-W22: 3 posts avg 2400 impressions vs 1200 baseline' }, + { directive: 'Move Tuesday posts to 08:00', evidence: 'weekly 2026-W22: Tuesday -40% impressions' } + ], + today: DONEXT_TODAY + }; + + test('creates a non-R-initial Do-Next Directives section when absent', () => { + assert.ok(!SAMPLE_STATE.includes('## Do-Next Directives')); + const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS); + assert.notEqual(result, null); + assert.ok(result.content.includes('## Do-Next Directives')); + }); + + test('records date, source tag, directive text and evidence pointer in each row', () => { + const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS); + assert.notEqual(result, null); + assert.match(result.content, /^- \[2026-05-30\] \(report\) Double down on AI-agent posts — evidence: weekly 2026-W22: 3 posts avg 2400 impressions vs 1200 baseline$/m); + assert.match(result.content, /^- \[2026-05-30\] \(report\) Move Tuesday posts to 08:00 — evidence: weekly 2026-W22: Tuesday -40% impressions$/m); + }); + + test('accepts a plain-string directive and marks the missing evidence pointer as none', () => { + const result = recordDoNext(SAMPLE_STATE, { + recordDate: '2026-05-30', + source: 'report', + directives: ['Ship one carousel this week'], + today: DONEXT_TODAY + }); + assert.notEqual(result, null); + assert.match(result.content, /^- \[2026-05-30\] \(report\) Ship one carousel this week — evidence: _\(none\)_$/m); + }); + + test('caps a single write at 3 directives (2-3 is the contract; a report dump is not a backlog)', () => { + const result = recordDoNext(SAMPLE_STATE, { + recordDate: '2026-05-30', + source: 'report', + directives: ['one', 'two', 'three', 'four', 'five'], + today: DONEXT_TODAY + }); + assert.notEqual(result, null); + const rows = result.content.match(/^- \[2026-05-30\] \(report\)/gm) || []; + assert.equal(rows.length, 3, 'at most 3 rows per write'); + assert.ok(!result.content.includes('(report) four')); + }); + + test('replaces the SAME source on the next write — directives have a lifetime, they do not accumulate', () => { + const first = recordDoNext(SAMPLE_STATE, REPORT_OPTS); + const second = recordDoNext(first.content, { + recordDate: '2026-06-06', + source: 'report', + directives: ['Test a poll format'], + today: DONEXT_TODAY + }); + assert.notEqual(second, null); + assert.ok(!second.content.includes('Double down on AI-agent posts'), 'superseded report directive must be gone'); + assert.ok(!second.content.includes('Move Tuesday posts to 08:00'), 'superseded report directive must be gone'); + assert.match(second.content, /^- \[2026-06-06\] \(report\) Test a poll format /m); + }); + + test('leaves OTHER sources untouched when one source rewrites its slot', () => { + const withAb = recordDoNext(SAMPLE_STATE, { + recordDate: '2026-05-28', + source: 'ab-test', + directives: [{ directive: 'Adopt question hooks', evidence: 'test hook-form: B +38% comments' }], + today: DONEXT_TODAY + }); + const withReport = recordDoNext(withAb.content, REPORT_OPTS); + assert.notEqual(withReport, null); + assert.ok(withReport.content.includes('(ab-test) Adopt question hooks'), 'a report write must not evict the A/B slot'); + assert.ok(withReport.content.includes('(report) Double down on AI-agent posts')); + }); + + test('prunes rows older than maxAgeDays regardless of source (bounded section)', () => { + const stale = daysBeforeFixedToday(90); + const fresh = daysBeforeFixedToday(10); + const seeded = SAMPLE_STATE.replace( + '## Milestone Log', + `## Do-Next Directives\n\n- [${stale}] (48h-monitor) Old learning — evidence: post 2026-01\n- [${fresh}] (48h-monitor) Recent learning — evidence: post 2026-12\n\n## Milestone Log` + ); + const result = recordDoNext(seeded, { ...REPORT_OPTS, recordDate: daysBeforeFixedToday(0), maxAgeDays: 60, today: FIXED_TODAY }); + assert.notEqual(result, null); + assert.ok(!result.content.includes('Old learning'), 'row past the age window must be dropped'); + assert.ok(result.content.includes('Recent learning'), 'row inside the age window must survive'); + }); + + test('prunes stale rows even when the write itself carries no directives', () => { + const stale = daysBeforeFixedToday(120); + const seeded = SAMPLE_STATE.replace( + '## Milestone Log', + `## Do-Next Directives\n\n- [${stale}] (report) Ancient directive — evidence: none\n\n## Milestone Log` + ); + const result = recordDoNext(seeded, { recordDate: daysBeforeFixedToday(0), source: 'report', directives: [], maxAgeDays: 60, today: FIXED_TODAY }); + assert.notEqual(result, null); + assert.ok(!result.content.includes('Ancient directive')); + }); + + test('returns null when there is nothing to record and nothing to prune', () => { + const result = recordDoNext(SAMPLE_STATE, { recordDate: '2026-05-30', source: 'report', directives: [], today: FIXED_TODAY }); + assert.equal(result, null); + }); + + test('is additive — inserts last_donext_date when the field is absent', () => { + assert.ok(!/^last_donext_date:/m.test(SAMPLE_STATE)); + const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS); + assert.notEqual(result, null); + assert.match(result.content, /^last_donext_date: "2026-05-30"$/m); + }); + + test('updates an existing last_donext_date without duplicating it', () => { + const withField = SAMPLE_STATE.replace( + 'last_post_date: "2026-04-05"', + 'last_post_date: "2026-04-05"\nlast_donext_date: null' + ); + const result = recordDoNext(withField, { ...REPORT_OPTS, recordDate: '2026-06-06' }); + assert.notEqual(result, null); + const hits = result.content.match(/^last_donext_date:/gm) || []; + assert.equal(hits.length, 1, 'field must not be duplicated'); + assert.match(result.content, /^last_donext_date: "2026-06-06"$/m); + }); + + test('no-anchor fall-through: scalar not written, not reported; section still written', () => { + assert.ok(!/^last_post_date:/m.test(NO_ANCHOR_STATE)); + const result = recordDoNext(NO_ANCHOR_STATE, REPORT_OPTS); + assert.notEqual(result, null); + assert.ok(!/^last_donext_date:/m.test(result.content), 'scalar must not be inserted without an anchor'); + assert.ok(!result.changes.some((c) => /last_donext_date/.test(c)), 'no date-scalar change entry without an anchor'); + assert.ok(result.changes.some((c) => /^Do-Next Directives/.test(c)), 'section write must still be recorded'); + assert.ok(result.content.includes('(report) Double down on AI-agent posts')); + }); + + test('writes into the pre-existing section (production path) without duplicating it or dropping its format comments', () => { + assert.ok(DONEXT_TEMPLATE_STATE.includes('## Do-Next Directives')); + const result = recordDoNext(DONEXT_TEMPLATE_STATE, REPORT_OPTS); + assert.notEqual(result, null); + const headings = result.content.match(/^## Do-Next Directives$/gm) || []; + assert.equal(headings.length, 1, 'section must not be duplicated'); + assert.ok(result.content.includes('<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->'), 'template format comment survives'); + const section = result.content.slice(result.content.indexOf('## Do-Next Directives')); + assert.ok(section.includes('(report) Double down on AI-agent posts'), 'rows must land inside the section'); + }); + + test('records $-bearing directive/evidence verbatim — special replacement patterns are not interpreted', () => { + const result = recordDoNext(DONEXT_TEMPLATE_STATE, { + recordDate: '2026-05-30', + source: 'report', + directives: [{ directive: 'Price the $&-offer at $99', evidence: 'ref $1, spend $$40' }], + today: DONEXT_TODAY + }); + assert.notEqual(result, null); + assert.ok(result.content.includes('Price the $&-offer at $99'), 'directive with $-tokens verbatim'); + assert.ok(result.content.includes('ref $1, spend $$40'), 'evidence with $-tokens verbatim'); + const headings = result.content.match(/^## Do-Next Directives$/gm) || []; + assert.equal(headings.length, 1, 'section must not be re-injected by a $1/$& expansion'); + }); + + test('leaves existing fields and sections untouched (round-trip)', () => { + const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS); + assert.notEqual(result, null); + assert.match(result.content, /^last_post_date: "2026-04-05"$/m); + assert.match(result.content, /^follower_count: 850$/m); + assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."')); + assert.ok(result.content.includes('## Milestone Log')); + }); + + test('returns a changes array describing what changed', () => { + const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS); + assert.notEqual(result, null); + assert.ok(Array.isArray(result.changes)); + assert.ok(result.changes.some((c) => /Do-Next Directives/.test(c))); + }); + + test('a do-next section does not break pruneContentHistory (non-R-initial section boundary)', () => { + // pruneContentHistory captures "## Recent Posts ... (?=\n## [^R])": the do-next + // heading must terminate that window, not extend it into the directive rows. + const withDoNext = recordDoNext(SAMPLE_STATE, REPORT_OPTS).content; + const pruned = pruneContentHistory(withDoNext, 30, FIXED_TODAY); + assert.notEqual(pruned, null, 'old Recent Posts entries must still be prunable'); + assert.ok(pruned.content.includes('(report) Double down on AI-agent posts'), 'directives must survive a content-history prune'); + }); +}); diff --git a/hooks/scripts/session-start.mjs b/hooks/scripts/session-start.mjs index e451b48..22b98ce 100644 --- a/hooks/scripts/session-start.mjs +++ b/hooks/scripts/session-start.mjs @@ -10,6 +10,15 @@ import { queueToday, queueOverdue, queueUpcoming } from './queue-manager.mjs'; import { applyWeekRollover } from './week-rollover.mjs'; import { getDataRoot } from './data-root.mjs'; import { migrateData } from './migrate-data.mjs'; +import { + activeEditions, + readSlots, + slotOccurrences, + slotVacancies, + daysUntil, + formatSlot, + defaultSlotsPath, +} from './slots.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); @@ -536,6 +545,49 @@ if (latestBrief && latestBrief.summary) { context += `\\n## Morning Brief (${latestBrief.date})\\n${latestBrief.summary}\\n→ Full brief: ${latestBrief.file}\\n`; } +// N13 (A1-10/A1-11): the week's production picture — long-form editions in +// flight, and publishing slots nothing covers yet. Unconditional like the brain +// nudge above (the fresh-install branch reassigns `context`, so an earlier +// append would be clobbered). Zero-tsx: slots.mjs reads the register, the queue +// and the grid as plain JSON, the same discipline as trendsNewestCapture. +// Both blocks are silent when their data is absent — an operator who has +// configured no slots and started no edition is never nagged. +const SLOT_HORIZON_DAYS = 14; +const SLOT_IMMINENT_DAYS = 3; +try { + const inFlight = activeEditions(); + if (inFlight.length > 0) { + context += '\\n## Editions in Flight\\n'; + for (const e of inFlight) { + const age = e.daysInFlight === null ? '' : ` | ${e.daysInFlight}d in flight`; + context += `- ${e.series} #${e.editionId} — ${e.title || 'untitled'} | phase: ${e.currentPhase}`; + context += ` | next: ${e.nextAction || 'unset'} | slot: ${e.slot || 'unclaimed'}${age}\\n`; + } + context += '- Resume an edition with /linkedin:newsletter (it reads edition-state.json, not this view).\\n'; + } + + const { slots } = readSlots(); + if (slots.length > 0) { + const today = new Date().toISOString().slice(0, 10); + const planned = slotOccurrences(slots, today, SLOT_HORIZON_DAYS).length; + const vacancies = slotVacancies({ from: today, days: SLOT_HORIZON_DAYS }); + context += '\\n## Publishing Slots\\n'; + if (vacancies.length > 0) { + const until = daysUntil(today, vacancies[0].date); + context += `- Next open slot: ${formatSlot(vacancies[0], today)}\\n`; + context += `- Open in the next ${SLOT_HORIZON_DAYS} days: ${vacancies.length} of ${planned} slot(s)\\n`; + if (until !== null && until <= SLOT_IMMINENT_DAYS) { + context += '- Fill it: /linkedin:batch (short-form) or /linkedin:newsletter (long-form).\\n'; + } + } else { + context += `- All ${planned} slot(s) in the next ${SLOT_HORIZON_DAYS} days are covered.\\n`; + } + context += `- Grid: ${defaultSlotsPath()}\\n`; + } +} catch { + // Non-critical: a broken grid or register must never block session start. +} + // Read REMEMBER.md for user session context const rememberFile = join(getDataRoot(), 'REMEMBER.md'); const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md'); diff --git a/hooks/scripts/slots.mjs b/hooks/scripts/slots.mjs new file mode 100644 index 0000000..ed0e81b --- /dev/null +++ b/hooks/scripts/slots.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node +// Publishing slots + work-in-progress view (N13 — A1-10). +// +// The week's production grid in one place: the slots the operator publishes in, +// what already covers them (the short-form queue + the editions register), and +// which long-form editions are still in flight. Session start reads it so the +// week is visible without opening a file; the commands read the SAME functions +// through `node --input-type=module -e` (the queue-manager.mjs pattern), so the +// vacancy the hook reports and the slot `/linkedin:newsletter` defaults to can +// never disagree. +// +// Zero-dep on purpose: this runs inside a SessionStart hook, which must not +// spawn tsx or touch node_modules (same discipline as session-start.mjs's direct +// trend-store read). +// +// DOMAIN-NEUTRAL: the plugin ships no publishing times. The grid lives in the +// per-user data dir (`profile/publishing-slots.json`); an absent config means +// silence, never a default schedule imposed on the user. +// +// The core is pure — dates in as `YYYY-MM-DD` strings, no clock — so vacancy is +// testable without freezing time. Only the thin readers below touch the disk. + +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { getDataRoot } from './data-root.mjs'; + +export const SLOTS_SCHEMA_VERSION = 1; + +const MS_PER_DAY = 86400000; + +// Canonical day keys (ISO-ish 3-letter, lowercase) → JS getUTCDay index. +const DAY_INDEX = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 }; +const DAY_LABEL = { sun: 'Sun', mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat' }; + +// A queue entry counts as covering its date when it is still going out or has +// already gone out. A cancelled entry does NOT — that slot fell vacant again. +const COVERING_QUEUE_STATUS = new Set(['scheduled', 'published']); + +const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; + +/** `${DATA}/profile/publishing-slots.json` — user config, survives reinstalls (M0). */ +export function defaultSlotsPath() { + return join(getDataRoot('profile'), 'publishing-slots.json'); +} + +/** `${DATA}/editions/register.json` — twin of scripts/editions/src/register.ts:defaultRegisterPath. */ +export function defaultRegisterPath() { + return join(getDataRoot('editions'), 'register.json'); +} + +/** `${DATA}/drafts/queue.json` — read directly (never via queue-manager's ensureQueue: a read-only check must not create files). */ +function queuePath() { + return join(getDataRoot('drafts'), 'queue.json'); +} + +function readJson(path) { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')); + } catch { + return null; + } +} + +/** + * Normalize a raw config into the grid the rest of the module speaks. + * Forgiving by design: an unusable row is dropped, never thrown — a typo in a + * config file must not take down session start. + */ +export function normalizeSlots(raw) { + const timezone = typeof raw?.timezone === 'string' ? raw.timezone : ''; + const rows = Array.isArray(raw?.slots) ? raw.slots : []; + const slots = []; + for (const r of rows) { + const day = String(r?.day ?? '').trim().toLowerCase().slice(0, 3); + const time = String(r?.time ?? '').trim(); + if (!(day in DAY_INDEX) || !TIME_RE.test(time)) continue; + slots.push({ day, time, label: typeof r?.label === 'string' ? r.label : '' }); + } + return { timezone, slots }; +} + +/** Read the per-user grid. Absent/malformed ⇒ empty grid (silence, not a nag). */ +export function readSlots(path = defaultSlotsPath()) { + return normalizeSlots(readJson(path)); +} + +function addDays(dateStr, n) { + // Noon UTC anchor — the codebase idiom (state-updater.mjs) that keeps date + // arithmetic clear of DST edges. + const d = new Date(`${dateStr}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() + n); + return d.toISOString().slice(0, 10); +} + +function weekdayKey(dateStr) { + const idx = new Date(`${dateStr}T12:00:00Z`).getUTCDay(); + return Object.keys(DAY_INDEX).find((k) => DAY_INDEX[k] === idx) ?? ''; +} + +/** + * Expand the weekly grid into concrete occurrences across `days` calendar days + * starting at (and including) `from` — a slot today is still actionable. + */ +export function slotOccurrences(slots, from, days) { + const out = []; + for (let i = 0; i < days; i += 1) { + const date = addDays(from, i); + const key = weekdayKey(date); + for (const s of slots) { + if (s.day === key) out.push({ date, day: s.day, dayLabel: DAY_LABEL[s.day], time: s.time, label: s.label }); + } + } + return out.sort((a, b) => `${a.date} ${a.time}`.localeCompare(`${b.date} ${b.time}`)); +} + +/** + * How many published/scheduled things land on each date — counted, not merely + * flagged, so a day carrying two slots needs two posts to be full. + */ +export function coverageByDate(queueEntries, editionRows) { + const counts = new Map(); + const bump = (date) => { + if (!date) return; + counts.set(date, (counts.get(date) ?? 0) + 1); + }; + + for (const e of queueEntries ?? []) { + if (COVERING_QUEUE_STATUS.has(e?.status)) bump(e?.scheduled_date); + } + // Register rows carry `slot` as "YYYY-MM-DD HH:MM". Completed rows count too: + // an edition that reached Step 10 IS the thing occupying that slot. + for (const r of editionRows ?? []) { + if (typeof r?.slot === 'string' && r.slot.length >= 10) bump(r.slot.slice(0, 10)); + } + return counts; +} + +/** The occurrences nothing covers, chronological. Per date, the earliest slots are considered filled first. */ +export function vacantSlots(occurrences, coverage) { + const remaining = new Map(coverage); + const vacant = []; + for (const occ of occurrences) { + const left = remaining.get(occ.date) ?? 0; + if (left > 0) remaining.set(occ.date, left - 1); + else vacant.push(occ); + } + return vacant; +} + +/** All register rows, whatever their status (coverage cares about completed ones too). */ +export function readRegisterRows(path = defaultRegisterPath()) { + const parsed = readJson(path); + return Array.isArray(parsed?.editions) ? parsed.editions : []; +} + +function readQueueEntries() { + const parsed = readJson(queuePath()); + return Array.isArray(parsed?.queue) ? parsed.queue : []; +} + +function todayISO() { + return new Date().toISOString().slice(0, 10); +} + +/** Every uncovered slot in the horizon — the vacancy warning's raw material. */ +export function slotVacancies({ from = todayISO(), days = 14 } = {}) { + const { slots } = readSlots(); + if (slots.length === 0) return []; + const occurrences = slotOccurrences(slots, from, days); + return vacantSlots(occurrences, coverageByDate(readQueueEntries(), readRegisterRows())); +} + +/** The slot `/linkedin:newsletter` Step 10 defaults to; null when nothing is configured or everything is covered. */ +export function nextFreeSlot(options = {}) { + return slotVacancies(options)[0] ?? null; +} + +/** + * Editions in production right now, with whole days in flight (the `daysSince` + * idiom the hook already reports elapsed time with). Deterministic order — + * series, then edition id — mirroring listEditions in scripts/editions. + */ +export function activeEditions({ now = new Date().toISOString() } = {}) { + const nowMs = Date.parse(now); + return readRegisterRows() + .filter((r) => r?.status === 'active') + .sort((a, b) => String(a.series).localeCompare(String(b.series)) || String(a.editionId).localeCompare(String(b.editionId))) + .map((r) => { + const started = Date.parse(r?.startedAt ?? ''); + const daysInFlight = + Number.isNaN(started) || Number.isNaN(nowMs) ? null : Math.floor((nowMs - started) / MS_PER_DAY); + return { ...r, daysInFlight }; + }); +} + +/** "today" / "tomorrow" / "in N days" — how far off a slot is, in words. */ +export function daysUntil(from, date) { + const a = Date.parse(`${from}T12:00:00Z`); + const b = Date.parse(`${date}T12:00:00Z`); + if (Number.isNaN(a) || Number.isNaN(b)) return null; + return Math.round((b - a) / MS_PER_DAY); +} + +/** One slot rendered for a human: "Thu 2026-07-30 12:00 (in 3 days)". */ +export function formatSlot(slot, from = todayISO()) { + const n = daysUntil(from, slot.date); + const when = n === null ? '' : n === 0 ? ' (today)' : n === 1 ? ' (tomorrow)' : ` (in ${n} days)`; + const label = slot.label ? ` — ${slot.label}` : ''; + return `${slot.dayLabel} ${slot.date} ${slot.time}${when}${label}`; +} diff --git a/hooks/scripts/state-updater.mjs b/hooks/scripts/state-updater.mjs index df1d9a5..6cb7d62 100644 --- a/hooks/scripts/state-updater.mjs +++ b/hooks/scripts/state-updater.mjs @@ -133,17 +133,21 @@ export function updatePostTracking(stateContent, { postDate, postTopic, hookText * Remove Recent Posts entries older than maxAgeDays. * @param {string} stateContent - Full state file content * @param {number} [maxAgeDays=90] + * @param {Date} [today=new Date()] - Injectable clock (tests pass a fixed date) * @returns {{ content: string, pruned: number } | null} */ -export function pruneContentHistory(stateContent, maxAgeDays = 90) { - const today = new Date(); +export function pruneContentHistory(stateContent, maxAgeDays = 90, today = new Date()) { const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - maxAgeDays); const cutoffStr = cutoff.toISOString().slice(0, 10); // Find all Recent Posts entries const entryPattern = /^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm; - const recentSection = stateContent.match(/## Recent Posts\n\n?([\s\S]*?)(?=\n## [^R]|\n## $|$)/m); + // No /m flag: with /m the $ alternative in the lookahead matched the end of + // EVERY line, so the lazy capture stopped after the FIRST entry line and + // older entries below it were never scanned (and never pruned). Without /m, + // $ matches only end-of-string and the capture spans the whole section. + const recentSection = stateContent.match(/## Recent Posts\n\n?([\s\S]*?)(?=\n## [^R]|\n## $|$)/); if (!recentSection || !recentSection[1].trim()) return null; const sectionContent = recentSection[1]; @@ -339,6 +343,112 @@ export function recordOutreachContact(stateContent, { contactDate, track = '', p return { content, changes }; } +/** + * Record 2-3 "do next" directives deterministically — the measurement→creation + * contract. A recommendation that only ever lands in chat dies there; a directive + * written HERE is read by the create surfaces at their Step 0, so the next post is + * actually shaped by the last report. + * + * Lifetime is REPLACE-BY-SOURCE plus an age floor, not append-forever: + * - each writer (report / analyze / ab-test / 48h-monitor) owns its own slot, so a + * new report supersedes the previous report's directives but never evicts an + * A/B verdict that landed in between; + * - rows older than `maxAgeDays` are dropped on every write regardless of source, + * so a writer that goes quiet cannot leave stale directives steering drafts + * forever (pruneContentHistory's bounded-section pattern); + * - at most 3 rows per write — this is a steering signal, not a backlog. + * + * Additive by contract, like recordFirstHourPlan/recordOutreachContact: a missing + * scalar is inserted, a missing section created, no existing field touched. The + * section name is deliberately non-`R`-initial ("Do-Next …") so it falls outside + * pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window. + * + * @param {string} stateContent - Full state file content + * @param {{ recordDate: string, source?: string, directives?: Array<string|{directive: string, evidence?: string}>, maxAgeDays?: number, today?: Date }} opts + * @returns {{ content: string, changes: string[] } | null} + */ +export function recordDoNext(stateContent, { recordDate, source = 'report', directives = [], maxAgeDays = 60, today = new Date() }) { + let content = stateContent; + const changes = []; + const MAX_PER_WRITE = 3; + + const cutoff = new Date(today); + cutoff.setDate(cutoff.getDate() - maxAgeDays); + const cutoffStr = cutoff.toISOString().slice(0, 10); + + // 1. Build this write's rows (capped; string and {directive, evidence} both accepted) + const rows = directives.slice(0, MAX_PER_WRITE).map((d) => { + const text = (typeof d === 'string' ? d : d && d.directive) || ''; + const evidence = (typeof d === 'string' ? '' : (d && d.evidence) || '').trim(); + return `- [${recordDate}] (${source}) ${String(text).trim()} — evidence: ${evidence || '_(none)_'}`; + }).filter((r) => !/\) +— evidence:/.test(r)); // drop rows with an empty directive + + // 2. Rebuild the existing section body: drop this source's rows (superseded) and + // every row past the age floor (any source). Non-row lines (format comments, + // blank lines) are kept verbatim. + // No /m flag — same trap pruneContentHistory documents: with /m the `$` alternative + // in the lookahead matches the end of EVERY line, so the lazy body capture stops + // after the first line and older rows below it are never scanned (never superseded, + // never pruned). Without /m, `$` is end-of-string and the capture spans the section. + const sectionRe = /(## Do-Next Directives\n\n?)([\s\S]*?)(?=\n## |$)/; + const existing = content.match(sectionRe); + let dropped = 0; + + if (existing) { + // The section's leading `<!-- … -->` format comments are documentation, not data: + // they stay directly under the heading, above the rows (a row prepended above them + // would push the format contract to the bottom of the section every write). + const lines = existing[2].split('\n'); + let cut = 0; + while (cut < lines.length && !/^- \[/.test(lines[cut]) && (lines[cut].trim() === '' || lines[cut].trim().startsWith('<!--'))) cut++; + const preamble = lines.slice(0, cut).join('\n').replace(/\s+$/, ''); + const kept = lines.slice(cut).filter((line) => { + if (line.trim() === '') return false; // rebuilt below, never accumulated + const row = line.match(/^- \[(\d{4}-\d{2}-\d{2})[^\]]*\] \(([^)]*)\)/); + if (!row) return true; // unexpected user text: keep verbatim + if (row[2] === source) { dropped++; return false; } // superseded by this write + if (row[1] < cutoffStr) { dropped++; return false; } // past the age floor + return true; + }); + + // Newest first: this write's rows go directly under the heading + comments. + const parts = []; + if (preamble) parts.push(preamble, ''); + parts.push(...rows, ...kept); + const body = `${parts.join('\n')}\n`; + // Replacement FUNCTION, not string: rows embed untrusted directive/evidence text — + // `$&`/`$1`/`$$` must be inserted verbatim (same class as the other section writes). + content = content.replace(sectionRe, () => `${existing[1]}${body}`); + } else if (rows.length) { + const trimmed = content.replace(/\s*$/, ''); + const header = '## Do-Next Directives\n\n<!-- Measurement -> next action. Written by /linkedin:report, /linkedin:analyze, /linkedin:ab-test (Adopt) and the 48h post-feedback-monitor; read by the create surfaces at Step 0. -->\n<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->\n\n'; + content = `${trimmed}\n\n${header}${rows.join('\n')}\n`; + } + + if (rows.length === 0 && dropped === 0) return null; + + // 3. last_donext_date — only when something was actually recorded. Replace in place, + // else insert after the nearest existing date anchor (additive). Report the change + // only inside the branch that writes it: with no anchor the scalar is not inserted. + if (rows.length) { + if (/^last_donext_date: .*/m.test(content)) { + content = replaceField(content, 'last_donext_date', `"${recordDate}"`); + changes.push(`last_donext_date → ${recordDate}`); + } else { + const anchor = ['last_outreach_date', 'last_firsthour_date', 'last_post_date'].find((f) => new RegExp(`^${f}: .*`, 'm').test(content)); + if (anchor) { + content = content.replace(new RegExp(`^(${anchor}: .*)$`, 'm'), (m) => `${m}\nlast_donext_date: "${recordDate}"`); // function, not string: keeps recordDate `$`-safe by construction + changes.push(`last_donext_date → ${recordDate}`); + } + } + changes.push(`Do-Next Directives ← ${rows.length} from ${source} (${recordDate})`); + } + if (dropped) changes.push(`Do-Next Directives: ${dropped} superseded/expired row(s) dropped`); + + if (content === stateContent) return null; + return { content, changes }; +} + /** * I/O wrapper: read state file, apply update function, write atomically. * @param {function(string): {content: string}|null} updateFn - Pure update function @@ -401,6 +511,19 @@ if (import.meta.url === `file://${process.argv[1]}`) { nextAction: getArg('--next') || '', dueDate: getArg('--due') || '' })); + } else if (args.includes('--record-do-next')) { + const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; }; + // Rows are `;`-separated; each row is `directive :: evidence` (evidence optional). + const parseDirectives = (s) => (s ? s.split(';').map((row) => { + const [directive, ...rest] = row.split('::'); + return { directive: (directive || '').trim(), evidence: rest.join('::').trim() }; + }).filter((d) => d.directive) : []); + writeState(content => recordDoNext(content, { + recordDate: getArg('--date') || new Date().toISOString().slice(0, 10), + source: getArg('--source') || 'report', + directives: parseDirectives(getArg('--directives')), + maxAgeDays: parseInt(getArg('--max-age') || '60', 10) + })); } else { console.log('Usage:'); console.log(' node state-updater.mjs --update-post --date YYYY-MM-DD --topic "topic" --hook "Hook text" --chars 1500 --format post'); @@ -408,5 +531,6 @@ if (import.meta.url === `file://${process.argv[1]}`) { console.log(' node state-updater.mjs --update-followers --count 920 --month 2026-04'); console.log(' node state-updater.mjs --record-firsthour --date "YYYY-MM-DD HH:MM" --topic "topic" --targets "a;b" --comments "c;d" --plan "e;f"'); console.log(' node state-updater.mjs --record-outreach --date "YYYY-MM-DD HH:MM" --track collab --partner "@name" --stage pitched --next "follow up" --due YYYY-MM-DD'); + console.log(' node state-updater.mjs --record-do-next --date YYYY-MM-DD --source report --directives "do X :: evidence;do Y :: evidence" [--max-age 60]'); } } diff --git a/references/algorithm-signals-reference.md b/references/algorithm-signals-reference.md index 3d21c35..2954c1b 100644 --- a/references/algorithm-signals-reference.md +++ b/references/algorithm-signals-reference.md @@ -113,14 +113,25 @@ improves writing); do not justify it as "reduces reach." ## The deployed ranking model — what we can and cannot say -> **An LLM-based relevance-ranking system is live on LinkedIn in 2026.** -> **No public name. No deployment date.** +> **LinkedIn's feed ranking model has an official name: the Generative Recommender (GR).** +> Announced 2026-03-12 on LinkedIn's engineering blog (Hristo Danchev, +> [Engineering the next generation of LinkedIn's feed](https://www.linkedin.com/blog/engineering/feed/engineering-the-next-generation-of-linkedins-feed)): +> a sequential transformer-based ranker that treats member interaction history as a +> timeline, paired with a unified LLM-embedding retrieval system. Rollout announced in +> the same post. | Claim | Statement | Source | Confidence | |-------|-----------|--------|------------| -| A live LLM relevance system exists | Confirmed in direction by LinkedIn's 2026 communications. | LinkedIn comms (2026) | high | -| Production name | **Not publishable as fact.** The most-cited arXiv paper (2501.16450) is a Jan-**2025** *pre-production research* model (V1.0, 150B params, offline parity only), **withdrawn 2025-08-23**. A circulating "Generative Recommender / Hristo Danchev" engineering-post citation was independently flagged as **likely fabricated** — do not propagate. | arXiv 2501.16450; Gemini provenance flag | high (on the negative claim) | -| Deployment date | No primary source. The "early-2026" date is third-party extrapolation from the paper's Jan-**2025** date. **Do not assert a date.** | — | n/a | +| Production name | **Generative Recommender (GR)** — official, primary-source. | LinkedIn engineering blog, 2026-03-12 | high | +| LLM-based retrieval | Confirmed: "a unified retrieval system leveraging advances in LLMs to generate a high-quality representation of our members and content." | Same post | high | +| Deployment | Rollout announced 2026-03-12 ("rolling out a new advanced ranking system"). Full-coverage completion date not stated — do not assert one. | Same post | high (announcement), n/a (completion) | +| "360Brew" as the production name | **Still not publishable.** The arXiv paper (2501.16450) is a Jan-**2025** *pre-production research* model (V1.0, 150B params, offline parity only), **withdrawn 2025-08-23**; the "360Brew" label is third-party and has no official confirmation. GR is the official name. | arXiv 2501.16450 | high (on the negative claim) | + +*Correction note (2026-07-17): this section previously said "No public name. No +deployment date." and flagged the Generative Recommender / Hristo Danchev +engineering-post citation as likely fabricated. That flag was wrong — and was already +wrong at "Last updated 2026-05": the official post had been live since 2026-03-12, +two months earlier. The fabrication flag rejected a genuine primary source.* ## Operational heuristics (directional — test per account) @@ -177,10 +188,11 @@ source.) --- -*Last updated: 2026-05. Maintained as the single canonical algorithm statement; cite, do -not restate.* +*Last updated: 2026-07-17 (GR-model correction). Maintained as the single canonical +algorithm statement; cite, do not restate.* -*Sources (per-claim quality/confidence noted inline): arXiv 2501.16450 (pre-production +*Sources (per-claim quality/confidence noted inline): LinkedIn Engineering — "Engineering +the next generation of LinkedIn's feed" (Hristo Danchev, 2026-03-12); arXiv 2501.16450 (pre-production research paper, withdrawn 2025-08-23); LinkedIn Engineering — "Leveraging Dwell Time" (2024); Tim Jurka, Head of Feed AI (2025-08-11); Laura Lorenzetti, VP & Exec Editor (2026-05-19); Gyanda Sachdeva, VP Product (2026-02-16); Matt Navarra relaying LinkedIn Sr. Director Product (Aug 2025); diff --git a/references/figure-design-guidelines.md b/references/figure-design-guidelines.md new file mode 100644 index 0000000..6118264 --- /dev/null +++ b/references/figure-design-guidelines.md @@ -0,0 +1,72 @@ +# Figure Design Guidelines — coded figures + +Design rules and conventions for **coded figures**: SVG/HTML sources rendered to +PNG with `${CLAUDE_PLUGIN_ROOT}/render/build-figur.mjs`. Coded is the **primary +route for data figures** (charts, diagrams, comparisons — anything whose content +is real numbers or real structure): precision and reproducibility beat generative +output, and the figure re-renders identically after a correction. Generative +images (`mcp-image`) remain the route for **illustrative** work (cover art, mood, +metaphor). Strategy-level guidance on *when* a post needs a visual at all lives in +`linkedin-visual-style.md`. + +## The three render targets + +| Target | Size | Use | +|--------|------|-----| +| `article` (default) | 1200 × content-driven height (aspect derived from the source's `viewBox`/dimensions; fallback 16:9) | inline figures in long-form articles | +| `carousel` | 1080 × 1350 (4:5) | carousel/document-post slides | +| `single` | 1200 × 1200 (1:1) | standalone feed-post image | + +`--width N` / `--height N` override any target. If an `article`-target source +declares no dimensions, the renderer warns and falls back to 16:9 — set an +explicit `viewBox` (SVG) or `--height`. + +```bash +node "${CLAUDE_PLUGIN_ROOT}/render/build-figur.mjs" figN.svg --target article --out figN.png +``` + +CLI and importable module (`renderFigure` is async). No npm dependencies; needs +headless Chrome (auto-discovered: app bundle → PATH → error with install hint). +No network access during render — sources must be local and self-contained. + +## Design rules + +Enforced as **warn-only validation** by the renderer (never a hard fail); treat +warnings as a review checklist, not noise: + +1. **Text ≥ 24pt/px effective size.** Figures are read on a phone in the feed — + smaller text is illegible at feed scale. (Same floor as the carousel rules in + `linkedin-visual-style.md`.) +2. **Color budget: max 1 primary + 1 secondary beyond neutrals.** Grays, white, + and black are free; every additional hue must earn its place. More colors read + as noise, not information. +3. **≥ 40 % whitespace — an editorial rule, not machine-checked.** Density is the + most common coded-figure failure. If the figure needs a legend to be parsed, + it probably needs to be two figures. + +## Brand-token convention + +The renderer injects design tokens as CSS variables (`--figur-*`) so figure +sources stay brand-agnostic. Tokens are **user data**, read from the data root +(see `data-path-convention.md`): + +``` +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/brand-tokens.json +``` + +| Key | CSS variable | Neutral default | +|-----|--------------|-----------------| +| `background` | `--figur-background` | `#FFFFFF` | +| `ink` | `--figur-ink` | `#1A1A1A` | +| `muted` | `--figur-muted` | `#5B5B5B` | +| `accent` | `--figur-accent` | `#2F6F9F` | +| `rule` | `--figur-rule` | `#D9D9D9` | +| `fontFamily` | `--figur-fontFamily` | `system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif` | + +Missing or unparsable file → neutral defaults with a warning. Partial files are +merged over the defaults. The user's brand is **data, never hardcoded** — figure +sources reference `var(--figur-accent)` etc. and render correctly for any user. + +**Authoring rule:** use the `--figur-*` variables for every color and font in a +figure source. A hex value hardcoded in the source defeats the token seam and +counts against the color budget. diff --git a/references/fold-in-loop.md b/references/fold-in-loop.md new file mode 100644 index 0000000..e900850 --- /dev/null +++ b/references/fold-in-loop.md @@ -0,0 +1,166 @@ +# The fold-in loop — how a correction made once gets enforced forever + +**Capture → Classify → Promote → Enforce.** This is the plugin's answer to the +single most expensive failure mode in a long-running writing practice: you +correct the same thing in edition after edition, because the correction lives in +a chat transcript that is gone by the next session. + +The loop makes a correction *stick*. You fix something in edition N; the machine +enforces it in edition N+1 without you remembering it existed. + +> **Domain-general by construction.** Nothing here is specific to a topic, a +> language, or an author. The stages and the artifacts they write are the same +> for any adopter. Where this document says "the contract", read "whatever +> document holds your writing rules" — the plugin ships one +> (`references/longform-quality-rules.md`) and it is enough. + +--- + +## The four stages + +| Stage | Where it runs | What it writes | Who decides | +|-------|---------------|----------------|-------------| +| **Capture** | `/linkedin:newsletter` Steps 2.5, 3a, 5.5, 6.5 — every step where the operator corrects prose | one row in `articles.NN.foldIns[]`, `decision: "pending"` | nobody — capture is unconditional | +| **Classify** | `/linkedin:newsletter` Step 11 (retro) | `classification` on the row: `mechanical-block` / `mechanical-warn` / `judgment` | the command layer proposes, the operator confirms | +| **Promote** | `/linkedin:newsletter` Step 11 (retro) | mechanical → a `scripts/contract-gate` rule; judgment → a line in the language-rules file / the craft checklist | **the operator** (JA/NEI per row) | +| **Enforce** | Step 4.5 (contract-gate), Step 4 + `language-reviewer` (language rules) | nothing — it reads | deterministic; no model in the loop for the mechanical half | + +### 1. Capture — never lose a correction + +The four fold-in steps are the four places where an operator's judgment enters +the text: skeleton annotations (2.5), spine annotations (3a), editorial flags +(5.5), cold-review flags (6.5). Each appends the correction **near-verbatim** +with the trigger that surfaced it, and leaves it `pending`. + +Capture is deliberately dumb and unconditional. Deciding *at capture time* +whether a correction is "worth" a rule is how the queue stays empty and the loop +dies — a five-second append is cheaper than re-discovering the rule in three +editions' time. Capture only what the operator actually corrected; do not invent +rules the operator did not ask for. + +Not every correction becomes a rule. A fix that is true only of *this* edition's +facts ("that number is 4.2, not 4.4") is edition-specific and does not belong in +the queue; a fix that would be true of the *next* edition too ("never open with a +meta-sentence about what the text will do") does. + +### 2. Classify — mechanical or judgment + +Two kinds of correction, two different permanent homes: + +- **Mechanical** — provable by string, regex, or count with ~zero false + positives ("this exact phrase is banned", "no more than one em-dash per 50 + words"). It becomes a **gate rule** and is enforced before a draft ever + reaches a human. +- **Judgment** — real editorial discretion ("the conclusion overloads; it needs + one grip, not four"). No gate can prove it. It becomes a **checklist line** + read by the craft reviewer and by the drafting step. + +Misclassifying a judgment call as mechanical is the expensive error: a gate rule +with false positives trains the operator to ignore the gate, and a gate nobody +trusts enforces nothing. **When unsure, classify as judgment.** + +### 3. Promote — the operator gates, atomically + +Each pending row gets an explicit **JA / NEI** from the operator. NEI marks the +row `rejected` — kept for traceability, never deleted, because "we considered +this and said no" is itself worth not re-discovering. + +JA promotes the row **atomically**: the rule entry, the contract row that +documents it, and the manifest row that binds them are written together, and the +fold-in row is marked `promoted` with the resulting rule id. A rule that exists +in code but not in the contract is a gate enforcing something undocumented; a +contract row with no rule is a rule nobody enforces. Both are drift, and both are +what the ratify check exists to refuse: + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/contract-gate" && node --import tsx src/cli.ts --ratify +``` + +**Green ratify is what makes the promotion count.** If it is red, the promotion +is not finished. + +### 4. Enforce — the loop's whole point + +- Mechanical rules run at **Step 4.5** (contract-gate) on the full draft, before + the costly AI gates. A violation dies there. +- Judgment rules are read at **Step 4** (drafting/consistency) and by + **`language-reviewer`** at Step 6.5 — so they prevent the defect as well as + catch it. A rule that only ever catches is worth half a rule. + +Language rules accumulate in a **user-owned, per-language file** under the +per-user data dir (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/language-rules/<lang>.md`, +template: `config/language-rules.template.md`). The shipped ban-list in +`references/longform-quality-rules.md` rule 3 stays the **baseline** and is never +edited by the loop; the user file **extends** it. Absent that file, the baseline +applies alone and nothing warns. + +--- + +## Adopting the loop without a private writing contract + +The author's own setup keeps an upstream writing contract with a rule manifest +outside this plugin. **You do not need one.** The plugin ships everything the +loop requires: + +- rules as data — `scripts/contract-gate/src/rules.ts` +- the baseline craft rules — `references/longform-quality-rules.md` +- the accumulating language file — `config/language-rules.template.md` +- the in-tree craft checklists — `agents/editorial-reviewer.md`, + `agents/language-reviewer.md` + +If you run the plugin without an external contract, `--ratify` has nothing to +bind against and says so plainly; promote to `rules.ts` + the language-rules file +and treat those as your contract. **That absence is not a gap** — it is the +default configuration. (Same mirror rule as `agents/editorial-reviewer.md`: an +upstream contract, when it exists, is the source of truth and the in-tree copy +must not drift from it; when it does not exist, the in-tree copy *is* the +contract.) + +--- + +## The other accumulation silos + +The fold-in loop is one of five places the plugin accumulates. Step 11 is where +they are all in view at once, because a retro that only empties one queue leaves +the operator believing the others are empty too: + +| Silo | What accumulates | Written by | +|------|------------------|------------| +| **fold-in loop** (this doc) | corrections → enforced rules | Steps 2.5/3a/5.5/6.5 → Step 11 | +| **specifics-bank** (`scripts/specifics-bank`) | the operator's lived specifics + where each was used | Step 1.5 / Step 8 `record-usage` | +| **brain** (`scripts/brain`, `consolidate`) | published-only knowledge from locked editions | Step 8 | +| **voice** (`voice-trainer`, drift log) | the chronicle voice + its recurring drifts | Step 8 auto-gold / `voice-scrubber` | +| **A/B learnings** (`/linkedin:ab-test`) | which variant actually won | `/linkedin:ab-test` adopt verdicts | + +Each is deterministic, each is read by an upstream phase, and each is +independently useless if never read. **A silo nothing reads is a diary, not a +loop.** + +--- + +## Anti-patterns + +- **Capturing nothing because the queue "should stay clean".** An empty queue + after a full edition means capture is broken, not that the edition was perfect. +- **Promoting everything.** A rule set that grows every edition without ever + rejecting becomes noise, and the gate loses the operator's trust. +- **Promoting without ratify.** A promotion that leaves rules and contract out of + sync has not happened, however good it felt. +- **Emptying the queue silently.** The retro promotes with an explicit verdict + per row; a queue drained by the model's own judgment is the operator's + authority quietly taken away. +- **Writing into the operator's own notes.** The retro asks one friction + question and hands the answer back. Their register is theirs. + +--- + +## Related + +- `${CLAUDE_PLUGIN_ROOT}/scripts/contract-gate/README.md` — the deterministic + mechanical gate + the ratify binding +- `${CLAUDE_PLUGIN_ROOT}/references/longform-quality-rules.md` — the shipped + baseline rules (rule 3 = the ban-list the language file extends) +- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — + `articles.NN.foldIns` + `articles.NN.retro` schema +- `${CLAUDE_PLUGIN_ROOT}/commands/newsletter.md` — Steps 2.5/3a/5.5/6.5 + (capture) and Step 11 (classify → promote) diff --git a/references/longform-quality-rules.md b/references/longform-quality-rules.md index f14d1e8..db0369c 100644 --- a/references/longform-quality-rules.md +++ b/references/longform-quality-rules.md @@ -31,8 +31,10 @@ they will do about it. - If the reader cannot state the takeaway in one sentence, the text is not done — tighten until they can. - **Serve what the reader can DO, not what the author knows.** The text exists to - give the *primær* reader (the non-technical line manager) something they can act - on from their own chair — not to demonstrate the author's breadth. Completeness + give the *primær* reader (whoever sits at the reader-facing end of the edition's + configured **target-level span** — see `config/personas.template.md`) something + they can act on from their own chair — not to demonstrate the author's breadth. + Completeness is not a virtue: if a passage exists to be thorough rather than to move the reader's decision forward, cut it. Choose ONE concrete, verifiable (preferably Norwegian) case over an exhaustive list. diff --git a/references/scheduling-strategy.md b/references/scheduling-strategy.md index 55c615e..ddbebd6 100644 --- a/references/scheduling-strategy.md +++ b/references/scheduling-strategy.md @@ -1,10 +1,27 @@ # Post Scheduling Strategy -Reference for calculating optimal posting schedule based on weekly goal and content mix. +Reference for calculating a posting schedule from weekly goal and content mix. -## Optimal Posting Slots +## Epistemic status (D-5) -Based on `weekly_goal` from state file: +**Confidence: low / directional** for every day/time in this file — practitioner +heuristic, no primary source. Same standard as +`references/algorithm-signals-reference.md`: treat these as a hypothesis to test per +account, not as a finding about your audience. LinkedIn publishes no optimal-time data, +and the numbers below come from general B2B practitioner lore, not from measurement of +*your* followers. + +The authoritative grid is therefore the **operator's own**, in +`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/publishing-slots.json` +(schema + opt-in: `config/publishing-slots.template.json`). That file is what +`/linkedin:calendar`, the session-start vacancy warning and `/linkedin:newsletter` +Step 10 actually read. The tables here are the **starting point** it is seeded from, +to be replaced with what `/linkedin:report` and `/linkedin:analyze` show for the +account — the plugin hardcodes no publishing times. + +## Directional Posting Slots + +Based on `weekly_goal` from state file (times: low / directional — test per account): ### 2x/week | Slot | Day | Time (CET) | Rationale | @@ -38,9 +55,14 @@ Based on `weekly_goal` from state file: ## Scheduling Algorithm +When the operator has a configured grid, `hooks/scripts/slots.mjs` answers "what is the +next free slot" deterministically (`nextFreeSlot`) — coverage counts both queue entries +and editions-register rows, so long-form and short-form cannot be scheduled on top of +each other. The steps below are the fallback for an unconfigured grid. + When assigning dates to batch-created posts: -1. **Start from next available optimal slot** after today +1. **Start from next available slot** after today 2. **Skip slots that already have queued posts** (check queue.json) 3. **If all slots this week are taken**, spill into next week 4. **Assign in slot order** (earliest available first) @@ -77,6 +99,11 @@ Ensure coverage across expertise areas: ## Time Zone Notes +Also low / directional, and written from one European vantage point — read the peaks +below as an illustration of the *shape* of the question, not as your audience's hours. +The operator's grid records its own IANA zone; the plugin schedules in local wall-clock +time and converts nothing. + - All times are CET (Central European Time) - Norwegian audience peaks: 7:30-9:00 and 11:30-13:00 - For international audiences, 08:30 CET catches both EU morning and US east coast pre-work diff --git a/references/trend-scoring-modes.md b/references/trend-scoring-modes.md index 1044648..635be09 100644 --- a/references/trend-scoring-modes.md +++ b/references/trend-scoring-modes.md @@ -92,9 +92,44 @@ The same priority bands apply to both modes (the composite is on the same 0–10 | 2.0–3.9 | **Low** | Note, skip for now | Park unless the angle sharpens | | 0–1.9 | **Skip** | Off positioning | Off positioning | +## Calibration — the expected score distribution (MR-F6) + +The five dimensions are **model judgment**, and unanchored judgment inflates: an observed sweep +put 13 of 20 candidates in **Immediate** (≥8.0), which makes the band meaningless — if most +things are "draft now", nothing is. The composite is only useful if the bands are **scarce at the +top**. So a scoring pass must calibrate against this distribution expectation, not score each +candidate generously in isolation: + +- In a **typical sweep**, most candidates belong in **Medium–High (4.0–7.9)**. That is the honest + home of "worth writing about, eventually". +- **Immediate (≥8.0) is the exception, not the rule** — reserve it for candidates that are + genuinely exceptional *relative to the rest of this sweep*. As a working target, expect on the + order of **≤3 of 20** in Immediate; a sweep that floods the top band is uncalibrated, not lucky. +- **Skip/Low (<4.0)** is a real outcome — an off-pillar or already-resolved topic should land + there, not get floated to Medium to be polite. + +Mechanics for the pass: + +- **Score relatively across the batch.** A 9–10 on a dimension means "exceptional versus the other + candidates in *this* sweep", not "good in the abstract". Rank the batch, then assign — a flat + batch of 8s is the failure mode this rule exists to catch. +- **The distribution shape is the mechanism; the threshold of what counts as exceptional is the + user's calibration** (the dimension rubrics above + the profile), never a hard-coded fact about + any one operator's topics. Domain-general: the anti-inflation rule travels; the specific bar does + not live here. +- **This is now measurable.** The composite + band are **persisted** on each record (RE-R3a, + `TrendRecord.score`), so the distribution can be checked across runs — e.g. count the Immediate + share of a stored sweep. The MR-F7 band-cap gate (N7) builds on the same persisted signal: a + candidate with no formulable reader-grip is capped below Immediate regardless of composite. + ## Consumers - `agents/trend-spotter.md` — reads the requested mode and applies the matching rubric instead of inlining a matrix (wired in research-engine slice 2b). - Any future research-engine pass that scores candidates before writing them to the trend store (`scripts/trends/`). + +**Note (RE-R3d):** the morning brief applies a *brief-time* **temporal overlay** (first-mover / +saturation, derived from the publish/capture dates + the seen-log) as a **within-composite-tier** +ranking refinement. It is a separate layer from this file — it does **not** change the capture-time +dimension weights, bands, or composite formula above. diff --git a/render/__tests__/build-figur.test.mjs b/render/__tests__/build-figur.test.mjs new file mode 100644 index 0000000..e814943 --- /dev/null +++ b/render/__tests__/build-figur.test.mjs @@ -0,0 +1,284 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import { + TARGETS, + DEFAULT_TOKENS, + deriveDimensions, + loadBrandTokens, + tokensToCss, + wrapSvg, + injectTokens, + parseSvgMeta, + validateFigure, + resolveChrome, + parseArgs, + renderFigure, +} from '../build-figur.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_SVG = path.join(__dirname, 'fixtures', 'demo-figur.svg'); + +function tmpdir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'build-figur-test-')); +} + +// Leser bredde/høyde fra PNG-IHDR (byte 16–23, big-endian). +function pngDimensions(file) { + const buf = fs.readFileSync(file); + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; +} + +describe('TARGETS + deriveDimensions', () => { + test('carousel er 1080×1350 (fast)', () => { + assert.equal(TARGETS.carousel.width, 1080); + assert.equal(TARGETS.carousel.height, 1350); + }); + + test('article er 1200 bred med innholdsstyrt høyde', () => { + assert.equal(TARGETS.article.width, 1200); + assert.equal(TARGETS.article.height, null); + }); + + test('single er kvadratisk 1200×1200', () => { + assert.equal(TARGETS.single.width, 1200); + assert.equal(TARGETS.single.height, 1200); + }); + + test('article + aspekt 16:9 gir avledet høyde 675', () => { + const d = deriveDimensions('article', 1200 / 675, {}); + assert.equal(d.width, 1200); + assert.equal(d.height, 675); + assert.equal(d.warnings.length, 0); + }); + + test('article uten aspekt gir fallback-høyde med warning', () => { + const d = deriveDimensions('article', null, {}); + assert.equal(d.width, 1200); + assert.ok(d.height > 0); + assert.ok(d.warnings.some((w) => /høyde/i.test(w))); + }); + + test('--width/--height-overstyringer vinner over målet', () => { + const d = deriveDimensions('carousel', null, { width: 800, height: 600 }); + assert.equal(d.width, 800); + assert.equal(d.height, 600); + }); + + test('ukjent mål kaster med gyldige mål i meldingen', () => { + assert.throws(() => deriveDimensions('poster', null, {}), /article/); + }); +}); + +describe('loadBrandTokens — token-seam (brukerdata, aldri hardkodet)', () => { + test('uten brand-tokens.json: nøytrale defaults, source=default', () => { + const dir = tmpdir(); + const r = loadBrandTokens(dir); + assert.equal(r.source, 'default'); + assert.deepEqual(r.tokens, DEFAULT_TOKENS); + assert.equal(r.warnings.length, 0); + }); + + test('gyldig fil merges over defaults, source=user', () => { + const dir = tmpdir(); + fs.mkdirSync(path.join(dir, 'profile'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'profile', 'brand-tokens.json'), + JSON.stringify({ accent: '#123456' }), + 'utf8' + ); + const r = loadBrandTokens(dir); + assert.equal(r.source, 'user'); + assert.equal(r.tokens.accent, '#123456'); + // umerkede felter beholder defaults + assert.equal(r.tokens.background, DEFAULT_TOKENS.background); + }); + + test('ugyldig JSON: defaults + warning, kaster aldri', () => { + const dir = tmpdir(); + fs.mkdirSync(path.join(dir, 'profile'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'profile', 'brand-tokens.json'), '{not json', 'utf8'); + let r; + assert.doesNotThrow(() => { r = loadBrandTokens(dir); }); + assert.equal(r.source, 'default'); + assert.ok(r.warnings.some((w) => /brand-tokens\.json/.test(w))); + }); +}); + +describe('tokensToCss + wrapSvg + injectTokens — CSS-injeksjon', () => { + test('tokensToCss produserer --figur-variabler for alle tokens', () => { + const css = tokensToCss(DEFAULT_TOKENS); + assert.match(css, /:root\{/); + for (const key of Object.keys(DEFAULT_TOKENS)) { + assert.ok(css.includes(`--figur-${key}`), `mangler --figur-${key}`); + } + }); + + test('egendefinerte tokenverdier reflekteres i CSS-en', () => { + const css = tokensToCss({ ...DEFAULT_TOKENS, accent: '#ABCDEF' }); + assert.match(css, /--figur-accent:\s*#ABCDEF/); + }); + + test('wrapSvg bygger komplett HTML med svg + tokens + mål-dimensjoner', () => { + const html = wrapSvg('<svg viewBox="0 0 10 10"></svg>', { width: 1080, height: 1350 }, DEFAULT_TOKENS); + assert.match(html, /<!DOCTYPE html>/i); + assert.match(html, /<svg viewBox="0 0 10 10">/); + assert.match(html, /--figur-accent/); + assert.match(html, /width:\s*1080px/); + assert.match(html, /height:\s*1350px/); + }); + + test('injectTokens legger style rett etter <head> når den finnes', () => { + const out = injectTokens('<html><head><title>x', DEFAULT_TOKENS); + assert.match(out, /\s* + +${svgContent} + + +`; +} + +export function injectTokens(html, tokens) { + const style = ``; + if (/]*>/i.test(html)) { + return html.replace(/]*>/i, (m) => `${m}${style}`); + } + return `${style}\n${html}`; +} + +// Aspekt (bredde/høyde) fra SVG-kilden: viewBox først, ellers width/height-attributter. +export function parseSvgMeta(svgContent) { + const vb = svgContent.match(/viewBox\s*=\s*["']\s*[\d.+-]+[\s,]+[\d.+-]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/); + if (vb) { + const w = parseFloat(vb[1]); + const h = parseFloat(vb[2]); + if (w > 0 && h > 0) return w / h; + } + const wm = svgContent.match(/]*\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/); + const hm = svgContent.match(/]*\bheight\s*=\s*["']([\d.]+)(?:px)?["']/); + if (wm && hm) { + const w = parseFloat(wm[1]); + const h = parseFloat(hm[1]); + if (w > 0 && h > 0) return w / h; + } + return null; +} + +// --------------------------------------------------------------------------- +// Warn-validering av designregler (aldri hard fail): +// - minst 24pt/px effektiv tekststørrelse +// - maks 1 primær + 1 sekundærfarge utover nøytraler (gråtoner/hvit/sort) +// (≥40 % whitespace er redaksjonell regel — ikke maskinelt målbar her.) +// --------------------------------------------------------------------------- +const MIN_FONT_SIZE = 24; +const MAX_NON_NEUTRAL_COLORS = 2; + +function isNeutralHex(hex6) { + const r = parseInt(hex6.slice(0, 2), 16); + const g = parseInt(hex6.slice(2, 4), 16); + const b = parseInt(hex6.slice(4, 6), 16); + const spread = Math.max(r, g, b) - Math.min(r, g, b); + return spread <= 18; +} + +export function validateFigure(source) { + const warnings = []; + + const smallSizes = []; + const sizeRe = /font-size\s*[:=]\s*["']?\s*([\d.]+)\s*(px|pt)?/gi; + let m; + while ((m = sizeRe.exec(source)) !== null) { + const size = parseFloat(m[1]); + if (size < MIN_FONT_SIZE) smallSizes.push(size); + } + if (smallSizes.length) { + warnings.push( + `Tekststørrelse under ${MIN_FONT_SIZE}pt funnet (${smallSizes.join(", ")}) — hev for lesbarhet på mobil.` + ); + } + + const nonNeutral = new Set(); + const colorRe = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g; + while ((m = colorRe.exec(source)) !== null) { + let hex = m[1].toLowerCase(); + if (hex.length === 3) hex = hex.split("").map((c) => c + c).join(""); + if (!isNeutralHex(hex)) nonNeutral.add(hex); + } + if (nonNeutral.size > MAX_NON_NEUTRAL_COLORS) { + warnings.push( + `Fargebudsjett: ${nonNeutral.size} ikke-nøytrale farger (maks 1 primær + 1 sekundær utover nøytraler).` + ); + } + + return warnings; +} + +// --------------------------------------------------------------------------- +// Robust Chrome-oppdagelse: app-bundle-stier → PATH → hint. Prober er +// injiserbare for test. +// --------------------------------------------------------------------------- +const CHROME_APP_CANDIDATES = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"), + "/Applications/Chromium.app/Contents/MacOS/Chromium", +]; +const CHROME_PATH_NAMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"]; + +const CHROME_HINT = + "Fant ingen Chrome/Chromium-binær — headless-render krever en.\n" + + ` Lette etter: ${CHROME_APP_CANDIDATES.join(" · ")} + PATH (${CHROME_PATH_NAMES.join(", ")})\n` + + " Installer Google Chrome (https://www.google.com/chrome/) eller `brew install chromium`."; + +function defaultWhich(cmd) { + try { + const out = execFileSync("/usr/bin/which", [cmd], { encoding: "utf8" }).trim(); + return out || null; + } catch { + return null; + } +} + +export function resolveChrome(probes = {}) { + const exists = probes.exists || fs.existsSync; + const which = probes.which || defaultWhich; + for (const candidate of CHROME_APP_CANDIDATES) { + if (exists(candidate)) return { available: true, path: candidate }; + } + for (const name of CHROME_PATH_NAMES) { + const found = which(name); + if (found) return { available: true, path: found }; + } + return { available: false, hint: CHROME_HINT }; +} + +// --------------------------------------------------------------------------- +// Render: kilde → wrapper-HTML i tmp → headless Chrome-screenshot → PNG. +// Kjent macOS-quirk (verifisert lokalt mot Chrome 150): headless skriver PNG-en +// og melder «N bytes written to file …» på stderr, men prosessen avslutter +// aldri. Watchdog: vi lytter på stderr-kvitteringen (evt. ren exit på andre +// plattformer), dreper prosessen og verifiserer at PNG-filen faktisk finnes. +// --------------------------------------------------------------------------- +const RENDER_TIMEOUT_MS = 30000; + +function runChromeScreenshot(chromePath, args, timeoutMs = RENDER_TIMEOUT_MS) { + return new Promise((resolve) => { + const child = spawn(chromePath, args, { stdio: ["ignore", "ignore", "pipe"] }); + let stderr = ""; + let settled = false; + const finish = (reason) => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill("SIGKILL"); + resolve({ reason, stderr }); + }; + const timer = setTimeout(() => finish("timeout"), timeoutMs); + child.stderr.on("data", (chunk) => { + stderr += chunk; + if (stderr.includes("written to file")) finish("written"); + }); + child.on("exit", () => finish("exit")); + child.on("error", () => finish("spawn-error")); + }); +} + +export async function renderFigure(opts) { + const { input, target = "article", out = null, dataDir, chrome = null } = opts; + const inPath = path.isAbsolute(input) ? input : path.join(process.cwd(), input); + if (!fs.existsSync(inPath)) throw new Error(`Fant ikke input-fil: ${inPath}`); + + const ext = path.extname(inPath).toLowerCase(); + if (![".svg", ".html", ".htm"].includes(ext)) { + throw new Error(`Ustøttet input-type «${ext}» — bruk .svg eller .html.`); + } + const source = fs.readFileSync(inPath, "utf8"); + + const { tokens, source: tokensSource, warnings } = loadBrandTokens(dataDir); + warnings.push(...validateFigure(source)); + + const aspect = ext === ".svg" ? parseSvgMeta(source) : null; + const dims = deriveDimensions(target, aspect, { width: opts.width ?? null, height: opts.height ?? null }); + warnings.push(...dims.warnings); + + const pageHtml = ext === ".svg" ? wrapSvg(source, dims, tokens) : injectTokens(source, tokens); + + const resolved = chrome || resolveChrome(); + if (!resolved.available) throw new Error(resolved.hint); + + const outPath = out + ? (path.isAbsolute(out) ? out : path.join(process.cwd(), out)) + : path.join(path.dirname(inPath), `${path.basename(inPath, ext)}-${target}.png`); + + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "build-figur-")); + try { + const htmlPath = path.join(workDir, "figur.html"); + fs.writeFileSync(htmlPath, pageHtml, "utf8"); + const run = await runChromeScreenshot(resolved.path, [ + "--headless", + "--disable-gpu", + "--hide-scrollbars", + "--force-device-scale-factor=1", + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--disable-sync", + "--disable-background-networking", + "--disable-component-update", + "--use-mock-keychain", + "--password-store=basic", + `--user-data-dir=${path.join(workDir, "profile")}`, + `--window-size=${dims.width},${dims.height}`, + `--screenshot=${outPath}`, + `file://${htmlPath}`, + ]); + if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) { + const tail = run.stderr.split("\n").filter(Boolean).slice(-5).join("\n"); + throw new Error(`Chrome skrev ikke ${outPath} (${run.reason}).\n${tail}`); + } + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } + + return { out: outPath, width: dims.width, height: dims.height, warnings, tokensSource }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- +const USAGE = + "Bruk: node build-figur.mjs [--target article|carousel|single] [--out ] [--width N] [--height N]"; + +export function parseArgs(argv) { + const cfg = { input: null, target: "article", out: null, width: null, height: null }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--target") cfg.target = argv[++i]; + else if (arg === "--out") cfg.out = argv[++i]; + else if (arg === "--width") cfg.width = parseInt(argv[++i], 10); + else if (arg === "--height") cfg.height = parseInt(argv[++i], 10); + else if (arg.startsWith("--")) return { error: `Ukjent flagg «${arg}». ${USAGE}` }; + else if (!cfg.input) cfg.input = arg; + else return { error: `Uventet argument «${arg}». ${USAGE}` }; + } + if (!cfg.input) return { error: USAGE }; + if (!TARGETS[cfg.target]) { + return { error: `Ukjent mål «${cfg.target}». Gyldige mål: ${Object.keys(TARGETS).join(", ")}` }; + } + if ((cfg.width !== null && !Number.isFinite(cfg.width)) || (cfg.height !== null && !Number.isFinite(cfg.height))) { + return { error: `--width/--height må være heltall. ${USAGE}` }; + } + return cfg; +} + +async function main() { + const cfg = parseArgs(process.argv.slice(2)); + if (cfg.error) { + console.error(cfg.error); + process.exit(1); + } + try { + const result = await renderFigure(cfg); + for (const w of result.warnings) console.warn(`⚠ ${w}`); + console.log( + `Figur: ${result.out} (${result.width}×${result.height}, mål: ${cfg.target}, tokens: ${result.tokensSource})` + ); + } catch (err) { + console.error(err.message); + process.exit(1); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/render/build-html.mjs b/render/build-html.mjs index dcad980..ca5d0f6 100644 --- a/render/build-html.mjs +++ b/render/build-html.mjs @@ -42,13 +42,19 @@ function esc(s) { } // --------------------------------------------------------------------------- -// Inline markdown: `kode`, **fet**, *kursiv*. «» og — beholdes uendret. +// Inline markdown: `kode`, [lenke](url), **fet**, *kursiv*. «» og — beholdes. // Tar uescapet tekst, returnerer escaped HTML med inline-tagger. // --------------------------------------------------------------------------- export function inline(text) { let out = esc(text); // `kode` først, slik at * og ** inni en kode-span ikke tolkes som fet/kursiv out = out.replace(/`([^`]+)`/g, (_, c) => `${c}`); + // [tekst](url) — kun http(s)/mailto får href; andre skjemaer beholder teksten + out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => + /^(https?:|mailto:)/i.test(url) + ? `${label}` + : label + ); // **fet** før *kursiv* for å unngå konflikt out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `${c}`); out = out.replace(/\*([^*]+)\*/g, (_, c) => `${c}`); @@ -118,10 +124,25 @@ function isTableLine(t) { // --------------------------------------------------------------------------- // Kompakt markdown -> HTML for body. // Håndterer: # .. #### overskrifter, | tabeller |, - punktlister, -// 1. nummererte lister, > blockquote, --- horisontal linje, `kode`, og -// avsnitt (blanklinje-separert). +// 1. nummererte lister, > blockquote (flerlinjers, med FIGUR-markør), +// [lenker](url), --- horisontal linje, `kode`, og avsnitt (blanklinje-separert). // Første avsnitt får drop-cap-klasse. Avsnitt etter det første: .indent. // --------------------------------------------------------------------------- + +// Figur-markør: les figurer/figN*.svg fra serie-mappa (process.cwd()). +// Returnerer rå SVG-streng, eller null hvis ingen matchende fil finnes. +function readFigureSvg(n) { + const figDir = path.join(process.cwd(), "figurer"); + if (!fs.existsSync(figDir)) return null; + const re = new RegExp("^fig" + n + "(?:[-.].*)?\\.svg$", "i"); + const match = fs.readdirSync(figDir).filter((f) => re.test(f)).sort()[0]; + if (!match) return null; + return fs + .readFileSync(path.join(figDir, match), "utf8") + .replace(/<\?xml[^>]*\?>\s*/i, "") + .trim(); +} + export function markdownToHtml(body) { const lines = body.replace(/\r\n/g, "\n").split("\n"); const blocks = []; @@ -167,14 +188,41 @@ export function markdownToHtml(body) { continue; } - // Blockquote (sammenhengende > -linjer) + // Blockquote (sammenhengende > -linjer; tom > -linje = nytt avsnitt i samme quote) if (/^>\s?/.test(trimmed)) { - const qbuf = []; + const qlines = []; while (i < lines.length && /^>\s?/.test(lines[i].trim())) { - qbuf.push(lines[i].trim().replace(/^>\s?/, "")); + qlines.push(lines[i].trim().replace(/^>\s?/, "")); i++; } - blocks.push(`

${inline(qbuf.join(" ").trim())}

`); + const paras = []; + let cur = []; + for (const ql of qlines) { + if (ql.trim() === "") { + if (cur.length) { paras.push(cur.join(" ").trim()); cur = []; } + } else cur.push(ql); + } + if (cur.length) paras.push(cur.join(" ").trim()); + + // Figur-markør: blockquote som starter med **[FIGUR N — ...]**. + // Finnes figurer/figN*.svg → inline SVG (rendres i nettleser). + // Ellers: vis spec'en som vanlig blockquote (uendret oppførsel). + const figm = paras.length && paras[0].match(/^\*\*\[FIGUR\s+(\d+)\b/); + if (figm) { + const svg = readFigureSvg(figm[1]); + if (svg) { + const capm = paras[0].match(/«([^»]+)»/); + const caption = capm ? `Figur ${figm[1]} — ${capm[1]}` : ""; + blocks.push( + `
${svg}` + + (caption ? `
${inline(caption)}
` : "") + + "
" + ); + continue; + } + } + + blocks.push("
" + paras.map((p) => `

${inline(p)}

`).join("") + "
"); continue; } @@ -337,6 +385,7 @@ h1.title { .body h2 { font-size: 1.5rem; font-weight: 700; margin: 2rem 0 0.6rem; line-height: 1.2; } .body h3 { font-size: 1.2rem; font-weight: 700; margin: 1.6rem 0 0.5rem; line-height: 1.25; } .body h4 { font-size: 1.05rem; font-weight: 700; margin: 1.3rem 0 0.4rem; line-height: 1.3; } +.body a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } .body code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.86em; @@ -367,6 +416,23 @@ h1.title { font-style: italic; color: #333; } +.body blockquote p { margin: 0 0 0.7em; } +.body blockquote p:last-child { margin-bottom: 0; } +.body figure.fig { margin: 1.9rem 0; text-align: center; } +.body figure.fig svg { + max-width: 100%; + height: auto; + border: 1px solid var(--rule); + background: #fff; + border-radius: 4px; +} +.body figure.fig figcaption { + font-family: var(--sans); + font-size: 0.8rem; + color: var(--muted); + font-style: italic; + margin-top: 0.5rem; +} .body hr { border: 0; border-top: 1px solid var(--rule); diff --git a/scripts/analytics/src/cli.ts b/scripts/analytics/src/cli.ts index 2024815..8566024 100644 --- a/scripts/analytics/src/cli.ts +++ b/scripts/analytics/src/cli.ts @@ -6,7 +6,7 @@ import { loadAllPosts, } from "./utils/storage.js"; import { detectAlerts } from "./utils/alerts.js"; -import { mean, standardDeviation } from "./utils/stats.js"; +import { mean, standardDeviation, weightedOutOfNetworkPct } from "./utils/stats.js"; import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js"; import { generateHeatmap } from "./reports/heatmap.js"; import { generateMonthlyReport } from "./reports/monthly.js"; @@ -30,6 +30,19 @@ function savesSuffix(saves?: number): string { return saves !== undefined ? ` | ${saves.toLocaleString()} saves` : ""; } +/** + * Per-post reach suffix. Empty string when the post carries no manual + * out-of-network share, so reach-free output stays identical to the pre-N16 CLI. + */ +function reachSuffix(outOfNetworkPct?: number): string { + return outOfNetworkPct !== undefined ? ` | ${outOfNetworkPct}% out-of-network` : ""; +} + +/** Round to one decimal — keeps the derived in-network half free of float noise. */ +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + function printUsage() { console.log(` LinkedIn Analytics CLI @@ -90,6 +103,15 @@ async function handleImport(root: string, args: string[]) { console.log(`Saves entered: ${totalSaves.toLocaleString()} across ${savesPosts.length} post(s) (manual)`); } + // Same for the reach split when the CSV carried an Out-of-network (or + // In-network) column. Counting the posts that carry a reading makes partial + // coverage visible instead of implying the whole batch was transcribed. + const reachPosts = batch.posts.filter((p) => p.metrics.outOfNetworkPct !== undefined); + if (reachPosts.length > 0) { + const weighted = weightedOutOfNetworkPct(batch.posts); + console.log(`Reach entered: ${weighted}% out-of-network across ${reachPosts.length} post(s) (manual, impressions-weighted)`); + } + // Run alert detection on imported posts const alerts = detectAlerts(batch.posts, "impressions"); @@ -144,6 +166,10 @@ async function handleReport(root: string, args: string[]) { if (report.summary.totalSaves !== undefined) { console.log(`Total saves: ${report.summary.totalSaves.toLocaleString()} (manual entry — top engagement signal)`); } + if (report.summary.avgOutOfNetworkPct !== undefined) { + console.log(`Out-of-network: ${report.summary.avgOutOfNetworkPct}% of impressions (manual entry — acquisition signal)`); + console.log(`In-network: ${round1(100 - report.summary.avgOutOfNetworkPct)}% of impressions (resonance with the audience you have)`); + } console.log(`Avg engagement: ${report.summary.avgEngagementRate.toFixed(2)}%`); console.log(`Avg impressions: ${Math.round(report.summary.avgImpressionsPerPost).toLocaleString()} per post`); console.log(); @@ -154,7 +180,7 @@ async function handleReport(root: string, args: string[]) { for (const post of report.topPerformers.slice(0, 5)) { const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title; console.log(`• ${title}`); - console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`); + console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`); } console.log(); } @@ -165,7 +191,7 @@ async function handleReport(root: string, args: string[]) { for (const post of report.underperformers.slice(0, 3)) { const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title; console.log(`• ${title}`); - console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`); + console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`); } console.log(); } @@ -342,6 +368,10 @@ async function handleMonthlyReport(root: string, month: string) { if (s.totalSaves !== undefined) { console.log(`Saves: ${s.totalSaves.toLocaleString()} (manual entry — top engagement signal)`); } + if (s.avgOutOfNetworkPct !== undefined) { + console.log(`Out-of-network: ${s.avgOutOfNetworkPct}% of impressions (manual entry — acquisition signal)`); + console.log(`In-network: ${round1(100 - s.avgOutOfNetworkPct)}% of impressions (resonance with the audience you have)`); + } console.log(); if (report.byWeek.length > 0) { @@ -359,7 +389,7 @@ async function handleMonthlyReport(root: string, month: string) { for (const post of report.topPerformers.slice(0, 5)) { const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title; console.log(`• ${title}`); - console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`); + console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng${savesSuffix(post.metrics.saves)}${reachSuffix(post.metrics.outOfNetworkPct)} | ${post.publishedDate}`); } console.log(); } diff --git a/scripts/analytics/src/models/types.ts b/scripts/analytics/src/models/types.ts index c9ed27f..62e9629 100644 --- a/scripts/analytics/src/models/types.ts +++ b/scripts/analytics/src/models/types.ts @@ -23,6 +23,24 @@ export interface PostMetrics { // It is deliberately NOT folded into engagementRate (which stays comparable // to historical, saves-free data) — saves is surfaced as its own signal. saves?: number; + // `outOfNetworkPct` is OPTIONAL and manually entered — the share (0–100) of a + // post's impressions that came from people who did NOT follow or connect with + // the author. LinkedIn shows the in-network/out-of-network split natively in + // post analytics (Discovery section, under Impressions; progressive global + // rollout from June 2026) as a PERCENTAGE split, and does NOT put it in the + // CSV export — so the ingest is a percent cell the user adds to the CSV, read + // off that panel (see csv-parser.ts). + // + // Only the out-of-network half is stored: in-network is its complement by + // definition (they describe one split), so keeping both would allow a + // self-contradicting record. A missing column, a blank cell, a non-numeric + // cell, a negative, or a value above 100 stays undefined — "unknown", never + // coerced to 0; a genuine 0 is kept as 0 (nothing left the network). + // + // NOT folded into engagementRate: reach is a distribution signal, not + // engagement. High out-of-network = the post acquired new audience; high + // in-network engagement = it deepened the existing one. + outOfNetworkPct?: number; // NOTE: `dwell` remains absent and unmeasurable. Dwell time is internal to // LinkedIn for organic posts — not exportable, no UI count to transcribe, no // API. Do not fabricate a dwell field or surface. @@ -48,6 +66,10 @@ export interface WeeklyReport { totalShares: number; totalClicks: number; totalSaves?: number; // optional — present only when ≥1 post carries manual saves data + // Optional — present only when ≥1 post carries a manual out-of-network share. + // Impressions-WEIGHTED, so a small post with a high share cannot outvote a + // large one (see weightedOutOfNetworkPct). + avgOutOfNetworkPct?: number; avgEngagementRate: number; avgImpressionsPerPost: number; }; @@ -122,6 +144,9 @@ export interface MonthlyReport { totalShares: number; totalClicks: number; totalSaves?: number; // optional — present only when ≥1 post carries manual saves data + // Optional — present only when ≥1 post carries a manual out-of-network share + // (impressions-weighted; see weightedOutOfNetworkPct). + avgOutOfNetworkPct?: number; avgEngagementRate: number; avgImpressionsPerPost: number; }; diff --git a/scripts/analytics/src/parsers/csv-parser.ts b/scripts/analytics/src/parsers/csv-parser.ts index 229b21c..c61eea2 100644 --- a/scripts/analytics/src/parsers/csv-parser.ts +++ b/scripts/analytics/src/parsers/csv-parser.ts @@ -84,6 +84,64 @@ function parseOptionalCount(value: string): number | undefined { return parsed; } +/** + * Rounding slack, in percentage points, allowed between the two halves of the + * reach split. LinkedIn rounds each half independently for display, so a + * transcribed "63% / 37%" can legitimately sum to 99 or 101. + */ +const REACH_SPLIT_TOLERANCE = 1; + +/** Round to one decimal — the UI reading is itself a rounded percentage. */ +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +/** + * Parse an OPTIONAL manually-entered PERCENTAGE share (out-of-network reach). + * Distinct from parseOptionalCount in two ways that matter: + * - a share never carries a thousands separator, so a comma is always the + * DECIMAL mark here ("36,5" → 36.5). parseOptionalCount's US-thousands rule + * would read that as 365. + * - a share above 100 is not a share. It is most likely an absolute + * impression count pasted into a percent column, and one column cannot tell + * a count from a share — so the honest answer is unknown, never a guess. + * Otherwise the same contract as the saves field: + * - blank / absent / non-numeric / negative → undefined ("unknown", never 0) + * - a genuine "0" → 0 (nothing left the network) + * - "37%", "37 %", "37" → 37 + */ +function parseOptionalPercent(value: string): number | undefined { + if (!value) return undefined; + const cleaned = value.replace(/"/g, "").replace(/%/g, "").trim(); + if (cleaned === "") return undefined; + + const parsed = Number(cleaned.replace(/,/g, ".")); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) return undefined; + return round1(parsed); +} + +/** + * Reduce whichever halves of the reach split the user transcribed to the single + * stored value: the out-of-network share. + * - out-of-network only → that value + * - in-network only → its complement (they describe one split) + * - both, consistent → the out-of-network reading + * - both, contradictory → undefined. One cell is a misreading and we cannot + * tell which, so the record stays unknown rather than silently trusting one. + */ +function resolveOutOfNetworkPct( + outOfNetwork: number | undefined, + inNetwork: number | undefined +): number | undefined { + if (outOfNetwork !== undefined && inNetwork !== undefined) { + const sum = outOfNetwork + inNetwork; + return Math.abs(sum - 100) <= REACH_SPLIT_TOLERANCE ? outOfNetwork : undefined; + } + if (outOfNetwork !== undefined) return outOfNetwork; + if (inNetwork !== undefined) return round1(100 - inNetwork); + return undefined; +} + /** * Normalizes date to YYYY-MM-DD format * Handles: DD.MM.YYYY, MM/DD/YYYY, YYYY-MM-DD @@ -225,6 +283,19 @@ export function parseLinkedInCSV( metrics.saves = saves; } + // Optional manual-entry reach split: only when the user augmented this CSV + // with an Out-of-network (or In-network) column, read off the native + // Discovery panel in post analytics — LinkedIn does not export it. Either + // half is accepted and reduced to the out-of-network share; anything + // unreadable or self-contradicting stays undefined ("unknown", never 0). + const outOfNetworkPct = resolveOutOfNetworkPct( + parseOptionalPercent(findColumn(record, ["out-of-network", "out of network", "outofnetwork"])), + parseOptionalPercent(findColumn(record, ["in-network", "in network", "innetwork"])) + ); + if (outOfNetworkPct !== undefined) { + metrics.outOfNetworkPct = outOfNetworkPct; + } + return { id: generatePostId(title, date), title, diff --git a/scripts/analytics/src/reports/monthly.ts b/scripts/analytics/src/reports/monthly.ts index db9f965..0e1540b 100644 --- a/scripts/analytics/src/reports/monthly.ts +++ b/scripts/analytics/src/reports/monthly.ts @@ -1,6 +1,6 @@ import type { PostAnalytics, MonthlyReport } from "../models/types.js"; import { loadAllPosts, loadMonthlyReport, saveMonthlyReport } from "../utils/storage.js"; -import { mean } from "../utils/stats.js"; +import { mean, weightedOutOfNetworkPct } from "../utils/stats.js"; import { detectAlerts } from "../utils/alerts.js"; import { getISOWeek } from "./weekly.js"; @@ -34,6 +34,9 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor const totalSaves = savesPosts.length > 0 ? savesPosts.reduce((s, p) => s + (p.metrics.saves ?? 0), 0) : undefined; + // Optional out-of-network share: impressions-weighted, present only when ≥1 + // post carries a reading — keeps reach-free months identical to pre-N16 output. + const avgOutOfNetworkPct = weightedOutOfNetworkPct(monthPosts); const avgEngagementRate = totalPosts > 0 ? parseFloat(mean(monthPosts.map(p => p.metrics.engagementRate)).toFixed(2)) : 0; @@ -108,6 +111,7 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor totalShares, totalClicks, ...(totalSaves !== undefined ? { totalSaves } : {}), + ...(avgOutOfNetworkPct !== undefined ? { avgOutOfNetworkPct } : {}), avgEngagementRate, avgImpressionsPerPost, }, diff --git a/scripts/analytics/src/reports/weekly.ts b/scripts/analytics/src/reports/weekly.ts index 52c9901..e325202 100644 --- a/scripts/analytics/src/reports/weekly.ts +++ b/scripts/analytics/src/reports/weekly.ts @@ -1,5 +1,5 @@ import type { PostAnalytics, WeeklyReport } from "../models/types.js"; -import { mean, trendDirection, percentChange } from "../utils/stats.js"; +import { mean, trendDirection, percentChange, weightedOutOfNetworkPct } from "../utils/stats.js"; import { detectAlerts, detectWeeklyAlerts } from "../utils/alerts.js"; import { loadAllPosts, loadWeeklyReport, saveWeeklyReport } from "../utils/storage.js"; @@ -173,6 +173,14 @@ export function generateWeeklyReport(analyticsRoot: string, week?: string): Week report.summary.totalSaves = totalSaves; } + // Same contract for the out-of-network share: impressions-weighted, and only + // present when at least one post carried a reading (reach-free reports stay + // byte-identical to pre-N16 output). + const avgOutOfNetworkPct = weightedOutOfNetworkPct(weekPosts); + if (avgOutOfNetworkPct !== undefined) { + report.summary.avgOutOfNetworkPct = avgOutOfNetworkPct; + } + // Calculate averages const engagementRates = weekPosts.map(post => post.metrics.engagementRate); report.summary.avgEngagementRate = mean(engagementRates); diff --git a/scripts/analytics/src/utils/stats.ts b/scripts/analytics/src/utils/stats.ts index a65112e..86e0230 100644 --- a/scripts/analytics/src/utils/stats.ts +++ b/scripts/analytics/src/utils/stats.ts @@ -50,6 +50,43 @@ export function percentChange(current: number, previous: number): number { return ((current - previous) / previous) * 100; } +/** + * Minimal shape needed to weight a reach share — keeps this helper usable from + * both report builders without dragging in the full PostAnalytics record. + */ +interface ReachWeightable { + metrics: { impressions: number; outOfNetworkPct?: number }; +} + +/** + * Roll per-post out-of-network shares up to one number, WEIGHTED by impressions. + * + * The share is a fraction of a post's own impressions, so a flat mean would let + * a 50-impression post at 90% outvote a 10,000-impression post at 20%. Posts + * without a share are excluded entirely — folding them in as 0 would invent + * data that was never entered. + * + * Returns undefined when no post carries a share, or when the posts that do + * carry one have no impressions to weight (a share of zero impressions has no + * meaning; 0 would be a fabricated reading and NaN a bug). + */ +export function weightedOutOfNetworkPct(posts: ReachWeightable[]): number | undefined { + let totalWeight = 0; + let weightedSum = 0; + let sawShare = false; + + for (const post of posts) { + const pct = post.metrics.outOfNetworkPct; + if (pct === undefined) continue; + sawShare = true; + totalWeight += post.metrics.impressions; + weightedSum += post.metrics.impressions * pct; + } + + if (!sawShare || totalWeight <= 0) return undefined; + return Math.round((weightedSum / totalWeight) * 10) / 10; +} + /** * Calculate how many standard deviations a value is from the mean. * Returns 0 if standard deviation is 0. diff --git a/scripts/analytics/tests/csv-parser.test.ts b/scripts/analytics/tests/csv-parser.test.ts index efc8833..054e08d 100644 --- a/scripts/analytics/tests/csv-parser.test.ts +++ b/scripts/analytics/tests/csv-parser.test.ts @@ -191,3 +191,189 @@ describe("Saves (manual-entry, optional)", () => { ); }); }); + +/** + * Out-of-network reach (manual-entry, optional) — N16. + * + * LinkedIn surfaces the in-network/out-of-network split natively in post + * analytics (Discovery section, under Impressions; progressive global rollout + * from June 2026) as a PERCENTAGE split — not as two absolute counts, and not + * in the CSV export. So the ingest is a percent cell the operator transcribes, + * and the stored field is a single share: `outOfNetworkPct`. In-network is its + * complement by definition, so storing both halves would only invite a + * self-contradicting record. + */ +describe("Out-of-network reach (manual-entry, optional)", () => { + it("should parse an Out-of-network percent cell written with a % suffix", () => { + const filePath = join(fixturesDir, "reach-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-export.csv"); + + assert.equal(batch.postCount, 4, "Should have 4 posts"); + assert.equal( + batch.posts[0].metrics.outOfNetworkPct, + 37, + "'37%' must parse to the number 37" + ); + }); + + it("should leave outOfNetworkPct undefined when the cell is blank (unknown != zero)", () => { + const filePath = join(fixturesDir, "reach-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-export.csv"); + + assert.equal( + batch.posts[1].metrics.outOfNetworkPct, + undefined, + "Blank Out-of-network cell must stay undefined, never coerced to 0" + ); + }); + + it("should treat an explicit '0' as a genuine zero share (nothing left the network)", () => { + const filePath = join(fixturesDir, "reach-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-export.csv"); + + assert.equal( + batch.posts[2].metrics.outOfNetworkPct, + 0, + "Explicit '0' is a real reading — must stay 0, not collapse to undefined" + ); + }); + + it("should read a European decimal comma as a decimal, not a thousands separator", () => { + const filePath = join(fixturesDir, "reach-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-export.csv"); + + // A share never carries a thousands separator, so "36,5" is 36.5 percent. + // parseOptionalCount's US-thousands rule would read this as 365 — wrong here. + assert.equal( + batch.posts[3].metrics.outOfNetworkPct, + 36.5, + "'36,5' must parse to 36.5 percent, never 365" + ); + }); + + it("should leave outOfNetworkPct undefined for a standard export with no reach column (backward-compat)", () => { + const filePath = join(fixturesDir, "sample-export.csv"); + const batch = parseLinkedInCSV(filePath, "sample-export.csv"); + + for (const post of batch.posts) { + assert.equal( + post.metrics.outOfNetworkPct, + undefined, + "Existing CSV exports without a reach column must round-trip unchanged" + ); + } + }); + + it("should leave outOfNetworkPct undefined for a non-numeric cell (unknown, never 0)", () => { + const filePath = join(fixturesDir, "reach-edge-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv"); + + assert.equal( + batch.posts[0].metrics.outOfNetworkPct, + undefined, + "Non-numeric reach cell must stay undefined — never coerced to 0" + ); + }); + + it("should refuse a value above 100 — a count and a share are undecidable in one column", () => { + const filePath = join(fixturesDir, "reach-edge-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv"); + + // "1234" in a share column is almost certainly an absolute impression count. + // We cannot tell which, so the honest answer is unknown — never a guess. + assert.equal( + batch.posts[1].metrics.outOfNetworkPct, + undefined, + "A share above 100 must stay undefined, never stored as-is" + ); + }); + + it("should leave outOfNetworkPct undefined for a negative cell", () => { + const filePath = join(fixturesDir, "reach-edge-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv"); + + assert.equal( + batch.posts[2].metrics.outOfNetworkPct, + undefined, + "A negative share is not a real reading — must stay undefined" + ); + }); + + it("should accept exactly 100 as a real reading (the boundary is inclusive)", () => { + const filePath = join(fixturesDir, "reach-edge-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-edge-export.csv"); + + assert.equal( + batch.posts[3].metrics.outOfNetworkPct, + 100, + "100 percent out-of-network is possible and must be kept" + ); + }); + + it("should derive outOfNetworkPct from an In-network column as its complement", () => { + const filePath = join(fixturesDir, "reach-in-network-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-in-network-export.csv"); + + // The operator transcribed the other half of the same split. + assert.equal( + batch.posts[0].metrics.outOfNetworkPct, + 37, + "'In-network 63%' must store out-of-network 37" + ); + assert.equal( + batch.posts[1].metrics.outOfNetworkPct, + undefined, + "A blank In-network cell leaves neither half known" + ); + }); + + it("should keep the out-of-network half when both columns agree", () => { + const filePath = join(fixturesDir, "reach-both-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-both-export.csv"); + + assert.equal( + batch.posts[0].metrics.outOfNetworkPct, + 37, + "63 + 37 = 100 is consistent — keep the out-of-network reading" + ); + }); + + it("should refuse a contradictory split rather than pick a half", () => { + const filePath = join(fixturesDir, "reach-both-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-both-export.csv"); + + // 63 + 20 = 83. One of the two cells is a misreading and we cannot tell + // which, so the record stays unknown instead of silently trusting one. + assert.equal( + batch.posts[1].metrics.outOfNetworkPct, + undefined, + "A split that does not sum to ~100 must stay undefined" + ); + }); + + it("should tolerate one point of rounding slack between the two halves", () => { + const filePath = join(fixturesDir, "reach-both-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-both-export.csv"); + + // 62 + 37 = 99: the UI rounds each half independently, so a one-point gap + // is rounding, not a misreading. + assert.equal( + batch.posts[2].metrics.outOfNetworkPct, + 37, + "A 99 or 101 sum is rounding slack — keep the out-of-network reading" + ); + }); + + it("should NOT fold out-of-network reach into engagementRate", () => { + const filePath = join(fixturesDir, "reach-export.csv"); + const batch = parseLinkedInCSV(filePath, "reach-export.csv"); + + // Row 1: (100+30+15+200)/5000 * 100 = 6.9. Reach is a distribution signal, + // not engagement — it must not touch the rate. + const expectedRate = ((100 + 30 + 15 + 200) / 5000) * 100; + assert.ok( + Math.abs(batch.posts[0].metrics.engagementRate - expectedRate) < 0.01, + `engagementRate should exclude reach (~${expectedRate}), got ${batch.posts[0].metrics.engagementRate}` + ); + }); +}); diff --git a/scripts/analytics/tests/fixtures/reach-both-export.csv b/scripts/analytics/tests/fixtures/reach-both-export.csv new file mode 100644 index 0000000..d57d9bf --- /dev/null +++ b/scripts/analytics/tests/fixtures/reach-both-export.csv @@ -0,0 +1,4 @@ +"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","In-network","Out-of-network" +"Both halves transcribed and they sum to 100 - consistent, out-of-network wins...",2026-02-28,2900,70,22,9,130,63%,37% +"Both halves transcribed but they contradict each other - refuse to guess which one is right...",2026-02-27,2800,70,22,9,130,63%,20% +"Both halves with rounding slack - 62 + 37 = 99 is within the one-point tolerance...",2026-02-26,2700,70,22,9,130,62%,37% diff --git a/scripts/analytics/tests/fixtures/reach-edge-export.csv b/scripts/analytics/tests/fixtures/reach-edge-export.csv new file mode 100644 index 0000000..b92640e --- /dev/null +++ b/scripts/analytics/tests/fixtures/reach-edge-export.csv @@ -0,0 +1,5 @@ +"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Out-of-network" +"Non-numeric out-of-network cell - the user jotted a note, not a share; stays unknown...",2026-03-06,3500,70,22,9,130,n/a +"Above 100 - most likely an absolute impression count pasted into a share column; undecidable, so unknown...",2026-03-05,3400,70,22,9,130,1234 +"Negative share - not a real reading; stays unknown...",2026-03-04,3300,70,22,9,130,-5 +"Exactly 100 - a real reading: every impression came from outside the network...",2026-03-03,3200,70,22,9,130,100 diff --git a/scripts/analytics/tests/fixtures/reach-export.csv b/scripts/analytics/tests/fixtures/reach-export.csv new file mode 100644 index 0000000..be59e8c --- /dev/null +++ b/scripts/analytics/tests/fixtures/reach-export.csv @@ -0,0 +1,5 @@ +"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Out-of-network" +"An out-of-network share the user read off the native Discovery panel, with a percent sign...",2026-03-10,5000,100,30,15,200,37% +"A post where the user left the Out-of-network cell blank - unknown, not zero...",2026-03-09,3000,60,20,8,120, +"Explicit zero out-of-network - a real reading: nothing left the network...",2026-03-08,4000,80,25,10,150,0 +"A share written with a European decimal comma - 36,5 percent, not 365...",2026-03-07,2000,40,10,5,60,"36,5" diff --git a/scripts/analytics/tests/fixtures/reach-in-network-export.csv b/scripts/analytics/tests/fixtures/reach-in-network-export.csv new file mode 100644 index 0000000..d22cad5 --- /dev/null +++ b/scripts/analytics/tests/fixtures/reach-in-network-export.csv @@ -0,0 +1,3 @@ +"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","In-network" +"The user transcribed the in-network half of the split instead - out-of-network is the complement...",2026-03-02,3100,70,22,9,130,63% +"In-network blank - neither half known...",2026-03-01,3000,60,20,8,120, diff --git a/scripts/analytics/tests/monthly.test.ts b/scripts/analytics/tests/monthly.test.ts index 9f2d107..5a8423e 100644 --- a/scripts/analytics/tests/monthly.test.ts +++ b/scripts/analytics/tests/monthly.test.ts @@ -104,6 +104,31 @@ describe("generateMonthlyReport", () => { assert.equal(report.summary.totalSaves, undefined); }); + test("rolls out-of-network shares up as an impressions-weighted average", () => { + const withReach = (p: PostAnalytics, outOfNetworkPct: number): PostAnalytics => ({ + ...p, + metrics: { ...p.metrics, outOfNetworkPct }, + }); + const posts: PostAnalytics[] = [ + withReach(createPost("2026-03-03", 10000, 3.0), 20), + withReach(createPost("2026-03-05", 1000, 4.0), 80), + createPost("2026-03-10", 5000, 3.5), // no share entered — must not dilute + ]; + const root = setupTestRoot(posts); + const report = generateMonthlyReport(root, "2026-03"); + assert.equal( + report.summary.avgOutOfNetworkPct, + 25.5, + "Should weight by impressions (25.5), not average the shares flat (50)" + ); + }); + + test("leaves avgOutOfNetworkPct undefined for reach-free months (backward-compat)", () => { + const root = setupTestRoot(marchPosts); + const report = generateMonthlyReport(root, "2026-03"); + assert.equal(report.summary.avgOutOfNetworkPct, undefined); + }); + test("generates weekly breakdown within month", () => { const root = setupTestRoot(marchPosts); const report = generateMonthlyReport(root, "2026-03"); diff --git a/scripts/analytics/tests/stats.test.ts b/scripts/analytics/tests/stats.test.ts index 5fb5a07..a86ceaf 100644 --- a/scripts/analytics/tests/stats.test.ts +++ b/scripts/analytics/tests/stats.test.ts @@ -6,6 +6,7 @@ import { trendDirection, percentChange, deviationsFromMean, + weightedOutOfNetworkPct, } from "../src/utils/stats.js"; describe("stats", () => { @@ -136,4 +137,60 @@ describe("stats", () => { assert.ok(Math.abs(result) < 0.01); }); }); + + /** + * Out-of-network reach aggregate (N16). The share is per-post, so the only + * honest roll-up is impressions-weighted: a 50-impression post at 90% must + * not outvote a 10,000-impression post at 20%. + */ + describe("weightedOutOfNetworkPct", () => { + const post = (impressions: number, outOfNetworkPct?: number) => ({ + metrics: { impressions, outOfNetworkPct }, + }); + + test("should weight each share by that post's impressions", () => { + // (10000*20 + 1000*80) / 11000 = 25.45… — an unweighted mean would say 50. + const result = weightedOutOfNetworkPct([post(10000, 20), post(1000, 80)]); + assert.equal(result, 25.5, "Should be the impressions-weighted share, not the plain mean"); + }); + + test("should exclude posts that carry no share from the weighting", () => { + // The 5000-impression post has no reading; folding it in as 0 would drag + // the answer to 17.5 and invent data that was never entered. + const result = weightedOutOfNetworkPct([ + post(10000, 20), + post(1000, 80), + post(5000, undefined), + ]); + assert.equal(result, 25.5, "Posts without a share must not dilute the aggregate"); + }); + + test("should keep a genuine 0 share in the weighting", () => { + // (1000*0 + 1000*50) / 2000 = 25 — an explicit zero is data, not absence. + const result = weightedOutOfNetworkPct([post(1000, 0), post(1000, 50)]); + assert.equal(result, 25); + }); + + test("should return undefined when no post carries a share", () => { + const result = weightedOutOfNetworkPct([post(1000), post(2000)]); + assert.equal(result, undefined, "Absent data must stay absent, never 0"); + }); + + test("should return undefined for an empty list", () => { + assert.equal(weightedOutOfNetworkPct([]), undefined); + }); + + test("should return undefined when the carrying posts have no impressions", () => { + // A share of zero impressions has no meaning, and the weights sum to 0 — + // returning 0 here would be a fabricated reading, and NaN a bug. + const result = weightedOutOfNetworkPct([post(0, 40)]); + assert.equal(result, undefined, "Zero total weight must yield undefined, never NaN or 0"); + }); + + test("should round to one decimal (the UI reading is itself rounded)", () => { + // (3000*33.3 + 1000*66.7) / 4000 = 41.65 → 41.7 + const result = weightedOutOfNetworkPct([post(3000, 33.3), post(1000, 66.7)]); + assert.equal(result, 41.7); + }); + }); }); diff --git a/scripts/analytics/tests/weekly.test.ts b/scripts/analytics/tests/weekly.test.ts index dbb5d94..b803923 100644 --- a/scripts/analytics/tests/weekly.test.ts +++ b/scripts/analytics/tests/weekly.test.ts @@ -319,6 +319,57 @@ describe("weekly", () => { assert.equal(report.summary.totalSaves, undefined, "Saves-free data must not introduce a totalSaves field"); }); + test("should roll out-of-network shares up as an impressions-weighted average", () => { + tempDir = setupTempDir(); + + const posts: PostAnalytics[] = [ + createTestPost({ + id: "reach1", + publishedDate: "2026-01-12", // 2026-W03 + metrics: { impressions: 10000, reactions: 500, comments: 100, shares: 50, clicks: 200, engagementRate: 8.5, outOfNetworkPct: 20 }, + }), + createTestPost({ + id: "reach2", + publishedDate: "2026-01-13", // 2026-W03 + metrics: { impressions: 1000, reactions: 50, comments: 10, shares: 5, clicks: 20, engagementRate: 8.5, outOfNetworkPct: 80 }, + }), + createTestPost({ + id: "reach3", + publishedDate: "2026-01-14", // 2026-W03 — no share entered; must not dilute. + metrics: { impressions: 5000, reactions: 250, comments: 50, shares: 25, clicks: 100, engagementRate: 8.5 }, + }), + ]; + + saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-14" }, posts })); + + const report = generateWeeklyReport(tempDir, "2026-W03"); + + assert.equal( + report.summary.avgOutOfNetworkPct, + 25.5, + "Should weight by impressions (25.5), not average the shares flat (50)" + ); + }); + + test("should leave avgOutOfNetworkPct undefined when no post carries a share (backward-compat)", () => { + tempDir = setupTempDir(); + + const posts: PostAnalytics[] = [ + createTestPost({ id: "noreach1", publishedDate: "2026-01-12" }), + createTestPost({ id: "noreach2", publishedDate: "2026-01-13" }), + ]; + + saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-13" }, posts })); + + const report = generateWeeklyReport(tempDir, "2026-W03"); + + assert.equal( + report.summary.avgOutOfNetworkPct, + undefined, + "Reach-free data must not introduce an avgOutOfNetworkPct field" + ); + }); + test("should identify top performers and underperformers", () => { tempDir = setupTempDir(); diff --git a/scripts/brain/src/cli.ts b/scripts/brain/src/cli.ts index 2e121dd..f8add96 100644 --- a/scripts/brain/src/cli.ts +++ b/scripts/brain/src/cli.ts @@ -249,7 +249,7 @@ function runReconcile(_flags: Record): void { } function renderDiffMd(diff: ProfileDiff): string { - const lines = ["# Pending profile diff", "", "> Operator-gated. Review, then `brain consolidate --apply --diff brain/pending-diff.json --confirm`.", ""]; + const lines = ["---", "type: PendingDiff", "---", "", "# Pending profile diff", "", "> Operator-gated. Review, then `brain consolidate --apply --diff brain/pending-diff.json --confirm`.", ""]; const section = (title: string, items: string[]) => { lines.push(`## ${title} (${items.length})`, ""); for (const i of items) lines.push(`- ${i}`); diff --git a/scripts/brain/src/profile.ts b/scripts/brain/src/profile.ts index 0cbbf4f..ae6346e 100644 --- a/scripts/brain/src/profile.ts +++ b/scripts/brain/src/profile.ts @@ -36,9 +36,20 @@ function serializeFact(f: ProfileFact): string { return f.value === "" ? `- [${bracket}]` : `- [${bracket}] ${f.value}`; } -/** Deterministic full-document serialization; both sections always present. */ +/** + * Deterministic full-document serialization; both sections always present. A + * constant OKF frontmatter block (`type: Profile`) leads the document so the brain + * bundle is OKF-compatible form (docs/okf-convergence-brief.md); it carries no + * typed-doc data, so `parseProfile` skips it and `parse ∘ serialize` stays identity. + */ export function serializeProfile(doc: ProfileDoc): string { const lines: string[] = [ + "---", + "type: Profile", + "title: Profile", + "description: Two-layer semantic profile (static and dynamic) - the distilled who-you-are.", + "---", + "", "# Profile", "", `schemaVersion: ${doc.schemaVersion}`, diff --git a/scripts/brain/src/scaffold.ts b/scripts/brain/src/scaffold.ts index d04e435..1c27b72 100644 --- a/scripts/brain/src/scaffold.ts +++ b/scripts/brain/src/scaffold.ts @@ -35,6 +35,8 @@ function indexSeed(): string { > Map of Content — one screen pointing at every tributary, with a freshness flag. > Generated by \`brain init\`; safe to hand-edit (a re-run never clobbers it). +okf_version: 0.1 + | Tributary | What it holds | Freshness | |-----------|---------------|-----------| | voice-samples | writing style | — | @@ -50,7 +52,12 @@ function indexSeed(): string { } function operationsSeed(): string { - return `# Operations + return `--- +type: Operations +title: Operations +description: Plans, ideas, and the current-direction anchor - the operations centre. +--- +# Operations > The operations centre — where you're headed now, what you're working on, what you might > do next. User-authored: the brain motor never writes here (a re-run of init never @@ -74,6 +81,15 @@ _As of YYYY-MM-DD:_ Append-only session entries (\`YYYY-MM-session.md\`). Raw, never edited — the +> source the consolidation loop distils from. One concept per file. A reserved +> OKF index (no frontmatter); the entries carry the \`type\`. +`; +} + function profileSeed(): string { const templateText = readFileSync(TEMPLATE_PATH, "utf8"); const instancePath = dataRoot(INSTANCE_SUB); @@ -111,6 +127,7 @@ export function initBrain(): InitResult { { sub: "brain/profile.md", seed: profileSeed }, { sub: "brain/index.md", seed: indexSeed }, { sub: "brain/operations.md", seed: operationsSeed }, + { sub: "brain/journal/index.md", seed: journalIndexSeed }, ]; for (const { sub, seed } of files) { diff --git a/scripts/brain/tests/consolidate-cli.test.ts b/scripts/brain/tests/consolidate-cli.test.ts index fdd3abd..d2744f1 100644 --- a/scripts/brain/tests/consolidate-cli.test.ts +++ b/scripts/brain/tests/consolidate-cli.test.ts @@ -59,6 +59,16 @@ describe("brain consolidate CLI (SC5)", () => { assert.equal(diff.additions.length, 1); }); + test("--propose writes pending-diff.md in OKF-compatible form (type: PendingDiff)", () => { + // The pending-diff is a transient operator-review artifact that lives in the + // brain/ bundle, so it must carry a type or okf-check fails the whole bundle + // mid-propose. (docs/okf-convergence-brief.md Stage-1 deferred item.) + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + const block = readFileSync(pendingMd(root), "utf8").match(/^---\n([\s\S]*?)\n---\n/); + assert.ok(block, "pending-diff.md leads with a frontmatter block"); + assert.match(block![1], /^type:\s*PendingDiff\s*$/m, "pending-diff.md is type PendingDiff"); + }); + test("--propose REJECTS a malformed candidate (missing field), no write", () => { const { code } = runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [{ key: "x", value: "y" }])]); assert.notEqual(code, 0, "non-zero exit on malformed candidate"); diff --git a/scripts/brain/tests/okf-conform.test.ts b/scripts/brain/tests/okf-conform.test.ts new file mode 100644 index 0000000..8a68220 --- /dev/null +++ b/scripts/brain/tests/okf-conform.test.ts @@ -0,0 +1,116 @@ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { initBrain } from "../src/scaffold.js"; +import { parseProfile } from "../src/profile.js"; + +/** + * OKF-compatible-form conformance for the `brain/` knowledge bundle. + * + * Stage 1 of the cross-plugin OKF convergence (docs/okf-convergence-brief.md): the + * brain is the REFERENCE design, OKF is a thin interop veneer. Contract — mirrors + * `okr/scripts/okf-check.mjs`, the reference checker: + * - every CONCEPT file (`*.md` except `index.md`) carries a non-empty `type` in a + * leading YAML frontmatter block; + * - the bundle-root `index.md` carries an `okf_version` marker as markdown TEXT + * (index files carry no frontmatter per the OKF spec); + * - each directory level has its own `index.md` (progressive disclosure). + * + * EXCLUDED by design (brief §6): the `ingest/` tributary. `ingest/published/*.md` + * is the byte-exact gold store with a hard round-trip invariant (SC2) that a YAML + * frontmatter block would break — it is a raw tributary, not a navigable concept, + * and the hub `index.md` points to it rather than folding it in. This test walks + * `brain/` ONLY; it never asserts frontmatter on the tributary. + */ + +/** + * Minimal frontmatter `type` reader. We only ever EMIT OKF frontmatter; we never add + * a YAML-parser dependency (the brain is deliberately YAML-free), so a constrained + * regex over the leading `---` block is the right reader here. + */ +function frontmatterType(text: string): string | null { + const block = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!block) return null; + const t = block[1].match(/^type:\s*(.+?)\s*$/m); + return t ? t[1].trim() : null; +} + +/** All concept files (`*.md` except `index.md`) under `root`, recursive. */ +function walkConceptMd(root: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.isFile() && e.name.endsWith(".md") && e.name !== "index.md") out.push(p); + } + }; + walk(root); + return out; +} + +describe("brain/ bundle is OKF-compatible form (Stage 1)", () => { + let root: string; + const prevEnv = process.env.LINKEDIN_STUDIO_DATA; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "brain-okf-")); + process.env.LINKEDIN_STUDIO_DATA = root; + initBrain(); + }); + + afterEach(() => { + if (prevEnv === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = prevEnv; + rmSync(root, { recursive: true, force: true }); + }); + + test("bundle-root index.md carries an okf_version marker (markdown text, no frontmatter)", () => { + const index = readFileSync(join(root, "brain/index.md"), "utf8"); + assert.match(index, /^okf_version:\s*\S+/m, "root index.md declares okf_version"); + assert.equal(frontmatterType(index), null, "index.md carries NO frontmatter (OKF reserved file)"); + }); + + test("every concept file under brain/ carries a non-empty frontmatter type", () => { + const concepts = walkConceptMd(join(root, "brain")); + assert.ok(concepts.length > 0, "the bundle has concept files to check"); + for (const f of concepts) { + const type = frontmatterType(readFileSync(f, "utf8")); + assert.ok(type && type.length > 0, `concept ${f} is missing a frontmatter type:`); + } + }); + + test("profile.md is type: Profile AND still parses (rich fields preserved — round-trip intact)", () => { + const text = readFileSync(join(root, "brain/profile.md"), "utf8"); + assert.equal(frontmatterType(text), "Profile"); + const doc = parseProfile(text); + assert.equal(doc.schemaVersion, 1, "line-grammar still parses through the frontmatter"); + assert.ok(doc.static.length > 0, "the seeded static facts survive conformance"); + }); + + test("operations.md is type: Operations", () => { + const text = readFileSync(join(root, "brain/operations.md"), "utf8"); + assert.equal(frontmatterType(text), "Operations"); + }); + + test("concept files carry the cheap recommended fields (title + description)", () => { + // OKF recommends title/description; they are free (constant) here and give a + // foreign agent a human label without a clock (timestamp/resource stay out — + // a timestamp would break the pure serializer; resource is N/A for an internal + // concept). okf-check still only REQUIRES type; these clear its warnings. + for (const sub of ["brain/profile.md", "brain/operations.md"]) { + const block = readFileSync(join(root, sub), "utf8").match(/^---\n([\s\S]*?)\n---\n/); + assert.ok(block, `${sub} has a frontmatter block`); + assert.match(block![1], /^title:\s*\S/m, `${sub} has a title`); + assert.match(block![1], /^description:\s*\S/m, `${sub} has a description`); + } + }); + + test("each directory level under brain/ has its own index.md (progressive disclosure)", () => { + assert.ok(existsSync(join(root, "brain/index.md")), "brain/index.md"); + assert.ok(existsSync(join(root, "brain/journal/index.md")), "brain/journal/index.md"); + }); +}); diff --git a/scripts/check-replace-safety.mjs b/scripts/check-replace-safety.mjs index a65caba..8a3dab1 100644 --- a/scripts/check-replace-safety.mjs +++ b/scripts/check-replace-safety.mjs @@ -118,25 +118,34 @@ const BATTERY = [ { path: "section rewrite of KEPT entries (:171)", run: () => { - const today = new Date(); - const old = new Date(today); old.setDate(old.getDate() - 100); - const recent = new Date(today); recent.setDate(recent.getDate() - 10); + // Fixed injectable "today" (third param): with the real clock, on some + // calendar days today-100d collides with a hardcoded SAMPLE date (e.g. + // 2026-04-05), making the whole-content assertion flake. A fixed date far + // from every SAMPLE date removes the collision class. + const fixedToday = new Date("2027-01-01T00:00:00Z"); + const old = new Date(fixedToday); old.setDate(old.getDate() - 100); + const recent = new Date(fixedToday); recent.setDate(recent.getDate() - 10); const oldDate = old.toISOString().slice(0, 10); const recentDate = recent.toISOString().slice(0, 10); - // Plant the payload via a FUNCTION replace so the fixture itself is not - // pre-corrupted by the very expansion under test. + // Replace the WHOLE section (heading + hardcoded entry) so no SAMPLE entry + // falls under the cutoff, via a FUNCTION replace so the fixture itself is + // not pre-corrupted by the very expansion under test. const state = SAMPLE.replace( - "## Recent Posts\n\n", - () => `## Recent Posts\n\n- [${oldDate}] "drop" (1000) - drop me\n- [${recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}\n` + /## Recent Posts\n\n[\s\S]*?(?=## Milestone Log)/, + () => `## Recent Posts\n\n- [${oldDate}] "drop" (1000) - drop me\n- [${recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}\n\n` ); - const r = mod.pruneContentHistory(state, 90); + const r = mod.pruneContentHistory(state, 90, fixedToday); return { ...r, _recentDate: recentDate, _oldDate: oldDate }; }, assert: (r) => { assert.notEqual(r, null, "prune returned null"); assert.equal(r.pruned, 1, "exactly the old entry pruned"); - assert.ok(!r.content.includes(r._oldDate), "old entry pruned"); - assert.ok(r.content.includes(`- [${r._recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}`), "kept $-entry survives verbatim"); + // Assert on the Recent Posts section only, not the whole content, so + // frontmatter/other-section dates can never satisfy or break the check. + const section = (r.content.match(/## Recent Posts\n([\s\S]*?)\n## Milestone Log/) || [])[1]; + assert.ok(section, "Recent Posts section present"); + assert.ok(!section.includes(r._oldDate), "old entry pruned"); + assert.ok(section.includes(`- [${r._recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}`), "kept $-entry survives verbatim"); }, once: [/^## Recent Posts$/gm], }, @@ -200,6 +209,45 @@ const BATTERY = [ }, ], }, + { + fn: "recordDoNext", + cases: [ + { + path: "section CREATE, free-text directive/evidence", + run: () => mod.recordDoNext(SAMPLE, { + recordDate: "2026-05-30", source: "report", + directives: [{ directive: PAYLOAD, evidence: PAYLOAD }], + }), + expect: [`(report) ${PAYLOAD} — evidence: ${PAYLOAD}`], + once: [/^## Do-Next Directives$/gm], + }, + { + path: "section REWRITE (supersede/prune path) — the kept + new rows are re-emitted", + run: () => { + // Two writes: the second rebuilds the section body, so a `$`-bearing row + // written by ANOTHER source has to survive being re-emitted verbatim. + const first = mod.recordDoNext(SAMPLE, { + recordDate: "2026-05-28", source: "ab-test", + directives: [{ directive: PAYLOAD, evidence: PAYLOAD }], + today: new Date("2026-06-10T00:00:00Z"), + }); + return mod.recordDoNext(first.content, { + recordDate: "2026-05-30", source: "report", + directives: [{ directive: PAYLOAD, evidence: PAYLOAD }], + today: new Date("2026-06-10T00:00:00Z"), + }); + }, + expect: [`(ab-test) ${PAYLOAD} — evidence: ${PAYLOAD}`, `(report) ${PAYLOAD} — evidence: ${PAYLOAD}`], + once: [/^## Do-Next Directives$/gm], + }, + { + path: "date additive-insert via last_post_date anchor — recordDate fuzzed", + run: () => mod.recordDoNext(SAMPLE, { recordDate: PAYLOAD, source: "report", directives: ["d"] }), + expect: [`last_donext_date: "${PAYLOAD}"`], + once: [/^last_donext_date:/gm], + }, + ], + }, ]; // --- Backstop 1: coverage completeness ------------------------------------------- diff --git a/scripts/editions/README.md b/scripts/editions/README.md new file mode 100644 index 0000000..d6d603e --- /dev/null +++ b/scripts/editions/README.md @@ -0,0 +1,136 @@ +# editions — series-level memory for long-form + +Two stores, one package, both about what a *series* knows rather than what an edition +knows: the **distillate** (N11) — what the reader has already been told — and the +**register** (N12) — what is in production right now and how long it is taking. + +## 1. Series distillate (N11, serie-nivå-vern) + +Every long-form gate agent sees **one** edition. At two editions a week the fastest- +growing defect class is not a bad edition — it is a **repeated** one: the anecdote, the +argument or the hook the reader already met three editions ago. That failure is +structurally invisible today, and the specifics-bank dedupe actively *encourages* +re-surfacing the same material. + +So each locked edition leaves behind a **distillate** of the narrative units it spent, +and the Step 2.5 skeleton gate checks the next skeleton against it — **before prose +exists**, the only point where changing course is still cheap. + +## Where the distillate lives + +`/linkedin/series-distillate.json` — beside `edition-state.json` in the **series +root**, not in the per-user data dir. It is series-scoped state, the series folder is +already where the edition's state lives, and putting it there means it travels with the +series and needs no slug→path map. + +Series-scoped is the point: this answers *"has this reader heard it?"*. Cross-series +re-use of the operator's raw material is a different grain, tracked by the +specifics-bank's `usedIn` log. + +## Setup + +```bash +cd scripts/editions && npm install +``` + +## Use + +```bash +# Step 8 (lock): fold the edition's spent units into the series distillate +node --import tsx src/cli.ts distil-append \ + --distillate "/linkedin/series-distillate.json" \ + --series seres --extract "/extract.json" + +# Step 2.5 (before prose): check the proposed skeleton against everything published +node --import tsx src/cli.ts distil-check \ + --distillate "/linkedin/series-distillate.json" \ + --skeleton "/skeleton.json" [--threshold 0.4] [--json] +``` + +`extract.json` = `{editionId, title, lockedAt, anecdotes[], arguments[], hooks[]}` — the +AI extract from the locked edition. `skeleton.json` = `{anecdotes?, arguments?, hooks?}`. + +Exit codes: `0` on success — **including a REUSE verdict**. The check is advisory by +design (it reports into the annotation gate the operator already reads at Step 2.5), +unlike the binding gate's BLOCK. `2` on usage error. + +## Model + +- **Extraction is AI, comparison is code.** The command layer distils the units from the + locked prose; the store, the similarity and the verdict are plain deterministic code — + no model in the loop, no run-to-run variance. +- **Similarity = character-trigram Jaccard**, not word overlap. The plugin is + language-general and the languages it is used in are inflection-heavy: *migrere* / + *migreringen* are the same unit to a reader but different word tokens. Calibrated + against real paraphrase pairs (see `tests/distillate.test.ts`): retellings score + 0.44–0.55, unrelated material 0.04–0.06, same-topic-different-story 0.24 — so the + default threshold sits in the gap at **0.40**. Word-Jaccard scored a shortened hook + paraphrase at 0.11 and would have let it through. +- **Units are compared within their kind.** An argument re-used as a hook is a new move, + not a retread. +- **A re-locked edition replaces its entry**, never duplicates it — `/linkedin:pivot` + re-opens and re-locks the same edition, and the reader still only met it once. +- **A missing distillate is not an error.** A series' first edition compares against + nothing and is CLEAR. + +## 2. Editions register (N12, A1-11 / A1-12) + +Two things were structurally invisible before this: **which editions are in flight** +(readable only by opening every series folder's `edition-state.json` by hand) and **how +long an edition takes** (not recorded anywhere at all). At a two-editions-a-week cadence +both are load-bearing — you cannot dimension a production line on numbers you never +collected. + +So every phase transition in `/linkedin:newsletter` runs **one** command that does two +writes: it appends `{phase, completedAt}` to `articles.NN.phaseLog` in the edition-state, +and mirrors the edition's row into the register. + +```bash +# at EVERY phase transition (after currentPhase has been written) +node --import tsx src/cli.ts register-upsert \ + --edition-state "/linkedin/edition-state.json" \ + --next "Step 3a — spine prose" [--slot "2026-07-28 08:30"] [--path ] + +# the week's work-in-progress, without opening a single series folder +node --import tsx src/cli.ts register-list [--all] [--json] + +# at Step 10, once the edition is scheduled — prints the measured lead time +node --import tsx src/cli.ts register-complete --series seres --edition 05 +``` + +### Where the register lives + +`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json` — the +per-user data dir (M0 convention), because it spans *all* series and must survive plugin +upgrades and reinstalls. Contrast the distillate above, which is per-series and therefore +lives in the series root. + +### Model + +- **The register is a MIRROR, never a source of truth.** Every row is derived from an + `edition-state.json` at a transition. Resumption reads edition-state and nothing else; + delete the register and the next transition rebuilds the row. Losing it costs telemetry, + not an edition — so a failed `register-upsert` is reported, never a reason to stop the + pipeline. +- **One call, both writes.** A phase log the command layer had to remember to append + separately would be incomplete inside a week, and an incomplete log measures nothing. +- **No clock in the core.** Every mutation takes `now` as an argument (`--at` / `--now` at + the CLI edge), exactly like the distillate takes `lockedAt`. Deterministic tests, no + hidden timezone behaviour. +- **`startedAt` never moves.** It is the lead-time anchor: re-upserting a completed + edition *reactivates* the same row (that is what `/linkedin:pivot` does), so the measured + time reflects real elapsed production rather than restarting the clock. +- **Idempotent where a re-run is legitimate, not where it is real work.** Repeating the + same transition logs once; a phase that recurs *after* another one is logged again, + because a pivot back through cleared gates is production time that actually happened. + Completing twice keeps the first `completedAt`. +- **Missing facts are errors, not defaults.** A row naming the wrong phase is worse than + no row — the whole point is that the list can be trusted without opening files. Only the + title is allowed to be empty; it is telemetry, not a gate. + +## Test + +```bash +npm test # node:test, deterministic (callers supply lockedAt) +npm run build # tsc — must stay clean +``` diff --git a/scripts/editions/package-lock.json b/scripts/editions/package-lock.json new file mode 100644 index 0000000..9f327b6 --- /dev/null +++ b/scripts/editions/package-lock.json @@ -0,0 +1,566 @@ +{ + "name": "linkedin-editions", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkedin-editions", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/scripts/editions/package.json b/scripts/editions/package.json new file mode 100644 index 0000000..973792e --- /dev/null +++ b/scripts/editions/package.json @@ -0,0 +1,16 @@ +{ + "name": "linkedin-editions", + "version": "0.1.0", + "type": "module", + "description": "Series-level memory for long-form editions (N11 — serie-nivå-vern). A per-series distillate of the anecdotes, arguments and hooks each locked edition already spent, so the Step 2.5 skeleton gate can surface «the reader has heard this before» BEFORE prose is written. Deterministic store + similarity check; the extraction of narrative units from a locked edition lives in the command/agent layer.", + "scripts": { + "build": "tsc", + "test": "node --import tsx --test tests/*.test.ts", + "start": "node --import tsx src/cli.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/scripts/editions/src/cli.ts b/scripts/editions/src/cli.ts new file mode 100644 index 0000000..93d0571 --- /dev/null +++ b/scripts/editions/src/cli.ts @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** + * CLI for series-level edition memory: the distillate (N11 — serie-nivå-vern) + * and the editions register (N12 — A1-11 / A1-12). + * + * node --import tsx src/cli.ts distil-append --distillate --series --extract + * node --import tsx src/cli.ts distil-check --distillate --skeleton + * [--threshold <0..1>] [--json] + * node --import tsx src/cli.ts register-upsert --edition-state [--next ] [--slot ] + * [--path ] [--register ] [--at ] + * node --import tsx src/cli.ts register-list [--all] [--json] [--register ] [--now ] + * node --import tsx src/cli.ts register-complete --series --edition + * [--register ] [--at ] + * + * `distil-append` runs at Step 8 lock: the command layer writes the AI extract + * (the anecdotes/arguments/hooks the edition actually spent) to a JSON file, and + * this folds it in deterministically. `distil-check` runs at Step 2.5, before + * prose, and reports re-use into the annotation gate. + * + * `register-upsert` runs at EVERY phase transition — it appends the `phaseLog` + * entry to the edition-state AND mirrors the row into the register in one call, + * so the telemetry cannot drift out of step with the state it describes. + * `register-complete` runs at Step 10 when the edition is scheduled. + * + * Clock at the edge only: `--at` / `--now` override it so runs are reproducible. + * + * Exit code: 0 on success (INCLUDING a REUSE verdict — the check is advisory by + * design, unlike the binding gate's BLOCK), 1 on a failed operation, 2 on usage + * error. + */ + +import { readFileSync } from "node:fs"; + +import { + DEFAULT_THRESHOLD, + appendEntry, + checkSkeleton, + loadDistillate, + saveDistillate, +} from "./distillate.js"; +import { + appendPhase, + editionFacts, + readEditionState, + saveEditionState, + seriesRootFromStatePath, +} from "./editionState.js"; +import { + completeEdition, + defaultRegisterPath, + elapsedDays, + listEditions, + loadRegister, + saveRegister, + upsertEdition, +} from "./register.js"; +import type { DistillateEntry, EditionRow, SkeletonCandidate } from "./types.js"; + +function parseFlags(args: string[]): Record { + const out: Record = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + out[key] = "true"; + } else { + out[key] = next; + i++; + } + } + } + return out; +} + +function usage(msg: string): never { + console.error(`error: ${msg}`); + console.error( + "usage:\n" + + " distil-append --distillate --series --extract \n" + + " distil-check --distillate --skeleton [--threshold <0..1>] [--json]\n" + + " register-upsert --edition-state [--next ] [--slot ] [--path ]\n" + + " [--register ] [--at ]\n" + + " register-list [--all] [--json] [--register ] [--now ]\n" + + " register-complete --series --edition [--register ] [--at ]", + ); + process.exit(2); +} + +/** A failed operation (as opposed to a malformed invocation). */ +function fail(msg: string): never { + console.error(`error: ${msg}`); + process.exit(1); +} + +/** A flag that was given a real value (not absent, not a bare boolean flag). */ +function value(flags: Record, key: string): string | undefined { + const v = flags[key]; + return v === undefined || v === "true" ? undefined : v; +} + +function readJson(path: string, what: string): unknown { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (err) { + usage(`could not read ${what} at ${path}: ${(err as Error).message}`); + } +} + +function stringList(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw.filter((x): x is string => typeof x === "string" && x.trim().length > 0); +} + +/** Validate the AI extract at the edge — a malformed extract must not become a silent empty entry. */ +function toEntry(raw: unknown): DistillateEntry { + const o = (raw ?? {}) as Record; + for (const field of ["editionId", "title", "lockedAt"]) { + const v = o[field]; + if (typeof v !== "string" || v.trim().length === 0) { + usage(`extract is missing required string field: ${field}`); + } + } + return { + editionId: o.editionId as string, + title: o.title as string, + lockedAt: o.lockedAt as string, + anecdotes: stringList(o.anecdotes), + arguments: stringList(o.arguments), + hooks: stringList(o.hooks), + }; +} + +/** + * One edition as the operator reads it: what it is, where it stands, what the + * one next move is, and how long it has been running. This block IS the WIP + * answer — if it needs a follow-up file to be useful, the register has failed. + */ +function renderRow(row: EditionRow, now: string): string { + const days = elapsedDays(row.startedAt, row.completedAt ?? now); + const age = + days === null + ? "age unknown" + : row.status === "complete" + ? `lead time ${days} day(s)` + : `${days} day(s) in flight`; + + return ( + `\n · ${row.series}/${row.editionId}${row.title ? ` — "${row.title}"` : ""}\n` + + ` phase: ${row.currentPhase} → next: ${row.nextAction ?? "—"}\n` + + ` ${age} · slot: ${row.slot ?? "—"}\n` + + ` ${row.path}` + ); +} + +function main(): void { + const [command, ...rest] = process.argv.slice(2); + const flags = parseFlags(rest); + const distillatePath = value(flags, "distillate"); + const asJson = flags.json === "true"; + + if (command === "distil-append") { + if (!distillatePath) usage("distil-append needs --distillate "); + const series = value(flags, "series"); + if (!series) usage("distil-append needs --series "); + const extractPath = value(flags, "extract"); + if (!extractPath) usage("distil-append needs --extract "); + + const entry = toEntry(readJson(extractPath, "extract")); + const res = appendEntry(loadDistillate(distillatePath, series), entry); + saveDistillate(distillatePath, res.distillate); + + const units = entry.anecdotes.length + entry.arguments.length + entry.hooks.length; + console.log( + `${res.replaced ? "Replaced" : "Appended"} edition ${entry.editionId} (${units} narrative unit(s)) ` + + `in ${distillatePath} — ${res.distillate.editions.length} edition(s) in the series distillate`, + ); + return; + } + + if (command === "distil-check") { + if (!distillatePath) usage("distil-check needs --distillate "); + const skeletonPath = value(flags, "skeleton"); + if (!skeletonPath) usage("distil-check needs --skeleton "); + + const rawThreshold = value(flags, "threshold"); + let threshold = DEFAULT_THRESHOLD; + if (rawThreshold !== undefined) { + threshold = Number(rawThreshold); + if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) { + usage("--threshold must be a number in (0, 1]"); + } + } + + const candidate = (readJson(skeletonPath, "skeleton") ?? {}) as SkeletonCandidate; + const report = checkSkeleton(loadDistillate(distillatePath, value(flags, "series") ?? ""), candidate, { + threshold, + }); + + if (asJson) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + if (report.verdict === "CLEAR") { + console.log( + `CLEAR — nothing in this skeleton resembles the ${report.comparedAgainst} locked edition(s) ` + + `(threshold ${report.threshold}).`, + ); + return; + } + console.log( + `REUSE — ${report.hits.length} unit(s) resemble earlier editions (threshold ${report.threshold}, ` + + `${report.comparedAgainst} edition(s) checked):`, + ); + for (const hit of report.hits) { + console.log(`\n · ${hit.kind} — ${(hit.score * 100).toFixed(0)}% like edition ${hit.editionId}`); + console.log(` new: ${hit.candidate}`); + console.log(` then: ${hit.matched}`); + } + console.log("\nAdvisory: a deliberate callback is fine — an unnoticed retread is not."); + return; + } + + if (command === "register-upsert") { + const statePath = value(flags, "edition-state"); + if (!statePath) usage("register-upsert needs --edition-state "); + + const at = value(flags, "at") ?? new Date().toISOString(); + const registerPath = value(flags, "register") ?? defaultRegisterPath(); + + let state; + try { + state = readEditionState(statePath); + } catch (err) { + usage(`could not read edition-state at ${statePath}: ${(err as Error).message}`); + } + + let facts; + try { + facts = editionFacts(state); + } catch (err) { + usage((err as Error).message); + } + + // Log first, mirror second: the state file is the source of truth, and a + // register row for a transition that was never logged would be a lie. + const logged = appendPhase(state, facts.editionId, facts.currentPhase, at); + if (logged.appended) saveEditionState(statePath, logged.state); + + const { register, row, created } = upsertEdition( + loadRegister(registerPath), + { + series: facts.series, + editionId: facts.editionId, + title: facts.title, + path: value(flags, "path") ?? seriesRootFromStatePath(statePath), + currentPhase: facts.currentPhase, + nextAction: value(flags, "next"), + slot: value(flags, "slot"), + }, + at, + ); + saveRegister(registerPath, register); + + console.log( + `${created ? "Registered" : "Updated"} ${row.series}/${row.editionId} at phase ${row.currentPhase} ` + + `(${logged.appended ? "phase logged" : "phase already logged"}) — ${registerPath}`, + ); + return; + } + + if (command === "register-list") { + const registerPath = value(flags, "register") ?? defaultRegisterPath(); + const includeComplete = flags.all === "true"; + const now = value(flags, "now") ?? new Date().toISOString(); + const rows = listEditions(loadRegister(registerPath), { includeComplete }); + + if (asJson) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + + if (rows.length === 0) { + console.log(includeComplete ? "No editions in the register." : "No editions in flight."); + return; + } + + console.log( + includeComplete + ? `${rows.length} edition(s) in the register:` + : `${rows.length} edition(s) in flight:`, + ); + for (const row of rows) console.log(renderRow(row, now)); + return; + } + + if (command === "register-complete") { + const series = value(flags, "series"); + const editionId = value(flags, "edition"); + if (!series || !editionId) usage("register-complete needs --series and --edition "); + + const at = value(flags, "at") ?? new Date().toISOString(); + const registerPath = value(flags, "register") ?? defaultRegisterPath(); + const { register, row } = completeEdition(loadRegister(registerPath), { series, editionId }, at); + if (!row) fail(`no register row for ${series}/${editionId} — nothing to complete`); + + saveRegister(registerPath, register); + const lead = elapsedDays(row.startedAt, row.completedAt ?? at); + console.log( + `Completed ${row.series}/${row.editionId}` + + (lead === null ? "" : ` — lead time ${lead} day(s)`) + + ` — ${registerPath}`, + ); + return; + } + + usage(command ? `unknown command: ${command}` : "no command given"); +} + +main(); diff --git a/scripts/editions/src/distillate.ts b/scripts/editions/src/distillate.ts new file mode 100644 index 0000000..3a724e2 --- /dev/null +++ b/scripts/editions/src/distillate.ts @@ -0,0 +1,179 @@ +/** + * Deterministic store + reuse check for the series distillate (N11 — C-5). + * + * Pure where it matters: normalization, similarity and the skeleton check are + * side-effect-free and fully testable. Only load/save touch the filesystem. + * + * Similarity is CHARACTER TRIGRAM Jaccard, not word overlap. The plugin is + * language-general, and the languages it is actually used in are inflection- + * heavy: "migrere"/"migreringen" and "pipeline"/"pipelinen" are the same unit to + * a reader but different word tokens. Calibrated against real paraphrase pairs + * (see tests/distillate.test.ts): retellings score 0.44–0.55, unrelated material + * 0.04–0.06, and same-topic-different-story 0.24 — so the default threshold sits + * in the gap at 0.40. Word-Jaccard scored a shortened hook paraphrase at 0.11 + * and would have let it through. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { SCHEMA_VERSION } from "./types.js"; +import type { + DistillateEntry, + NarrativeKind, + ReuseHit, + ReuseReport, + SeriesDistillate, + SkeletonCandidate, +} from "./types.js"; + +export { SCHEMA_VERSION } from "./types.js"; + +/** Similarity at or above this counts as re-use. Empirically calibrated — see the header. */ +export const DEFAULT_THRESHOLD = 0.4; + +const KINDS: NarrativeKind[] = ["anecdote", "argument", "hook"]; + +/** Lowercase, strip everything that is not a letter or digit, collapse whitespace. */ +export function normalizeUnit(text: string): string { + return text + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim() + .replace(/\s+/g, " "); +} + +/** Character trigrams over the normalized text, space-padded so short units still overlap. */ +function trigrams(text: string): Set { + const normalized = normalizeUnit(text); + if (normalized.length === 0) return new Set(); + const padded = ` ${normalized} `; + const out = new Set(); + for (let i = 0; i + 3 <= padded.length; i++) out.add(padded.slice(i, i + 3)); + return out; +} + +/** Jaccard over character trigrams: 1 = identical after normalization, 0 = nothing shared. */ +export function similarity(a: string, b: string): number { + const A = trigrams(a); + const B = trigrams(b); + if (A.size === 0 || B.size === 0) return 0; + let intersection = 0; + for (const gram of A) if (B.has(gram)) intersection++; + return intersection / (A.size + B.size - intersection); +} + +export function emptyDistillate(series: string): SeriesDistillate { + return { schemaVersion: SCHEMA_VERSION, series, editions: [] }; +} + +/** Read the distillate; a missing file is an empty one (a series' first edition is not an error). */ +export function loadDistillate(path: string, series: string): SeriesDistillate { + if (!existsSync(path)) return emptyDistillate(series); + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + return { + schemaVersion: parsed.schemaVersion ?? SCHEMA_VERSION, + series: parsed.series ?? series, + editions: Array.isArray(parsed.editions) ? parsed.editions : [], + }; +} + +/** Write as pretty JSON with a trailing newline, creating the parent dir if needed. */ +export function saveDistillate(path: string, distillate: SeriesDistillate): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(distillate, null, 2) + "\n", "utf8"); +} + +export interface AppendResult { + distillate: SeriesDistillate; + /** true iff the edition was new. */ + appended: boolean; + /** true iff an existing edition was overwritten in place (a pivot re-lock). */ + replaced: boolean; +} + +/** + * Fold one locked edition into the distillate. An edition already present is + * REPLACED in place, never duplicated: `/linkedin:pivot` re-opens and re-locks + * the same edition, and the reader still only ever met it once. + */ +export function appendEntry(distillate: SeriesDistillate, entry: DistillateEntry): AppendResult { + const stored: DistillateEntry = { + editionId: entry.editionId, + title: entry.title, + lockedAt: entry.lockedAt, + anecdotes: [...entry.anecdotes], + arguments: [...entry.arguments], + hooks: [...entry.hooks], + }; + const at = distillate.editions.findIndex((e) => e.editionId === entry.editionId); + if (at >= 0) { + distillate.editions[at] = stored; + return { distillate, appended: false, replaced: true }; + } + distillate.editions.push(stored); + return { distillate, appended: true, replaced: false }; +} + +function unitsOf(entry: DistillateEntry, kind: NarrativeKind): string[] { + if (kind === "anecdote") return entry.anecdotes; + if (kind === "argument") return entry.arguments; + return entry.hooks; +} + +function candidateUnits(candidate: SkeletonCandidate, kind: NarrativeKind): string[] { + if (kind === "anecdote") return candidate.anecdotes ?? []; + if (kind === "argument") return candidate.arguments ?? []; + return candidate.hooks ?? []; +} + +export interface CheckOptions { + /** Override the calibrated default when a series wants to be stricter or looser. */ + threshold?: number; +} + +/** + * Check a proposed skeleton against everything the series already published. + * Units are compared WITHIN their kind — an argument re-used as a hook is a new + * move, not a retread. Advisory: the verdict feeds the Step 2.5 annotation gate. + */ +export function checkSkeleton( + distillate: SeriesDistillate, + candidate: SkeletonCandidate, + options: CheckOptions = {}, +): ReuseReport { + const threshold = options.threshold ?? DEFAULT_THRESHOLD; + const hits: ReuseHit[] = []; + + for (const kind of KINDS) { + for (const proposed of candidateUnits(candidate, kind)) { + for (const edition of distillate.editions) { + for (const published of unitsOf(edition, kind)) { + const score = similarity(proposed, published); + if (score >= threshold) { + hits.push({ kind, candidate: proposed, matched: published, editionId: edition.editionId, score }); + } + } + } + } + } + + hits.sort((a, b) => b.score - a.score); + + return { + verdict: hits.length > 0 ? "REUSE" : "CLEAR", + hits, + threshold, + comparedAgainst: distillate.editions.length, + }; +} + +/** + * The distillate sits beside `edition-state.json` in the series root, not in the + * per-user data dir: it is series-scoped state, and the series folder is where + * the edition's state already lives (`/linkedin/`), so it travels with + * the series and needs no slug→path map. + */ +export function defaultDistillatePath(seriesRoot: string): string { + return join(seriesRoot, "linkedin", "series-distillate.json"); +} diff --git a/scripts/editions/src/editionState.ts b/scripts/editions/src/editionState.ts new file mode 100644 index 0000000..594b481 --- /dev/null +++ b/scripts/editions/src/editionState.ts @@ -0,0 +1,106 @@ +/** + * The edition-state side of the register (N12 — A1-12). + * + * Two jobs, both deterministic: + * 1. read the handful of facts the register mirrors (series, edition, title, + * phase) — refusing to guess when any of them is missing; + * 2. append the `phaseLog` entry that makes lead time measurable. + * + * Why code and not the command layer: the phase log is only useful if it is + * complete, and "remember to append the right JSON object at all ~17 transitions" + * is exactly the discipline a model quietly drops mid-pipeline. One CLI call at + * each transition writes both the log and the register, or neither. + * + * The state file is read and written whole — this package touches `phaseLog` and + * nothing else. `edition-state.json` stays owned by /linkedin:newsletter. + */ + +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +import type { EditionFacts, EditionStateFile, PhaseLogEntry } from "./types.js"; + +/** Read the state file. A missing file throws — the register mirrors state, it never invents it. */ +export function readEditionState(path: string): EditionStateFile { + return JSON.parse(readFileSync(path, "utf8")) as EditionStateFile; +} + +/** Write the state back, preserving everything this package did not touch. */ +export function saveEditionState(path: string, state: EditionStateFile): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(state, null, 2) + "\n", "utf8"); +} + +/** + * Extract what the register mirrors. Every missing field is an error rather than + * a default: a row naming the wrong phase is worse than no row, because the + * whole point is that the operator trusts the list without opening files. + * The title is the one exception — it is telemetry, not a gate. + */ +export function editionFacts(state: EditionStateFile): EditionFacts { + const series = state.series?.slug; + if (typeof series !== "string" || series.trim().length === 0) { + throw new Error("edition-state is missing series.slug"); + } + + const editionId = state.currentArticle; + if (typeof editionId !== "string" || editionId.trim().length === 0) { + throw new Error("edition-state is missing currentArticle"); + } + + const article = state.articles?.[editionId]; + if (!article || typeof article !== "object") { + throw new Error(`edition-state has no articles entry for currentArticle ${editionId}`); + } + + const currentPhase = state.currentPhase; + if (typeof currentPhase !== "string" || currentPhase.trim().length === 0) { + throw new Error("edition-state is missing currentPhase"); + } + + return { series, editionId, title: typeof article.title === "string" ? article.title : "", currentPhase }; +} + +export interface AppendPhaseResult { + state: EditionStateFile; + /** false iff the same transition was already the newest entry. */ + appended: boolean; +} + +/** + * Append one completed phase to the article's log. + * + * Idempotent against an immediately repeated transition (a re-run of the same + * step), but NOT against a phase that recurs after another one: `/linkedin:pivot` + * legitimately sends an edition back through cleared gates, and that second pass + * is real production time — collapsing it would understate the lead time of + * exactly the editions that cost the most. + */ +export function appendPhase( + state: EditionStateFile, + editionId: string, + phase: string, + at: string, +): AppendPhaseResult { + const article = state.articles?.[editionId]; + if (!article || typeof article !== "object") { + throw new Error(`edition-state has no articles entry for ${editionId}`); + } + + const log: PhaseLogEntry[] = Array.isArray(article.phaseLog) ? article.phaseLog : []; + article.phaseLog = log; + + if (log.length > 0 && log[log.length - 1].phase === phase) return { state, appended: false }; + + log.push({ phase, completedAt: at }); + return { state, appended: true }; +} + +/** + * Derive the series root from the conventional + * `/linkedin/edition-state.json` layout, so a transition does not have to + * repeat a path the state file's own location already carries. + */ +export function seriesRootFromStatePath(statePath: string): string { + return dirname(dirname(statePath)); +} diff --git a/scripts/editions/src/register.ts b/scripts/editions/src/register.ts new file mode 100644 index 0000000..1271c98 --- /dev/null +++ b/scripts/editions/src/register.ts @@ -0,0 +1,171 @@ +/** + * The editions register (N12 — A1-11 / A1-12). + * + * One row per edition in production, so the week's work-in-progress is readable + * without opening a single `edition-state.json`, and lead time (first transition + * → completion) is measured rather than assumed. + * + * Pure where it matters: every mutation takes `now` as an argument, exactly like + * the distillate takes `lockedAt`. No clock in the core means no run-to-run + * variance in the tests and no hidden timezone behaviour in the data. + * + * Mirror discipline: nothing here is authoritative. Rows are derived from + * edition-state files at phase transitions; `startedAt` is the only value that + * cannot be recovered by re-running one, which is why it never moves. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +import { REGISTER_SCHEMA_VERSION } from "./types.js"; +import type { EditionRegister, EditionRow, EditionStatus, UpsertInput } from "./types.js"; + +export { REGISTER_SCHEMA_VERSION } from "./types.js"; + +const MS_PER_DAY = 86_400_000; + +export function emptyRegister(): EditionRegister { + return { schemaVersion: REGISTER_SCHEMA_VERSION, editions: [] }; +} + +/** Read the register; a missing file is an empty one (the first edition is not an error). */ +export function loadRegister(path: string): EditionRegister { + if (!existsSync(path)) return emptyRegister(); + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + return { + schemaVersion: parsed.schemaVersion ?? REGISTER_SCHEMA_VERSION, + editions: Array.isArray(parsed.editions) ? parsed.editions : [], + }; +} + +/** Write as pretty JSON with a trailing newline, creating the parent dir if needed. */ +export function saveRegister(path: string, register: EditionRegister): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(register, null, 2) + "\n", "utf8"); +} + +export interface UpsertResult { + register: EditionRegister; + row: EditionRow; + /** true iff the edition had no row yet. */ + created: boolean; +} + +/** + * Mirror one phase transition into the register. + * + * Omitted optionals are preserved: a transition reports the phase it just + * completed, and it must not blank the slot or the title just because it had no + * reason to mention them. Re-upserting a completed edition REACTIVATES its row — + * `/linkedin:pivot` re-opens an edition, and the lead-time clock it started + * should keep running rather than fork a second row. + */ +export function upsertEdition(register: EditionRegister, input: UpsertInput, now: string): UpsertResult { + const at = register.editions.findIndex( + (e) => e.series === input.series && e.editionId === input.editionId, + ); + + if (at < 0) { + const row: EditionRow = { + series: input.series, + editionId: input.editionId, + title: input.title ?? "", + path: input.path, + currentPhase: input.currentPhase, + nextAction: input.nextAction ?? null, + slot: input.slot ?? null, + startedAt: now, + updatedAt: now, + completedAt: null, + status: "active", + }; + register.editions.push(row); + return { register, row, created: true }; + } + + const existing = register.editions[at]; + const row: EditionRow = { + ...existing, + title: input.title ?? existing.title, + path: input.path, + currentPhase: input.currentPhase, + nextAction: input.nextAction ?? existing.nextAction, + slot: input.slot ?? existing.slot, + updatedAt: now, + completedAt: null, + status: "active", + }; + register.editions[at] = row; + return { register, row, created: false }; +} + +export interface CompleteResult { + register: EditionRegister; + /** null iff no row matched — completing an edition that was never registered is a caller error. */ + row: EditionRow | null; +} + +/** + * Close an edition. Idempotent by design: a second call keeps the FIRST + * `completedAt`, so re-running Step 10 cannot quietly stretch the measured lead + * time of an edition that was already done. + */ +export function completeEdition( + register: EditionRegister, + key: { series: string; editionId: string }, + now: string, +): CompleteResult { + const at = register.editions.findIndex((e) => e.series === key.series && e.editionId === key.editionId); + if (at < 0) return { register, row: null }; + + const existing = register.editions[at]; + if (existing.status === "complete") return { register, row: existing }; + + const row: EditionRow = { ...existing, status: "complete", completedAt: now, updatedAt: now }; + register.editions[at] = row; + return { register, row }; +} + +export interface ListOptions { + /** Include finished editions — the history behind the in-flight view. */ + includeComplete?: boolean; +} + +const STATUS_ORDER: Record = { active: 0, complete: 1 }; + +/** Deterministic order: in flight first, then by series, then by edition id. */ +export function listEditions(register: EditionRegister, options: ListOptions = {}): EditionRow[] { + const rows = options.includeComplete + ? [...register.editions] + : register.editions.filter((e) => e.status === "active"); + + return rows.sort( + (a, b) => + STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || + a.series.localeCompare(b.series) || + a.editionId.localeCompare(b.editionId), + ); +} + +/** + * Days between two ISO stamps, to two decimals — the lead-time primitive. + * Sub-day precision matters: at two editions a week, "1 day" and "0.5 days" are + * a different production reality. An unparseable stamp yields null, never NaN. + */ +export function elapsedDays(from: string, to: string): number | null { + const a = Date.parse(from); + const b = Date.parse(to); + if (Number.isNaN(a) || Number.isNaN(b)) return null; + return Math.round(((b - a) / MS_PER_DAY) * 100) / 100; +} + +/** + * Default register path under the per-user data dir (M0 data-path convention), + * so the WIP view survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` + * overrides the root; otherwise `~/.claude/linkedin-studio`. + */ +export function defaultRegisterPath(): string { + const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); + return join(root, "editions", "register.json"); +} diff --git a/scripts/editions/src/types.ts b/scripts/editions/src/types.ts new file mode 100644 index 0000000..d457e7b --- /dev/null +++ b/scripts/editions/src/types.ts @@ -0,0 +1,174 @@ +/** + * Types for the series distillate (N11 — serie-nivå-vern / C-5). + * + * The gap this closes: every long-form gate agent sees ONE edition. At two + * editions a week the fastest-growing defect class is not a bad edition — it is + * a *repeated* one: the anecdote, the argument or the hook the reader already + * met three editions ago. Structurally invisible today, and the specifics-bank + * dedupe actively encourages re-surfacing the same material. + * + * So each locked edition leaves behind a distillate of the narrative units it + * spent, and the Step 2.5 skeleton gate checks the NEXT skeleton against it — + * before prose exists, which is the only point where changing course is cheap. + * + * Deterministic by design: the extraction of units from a locked edition is an + * AI job (the command layer), but the store, the comparison and the verdict are + * plain code — no model in the loop, no run-to-run variance. + * + * Scope note: this file is SERIES-scoped memory ("has this reader heard it?"). + * Cross-series re-use of the operator's raw material is the specifics-bank's + * `usedIn` log — a different grain, deliberately a different store. + */ + +export const SCHEMA_VERSION = 1; + +/** The three narrative units a reader actually recognizes across editions. */ +export type NarrativeKind = "anecdote" | "argument" | "hook"; + +/** What one locked edition spent. */ +export interface DistillateEntry { + /** Edition id within the series, e.g. "05". */ + editionId: string; + title: string; + /** ISO date the edition was locked. Supplied by the caller (CLI edge) — no clock in the pure core. */ + lockedAt: string; + /** Stories told, in the operator's own phrasing. */ + anecdotes: string[]; + /** Claims argued. */ + arguments: string[]; + /** Opening moves used. */ + hooks: string[]; +} + +export interface SeriesDistillate { + schemaVersion: number; + /** Series slug — the folder the editions live in. */ + series: string; + editions: DistillateEntry[]; +} + +/** The units a NEW skeleton proposes, checked before prose (Step 2.5). */ +export interface SkeletonCandidate { + anecdotes?: string[]; + arguments?: string[]; + hooks?: string[]; +} + +/** One proposed unit that resembles something the series already published. */ +export interface ReuseHit { + kind: NarrativeKind; + /** The proposed text. */ + candidate: string; + /** The already-published text it resembles. */ + matched: string; + /** Which edition spent it. */ + editionId: string; + /** 0..1 similarity; 1 = verbatim. */ + score: number; +} + +export type ReuseVerdict = "CLEAR" | "REUSE"; + +/* ------------------------------------------------------------------ * + * Editions register (N12 — A1-11 / A1-12) + * + * A second, orthogonal grain of series memory: the distillate above answers + * "has the reader heard this before?", the register answers "what is in flight + * right now, and how long has it taken?". At two editions a week the expensive + * blind spot is not content — it is that the only record of an in-flight edition + * lives inside its own `edition-state.json`, so seeing the week means opening + * every series folder by hand, and lead time is never measured at all. + * + * The register is a MIRROR, never a source of truth: every row is derived from + * an `edition-state.json` at a phase transition. Losing it costs telemetry, not + * an edition — rebuild by re-running the transitions. + * ------------------------------------------------------------------ */ + +export const REGISTER_SCHEMA_VERSION = 1; + +export type EditionStatus = "active" | "complete"; + +/** One in-flight (or finished) edition, keyed by `(series, editionId)`. */ +export interface EditionRow { + /** Series slug — `series.slug` in edition-state.json. */ + series: string; + /** Edition id within the series — `currentArticle`, e.g. "05". */ + editionId: string; + title: string; + /** Absolute path to the series root holding this edition. */ + path: string; + /** Last completed phase, mirrored from `currentPhase`. */ + currentPhase: string; + /** The one next concrete step, in the operator's words. */ + nextAction: string | null; + /** Publishing slot, once one is claimed (N13 wires the slot layer). */ + slot: string | null; + /** First transition seen — the lead-time anchor. Never moves. */ + startedAt: string; + updatedAt: string; + completedAt: string | null; + status: EditionStatus; +} + +export interface EditionRegister { + schemaVersion: number; + editions: EditionRow[]; +} + +/** What a phase transition supplies. Omitted optionals are PRESERVED, not blanked. */ +export interface UpsertInput { + series: string; + editionId: string; + path: string; + currentPhase: string; + title?: string; + nextAction?: string; + slot?: string; +} + +/* ---- edition-state.json: the mirrored source of truth ---- */ + +/** One completed phase transition — the raw material for lead-time measurement. */ +export interface PhaseLogEntry { + phase: string; + /** ISO timestamp. Supplied by the caller (CLI edge) — no clock in the pure core. */ + completedAt: string; +} + +/** Structurally open: this package reads a few fields and must preserve the rest verbatim. */ +export interface EditionArticle { + title?: string; + phase?: string; + phaseLog?: PhaseLogEntry[]; + [key: string]: unknown; +} + +export interface EditionStateFile { + schemaVersion?: number; + series?: { slug?: string; title?: string }; + currentArticle?: string; + currentPhase?: string; + articles: Record; + [key: string]: unknown; +} + +/** The fields the register mirrors out of an edition-state file. */ +export interface EditionFacts { + series: string; + editionId: string; + title: string; + currentPhase: string; +} + +/** + * Advisory by design — this reports into the annotation gate the operator + * already reads at Step 2.5; it never blocks. Re-use is sometimes the right + * call (a deliberate callback); silence about it never is. + */ +export interface ReuseReport { + verdict: ReuseVerdict; + hits: ReuseHit[]; + threshold: number; + /** How many locked editions the skeleton was compared against. */ + comparedAgainst: number; +} diff --git a/scripts/editions/tests/cli-register.test.ts b/scripts/editions/tests/cli-register.test.ts new file mode 100644 index 0000000..99e02dd --- /dev/null +++ b/scripts/editions/tests/cli-register.test.ts @@ -0,0 +1,194 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Resolve the package root (scripts/editions) so the subprocess `src/cli.ts` path + +// the `tsx` loader resolve regardless of the runner's cwd. +const editionsDir = fileURLToPath(new URL("..", import.meta.url)); + +function run(args: string[], dataRoot?: string): { status: number | null; stdout: string; stderr: string } { + const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], { + encoding: "utf8", + cwd: editionsDir, + env: dataRoot ? { ...process.env, LINKEDIN_STUDIO_DATA: dataRoot } : process.env, + }); + return { status: res.status, stdout: res.stdout, stderr: res.stderr }; +} + +const T1 = "2026-07-21T09:30:00.000Z"; +const T2 = "2026-07-24T09:30:00.000Z"; + +/** A scratch series root with a realistic edition-state.json, plus a scratch data dir. */ +function fixture(): { dir: string; statePath: string; dataRoot: string; seriesRoot: string } { + const dir = mkdtempSync(join(tmpdir(), "editions-cli-register-")); + const seriesRoot = join(dir, "series", "seres"); + const statePath = join(seriesRoot, "linkedin", "edition-state.json"); + mkdirSync(join(seriesRoot, "linkedin"), { recursive: true }); + writeFileSync( + statePath, + JSON.stringify( + { + schemaVersion: 1, + series: { slug: "seres", title: "Seres" }, + currentArticle: "05", + currentPhase: "skeleton-pitch", + articles: { "05": { title: "Da pipelinen brøt sammen", phase: "skeleton-pitch", phaseLog: [] } }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + return { dir, statePath, dataRoot: join(dir, "data"), seriesRoot }; +} + +describe("editions CLI — register upsert/list/complete round-trip (N12 AC)", () => { + test("upsert -> list -> complete round-trips through a real register in a scratch data dir", () => { + const { dir, statePath, dataRoot, seriesRoot } = fixture(); + try { + // 1. a phase transition mirrors the edition into the register + const up = run( + ["register-upsert", "--edition-state", statePath, "--next", "Step 3a — spine prose", "--at", T1], + dataRoot, + ); + assert.equal(up.status, 0, up.stderr); + + const registerPath = join(dataRoot, "editions", "register.json"); + assert.ok(existsSync(registerPath), "register-upsert must create the register under ${DATA}/editions/"); + + // 2. list surfaces it without opening a single edition-state.json + const listed = run(["register-list", "--json"], dataRoot); + assert.equal(listed.status, 0, listed.stderr); + const rows = JSON.parse(listed.stdout); + assert.equal(rows.length, 1); + assert.equal(rows[0].series, "seres"); + assert.equal(rows[0].editionId, "05"); + assert.equal(rows[0].title, "Da pipelinen brøt sammen"); + assert.equal(rows[0].currentPhase, "skeleton-pitch"); + assert.equal(rows[0].nextAction, "Step 3a — spine prose"); + assert.equal(rows[0].path, seriesRoot, "the series root is derived from the state-file path"); + assert.equal(rows[0].startedAt, T1); + assert.equal(rows[0].status, "active"); + + // 3. complete closes the row and takes it out of the in-flight view + const done = run(["register-complete", "--series", "seres", "--edition", "05", "--at", T2], dataRoot); + assert.equal(done.status, 0, done.stderr); + assert.equal(JSON.parse(run(["register-list", "--json"], dataRoot).stdout).length, 0); + + const all = JSON.parse(run(["register-list", "--json", "--all"], dataRoot).stdout); + assert.equal(all.length, 1); + assert.equal(all[0].status, "complete"); + assert.equal(all[0].completedAt, T2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("the same upsert also appends the phaseLog entry — telemetry is not a separate step", () => { + const { dir, statePath, dataRoot } = fixture(); + try { + assert.equal(run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot).status, 0); + const state = JSON.parse(readFileSync(statePath, "utf8")); + assert.deepEqual(state.articles["05"].phaseLog, [{ phase: "skeleton-pitch", completedAt: T1 }]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a repeated transition is idempotent in both the log and the register", () => { + const { dir, statePath, dataRoot } = fixture(); + try { + run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot); + run(["register-upsert", "--edition-state", statePath, "--at", T2], dataRoot); + const state = JSON.parse(readFileSync(statePath, "utf8")); + assert.equal(state.articles["05"].phaseLog.length, 1); + const rows = JSON.parse(run(["register-list", "--json"], dataRoot).stdout); + assert.equal(rows.length, 1); + assert.equal(rows[0].startedAt, T1, "lead time is anchored at the first transition"); + assert.equal(rows[0].updatedAt, T2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("the human view shows phase, next action and days in flight — the WIP answer without opening files", () => { + const { dir, statePath, dataRoot } = fixture(); + try { + run(["register-upsert", "--edition-state", statePath, "--next", "Step 3a — spine prose", "--at", T1], dataRoot); + const out = run(["register-list", "--now", T2], dataRoot); + assert.equal(out.status, 0, out.stderr); + assert.match(out.stdout, /seres/); + assert.match(out.stdout, /05/); + assert.match(out.stdout, /skeleton-pitch/); + assert.match(out.stdout, /Step 3a — spine prose/); + assert.match(out.stdout, /3(\.0)? day/, "days in flight is the lead-time signal (T1 -> T2 = 3 days)"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("an empty register says so plainly instead of printing nothing", () => { + const { dir, dataRoot } = fixture(); + try { + const out = run(["register-list"], dataRoot); + assert.equal(out.status, 0, out.stderr); + assert.match(out.stdout, /no editions in flight/i); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("--register overrides the data-dir default", () => { + const { dir, statePath, dataRoot } = fixture(); + try { + const elsewhere = join(dir, "elsewhere", "register.json"); + assert.equal( + run(["register-upsert", "--edition-state", statePath, "--register", elsewhere, "--at", T1], dataRoot).status, + 0, + ); + assert.ok(existsSync(elsewhere)); + assert.ok(!existsSync(join(dataRoot, "editions", "register.json"))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("register-upsert without --edition-state is a usage error (exit 2)", () => { + const { dir, dataRoot } = fixture(); + try { + const out = run(["register-upsert", "--next", "Step 3a"], dataRoot); + assert.equal(out.status, 2); + assert.match(out.stderr, /--edition-state/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("register-complete on an unknown edition fails loudly instead of silently succeeding", () => { + const { dir, statePath, dataRoot } = fixture(); + try { + run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot); + const out = run(["register-complete", "--series", "seres", "--edition", "99", "--at", T2], dataRoot); + assert.notEqual(out.status, 0); + assert.match(out.stderr, /99/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a malformed edition-state is refused at the edge, not mirrored", () => { + const { dir, statePath, dataRoot } = fixture(); + try { + writeFileSync(statePath, JSON.stringify({ schemaVersion: 1, series: { slug: "seres" } }), "utf8"); + const out = run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot); + assert.equal(out.status, 2); + assert.match(out.stderr, /currentArticle/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/editions/tests/cli.test.ts b/scripts/editions/tests/cli.test.ts new file mode 100644 index 0000000..77d36a7 --- /dev/null +++ b/scripts/editions/tests/cli.test.ts @@ -0,0 +1,152 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Resolve the package root (scripts/editions) so the subprocess `src/cli.ts` path + +// the `tsx` loader resolve regardless of the runner's cwd. +const editionsDir = fileURLToPath(new URL("..", import.meta.url)); + +function run(args: string[]): { status: number | null; stdout: string; stderr: string } { + const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], { + encoding: "utf8", + cwd: editionsDir, + }); + return { status: res.status, stdout: res.stdout, stderr: res.stderr }; +} + +const tmp = () => mkdtempSync(join(tmpdir(), "editions-cli-")); + +const EXTRACT = { + editionId: "05", + title: "Da pipelinen brøt sammen", + lockedAt: "2026-07-20", + anecdotes: ["Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata."], + arguments: ["Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år."], + hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."], +}; + +describe("editions CLI — distil-append / distil-check round-trip (N11 AC)", () => { + test("append -> check round-trips through a real file and flags the reuse", () => { + const dir = tmp(); + try { + const distillate = join(dir, "linkedin", "series-distillate.json"); + const extract = join(dir, "extract.json"); + const skeleton = join(dir, "skeleton.json"); + writeFileSync(extract, JSON.stringify(EXTRACT), "utf8"); + + // 1. lock writes the distillate (file did not exist) + const appended = run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]); + assert.equal(appended.status, 0, appended.stderr); + assert.ok(existsSync(distillate), "distil-append must create the distillate file"); + const onDisk = JSON.parse(readFileSync(distillate, "utf8")); + assert.equal(onDisk.series, "seres"); + assert.equal(onDisk.editions.length, 1); + assert.equal(onDisk.editions[0].editionId, "05"); + + // 2. a later skeleton reusing the same hook is flagged, with attribution + writeFileSync(skeleton, JSON.stringify({ hooks: [EXTRACT.hooks[0]] }), "utf8"); + const checked = run(["distil-check", "--distillate", distillate, "--skeleton", skeleton, "--json"]); + assert.equal(checked.status, 0, checked.stderr); + const report = JSON.parse(checked.stdout); + assert.equal(report.verdict, "REUSE"); + assert.equal(report.hits[0].editionId, "05"); + assert.equal(report.hits[0].kind, "hook"); + + // 3. fresh material on the same distillate is CLEAR + writeFileSync( + skeleton, + JSON.stringify({ hooks: ["Innkjøpsavdelingen krevde en garanti vi ikke kunne gi uten revisjon."] }), + "utf8", + ); + const clear = run(["distil-check", "--distillate", distillate, "--skeleton", skeleton, "--json"]); + assert.equal(clear.status, 0, clear.stderr); + assert.equal(JSON.parse(clear.stdout).verdict, "CLEAR"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("re-appending the same edition replaces it — a pivot re-lock does not duplicate", () => { + const dir = tmp(); + try { + const distillate = join(dir, "series-distillate.json"); + const extract = join(dir, "extract.json"); + writeFileSync(extract, JSON.stringify(EXTRACT), "utf8"); + run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]); + + writeFileSync(extract, JSON.stringify({ ...EXTRACT, title: "Revidert" }), "utf8"); + const again = run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]); + assert.equal(again.status, 0, again.stderr); + + const onDisk = JSON.parse(readFileSync(distillate, "utf8")); + assert.equal(onDisk.editions.length, 1); + assert.equal(onDisk.editions[0].title, "Revidert"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("distil-check against a distillate that does not exist yet is CLEAR, not an error", () => { + const dir = tmp(); + try { + const skeleton = join(dir, "skeleton.json"); + writeFileSync(skeleton, JSON.stringify({ hooks: ["første utgave i serien"] }), "utf8"); + const res = run([ + "distil-check", + "--distillate", + join(dir, "absent.json"), + "--skeleton", + skeleton, + "--json", + ]); + assert.equal(res.status, 0, res.stderr); + const report = JSON.parse(res.stdout); + assert.equal(report.verdict, "CLEAR"); + assert.equal(report.comparedAgainst, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("human-readable output names the reused unit and its edition", () => { + const dir = tmp(); + try { + const distillate = join(dir, "d.json"); + const extract = join(dir, "extract.json"); + const skeleton = join(dir, "skeleton.json"); + writeFileSync(extract, JSON.stringify(EXTRACT), "utf8"); + run(["distil-append", "--distillate", distillate, "--series", "seres", "--extract", extract]); + writeFileSync(skeleton, JSON.stringify({ hooks: [EXTRACT.hooks[0]] }), "utf8"); + + const res = run(["distil-check", "--distillate", distillate, "--skeleton", skeleton]); + assert.equal(res.status, 0, res.stderr); + assert.match(res.stdout, /REUSE/); + assert.match(res.stdout, /05/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("bad invocations exit 2 with usage on stderr", () => { + const dir = tmp(); + try { + assert.equal(run(["distil-append", "--series", "seres"]).status, 2); // no --extract/--distillate + assert.equal(run(["distil-check"]).status, 2); // no --skeleton + assert.equal(run(["nonsense"]).status, 2); + assert.equal(run([]).status, 2); + + // an extract missing required fields is a usage error, not a silent empty entry + const bad = join(dir, "bad.json"); + writeFileSync(bad, JSON.stringify({ title: "no id" }), "utf8"); + const res = run(["distil-append", "--distillate", join(dir, "d.json"), "--series", "s", "--extract", bad]); + assert.equal(res.status, 2); + assert.match(res.stderr, /editionId/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/editions/tests/distillate.test.ts b/scripts/editions/tests/distillate.test.ts new file mode 100644 index 0000000..aa7a472 --- /dev/null +++ b/scripts/editions/tests/distillate.test.ts @@ -0,0 +1,260 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + DEFAULT_THRESHOLD, + appendEntry, + checkSkeleton, + defaultDistillatePath, + emptyDistillate, + loadDistillate, + normalizeUnit, + saveDistillate, + similarity, +} from "../src/distillate.js"; +import { SCHEMA_VERSION } from "../src/types.js"; +import type { DistillateEntry } from "../src/types.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "editions-")); + +const entry = (over: Partial = {}): DistillateEntry => ({ + editionId: "05", + title: "Da pipelinen brøt sammen", + lockedAt: "2026-07-20", + anecdotes: ["Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata."], + arguments: ["Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år."], + hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."], + ...over, +}); + +describe("series distillate — store", () => { + test("emptyDistillate carries the schema version, the series and no editions", () => { + const d = emptyDistillate("seres"); + assert.equal(d.schemaVersion, SCHEMA_VERSION); + assert.equal(d.series, "seres"); + assert.deepEqual(d.editions, []); + }); + + test("loadDistillate on a missing file returns an empty distillate (never throws)", () => { + const dir = tmp(); + try { + const d = loadDistillate(join(dir, "nope.json"), "seres"); + assert.equal(d.series, "seres"); + assert.deepEqual(d.editions, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("save -> load round-trips an appended edition byte-faithfully", () => { + const dir = tmp(); + try { + const path = join(dir, "linkedin", "series-distillate.json"); + const { distillate } = appendEntry(emptyDistillate("seres"), entry()); + saveDistillate(path, distillate); + + const back = loadDistillate(path, "seres"); + assert.equal(back.editions.length, 1); + assert.deepEqual(back.editions[0], entry()); + assert.equal(back.schemaVersion, SCHEMA_VERSION); + // trailing newline, like every other store in the plugin + assert.ok(readFileSync(path, "utf8").endsWith("}\n")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("appendEntry appends a new edition and reports it", () => { + const res = appendEntry(emptyDistillate("seres"), entry()); + assert.equal(res.appended, true); + assert.equal(res.replaced, false); + assert.equal(res.distillate.editions.length, 1); + }); + + test("re-locking the same edition REPLACES it, never duplicates (pivot re-lock)", () => { + const first = appendEntry(emptyDistillate("seres"), entry()); + const revised = entry({ title: "Da pipelinen brøt sammen (revidert)" }); + const second = appendEntry(first.distillate, revised); + + assert.equal(second.appended, false); + assert.equal(second.replaced, true); + assert.equal(second.distillate.editions.length, 1); + assert.equal(second.distillate.editions[0].title, "Da pipelinen brøt sammen (revidert)"); + }); + + test("editions keep insertion order", () => { + let d = emptyDistillate("seres"); + d = appendEntry(d, entry({ editionId: "03" })).distillate; + d = appendEntry(d, entry({ editionId: "05" })).distillate; + d = appendEntry(d, entry({ editionId: "04" })).distillate; + assert.deepEqual( + d.editions.map((e) => e.editionId), + ["03", "05", "04"], + ); + }); + + test("defaultDistillatePath sits beside edition-state in the series root", () => { + assert.equal( + defaultDistillatePath("/series/seres"), + join("/series/seres", "linkedin", "series-distillate.json"), + ); + }); +}); + +describe("series distillate — similarity (language-agnostic character trigrams)", () => { + test("normalizeUnit lowercases, strips punctuation and collapses whitespace", () => { + assert.equal(normalizeUnit(" Hei, DU — da! "), "hei du da"); + }); + + test("identical text scores 1", () => { + const s = "Vi målte 40 prosent kortere ledetid etter omleggingen."; + assert.equal(similarity(s, s), 1); + }); + + test("empty input scores 0 instead of dividing by zero", () => { + assert.equal(similarity("", "noe tekst"), 0); + assert.equal(similarity("noe tekst", " "), 0); + }); + + test("similarity is symmetric", () => { + const a = "Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år."; + const b = "Problemet var aldri modellen. Det var fem år med data som ingen hadde ryddet."; + assert.equal(similarity(a, b), similarity(b, a)); + }); + + // The calibration set the threshold is derived from. Paraphrase (the same story + // retold) must land ABOVE the default; unrelated material and same-topic-different- + // story must land BELOW it — inflection-heavy Norwegian included, which is why this + // is character trigrams and not word tokens (a shortened hook paraphrase scores + // 0.11 on word-Jaccard and would slip through). + test("paraphrase of the same story clears the default threshold", () => { + const pairs: Array<[string, string]> = [ + [ + "Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata.", + "Migreringen av pipelinen tok tre uker, og halve tiden gikk med til testdata.", + ], + [ + "Modellen var ikke problemet — det var dataene ingen hadde ryddet på fem år.", + "Problemet var aldri modellen. Det var fem år med data som ingen hadde ryddet.", + ], + [ + "De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data.", + "AI-prosjekter dør sjelden av modellen. De dør av dataene.", + ], + ]; + for (const [a, b] of pairs) { + assert.ok( + similarity(a, b) >= DEFAULT_THRESHOLD, + `expected paraphrase >= ${DEFAULT_THRESHOLD}, got ${similarity(a, b).toFixed(3)}: ${a}`, + ); + } + }); + + test("unrelated material and same-topic-different-story stay below the threshold", () => { + const pairs: Array<[string, string]> = [ + [ + "Jeg brukte tre uker på å migrere pipeline-en, og halvparten av tiden gikk til testdata.", + "Innkjøpsavdelingen krevde en leverandørgaranti vi ikke kunne gi uten en revisjon.", + ], + [ + "De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data.", + "Vi har hatt en fast ukentlig gjennomgang av porteføljen siden januar.", + ], + [ + // same topic, different measured claim — a NEW edition, not a retread + "Vi målte 40 prosent kortere ledetid etter omleggingen.", + "Vi målte 12 prosent høyere treffrate etter at vi byttet embedding-modell.", + ], + ]; + for (const [a, b] of pairs) { + assert.ok( + similarity(a, b) < DEFAULT_THRESHOLD, + `expected non-reuse < ${DEFAULT_THRESHOLD}, got ${similarity(a, b).toFixed(3)}: ${a}`, + ); + } + }); +}); + +describe("series distillate — skeleton check (Step 2.5)", () => { + const distillate = appendEntry(emptyDistillate("seres"), entry()).distillate; + + test("an empty distillate is always CLEAR (first edition in a series)", () => { + const r = checkSkeleton(emptyDistillate("seres"), { hooks: ["hva som helst"] }); + assert.equal(r.verdict, "CLEAR"); + assert.deepEqual(r.hits, []); + assert.equal(r.comparedAgainst, 0); + }); + + test("fresh material is CLEAR", () => { + const r = checkSkeleton(distillate, { + anecdotes: ["Innkjøpsavdelingen krevde en leverandørgaranti vi ikke kunne gi uten en revisjon."], + }); + assert.equal(r.verdict, "CLEAR"); + assert.deepEqual(r.hits, []); + }); + + test("verbatim reuse is REUSE and names the edition it came from", () => { + const r = checkSkeleton(distillate, { hooks: [entry().hooks[0]] }); + assert.equal(r.verdict, "REUSE"); + assert.equal(r.hits.length, 1); + assert.equal(r.hits[0].kind, "hook"); + assert.equal(r.hits[0].editionId, "05"); + assert.equal(r.hits[0].score, 1); + assert.equal(r.hits[0].matched, entry().hooks[0]); + }); + + test("paraphrased reuse is caught, not just verbatim", () => { + const r = checkSkeleton(distillate, { + anecdotes: ["Migreringen av pipelinen tok tre uker, og halve tiden gikk med til testdata."], + }); + assert.equal(r.verdict, "REUSE"); + assert.equal(r.hits[0].kind, "anecdote"); + assert.ok(r.hits[0].score >= DEFAULT_THRESHOLD); + }); + + test("kinds are compared within kind — a hook is not matched against an anecdote", () => { + // the anecdote text, offered as a hook: the anecdote bucket must not match it + const r = checkSkeleton(distillate, { hooks: [entry().anecdotes[0]] }); + assert.equal(r.verdict, "CLEAR"); + }); + + test("hits are sorted by score descending", () => { + let d = emptyDistillate("seres"); + d = appendEntry(d, entry({ editionId: "03", hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."] })).distillate; + d = appendEntry(d, entry({ editionId: "04", hooks: ["AI-prosjekter dør sjelden av modellen. De dør av dataene."], anecdotes: [], arguments: [] })).distillate; + + const r = checkSkeleton(d, { hooks: ["De fleste AI-prosjekter dør ikke av dårlig modell. De dør av dårlig data."] }); + assert.equal(r.verdict, "REUSE"); + assert.ok(r.hits.length >= 2); + for (let i = 1; i < r.hits.length; i++) { + assert.ok(r.hits[i - 1].score >= r.hits[i].score, "hits must be sorted by score desc"); + } + assert.equal(r.hits[0].score, 1); + }); + + test("the threshold is configurable and reported back", () => { + const strict = checkSkeleton( + distillate, + { anecdotes: ["Migreringen av pipelinen tok tre uker, og halve tiden gikk med til testdata."] }, + { threshold: 0.95 }, + ); + assert.equal(strict.verdict, "CLEAR"); + assert.equal(strict.threshold, 0.95); + }); + + test("comparedAgainst reports how many editions the skeleton was checked against", () => { + let d = emptyDistillate("seres"); + d = appendEntry(d, entry({ editionId: "03" })).distillate; + d = appendEntry(d, entry({ editionId: "04" })).distillate; + assert.equal(checkSkeleton(d, { hooks: ["noe helt annet om innkjøp og garantier"] }).comparedAgainst, 2); + }); + + test("an absent bucket on the candidate is not an error", () => { + const r = checkSkeleton(distillate, {}); + assert.equal(r.verdict, "CLEAR"); + assert.deepEqual(r.hits, []); + }); +}); diff --git a/scripts/editions/tests/editionState.test.ts b/scripts/editions/tests/editionState.test.ts new file mode 100644 index 0000000..6bab6a6 --- /dev/null +++ b/scripts/editions/tests/editionState.test.ts @@ -0,0 +1,139 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + appendPhase, + editionFacts, + readEditionState, + saveEditionState, + seriesRootFromStatePath, +} from "../src/editionState.js"; +import type { EditionStateFile } from "../src/types.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "editions-state-")); + +const AT = "2026-07-21T09:30:00.000Z"; + +function state(): EditionStateFile { + return { + schemaVersion: 1, + series: { slug: "seres", title: "Seres" }, + currentArticle: "05", + currentPhase: "skeleton-pitch", + articles: { + "04": { title: "Forrige", phase: "scheduling", phaseLog: [{ phase: "scheduling", completedAt: AT }] }, + "05": { title: "Da pipelinen brøt sammen", phase: "skeleton-pitch", phaseLog: [] }, + }, + } as unknown as EditionStateFile; +} + +describe("editionState — facts", () => { + test("reads series, edition id, title and phase from the state file", () => { + const facts = editionFacts(state()); + assert.deepEqual(facts, { + series: "seres", + editionId: "05", + title: "Da pipelinen brøt sammen", + currentPhase: "skeleton-pitch", + }); + }); + + test("a missing currentArticle is an error, not a guess", () => { + const broken = { ...state(), currentArticle: undefined } as unknown as EditionStateFile; + assert.throws(() => editionFacts(broken), /currentArticle/); + }); + + test("a currentArticle with no matching articles entry is an error", () => { + const broken = { ...state(), currentArticle: "99" } as unknown as EditionStateFile; + assert.throws(() => editionFacts(broken), /99/); + }); + + test("a missing currentPhase is an error — the register must never mirror a guessed phase", () => { + const broken = { ...state(), currentPhase: undefined } as unknown as EditionStateFile; + assert.throws(() => editionFacts(broken), /currentPhase/); + }); + + test("an untitled article still resolves — the title is telemetry, not a gate", () => { + const s = state(); + delete (s.articles["05"] as Record).title; + assert.equal(editionFacts(s).title, ""); + }); +}); + +describe("editionState — phaseLog", () => { + test("appends {phase, completedAt} to the article's log", () => { + const { state: next, appended } = appendPhase(state(), "05", "skeleton-pitch", AT); + assert.equal(appended, true); + assert.deepEqual(next.articles["05"].phaseLog, [{ phase: "skeleton-pitch", completedAt: AT }]); + }); + + test("re-running the same transition does not double-log", () => { + const once = appendPhase(state(), "05", "skeleton-pitch", AT).state; + const { state: twice, appended } = appendPhase(once, "05", "skeleton-pitch", "2026-07-21T10:00:00.000Z"); + assert.equal(appended, false); + assert.equal(twice.articles["05"].phaseLog?.length, 1); + assert.equal(twice.articles["05"].phaseLog?.[0].completedAt, AT, "the first stamp is the real one"); + }); + + test("a phase repeated AFTER another phase is logged again — /linkedin:pivot re-runs cleared gates", () => { + let s = appendPhase(state(), "05", "factcheck-sweep", AT).state; + s = appendPhase(s, "05", "editorial-review", "2026-07-21T11:00:00.000Z").state; + const { state: pivoted, appended } = appendPhase(s, "05", "factcheck-sweep", "2026-07-22T08:00:00.000Z"); + assert.equal(appended, true); + assert.equal(pivoted.articles["05"].phaseLog?.length, 3); + assert.deepEqual( + pivoted.articles["05"].phaseLog?.map((e) => e.phase), + ["factcheck-sweep", "editorial-review", "factcheck-sweep"], + ); + }); + + test("an article written before phaseLog existed gets the field created", () => { + const s = state(); + delete (s.articles["05"] as Record).phaseLog; + const { state: next } = appendPhase(s, "05", "research", AT); + assert.deepEqual(next.articles["05"].phaseLog, [{ phase: "research", completedAt: AT }]); + }); + + test("other articles are left alone", () => { + const next = appendPhase(state(), "05", "research", AT).state; + assert.equal(next.articles["04"].phaseLog?.length, 1); + assert.equal(next.articles["04"].phaseLog?.[0].phase, "scheduling"); + }); + + test("an unknown article is an error, not a silently created one", () => { + assert.throws(() => appendPhase(state(), "99", "research", AT), /99/); + }); +}); + +describe("editionState — io", () => { + test("save then read round-trips, pretty-printed with a trailing newline", () => { + const dir = tmp(); + try { + const path = join(dir, "linkedin", "edition-state.json"); + saveEditionState(path, appendPhase(state(), "05", "research", AT).state); + const raw = readFileSync(path, "utf8"); + assert.ok(raw.endsWith("\n")); + assert.ok(raw.includes('\n "schemaVersion"'), "must stay human-diffable (2-space indent)"); + const back = readEditionState(path); + assert.equal(back.articles["05"].phaseLog?.[0].phase, "research"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("reading a missing state file is an error — the register mirrors state, it never invents it", () => { + const dir = tmp(); + try { + assert.throws(() => readEditionState(join(dir, "absent.json"))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("the series root is derived from the conventional /linkedin/edition-state.json path", () => { + assert.equal(seriesRootFromStatePath("/series/seres/linkedin/edition-state.json"), "/series/seres"); + }); +}); diff --git a/scripts/editions/tests/register.test.ts b/scripts/editions/tests/register.test.ts new file mode 100644 index 0000000..637aec2 --- /dev/null +++ b/scripts/editions/tests/register.test.ts @@ -0,0 +1,277 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir, homedir } from "node:os"; + +import { + REGISTER_SCHEMA_VERSION, + completeEdition, + defaultRegisterPath, + elapsedDays, + emptyRegister, + listEditions, + loadRegister, + saveRegister, + upsertEdition, +} from "../src/register.js"; +import type { EditionRegister } from "../src/types.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "editions-register-")); + +const T0 = "2026-07-20T08:00:00.000Z"; +const T1 = "2026-07-21T08:00:00.000Z"; +const T2 = "2026-07-24T20:00:00.000Z"; + +function seed(): EditionRegister { + return upsertEdition( + emptyRegister(), + { + series: "seres", + editionId: "05", + title: "Da pipelinen brøt sammen", + path: "/series/seres", + currentPhase: "research", + nextAction: "Step 2.5 — skeleton", + }, + T0, + ).register; +} + +describe("register — store shape", () => { + test("emptyRegister carries the schema version and no rows", () => { + const reg = emptyRegister(); + assert.equal(reg.schemaVersion, REGISTER_SCHEMA_VERSION); + assert.deepEqual(reg.editions, []); + }); + + test("a missing register file loads as an empty one — the first edition is not an error", () => { + const dir = tmp(); + try { + const reg = loadRegister(join(dir, "nope", "register.json")); + assert.equal(reg.schemaVersion, REGISTER_SCHEMA_VERSION); + assert.deepEqual(reg.editions, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("save creates the parent directory and round-trips the rows", () => { + const dir = tmp(); + try { + const path = join(dir, "editions", "register.json"); + saveRegister(path, seed()); + assert.ok(existsSync(path), "saveRegister must create the parent dir"); + assert.ok(readFileSync(path, "utf8").endsWith("\n"), "file must end with a newline"); + const back = loadRegister(path); + assert.equal(back.editions.length, 1); + assert.equal(back.editions[0].editionId, "05"); + assert.equal(back.editions[0].startedAt, T0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a malformed register degrades to empty rows instead of throwing", () => { + const dir = tmp(); + try { + const path = join(dir, "register.json"); + writeFileSync(path, JSON.stringify({ schemaVersion: 1, editions: "not-an-array" }), "utf8"); + assert.deepEqual(loadRegister(path).editions, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("register — upsert", () => { + test("a new edition is created as active, with startedAt == updatedAt", () => { + const { register, row, created } = upsertEdition( + emptyRegister(), + { series: "seres", editionId: "05", title: "T", path: "/p", currentPhase: "load-context" }, + T0, + ); + assert.equal(created, true); + assert.equal(register.editions.length, 1); + assert.equal(row.status, "active"); + assert.equal(row.startedAt, T0); + assert.equal(row.updatedAt, T0); + assert.equal(row.completedAt, null); + }); + + test("the same (series, editionId) is updated in place — never duplicated", () => { + const { register, row, created } = upsertEdition( + seed(), + { series: "seres", editionId: "05", title: "T", path: "/p", currentPhase: "skeleton-pitch" }, + T1, + ); + assert.equal(created, false); + assert.equal(register.editions.length, 1); + assert.equal(row.currentPhase, "skeleton-pitch"); + assert.equal(row.startedAt, T0, "startedAt is the lead-time anchor — it must survive every update"); + assert.equal(row.updatedAt, T1); + }); + + test("omitted nextAction/slot/title are PRESERVED, not blanked", () => { + const withSlot = upsertEdition( + seed(), + { series: "seres", editionId: "05", path: "/p", currentPhase: "draft", slot: "2026-07-28 08:30" }, + T1, + ).register; + const { row } = upsertEdition( + withSlot, + { series: "seres", editionId: "05", path: "/p", currentPhase: "consistency-quality" }, + T2, + ); + assert.equal(row.slot, "2026-07-28 08:30"); + assert.equal(row.nextAction, "Step 2.5 — skeleton"); + assert.equal(row.title, "Da pipelinen brøt sammen"); + }); + + test("supplied nextAction/slot overwrite the previous values", () => { + const { row } = upsertEdition( + seed(), + { + series: "seres", + editionId: "05", + path: "/p", + currentPhase: "draft", + nextAction: "Step 4 — consistency", + slot: "2026-07-28 08:30", + }, + T1, + ); + assert.equal(row.nextAction, "Step 4 — consistency"); + assert.equal(row.slot, "2026-07-28 08:30"); + }); + + test("two editions of the same series are two rows", () => { + const { register } = upsertEdition( + seed(), + { series: "seres", editionId: "06", title: "Neste", path: "/series/seres", currentPhase: "load-context" }, + T1, + ); + assert.equal(register.editions.length, 2); + }); + + test("the same edition number in a different series is a different row", () => { + const { register } = upsertEdition( + seed(), + { series: "annen", editionId: "05", title: "Annen 05", path: "/series/annen", currentPhase: "load-context" }, + T1, + ); + assert.equal(register.editions.length, 2); + assert.equal(register.editions[1].series, "annen"); + }); + + test("re-upserting a completed edition reactivates it — a pivot re-opens the row it already has", () => { + const done = completeEdition(seed(), { series: "seres", editionId: "05" }, T1).register; + const { register, row } = upsertEdition( + done, + { series: "seres", editionId: "05", path: "/p", currentPhase: "factcheck-sweep" }, + T2, + ); + assert.equal(register.editions.length, 1); + assert.equal(row.status, "active"); + assert.equal(row.completedAt, null); + assert.equal(row.startedAt, T0); + }); +}); + +describe("register — complete", () => { + test("complete stamps completedAt and flips the status", () => { + const { register, row } = completeEdition(seed(), { series: "seres", editionId: "05" }, T1); + assert.equal(row?.status, "complete"); + assert.equal(row?.completedAt, T1); + assert.equal(register.editions[0].updatedAt, T1); + }); + + test("completing twice keeps the FIRST completedAt — lead time must not drift on a re-run", () => { + const once = completeEdition(seed(), { series: "seres", editionId: "05" }, T1).register; + const { row } = completeEdition(once, { series: "seres", editionId: "05" }, T2); + assert.equal(row?.completedAt, T1); + }); + + test("completing an unknown edition returns null and leaves the register untouched", () => { + const reg = seed(); + const { register, row } = completeEdition(reg, { series: "seres", editionId: "99" }, T1); + assert.equal(row, null); + assert.equal(register.editions[0].status, "active"); + }); +}); + +describe("register — list", () => { + function twoActiveOneDone(): EditionRegister { + let reg = seed(); + reg = upsertEdition( + reg, + { series: "annen", editionId: "02", title: "A", path: "/p", currentPhase: "draft" }, + T1, + ).register; + reg = upsertEdition( + reg, + { series: "seres", editionId: "04", title: "Eldre", path: "/p", currentPhase: "scheduling" }, + T1, + ).register; + return completeEdition(reg, { series: "seres", editionId: "04" }, T2).register; + } + + test("list shows only the editions in flight by default", () => { + const rows = listEditions(twoActiveOneDone()); + assert.equal(rows.length, 2); + assert.ok(rows.every((r) => r.status === "active")); + }); + + test("includeComplete lists the whole history", () => { + assert.equal(listEditions(twoActiveOneDone(), { includeComplete: true }).length, 3); + }); + + test("ordering is deterministic — active first, then series, then edition id", () => { + const rows = listEditions(twoActiveOneDone(), { includeComplete: true }); + assert.deepEqual( + rows.map((r) => `${r.status}:${r.series}/${r.editionId}`), + ["active:annen/02", "active:seres/05", "complete:seres/04"], + ); + }); +}); + +describe("register — lead time", () => { + test("elapsedDays measures the span between two ISO stamps", () => { + assert.equal(elapsedDays(T0, T1), 1); + assert.equal(elapsedDays(T0, T0), 0); + }); + + test("elapsedDays keeps sub-day precision — a two-editions-a-week cadence is measured in hours", () => { + assert.equal(elapsedDays("2026-07-20T00:00:00.000Z", "2026-07-20T12:00:00.000Z"), 0.5); + }); + + test("an unparseable stamp yields null rather than NaN", () => { + assert.equal(elapsedDays("ikke-en-dato", T1), null); + }); +}); + +describe("register — default path (M0 data-path convention)", () => { + test("LINKEDIN_STUDIO_DATA relocates the register", () => { + const prev = process.env.LINKEDIN_STUDIO_DATA; + process.env.LINKEDIN_STUDIO_DATA = "/scratch/data"; + try { + assert.equal(defaultRegisterPath(), join("/scratch/data", "editions", "register.json")); + } finally { + if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = prev; + } + }); + + test("without the env-var it falls back to the per-user data dir", () => { + const prev = process.env.LINKEDIN_STUDIO_DATA; + delete process.env.LINKEDIN_STUDIO_DATA; + try { + assert.equal( + defaultRegisterPath(), + join(homedir(), ".claude", "linkedin-studio", "editions", "register.json"), + ); + } finally { + if (prev !== undefined) process.env.LINKEDIN_STUDIO_DATA = prev; + } + }); +}); diff --git a/scripts/editions/tsconfig.json b/scripts/editions/tsconfig.json new file mode 100644 index 0000000..eaa1e8e --- /dev/null +++ b/scripts/editions/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "tests"] +} diff --git a/scripts/specifics-bank/src/bank.ts b/scripts/specifics-bank/src/bank.ts index 5163a34..38060ff 100644 --- a/scripts/specifics-bank/src/bank.ts +++ b/scripts/specifics-bank/src/bank.ts @@ -142,6 +142,56 @@ export function queryByTopic(bank: Bank, tags: string[]): QueryHit[] { return hits; } +/** What a caller supplies to recordUsage. */ +export interface UsageInput { + /** Bank ids the edition actually consumed — see `boundSpecificIds` in binding.ts. */ + ids: string[]; + /** The edition the material was published in, e.g. "seres/05". */ + editionId: string; +} + +export interface UsageResult { + bank: Bank; + /** ids that gained this edition now. */ + recorded: string[]; + /** ids already carrying it — a re-lock after a pivot is idempotent. */ + alreadyRecorded: string[]; + /** ids absent from the bank; the caller decides how loud to be. */ + unknown: string[]; +} + +/** + * Stamp "used in edition NN" on the specifics an edition consumed. Called at + * LOCK (Step 8) rather than at binding (Step 2.5), so an abandoned draft never + * pollutes the history — `usedIn` means published, not considered. + * Archived material still records: the use is historical fact. + */ +export function recordUsage(bank: Bank, input: UsageInput): UsageResult { + const editionId = input.editionId.trim(); + if (editionId.length === 0) { + throw new Error("recordUsage: editionId must be non-blank — an unattributable use is worse than none"); + } + const byId = new Map(bank.specifics.map((s) => [s.id, s])); + const recorded: string[] = []; + const alreadyRecorded: string[] = []; + const unknown: string[] = []; + for (const id of input.ids) { + const specific = byId.get(id); + if (!specific) { + unknown.push(id); + continue; + } + const usedIn = specific.usedIn ?? []; + if (usedIn.includes(editionId)) { + alreadyRecorded.push(id); + continue; + } + specific.usedIn = [...usedIn, editionId]; + recorded.push(id); + } + return { bank, recorded, alreadyRecorded, unknown }; +} + /** * Default bank path under the per-user data dir (M0 data-path convention), so the * bank survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` overrides the diff --git a/scripts/specifics-bank/src/binding.ts b/scripts/specifics-bank/src/binding.ts index f6d10dc..751d3ac 100644 --- a/scripts/specifics-bank/src/binding.ts +++ b/scripts/specifics-bank/src/binding.ts @@ -154,3 +154,21 @@ export function validateBinding(ls: LivedSpecifics, bank: Bank): BindingResult { coverage, }; } + +/** + * The bank ids an edition's slot-map actually consumed — slot order, deduped. + * Pure. This is the input to `recordUsage` at lock: only `specific` bindings + * count as consumption (abstrakt/ekstern/unresolved consume no bank material). + */ +export function boundSpecificIds(ls: LivedSpecifics): string[] { + const seen = new Set(); + const ids: string[] = []; + for (const slot of ls.slots) { + if (slot.binding.type !== "specific") continue; + const { specificId } = slot.binding; + if (seen.has(specificId)) continue; + seen.add(specificId); + ids.push(specificId); + } + return ids; +} diff --git a/scripts/specifics-bank/src/cli.ts b/scripts/specifics-bank/src/cli.ts index 6ade4ca..96e6e5e 100644 --- a/scripts/specifics-bank/src/cli.ts +++ b/scripts/specifics-bank/src/cli.ts @@ -21,9 +21,10 @@ import { defaultBankPath, loadBank, queryByTopic, + recordUsage, saveBank, } from "./bank.js"; -import { validateBinding } from "./binding.js"; +import { boundSpecificIds, validateBinding } from "./binding.js"; import { renderKilder } from "./kilder.js"; import type { LivedSpecifics } from "./binding.js"; import type { SpecificType, Verification } from "./types.js"; @@ -72,7 +73,8 @@ function usage(msg: string): never { ' add --type --content "" --tags [--source ] [--verification ] [--bank ]\n' + " list [--bank ] [--json]\n" + " validate-binding --edition [--article ] [--bank ] [--json]\n" + - " render-kilder --edition [--article ] [--bank ] [--out ]", + " render-kilder --edition [--article ] [--bank ] [--out ]\n" + + " record-usage --edition --edition-id [--article ] [--bank ] [--json]", ); process.exit(2); } @@ -213,6 +215,32 @@ function main(): void { return; } + if (command === "record-usage") { + if (!flags.edition || flags.edition === "true") usage("record-usage needs --edition "); + const editionId = flags["edition-id"]; + if (!editionId || editionId === "true") { + usage("record-usage needs --edition-id — an unattributable use is worse than none"); + } + const edition = loadEdition(flags.edition); + const { id, article } = resolveArticle(edition, flags.article); + const ids = boundSpecificIds(article.livedSpecifics ?? EMPTY_LS); + const res = recordUsage(loadBank(bankPath), { ids, editionId }); + saveBank(bankPath, res.bank); + + if (asJson) { + console.log(JSON.stringify({ article: id, editionId, ...res, bank: undefined }, null, 2)); + return; + } + console.log( + `Usage recorded for article ${id} as "${editionId}": ` + + `${res.recorded.length} stamped, ${res.alreadyRecorded.length} already logged.`, + ); + if (res.unknown.length > 0) { + console.log(` ⚠ ${res.unknown.length} bound id(s) absent from the bank: ${res.unknown.join(", ")}`); + } + return; + } + usage(command ? `unknown command: ${command}` : "no command given"); } diff --git a/scripts/specifics-bank/src/types.ts b/scripts/specifics-bank/src/types.ts index 15a5f40..3a1bc14 100644 --- a/scripts/specifics-bank/src/types.ts +++ b/scripts/specifics-bank/src/types.ts @@ -55,6 +55,14 @@ export interface Specific { /** Numbers must be fact-checked before a draft asserts them as fact (regel 6/7). */ verification: Verification; status: SpecificStatus; + /** + * Editions this material was actually published in ("seres/05"), appended at + * lock. Additive-optional: absent means never used, so existing banks load + * unchanged and the schema stays v1. The dedupe in `addSpecific` deliberately + * ENCOURAGES re-surfacing the same material; this field is what makes the + * re-use visible to the operator at Step 1.5 instead of silent (N11 / C-5). + */ + usedIn?: string[]; } export interface Bank { diff --git a/scripts/specifics-bank/tests/cli-usage.test.ts b/scripts/specifics-bank/tests/cli-usage.test.ts new file mode 100644 index 0000000..5d64b8f --- /dev/null +++ b/scripts/specifics-bank/tests/cli-usage.test.ts @@ -0,0 +1,142 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { addSpecific, emptyBank, specificId, saveBank } from "../src/bank.js"; + +// Resolve the package root so the subprocess `src/cli.ts` path + the `tsx` loader +// resolve regardless of the runner's cwd. +const bankDir = fileURLToPath(new URL("..", import.meta.url)); + +function run(args: string[]): { status: number | null; stdout: string; stderr: string } { + const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], { + encoding: "utf8", + cwd: bankDir, + }); + return { status: res.status, stdout: res.stdout, stderr: res.stderr }; +} + +const tmp = () => mkdtempSync(join(tmpdir(), "specifics-cli-usage-")); + +const CONTENT = "Vi målte 40 prosent kortere ledetid etter omleggingen."; + +/** A bank + an edition-state whose slot-map binds one slot to the bank specific. */ +function fixture(dir: string): { bankPath: string; editionPath: string; id: string } { + const bankPath = join(dir, "specifics-bank.json"); + const id = specificId(CONTENT); + const bank = addSpecific(emptyBank(), { + type: "number", + content: CONTENT, + topicTags: ["ai"], + provenance: { capturedAt: "2026-06-20", source: "test" }, + }).bank; + saveBank(bankPath, bank); + + const editionPath = join(dir, "edition-state.json"); + writeFileSync( + editionPath, + JSON.stringify({ + currentArticle: "05", + articles: { + "05": { + title: "Da pipelinen brøt sammen", + livedSpecifics: { + status: "bound", + slots: [ + { slotId: "kp1", kind: "key-point", label: "ledetid", binding: { type: "specific", specificId: id } }, + { slotId: "kp2", kind: "key-point", label: "annet", binding: { type: "abstrakt", rationale: "bevisst" } }, + ], + }, + }, + }, + }), + "utf8", + ); + return { bankPath, editionPath, id }; +} + +describe("specifics-bank CLI — record-usage (Step 8 lock)", () => { + test("stamps the edition on the bound specifics and persists it", () => { + const dir = tmp(); + try { + const { bankPath, editionPath, id } = fixture(dir); + + const res = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath]); + assert.equal(res.status, 0, res.stderr); + + const onDisk = JSON.parse(readFileSync(bankPath, "utf8")); + const stamped = onDisk.specifics.find((s: { id: string }) => s.id === id); + assert.deepEqual(stamped.usedIn, ["seres/05"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("only specific-bound slots are stamped — abstrakt/ekstern consume no bank material", () => { + const dir = tmp(); + try { + const { bankPath, editionPath } = fixture(dir); + const res = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath, "--json"]); + assert.equal(res.status, 0, res.stderr); + + const out = JSON.parse(res.stdout); + assert.equal(out.recorded.length, 1); + assert.deepEqual(out.unknown, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("re-running after a pivot re-lock is idempotent", () => { + const dir = tmp(); + try { + const { bankPath, editionPath, id } = fixture(dir); + run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath]); + const again = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath, "--json"]); + assert.equal(again.status, 0, again.stderr); + + const out = JSON.parse(again.stdout); + assert.deepEqual(out.recorded, []); + assert.equal(out.alreadyRecorded.length, 1); + + const onDisk = JSON.parse(readFileSync(bankPath, "utf8")); + const stamped = onDisk.specifics.find((s: { id: string }) => s.id === id); + assert.deepEqual(stamped.usedIn, ["seres/05"], "a re-lock must not double-stamp"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("an edition with no bound specifics is a no-op, exit 0", () => { + const dir = tmp(); + try { + const { bankPath } = fixture(dir); + const empty = join(dir, "empty-edition.json"); + writeFileSync(empty, JSON.stringify({ currentArticle: "06", articles: { "06": {} } }), "utf8"); + + const res = run(["record-usage", "--edition", empty, "--edition-id", "seres/06", "--bank", bankPath, "--json"]); + assert.equal(res.status, 0, res.stderr); + assert.deepEqual(JSON.parse(res.stdout).recorded, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("bad invocations exit 2", () => { + const dir = tmp(); + try { + const { bankPath, editionPath } = fixture(dir); + assert.equal(run(["record-usage", "--bank", bankPath]).status, 2); // no --edition + assert.equal( + run(["record-usage", "--edition", editionPath, "--bank", bankPath]).status, + 2, // no --edition-id: an unattributable use is worse than none + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/specifics-bank/tests/usage.test.ts b/scripts/specifics-bank/tests/usage.test.ts new file mode 100644 index 0000000..6c8327f --- /dev/null +++ b/scripts/specifics-bank/tests/usage.test.ts @@ -0,0 +1,157 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { addSpecific, emptyBank, loadBank, recordUsage, saveBank, specificId } from "../src/bank.js"; +import { boundSpecificIds } from "../src/binding.js"; +import type { LivedSpecifics } from "../src/binding.js"; +import type { Bank } from "../src/types.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "specifics-usage-")); + +const CONTENT = { + a: "Vi målte 40 prosent kortere ledetid etter omleggingen.", + b: "Kunden avlyste pilotene fordi ingen eide dataene.", +}; + +function bankWith(...contents: string[]): Bank { + let bank = emptyBank(); + for (const content of contents) { + bank = addSpecific(bank, { + type: "number", + content, + topicTags: ["ai"], + provenance: { capturedAt: "2026-06-20", source: "test" }, + }).bank; + } + return bank; +} + +describe("specifics-bank — usage logging (N11: «used in edition NN»)", () => { + test("recording usage stamps the edition on the specific", () => { + const bank = bankWith(CONTENT.a); + const id = specificId(CONTENT.a); + + const res = recordUsage(bank, { ids: [id], editionId: "seres/05" }); + + assert.deepEqual(res.recorded, [id]); + assert.deepEqual(res.alreadyRecorded, []); + assert.deepEqual(res.unknown, []); + assert.deepEqual(res.bank.specifics[0].usedIn, ["seres/05"]); + }); + + test("recording the same edition twice is idempotent (a re-lock does not double-count)", () => { + const bank = bankWith(CONTENT.a); + const id = specificId(CONTENT.a); + + const once = recordUsage(bank, { ids: [id], editionId: "seres/05" }); + const twice = recordUsage(once.bank, { ids: [id], editionId: "seres/05" }); + + assert.deepEqual(twice.recorded, []); + assert.deepEqual(twice.alreadyRecorded, [id]); + assert.deepEqual(twice.bank.specifics[0].usedIn, ["seres/05"]); + }); + + test("a second edition appends, preserving order", () => { + const bank = bankWith(CONTENT.a); + const id = specificId(CONTENT.a); + + const first = recordUsage(bank, { ids: [id], editionId: "seres/05" }); + const second = recordUsage(first.bank, { ids: [id], editionId: "seres/06" }); + + assert.deepEqual(second.bank.specifics[0].usedIn, ["seres/05", "seres/06"]); + }); + + test("an id absent from the bank is reported as unknown, not silently dropped", () => { + const bank = bankWith(CONTENT.a); + const res = recordUsage(bank, { ids: [specificId(CONTENT.a), "deadbeef1234"], editionId: "seres/05" }); + + assert.deepEqual(res.unknown, ["deadbeef1234"]); + assert.deepEqual(res.recorded, [specificId(CONTENT.a)]); + }); + + test("archived material still records use — the use is historical fact", () => { + let bank = emptyBank(); + bank = addSpecific(bank, { + type: "named-case", + content: CONTENT.b, + topicTags: ["ai"], + provenance: { capturedAt: "2026-06-20", source: "test" }, + status: "archived", + }).bank; + + const res = recordUsage(bank, { ids: [specificId(CONTENT.b)], editionId: "seres/05" }); + assert.deepEqual(res.recorded, [specificId(CONTENT.b)]); + assert.deepEqual(res.bank.specifics[0].usedIn, ["seres/05"]); + }); + + test("recording touches only the named specifics", () => { + const bank = bankWith(CONTENT.a, CONTENT.b); + const res = recordUsage(bank, { ids: [specificId(CONTENT.a)], editionId: "seres/05" }); + + assert.deepEqual(res.bank.specifics[0].usedIn, ["seres/05"]); + assert.equal(res.bank.specifics[1].usedIn, undefined); + }); + + test("an empty id list is a no-op, not an error", () => { + const bank = bankWith(CONTENT.a); + const res = recordUsage(bank, { ids: [], editionId: "seres/05" }); + assert.deepEqual(res.recorded, []); + assert.equal(res.bank.specifics[0].usedIn, undefined); + }); + + test("a blank edition id throws — an unattributable use is worse than none", () => { + const bank = bankWith(CONTENT.a); + assert.throws(() => recordUsage(bank, { ids: [specificId(CONTENT.a)], editionId: " " }), /editionId/); + }); + + test("usedIn survives a save/load round-trip; an untouched bank stays field-free", () => { + const dir = tmp(); + try { + const path = join(dir, "specifics-bank.json"); + const bank = bankWith(CONTENT.a, CONTENT.b); + const res = recordUsage(bank, { ids: [specificId(CONTENT.a)], editionId: "seres/05" }); + saveBank(path, res.bank); + + const back = loadBank(path); + assert.deepEqual(back.specifics[0].usedIn, ["seres/05"]); + // additive-optional: never-used material carries no field at all (schema stays v1) + assert.equal("usedIn" in back.specifics[1], false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("specifics-bank — boundSpecificIds (what an edition actually consumed)", () => { + const ls = (slots: LivedSpecifics["slots"]): LivedSpecifics => ({ slots, status: "bound" }); + + test("extracts the ids of specific-bound slots only", () => { + const res = boundSpecificIds( + ls([ + { slotId: "kp1", kind: "key-point", label: "a", binding: { type: "specific", specificId: "aaa111" } }, + { slotId: "kp2", kind: "key-point", label: "b", binding: { type: "abstrakt", rationale: "bevisst" } }, + { slotId: "kp3", kind: "section", label: "c", binding: { type: "ekstern", source: "https://x" } }, + { slotId: "kp4", kind: "section", label: "d", binding: { type: "unresolved" } }, + ]), + ); + assert.deepEqual(res, ["aaa111"]); + }); + + test("dedupes an id bound in several slots", () => { + const res = boundSpecificIds( + ls([ + { slotId: "kp1", kind: "key-point", label: "a", binding: { type: "specific", specificId: "aaa111" } }, + { slotId: "kp2", kind: "section", label: "b", binding: { type: "specific", specificId: "aaa111" } }, + { slotId: "kp3", kind: "section", label: "c", binding: { type: "specific", specificId: "bbb222" } }, + ]), + ); + assert.deepEqual(res, ["aaa111", "bbb222"]); + }); + + test("an empty slot map yields no ids", () => { + assert.deepEqual(boundSpecificIds(ls([])), []); + }); +}); diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index 0079d24..bee3bd7 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -46,8 +46,34 @@ # trends-brief wiring guard (RE-R2b: scripts/trends/src/cli.ts dispatches `brief`, # agents/trend-spotter.md references the brief CLI 'src/cli.ts brief', AND # hooks/scripts/session-start.mjs surfaces it via 'latestMorningBrief', with a non-vacuity -# self-test) in Section 16i; the assertion-count anti-erosion floor (SC6) in Section 18. All -# are live below (Sections 8–18). +# self-test) in Section 16i; the trends-score wiring guard (RE-R3a: scripts/trends/src/score.ts +# exports the 'export interface TrendScore' persist envelope, scripts/trends/src/types.ts carries +# 'score?: TrendScore' on the record, agents/trend-spotter.md carries the judgment via '"dimensions"', +# AND scripts/trends/src/brief.ts ranks on 'score?.composite', with a non-vacuity self-test) in +# Section 16j; the trends-lifecycle wiring guard (RE-R3b: scripts/trends/src/types.ts declares +# 'export type TrendStatus' AND carries 'surfacedCount', scripts/trends/src/store.ts owns +# 'export function markSurfaced', scripts/trends/src/brief.ts excludes handled via 'effectiveStatus', +# AND scripts/trends/src/cli.ts exposes 'command === "act"', with a non-vacuity self-test) in +# Section 16k; the trends-scheduler/headless wiring guard (RE-R3c: scripts/trends/src/schedule.ts +# emits 'export function launchdPlist' AND 'export function crontabLine', scripts/trends/src/cli.ts +# exposes 'command === "schedule"', scripts/trends/run-daily.sh runs 'cli.ts" brief' AND uses +# 'LINKEDIN_STUDIO_DATA:-', with a non-vacuity self-test) in Section 16l; the trends-temporal-overlay +# wiring guard (RE-R3d: scripts/trends/src/brief.ts has 'export function temporalSignal' AND the cmp +# key 'b.temporal.rank' AND the '"first-mover"' tier, scripts/trends/src/cli.ts exposes the +# 'first-mover-days' AND 'saturation-at' brief flags, with a non-vacuity self-test) in Section 16m; +# the trends-brief-history/diff wiring guard (RE-R3e: scripts/trends/src/brief.ts has +# 'export function diffSurfaced' AND 'parseSurfacedFrontmatter' AND the 'Nytt siden sist' section AND +# the 'surfaced: ' frontmatter emit, scripts/trends/src/cli.ts wires 'selectPriorBriefFile', with a +# non-vacuity self-test) in Section 16n; the randsone-gates guard (N9: newsletter.md Step 9 runs a scoped +# fact-reviewer + language-reviewer pass over the carousel slide text AND edition title, gates title-strength, +# declares the deterministic 'numbers, names, or quotes' recheck trigger, and both fact agents carry WebFetch, +# with a non-vacuity self-test) in Section 16p; the configurable target-level span guard (N10: the hardcoded +# 'non-technical line manager' is gone from references/ + config/, references/longform-quality-rules.md names +# the 'target-level span', config/personas.template.md requires a mandatory secondary at the opposite end + +# an explicit ceiling justification + seeds a technical-end persona, config/edition-state.template.json carries +# the additive articles.NN.targetLevel slot, commands/newsletter.md persists it, agents/persona-reviewer.md +# consumes it onto the span, with a non-vacuity self-test) in Section 16q; the assertion-count anti-erosion +# floor (SC6) in Section 18. All are live below (Sections 8–18). # # Usage: bash scripts/test-runner.sh # bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`. @@ -72,9 +98,9 @@ warn() { echo -e "${YELLOW}⚠${NC} $1"; WARN=$((WARN + 1)); } # --- Declared registration contract (the "Telling" block) --- # Source of truth: CLAUDE.md headers + STATE.md Telling. Bump these together # with the files when adding/removing an agent, command, reference, or skill. -EXPECT_AGENTS=19 -EXPECT_COMMANDS=29 -EXPECT_REFS=27 +EXPECT_AGENTS=20 +EXPECT_COMMANDS=30 +EXPECT_REFS=29 EXPECT_SKILLS=6 # Pre-M0 references/ baseline was 25. Every ref doc added since is NAMED below, so the # count bump always maps to an intended, named addition — never an incidental doc masked @@ -83,7 +109,11 @@ EXPECT_SKILLS=6 # AND that every named doc actually exists. bash 3.2-safe: plain indexed array. REFS_BASELINE_PRE_M0=25 M0_REF="references/data-path-convention.md" -POSTM0_REFS=("references/trend-scoring-modes.md") # research-engine slice 2a (scoring SSOT) +POSTM0_REFS=( + "references/trend-scoring-modes.md" # research-engine slice 2a (scoring SSOT) + "references/figure-design-guidelines.md" # MR-F4 N3 (coded-figure design rules + token convention) + "references/fold-in-loop.md" # N14 A2-F9 (capture->classify->promote->enforce, domain-general) +) echo "================================================" echo "LinkedIn Studio Plugin — Structure Validator" @@ -669,7 +699,7 @@ if [ -x "$SB_DIR/node_modules/.bin/tsx" ]; then SB_OUT=$( set +e; (cd "$SB_DIR" && npm test) 2>&1; echo "SB_EXIT:$?" ) SB_EXIT=$(echo "$SB_OUT" | grep -oE 'SB_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) SB_TESTS=$(echo "$SB_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) - SPECIFICS_BANK_TESTS_FLOOR=28 + SPECIFICS_BANK_TESTS_FLOOR=45 if [ "$SB_EXIT" = "0" ] && [ -n "$SB_TESTS" ] && [ "$SB_TESTS" -ge "$SPECIFICS_BANK_TESTS_FLOOR" ]; then pass "specifics-bank suite green: $SB_TESTS tests pass (floor $SPECIFICS_BANK_TESTS_FLOOR)" else @@ -681,6 +711,30 @@ fi echo "" +# --- Section 15b: Series-Distillate Suite (N11 / C-5) --- +echo "--- Series Distillate ---" + +# The series distillate (scripts/editions) is wired into /linkedin:newsletter as the Step 2.5 +# destillat-sjekk and the Step 8 lock write. Same discipline as Section 15: the suite belongs in +# CI (store + similarity + skeleton-check stay green, case count never erodes), and an adopter +# without deps warn-skips rather than failing. Same set +e / subshell discipline (bash 3.2-safe). +ED_DIR="scripts/editions" +if [ -x "$ED_DIR/node_modules/.bin/tsx" ]; then + ED_OUT=$( set +e; (cd "$ED_DIR" && npm test) 2>&1; echo "ED_EXIT:$?" ) + ED_EXIT=$(echo "$ED_OUT" | grep -oE 'ED_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) + ED_TESTS=$(echo "$ED_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) + EDITIONS_TESTS_FLOOR=72 + if [ "$ED_EXIT" = "0" ] && [ -n "$ED_TESTS" ] && [ "$ED_TESTS" -ge "$EDITIONS_TESTS_FLOOR" ]; then + pass "editions suite green: $ED_TESTS tests pass (floor $EDITIONS_TESTS_FLOOR)" + else + fail "editions suite NOT green (exit=${ED_EXIT:-?}, tests=${ED_TESTS:-?}, floor $EDITIONS_TESTS_FLOOR) — run: (cd $ED_DIR && npm test)" + fi +else + warn "editions skipped — deps absent ($ED_DIR/node_modules); run: (cd $ED_DIR && npm install)" +fi + +echo "" + # --- Section 16: Trends-Store Binding (research-engine slice 2b) --- echo "--- Trends-Store Binding ---" @@ -698,7 +752,7 @@ if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" ) TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) - TRENDS_TESTS_FLOOR=104 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + TRENDS_TESTS_FLOOR=245 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log) + RE-R3c: schedule +9, cli +8, run-daily +4 (scheduler + headless wrapper) + RE-R3d: brief +21, cli +3 (temporal overlay: first-mover + saturation) + RE-R3e: brief +27, cli +2 (brief history + diff) if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)" else @@ -1170,6 +1224,1491 @@ fi echo "" +# --- Section 16j: Trends Score Wiring (research-engine RE-R3a) --- +echo "--- Trends Score Wiring ---" + +# RE-R3a persists the relevance score the trend-spotter agent already computes and ranks the +# morning brief on its composite. Four literals must hold, grepped EXACT (grep -F), +# deps-absent-safe (pure grep, no tsx): +# (1) score.ts exports the persist envelope, by the literal 'export interface TrendScore'; +# (2) types.ts carries it on the record, by the literal 'score?: TrendScore'; +# (3) agents/trend-spotter.md carries the judgment in the capture batch, by 'dimensions' +# (verified absent pre-R3a -> the grep is non-vacuous); +# (4) brief.ts ranks on the composite, by the literal 'score?.composite' (the payoff is wired, +# not merely doc'd). +# Non-vacuity self-test mirrors Section 16i: the rank predicate must accept a probe carrying the +# composite-rank literal and reject one without it. Placed after Section 16i / before Section 18 +# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts +# toward ASSERT_BASELINE_FLOOR. +SCORE_IFACE_LIT='export interface TrendScore' +SCORE_TYPE_LIT='score?: TrendScore' +SCORE_DIMS_LIT='"dimensions"' +SCORE_RANK_LIT='score?.composite' + +I16J_SELFTEST_OK=1 +if ! echo 'rankForBrief sorts on (b.trend.score?.composite ?? -1) first' | grep -qF "$SCORE_RANK_LIT"; then + I16J_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired composite-rank probe was not detected" +fi +if echo 'the brief ranks on pillar overlap only' | grep -qF "$SCORE_RANK_LIT"; then + I16J_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the composite-rank pointer" +fi +if [ "$I16J_SELFTEST_OK" -eq 1 ]; then + pass "trends-score self-test: composite-rank predicate detects wiring, rejects the under-wired form" +else + fail "trends-score self-test failed — the score-wiring lint is vacuous or over-eager" +fi + +if grep -qF "$SCORE_IFACE_LIT" scripts/trends/src/score.ts; then + pass "score.ts exports the persist envelope ('$SCORE_IFACE_LIT')" +else + fail "score.ts has no TrendScore envelope — add '$SCORE_IFACE_LIT' (RE-R3a persist)" +fi + +if grep -qF "$SCORE_TYPE_LIT" scripts/trends/src/types.ts; then + pass "types.ts carries the score on the record ('$SCORE_TYPE_LIT')" +else + fail "types.ts does not carry the score — add '$SCORE_TYPE_LIT' to TrendRecord (RE-R3a schema v3)" +fi + +if grep -qF "$SCORE_DIMS_LIT" agents/trend-spotter.md; then + pass "trend-spotter.md carries the judgment in the capture batch ($SCORE_DIMS_LIT)" +else + fail "trend-spotter.md does not carry the judgment — add the per-item $SCORE_DIMS_LIT to Step 4.5 (RE-R3a wiring)" +fi + +if grep -qF "$SCORE_RANK_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts ranks on the composite ('$SCORE_RANK_LIT')" +else + fail "brief.ts does not rank on the composite — add the '$SCORE_RANK_LIT' comparator term (RE-R3a payoff)" +fi + +echo "" + +# --- Section 16k: Trends Lifecycle Wiring (research-engine RE-R3b) --- +echo "--- Trends Lifecycle Wiring ---" + +# RE-R3b adds the trend lifecycle: re-score on re-capture, a status (new/acted/skipped) the brief +# EXCLUDES, and a seen-log (surfacedCount/lastSurfacedAt) the brief records. Five literals must hold, +# grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx): +# (1) types.ts declares the status type, by 'export type TrendStatus'; +# (2) types.ts carries the seen-log field, by 'surfacedCount'; +# (3) store.ts owns the seen-log writer, by 'export function markSurfaced'; +# (4) brief.ts excludes handled trends, by 'effectiveStatus' (the filter is wired, not doc'd); +# (5) cli.ts exposes the lifecycle verb, by 'command === "act"'. +# Non-vacuity self-test mirrors Section 16j: the brief-filter predicate must accept a probe carrying +# the effectiveStatus pointer and reject one without it. Placed after Section 16j / before Section 18 +# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward +# ASSERT_BASELINE_FLOOR. +LIFECYCLE_STATUS_LIT='export type TrendStatus' +LIFECYCLE_SEEN_LIT='surfacedCount' +LIFECYCLE_MARK_LIT='export function markSurfaced' +LIFECYCLE_FILTER_LIT='effectiveStatus' +LIFECYCLE_VERB_LIT='command === "act"' + +I16K_SELFTEST_OK=1 +if ! echo 'rankForBrief drops a trend when effectiveStatus(trend) !== "new"' | grep -qF "$LIFECYCLE_FILTER_LIT"; then + I16K_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired status-filter probe was not detected" +fi +if echo 'the brief shows every record regardless of status' | grep -qF "$LIFECYCLE_FILTER_LIT"; then + I16K_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the status-filter pointer" +fi +if [ "$I16K_SELFTEST_OK" -eq 1 ]; then + pass "trends-lifecycle self-test: status-filter predicate detects wiring, rejects the unfiltered form" +else + fail "trends-lifecycle self-test failed — the lifecycle-wiring lint is vacuous or over-eager" +fi + +if grep -qF "$LIFECYCLE_STATUS_LIT" scripts/trends/src/types.ts; then + pass "types.ts declares the lifecycle status type ('$LIFECYCLE_STATUS_LIT')" +else + fail "types.ts has no TrendStatus — add '$LIFECYCLE_STATUS_LIT' (RE-R3b lifecycle)" +fi + +if grep -qF "$LIFECYCLE_SEEN_LIT" scripts/trends/src/types.ts; then + pass "types.ts carries the seen-log field ('$LIFECYCLE_SEEN_LIT')" +else + fail "types.ts does not carry the seen-log — add '$LIFECYCLE_SEEN_LIT' to TrendRecord (RE-R3b schema v4)" +fi + +if grep -qF "$LIFECYCLE_MARK_LIT" scripts/trends/src/store.ts; then + pass "store.ts owns the seen-log writer ('$LIFECYCLE_MARK_LIT')" +else + fail "store.ts has no markSurfaced — add '$LIFECYCLE_MARK_LIT' (RE-R3b seen-log)" +fi + +if grep -qF "$LIFECYCLE_FILTER_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts excludes handled trends ('$LIFECYCLE_FILTER_LIT')" +else + fail "brief.ts does not exclude handled trends — wire '$LIFECYCLE_FILTER_LIT' into rankForBrief (RE-R3b)" +fi + +if grep -qF "$LIFECYCLE_VERB_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the lifecycle verb ('$LIFECYCLE_VERB_LIT')" +else + fail "cli.ts has no act/skip/reset — add '$LIFECYCLE_VERB_LIT' (RE-R3b lifecycle verbs)" +fi + +echo "" + +# --- Section 16l: Trends Scheduler / Headless Wiring (research-engine RE-R3c) --- +echo "--- Trends Scheduler / Headless Wiring ---" + +# RE-R3c adds the autonomous trigger + headless entry: a `schedule` CLI verb emitting a launchd plist / +# cron line (print-first), and a bash wrapper that runs the DETERMINISTIC brief headless. Five literals +# must hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx): +# (1) schedule.ts emits the launchd plist, by 'export function launchdPlist'; +# (2) schedule.ts emits the cron line, by 'export function crontabLine'; +# (3) cli.ts exposes the schedule verb, by 'command === "schedule"'; +# (4) run-daily.sh runs the deterministic brief, by 'cli.ts" brief' (the wrapper owns the subcommand); +# (5) run-daily.sh uses the data-path twin seam, by 'LINKEDIN_STUDIO_DATA:-'. +# Non-vacuity self-test mirrors Section 16k: a probe carrying the launchdPlist pointer is accepted and +# one without it rejected. Placed after Section 16k / before Section 18 (anti-erosion must run last so it +# sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +SCHED_PLIST_LIT='export function launchdPlist' +SCHED_CRON_LIT='export function crontabLine' +SCHED_VERB_LIT='command === "schedule"' +SCHED_BRIEF_LIT='cli.ts" brief' +SCHED_DATA_LIT='LINKEDIN_STUDIO_DATA:-' + +I16L_SELFTEST_OK=1 +if ! echo 'a wired scheduler declares: export function launchdPlist(spec)' | grep -qF "$SCHED_PLIST_LIT"; then + I16L_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired scheduler-emit probe was not detected" +fi +if echo 'an unwired module emits no artifact at all' | grep -qF "$SCHED_PLIST_LIT"; then + I16L_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the scheduler-emit pointer" +fi +if [ "$I16L_SELFTEST_OK" -eq 1 ]; then + pass "trends-scheduler self-test: the emit pointer is detected, the no-emit form rejected" +else + fail "trends-scheduler self-test failed — the scheduler-wiring lint is vacuous or over-eager" +fi + +if grep -qF "$SCHED_PLIST_LIT" scripts/trends/src/schedule.ts; then + pass "schedule.ts emits the launchd plist ('$SCHED_PLIST_LIT')" +else + fail "schedule.ts has no launchd emitter — add '$SCHED_PLIST_LIT' (RE-R3c scheduler)" +fi + +if grep -qF "$SCHED_CRON_LIT" scripts/trends/src/schedule.ts; then + pass "schedule.ts emits the cron line ('$SCHED_CRON_LIT')" +else + fail "schedule.ts has no cron-line emitter — add '$SCHED_CRON_LIT' (RE-R3c scheduler)" +fi + +if grep -qF "$SCHED_VERB_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the schedule verb ('$SCHED_VERB_LIT')" +else + fail "cli.ts has no schedule verb — add '$SCHED_VERB_LIT' (RE-R3c trigger)" +fi + +if grep -qF "$SCHED_BRIEF_LIT" scripts/trends/run-daily.sh; then + pass "run-daily.sh runs the deterministic brief ('$SCHED_BRIEF_LIT')" +else + fail "run-daily.sh does not run the brief — wire '$SCHED_BRIEF_LIT' (RE-R3c headless entry)" +fi + +if grep -qF "$SCHED_DATA_LIT" scripts/trends/run-daily.sh; then + pass "run-daily.sh uses the data-path twin seam ('$SCHED_DATA_LIT')" +else + fail "run-daily.sh has no data-path seam — add '$SCHED_DATA_LIT' (RE-R3c fourth twin)" +fi + +echo "" + +# --- Section 16m: Trends Temporal Overlay (research-engine RE-R3d) --- +echo "--- Trends Temporal Overlay ---" + +# RE-R3d adds the DERIVED temporal overlay (first-mover + saturation) to the morning-brief ranking: +# a pure temporalSignal in brief.ts, a new cmp key, and two tunable CLI flags. Five literals must +# hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx); ASCII-only (bash 3.2 set -u): +# (1) brief.ts exports the signal, by 'export function temporalSignal'; +# (2) brief.ts ranks on it, by 'b.temporal.rank' (the cmp key); +# (3) brief.ts declares the first-mover tier, by '"first-mover"'; +# (4) cli.ts exposes the first-mover threshold flag, by 'first-mover-days'; +# (5) cli.ts exposes the saturation threshold flag, by 'saturation-at'. +# Non-vacuity self-test mirrors Section 16l. Placed after Section 16l / before Section 18 (anti-erosion +# must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +TEMP_SIGNAL_LIT='export function temporalSignal' +TEMP_RANK_LIT='b.temporal.rank' +TEMP_TIER_LIT='"first-mover"' +TEMP_FMDAYS_LIT='first-mover-days' +TEMP_SATAT_LIT='saturation-at' + +I16M_SELFTEST_OK=1 +if ! echo 'a wired overlay declares: export function temporalSignal(ageDays)' | grep -qF "$TEMP_SIGNAL_LIT"; then + I16M_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired temporal-overlay probe was not detected" +fi +if echo 'an unwired module derives no temporal signal at all' | grep -qF "$TEMP_SIGNAL_LIT"; then + I16M_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the temporal-overlay pointer" +fi +if [ "$I16M_SELFTEST_OK" -eq 1 ]; then + pass "trends-temporal self-test: the signal pointer is detected, the no-signal form rejected" +else + fail "trends-temporal self-test failed — the temporal-overlay lint is vacuous or over-eager" +fi + +if grep -qF "$TEMP_SIGNAL_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts derives the temporal signal ('$TEMP_SIGNAL_LIT')" +else + fail "brief.ts has no temporal signal — add '$TEMP_SIGNAL_LIT' (RE-R3d overlay)" +fi + +if grep -qF "$TEMP_RANK_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts ranks on the temporal overlay ('$TEMP_RANK_LIT')" +else + fail "brief.ts cmp does not use the temporal rank — add '$TEMP_RANK_LIT' (RE-R3d ranking)" +fi + +if grep -qF "$TEMP_TIER_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts declares the first-mover tier ('$TEMP_TIER_LIT')" +else + fail "brief.ts has no first-mover tier — add '$TEMP_TIER_LIT' (RE-R3d tiers)" +fi + +if grep -qF "$TEMP_FMDAYS_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the first-mover-days flag ('$TEMP_FMDAYS_LIT')" +else + fail "cli.ts has no first-mover-days flag — add '$TEMP_FMDAYS_LIT' (RE-R3d brief flag)" +fi + +if grep -qF "$TEMP_SATAT_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the saturation-at flag ('$TEMP_SATAT_LIT')" +else + fail "cli.ts has no saturation-at flag — add '$TEMP_SATAT_LIT' (RE-R3d brief flag)" +fi + +echo "" + +# --- Section 16n: Trends Brief History / Diff (research-engine RE-R3e) --- +echo "--- Trends Brief History / Diff ---" + +# RE-R3e adds the day-over-day brief diff: a 'surfaced:' frontmatter record + three pure helpers +# (diffSurfaced / parseSurfacedFrontmatter / selectPriorBriefFile) in brief.ts, wired at the cli.ts +# edge, rendering a 'Nytt siden sist' section. Five literals must hold, grepped EXACT (grep -F), +# deps-absent-safe (pure grep, no tsx); ASCII-only (bash 3.2 set -u — the section's emoji is NEVER +# grepped, only its ASCII tail 'Nytt siden sist'): +# (1) brief.ts exports the diff, by 'export function diffSurfaced'; +# (2) brief.ts parses the prior record, by 'parseSurfacedFrontmatter'; +# (3) brief.ts renders the section, by 'Nytt siden sist' (the ASCII header tail); +# (4) cli.ts wires prior-discovery, by 'selectPriorBriefFile'; +# (5) brief.ts emits the membership record, by 'surfaced: ' (the frontmatter line). +# Non-vacuity self-test mirrors Section 16m. Placed after Section 16m / before Section 18 (anti-erosion +# must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +DIFF_FN_LIT='export function diffSurfaced' +DIFF_PARSE_LIT='parseSurfacedFrontmatter' +DIFF_SECTION_LIT='Nytt siden sist' +DIFF_SELECT_LIT='selectPriorBriefFile' +DIFF_SURFACED_LIT='surfaced: ' + +I16N_SELFTEST_OK=1 +if ! echo 'a wired diff declares: export function diffSurfaced(currentIds, priorIds)' | grep -qF "$DIFF_FN_LIT"; then + I16N_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired brief-diff probe was not detected" +fi +if echo 'an unwired module computes no day-over-day diff at all' | grep -qF "$DIFF_FN_LIT"; then + I16N_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the brief-diff pointer" +fi +if [ "$I16N_SELFTEST_OK" -eq 1 ]; then + pass "trends-brief-diff self-test: the diff pointer is detected, the no-diff form rejected" +else + fail "trends-brief-diff self-test failed — the brief-diff lint is vacuous or over-eager" +fi + +if grep -qF "$DIFF_FN_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts exports the day-over-day diff ('$DIFF_FN_LIT')" +else + fail "brief.ts has no diff — add '$DIFF_FN_LIT' (RE-R3e brief history)" +fi + +if grep -qF "$DIFF_PARSE_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts parses the prior surfaced record ('$DIFF_PARSE_LIT')" +else + fail "brief.ts cannot read a prior brief — add '$DIFF_PARSE_LIT' (RE-R3e)" +fi + +if grep -qF "$DIFF_SECTION_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts renders the diff section ('$DIFF_SECTION_LIT')" +else + fail "brief.ts has no diff section — add '$DIFF_SECTION_LIT' (RE-R3e section header)" +fi + +if grep -qF "$DIFF_SELECT_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts wires prior-brief discovery ('$DIFF_SELECT_LIT')" +else + fail "cli.ts does not discover the prior brief — add '$DIFF_SELECT_LIT' (RE-R3e wiring)" +fi + +if grep -qF "$DIFF_SURFACED_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts emits the surfaced: membership record ('$DIFF_SURFACED_LIT')" +else + fail "brief.ts records no membership — add the 'surfaced:' frontmatter line (RE-R3e)" +fi + +echo "" + +# --- Section 16o: Voice Chain — chronicle-voice-profile binding (N8 / C-1,C-2,C-3,A2-F1,A2-F2) --- +echo "--- Voice Chain (N8) ---" + +# N8 binds the long-form VOICE before prose — the same discipline the line already applies to +# SOURCES (validate-binding), its strongest link. Five invariants must hold in the .md/.json surfaces: +# (C-1/A2-F1) voice-trainer.md distils a chronicle-voice-profile from the PUBLISHED gold corpus, and +# newsletter.md binds drafting (Step 0/3a/3b) to THAT profile — not to the English short-form +# authentic-voice-samples corpus the engine itself calls degrading for chronicle voice. +# (C-2) Step 3a READS the chronicle-voice-drift-log as a Pass-0 negative list (today write-only). +# (C-3) Step 1 RESOLVES + persists the review `language`, making the edition-state doc-string's +# "Resolved at Step 1" claim true, so the Norwegian axis fires when content IS Norwegian. +# (A2-F2) Step 8 lock appends the locked edition to the gold corpus (ingest/published, +# provenance=published) so the corpus the profile distils from grows automatically. +# All are UNCONDITIONAL .md/.json greps (no TS deps → they raise the deps-absent floor). Auto-gull needs +# BOTH gold-corpus literals; a non-vacuity self-test guards that compound predicate (mirrors Section 16c). +CVP_LIT='chronicle-voice-profile' +DRIFTLOG_LIT='chronicle-voice-drift-log' +LANG_RESOLVE_LIT='Resolve the review language' +GOLD_PATH_LIT='ingest/published' +GOLD_PROV_LIT='provenance=published' + +autogull_wired() { # $1 = text; wired iff BOTH gold-corpus literals present (echo twice — grep consumes stdin) + echo "$1" | grep -qF "$GOLD_PATH_LIT" && echo "$1" | grep -qF "$GOLD_PROV_LIT" +} + +VC_SELFTEST_OK=1 +if ! autogull_wired "lock appends the edition to ingest/published with provenance=published"; then + VC_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired auto-gull probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if autogull_wired "$probe"; then + VC_SELFTEST_OK=0; echo " false-positive FAIL: under-wired auto-gull probe accepted -> $probe" + fi +done <<'NEGATIVE16O' +lock appends the edition to ingest/published/ but never tags provenance +marks provenance=published but never names the gold-corpus path +NEGATIVE16O +if [ "$VC_SELFTEST_OK" -eq 1 ]; then + pass "voice-chain self-test: auto-gull predicate needs BOTH gold-corpus literals (1 accepted, 2 under-wired rejected)" +else + fail "voice-chain self-test failed — the auto-gull lint is vacuous or over-eager" +fi + +NL="commands/newsletter.md" +VT_N8="agents/voice-trainer.md" + +# (C-1/A2-F1) chronicle-voice-profile artifact generated + bound +if grep -qF "$CVP_LIT" "$VT_N8"; then + pass "voice-trainer.md distils the $CVP_LIT (per-language gold-corpus voice artifact)" +else + fail "voice-trainer.md missing '$CVP_LIT' — the chronicle voice artifact is never generated (C-1/A2-F1)" +fi +if grep -qF "$CVP_LIT" "$NL"; then + pass "newsletter.md binds long-form drafting to the $CVP_LIT (C-1)" +else + fail "newsletter.md missing '$CVP_LIT' — drafting still binds only to the short-form corpus (C-1)" +fi + +# (C-2) drift-log READ (Pass 0 negative list), not just written +if grep -qF "$DRIFTLOG_LIT" "$NL"; then + pass "newsletter.md reads the $DRIFTLOG_LIT as a Pass-0 negative list (C-2)" +else + fail "newsletter.md missing '$DRIFTLOG_LIT' — the drift log is still write-only (C-2)" +fi + +# (C-3) language resolved + persisted at Step 1 +if grep -qF "$LANG_RESOLVE_LIT" "$NL"; then + pass "newsletter.md Step 1 resolves + persists the review language ('$LANG_RESOLVE_LIT', C-3)" +else + fail "newsletter.md missing the Step 1 language-resolution instruction ('$LANG_RESOLVE_LIT', C-3)" +fi + +# (A2-F2) auto-gull at Step 8 lock — the gold corpus grows on its own +if autogull_wired "$(cat "$NL")"; then + pass "newsletter.md Step 8 auto-gull: locked edition appended to the gold corpus ('$GOLD_PATH_LIT' + '$GOLD_PROV_LIT', A2-F2)" +else + fail "newsletter.md missing auto-gull — lock does not grow the gold corpus (needs '$GOLD_PATH_LIT' + '$GOLD_PROV_LIT', A2-F2)" +fi + +echo "" + +# --- Section 16p: Randsone-Gates - feed-facing surfaces + deterministic fact-recheck + WebFetch (N9 / C-4,C-6,C-7,C-9) --- +echo "--- Randsone Gates (N9) ---" + +# N9 closes the border-zone gap: the hook (delingstekst), the samle post, the carousel slide text and the +# edition title reach the feed today with NO fact/language gate (Step 5/6.5 run on the article BODY; +# carousel is authored in Step 7.5 AFTER the last content gate; Step 9 judged only the hook's click-worth). +# Four invariants must hold in the .md surfaces: +# (C-4/C-9) Step 9's post-lock gate is extended with a scoped fact-reviewer + language-reviewer pass over +# all four distribution surfaces (delingstekst hook, samle post, carousel slide text, edition +# title candidates). Step 9 is the first phase where all four exist, and it already operates on +# post-lock distribution copy the gate may revise without reopening the locked body. +# (C-6) any post-factcheck fold-in whose diff touches a sentence with numbers, names or quotes fires a +# mandatory scoped fact-reviewer mini-recheck - a DETERMINISTIC trigger ("not a judgment call"), +# which also covers the pivot-heuristic blind spot (a swapped number no >20% rule would catch). +# (C-7) both fact agents (fact-checker, fact-reviewer) carry WebFetch - quote precision needs the +# verbatim source text, not just a search snippet. +# The randsone predicate is compound (both reviewers AND both new surfaces), so a non-vacuity self-test +# guards it (mirrors Section 16o). All checks are UNCONDITIONAL .md greps -> they raise the deps-absent floor. +NL_N9="commands/newsletter.md" +# Slice the Step 9 block (## Step 9: up to ## Step 10:) so the gate literals are scoped to that phase. +STEP9_BLOCK=$(awk '/^## Step 9:/{f=1} /^## Step 10:/{f=0} f' "$NL_N9") + +randsone_gated() { # $1 = text; wired iff BOTH reviewers AND BOTH new surfaces are named + echo "$1" | grep -qF "fact-reviewer" \ + && echo "$1" | grep -qF "language-reviewer" \ + && echo "$1" | grep -qF "carousel slide text" \ + && echo "$1" | grep -qF "edition title" +} + +RZ_SELFTEST_OK=1 +if ! randsone_gated "scoped fact-reviewer + language-reviewer over the carousel slide text and edition title candidates"; then + RZ_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired randsone-gate probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if randsone_gated "$probe"; then + RZ_SELFTEST_OK=0; echo " false-positive FAIL: under-wired randsone-gate probe accepted -> $probe" + fi +done <<'NEGATIVE16P' +language-reviewer over the carousel slide text and edition title but no fact reviewer here +fact-reviewer + language-reviewer over the edition title only, no carousel surface +fact-reviewer + language-reviewer over the carousel slide text only, no title surface +NEGATIVE16P +if [ "$RZ_SELFTEST_OK" -eq 1 ]; then + pass "randsone-gate self-test: predicate needs BOTH fact+language reviewers AND both new surfaces (1 accepted, 3 under-wired rejected)" +else + fail "randsone-gate self-test failed - the randsone lint is vacuous or over-eager" +fi + +# (C-4/C-9) Step 9 gates all four distribution surfaces through fact + language reviewers +if randsone_gated "$STEP9_BLOCK"; then + pass "newsletter.md Step 9 runs the scoped fact-reviewer + language-reviewer randsone gate over carousel + title (C-4/C-9)" +else + fail "newsletter.md Step 9 missing the randsone fact+language gate - hook/samle/carousel/title still reach the feed ungated (C-4/C-9)" +fi + +# (C-9) the edition's own title now faces a strength gate at Step 9 (today only the hook's two lines were judged) +if echo "$STEP9_BLOCK" | grep -qF "title-strength"; then + pass "newsletter.md Step 9 gates the edition title-strength, not only the hook (C-9)" +else + fail "newsletter.md Step 9 missing the title-strength gate - the edition title still ships unjudged (C-9)" +fi + +# (C-6) deterministic post-factcheck fact-recheck trigger (nouns + mechanism + determinism marker) +if grep -qF "numbers, names, or quotes" "$NL_N9"; then + pass "newsletter.md declares the deterministic recheck trigger nouns ('numbers, names, or quotes', C-6)" +else + fail "newsletter.md missing the recheck trigger nouns 'numbers, names, or quotes' (C-6)" +fi +if grep -qF "scoped fact-reviewer" "$NL_N9"; then + pass "newsletter.md routes the post-factcheck recheck to a scoped fact-reviewer (C-6)" +else + fail "newsletter.md missing 'scoped fact-reviewer' - the recheck has no mechanism (C-6)" +fi +if grep -qF "not a judgment call" "$NL_N9"; then + pass "newsletter.md marks the recheck trigger deterministic ('not a judgment call', C-6)" +else + fail "newsletter.md missing the determinism marker 'not a judgment call' - the trigger is still discretionary (C-6)" +fi + +# (C-7) both fact agents carry WebFetch (verbatim source text for quote precision) +if grep -qF "WebFetch" agents/fact-checker.md; then + pass "fact-checker.md carries WebFetch (verbatim source text for quote precision, C-7)" +else + fail "fact-checker.md missing WebFetch in tools - quote precision has no verbatim-source fetch (C-7)" +fi +if grep -qF "WebFetch" agents/fact-reviewer.md; then + pass "fact-reviewer.md carries WebFetch (verbatim source text for quote precision, C-7)" +else + fail "fact-reviewer.md missing WebFetch in tools - cold re-verification has no verbatim-source fetch (C-7)" +fi + +echo "" + +# --- Section 16q: Configurable Target-Level Span (N10 / C-8) --- +echo "--- Configurable Target-Level Span (N10) ---" + +# N10 turns "practically usable at all levels" into a CONFIGURABLE gate axis and removes the +# hardcoded primary persona. Six .md/.json surfaces must hold the invariants (all UNCONDITIONAL +# greps -> they raise the deps-absent floor): +# (C-8 absence) the hardcoded primary "non-technical line manager" is GONE from references/ + config/ +# — the quality-rule now points at the edition's CONFIGURED target-level span instead. +# (C-8 span-ref) references/longform-quality-rules.md names the "target-level span" (domain-general), +# not a single baked-in reader role. +# (C-8 obligatorisk-sekundær) config/personas.template.md's «primær trumfer» rule now REQUIRES a +# mandatory secondary at the opposite end of the span; a secondary NO may be waived +# as «signal, not failure» ONLY with an explicit ceiling justification. +# (C-8 tekniker-seed) the seed library gains a technical-end persona (today all three seeds are leaders). +# (C-8 edition-state) config/edition-state.template.json carries an additive per-article targetLevel slot. +# (C-8 persist) commands/newsletter.md persists the resolved targetLevel to articles.NN.targetLevel. +# (C-8 consume) agents/persona-reviewer.md READS articles.NN.targetLevel and places personas on the span +# — a compound predicate (targetLevel field + edition-state read + span placement), so a +# non-vacuity self-test guards it (mirrors Section 16p). +PR_N10="agents/persona-reviewer.md" + +targetlevel_consumed() { # $1 = text; wired iff it reads targetLevel FROM edition-state AND places on the span + echo "$1" | grep -qF "targetLevel" \ + && echo "$1" | grep -qF "articles." \ + && echo "$1" | grep -qF "target-level span" +} + +TL_SELFTEST_OK=1 +if ! targetlevel_consumed "read articles.NN.targetLevel and place the personas on the target-level span"; then + TL_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired targetLevel-consumption probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if targetlevel_consumed "$probe"; then + TL_SELFTEST_OK=0; echo " false-positive FAIL: under-wired targetLevel probe accepted -> $probe" + fi +done <<'NEGATIVE16Q' +place the personas on the target-level span but never read the field +read articles.NN.targetLevel but do not mention the span placement here +the targetLevel matters for the target-level span without an edition-state read +NEGATIVE16Q +if [ "$TL_SELFTEST_OK" -eq 1 ]; then + pass "targetLevel-consumption self-test: predicate needs targetLevel + edition-state read (articles.) + span placement (1 accepted, 3 under-wired rejected)" +else + fail "targetLevel-consumption self-test failed - the N10 consumption lint is vacuous or over-eager" +fi + +# (C-8 absence) the hardcoded primary persona is gone from the shippable surfaces +if grep -rn "non-technical line manager" references/ config/ >/dev/null 2>&1; then + fail "N10: hardcoded 'non-technical line manager' still present in references/ or config/ — the primary persona is not de-hardcoded (C-8)" +else + pass "N10: no hardcoded 'non-technical line manager' in references/ or config/ (C-8 absence)" +fi + +# (C-8 span-ref) the quality rule points at the configured span, not a baked-in role +if grep -qF "target-level span" references/longform-quality-rules.md; then + pass "references/longform-quality-rules.md names the configured 'target-level span' (C-8 span-ref)" +else + fail "references/longform-quality-rules.md missing 'target-level span' — the served-reader rule is still role-hardcoded (C-8)" +fi + +# (C-8 obligatorisk-sekundær) the «primær trumfer» rule requires a mandatory secondary at the opposite end +if grep -qF "opposite end of the target-level span" config/personas.template.md; then + pass "config/personas.template.md requires a mandatory secondary at the opposite end of the span (C-8 obligatorisk-sekundær)" +else + fail "config/personas.template.md missing the 'opposite end of the target-level span' requirement — one reader can still self-certify all levels (C-8)" +fi + +# (C-8 ceiling-begrunnelse) a secondary NO may only be waived with an explicit ceiling justification +if grep -qF "explicit ceiling justification" config/personas.template.md; then + pass "config/personas.template.md gates the secondary-NO waiver behind an explicit ceiling justification (C-8 ceiling-begrunnelse)" +else + fail "config/personas.template.md missing 'explicit ceiling justification' — a secondary NO can still be waved away as 'signal, not failure' unproven (C-8)" +fi + +# (C-8 tekniker-seed) the seed library gains a technical-end persona (today all three seeds are leaders) +if grep -qF "Technical end of the target-level span" config/personas.template.md; then + pass "config/personas.template.md seeds a technical-end persona opposite the leader personas (C-8 tekniker-seed)" +else + fail "config/personas.template.md missing a 'Technical end of the target-level span' seed — the library is still leader-only (C-8)" +fi + +# (C-8 edition-state) the per-article targetLevel SLOT exists (additive, no schemaVersion bump). +# Grep the slot's default literal `"targetLevel": null`, NOT the bare word — the latter already occurs +# inside the sourceTrendId doc-string ("angle / targetLevel / key-points"), which would match vacuously. +if grep -qF '"targetLevel": null' config/edition-state.template.json; then + pass "config/edition-state.template.json carries the additive articles.NN.targetLevel slot (default null, C-8 edition-state)" +else + fail "config/edition-state.template.json missing the '\"targetLevel\": null' slot — persona-reviewer would consume a phantom field (C-8)" +fi + +# (C-8 persist) newsletter.md persists the resolved targetLevel to the per-article slot +if grep -qF "articles.NN.targetLevel" commands/newsletter.md; then + pass "commands/newsletter.md persists the resolved targetLevel to articles.NN.targetLevel (C-8 persist)" +else + fail "commands/newsletter.md never persists articles.NN.targetLevel — the slot stays null and consumption is vacuous (C-8)" +fi + +# (C-8 consume) persona-reviewer reads targetLevel from edition-state and places personas on the span +PR_CONTENT=$(cat "$PR_N10") +if targetlevel_consumed "$PR_CONTENT"; then + pass "agents/persona-reviewer.md reads articles.NN.targetLevel and places personas on the target-level span (C-8 consume)" +else + fail "agents/persona-reviewer.md does not consume articles.NN.targetLevel onto the span — targetLevel is set but never read (C-8)" +fi + +echo "" + +# --- Section 16r: Series-Level Protection - distillate + usage log (N11 / C-5) --- +echo "--- Series-Level Protection (N11) ---" + +# N11 makes "the reader has heard this before" visible BEFORE the skeleton is approved. +# Every other gate sees ONE edition; at series cadence the fastest-growing defect is a +# REPEATED edition, and the specifics-bank dedupe actively encourages re-surfacing material. +# Two stores, two grains, both UNCONDITIONAL greps (they raise the deps-absent floor): +# (C-5 series grain) scripts/editions - what THIS SERIES already published (anecdote/argument/hook) +# (C-5 material grain) specifics-bank usedIn - which editions consumed a given lived specific +# (C-5 Step 2.5) the destillat-sjekk feeds the annotation gate BEFORE prose - a compound predicate +# (distil-check call + the series-scoped store + the advisory REUSE verdict), so a +# non-vacuity self-test guards it (mirrors Sections 16p/16q). +# (C-5 Step 8) lock writes both stores - published, never merely considered. +NL_N11="commands/newsletter.md" +DIST_SRC="scripts/editions/src/distillate.ts" + +distillate_gated() { # $1 = text; wired iff it CALLS the check, names the series-scoped store AND carries the advisory verdict + echo "$1" | grep -qF "distil-check" \ + && echo "$1" | grep -qF "series-distillate.json" \ + && echo "$1" | grep -qF "REUSE" +} + +DG_SELFTEST_OK=1 +if ! distillate_gated "run distil-check --distillate /linkedin/series-distillate.json; on REUSE fold the finding into the annotation page"; then + DG_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired destillat-sjekk probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if distillate_gated "$probe"; then + DG_SELFTEST_OK=0; echo " false-positive FAIL: under-wired destillat probe accepted -> $probe" + fi +done <<'NEGATIVE16R' +run distil-check against the store and act on REUSE without naming the file +the series-distillate.json store exists and REUSE is possible, but nothing calls the check +call distil-check on series-distillate.json but never say what a hit means +NEGATIVE16R +if [ "$DG_SELFTEST_OK" -eq 1 ]; then + pass "destillat-gate self-test: predicate needs distil-check + series-distillate.json + REUSE verdict (1 accepted, 3 under-wired rejected)" +else + fail "destillat-gate self-test failed - the N11 wiring lint is vacuous or over-eager" +fi + +# (C-5 Step 2.5) the destillat-sjekk runs in the skeleton gate, BEFORE prose +STEP25_BLOCK=$(awk '/^## Step 2.5:/{f=1} /^## Step 3a:/{f=0} f' "$NL_N11") +if distillate_gated "$STEP25_BLOCK"; then + pass "newsletter Step 2.5 runs the destillat-sjekk against the series store with an advisory REUSE verdict (C-5 Step 2.5)" +else + fail "newsletter Step 2.5 does not run the destillat-sjekk - series-level re-use stays invisible before prose (C-5)" +fi + +# (C-5 Step 8 distillate) lock folds the spent units into the series distillate +STEP8_BLOCK=$(awk '/^## Step 8:/{f=1} /^## Step 9:/{f=0} f' "$NL_N11") +if echo "$STEP8_BLOCK" | grep -qF "distil-append"; then + pass "newsletter Step 8 lock writes the series distillate (distil-append) (C-5 Step 8)" +else + fail "newsletter Step 8 never calls distil-append - nothing feeds the next edition's Step 2.5 check (C-5)" +fi + +# (C-5 Step 8 usage) lock stamps the consumed lived material +if echo "$STEP8_BLOCK" | grep -qF "record-usage"; then + pass "newsletter Step 8 lock stamps consumed specifics (record-usage) (C-5 Step 8)" +else + fail "newsletter Step 8 never calls record-usage - bank re-use across editions stays invisible (C-5)" +fi + +# (C-5 material grain) the bank record carries the usage log +if grep -qF "usedIn" scripts/specifics-bank/src/types.ts; then + pass "specifics-bank Specific declares usedIn - the cross-edition usage log exists (C-5 material grain)" +else + fail "specifics-bank/src/types.ts has no usedIn field - «used in edition NN» is unrecordable (C-5)" +fi + +if grep -qF "export function recordUsage" scripts/specifics-bank/src/bank.ts; then + pass "specifics-bank exports recordUsage (C-5 material grain)" +else + fail "specifics-bank/src/bank.ts does not export recordUsage (C-5)" +fi + +if grep -qF "export function boundSpecificIds" scripts/specifics-bank/src/binding.ts; then + pass "specifics-bank exports boundSpecificIds - only specific-bound slots count as consumption (C-5)" +else + fail "specifics-bank/src/binding.ts does not export boundSpecificIds (C-5)" +fi + +# (C-5 series grain) the distillate module exists with its deterministic check +if grep -qF "export function checkSkeleton" "$DIST_SRC"; then + pass "scripts/editions exports checkSkeleton - the deterministic series check exists (C-5 series grain)" +else + fail "$DIST_SRC does not export checkSkeleton (C-5)" +fi + +# (C-5 placement) the distillate lives in the SERIES root beside edition-state, not the data dir +if grep -qF "series-distillate.json" "$DIST_SRC" && grep -qF '"linkedin"' "$DIST_SRC"; then + pass "series distillate resolves under /linkedin/ beside edition-state (C-5 placement)" +else + fail "$DIST_SRC does not resolve the distillate under the series root - series-scoped state placed wrong (C-5)" +fi + +echo "" + +# --- Section 16s: Editions Register + phaseLog Telemetry (N12 / A1-11, A1-12) --- +echo "--- Editions Register (N12) ---" + +# N12 makes two structurally invisible things visible: WHICH editions are in flight (until +# now readable only by opening every series folder's edition-state.json) and HOW LONG an +# edition actually takes (until now not recorded at all). The register is a MIRROR - only +# edition-state.json drives resumption - so the binding worth linting is that EVERY phase +# transition writes both the phaseLog entry and the register row, from ONE call: +# (A1-12 protocol) the transition protocol is defined once and is fully wired - a compound +# predicate (verb + logged field + mirrored store), so a non-vacuity +# self-test guards it (mirrors Sections 16p/16q/16r). +# (A1-12 coverage) every canonical phase calls it - a phase with no call is a silent hole +# in the lead-time log, which is exactly how telemetry dies. +# (A1-11 store) the register module + its data-dir placement exist. +# (A1-12 schema) edition-state carries the phaseLog slot the CLI appends to. +NL_N12="commands/newsletter.md" +REG_SRC="scripts/editions/src/register.ts" +EST_SRC="scripts/editions/src/editionState.ts" +EST_TPL="config/edition-state.template.json" +BT='`' + +transition_protocol() { # $1 = text; wired iff it CALLS the verb, names the field it logs AND the store it mirrors to + echo "$1" | grep -qF "register-upsert" \ + && echo "$1" | grep -qF "phaseLog" \ + && echo "$1" | grep -qF "editions/register.json" +} + +TP_SELFTEST_OK=1 +if ! transition_protocol "run register-upsert --edition-state ; it appends articles.NN.phaseLog and mirrors the row into /editions/register.json"; then + TP_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired transition-protocol probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if transition_protocol "$probe"; then + TP_SELFTEST_OK=0; echo " false-positive FAIL: under-wired transition probe accepted -> $probe" + fi +done <<'NEGATIVE16S' +run register-upsert at every transition and let phaseLog grow, store left unspecified +append articles.NN.phaseLog and mirror into editions/register.json, but nothing calls the verb +run register-upsert and write editions/register.json without logging the phase +NEGATIVE16S +if [ "$TP_SELFTEST_OK" -eq 1 ]; then + pass "transition-protocol self-test: predicate needs register-upsert + phaseLog + editions/register.json (1 accepted, 3 under-wired rejected)" +else + fail "transition-protocol self-test failed - the N12 wiring lint is vacuous or over-eager" +fi + +# (A1-12 protocol) the protocol is defined once, in the resumption area, fully wired +PROTO_BLOCK=$(awk '/^### Phase-transition protocol/{f=1} /^## Step 1:/{f=0} f' "$NL_N12") +if transition_protocol "$PROTO_BLOCK"; then + pass "newsletter defines a wired phase-transition protocol (register-upsert appends phaseLog + mirrors the register) (A1-11/A1-12)" +else + fail "newsletter has no wired phase-transition protocol - phase telemetry falls back to the model remembering (A1-12)" +fi + +# (A1-11 invariant) the mirror rule is stated where the protocol is defined - a register read +# as source-of-truth would break deterministic resumption, the one thing edition-state owns +if echo "$PROTO_BLOCK" | grep -qF "MIRROR, never a source of truth"; then + pass "newsletter states the mirror invariant - resumption reads edition-state only (A1-11)" +else + fail "newsletter does not state that the register is a mirror - it can drift into a second source of truth (A1-11)" +fi + +# (A1-12 coverage) every canonical phase transition calls the protocol +NL_FLAT=$(tr '\n' ' ' < "$NL_N12" | tr -s ' ') +MISSING_PHASES="" +for phase in lived-specifics research skeleton-pitch spine-prose draft consistency-quality \ + contract-gate factcheck-sweep editorial-review persona-sweep-prelock \ + headless-review annotation visual-assets lock-delivery hook-conversion-gate scheduling \ + retro; do + if ! printf '%s' "$NL_FLAT" | grep -qF "run the protocol (Step 0) for ${BT}${phase}${BT}"; then + MISSING_PHASES="$MISSING_PHASES $phase" + fi +done +if [ -z "$MISSING_PHASES" ]; then + pass "all 17 canonical phases carry a transition call - the lead-time log has no holes (A1-12)" +else + fail "newsletter phases with no transition call:$MISSING_PHASES - lead time silently undercounts them (A1-12)" +fi + +# (A1-12 close) Step 10 closes the row - without it every edition stays 'in flight' forever +STEP10_BLOCK=$(awk '/^## Step 10:/{f=1} /^## Distribution channel/{f=0} f' "$NL_N12") +if echo "$STEP10_BLOCK" | grep -qF "register-complete"; then + pass "newsletter Step 10 closes the register row (register-complete) - lead time becomes a measured number (A1-12)" +else + fail "newsletter Step 10 never calls register-complete - editions never leave the in-flight view (A1-12)" +fi + +# (A1-11 store) the register module exists with its mutations +if grep -qF "export function upsertEdition" "$REG_SRC"; then + pass "scripts/editions exports upsertEdition - the deterministic register write exists (A1-11)" +else + fail "$REG_SRC does not export upsertEdition (A1-11)" +fi + +if grep -qF "export function completeEdition" "$REG_SRC" && grep -qF "export function listEditions" "$REG_SRC"; then + pass "scripts/editions exports completeEdition + listEditions - the WIP view and its close verb exist (A1-11)" +else + fail "$REG_SRC does not export both completeEdition and listEditions (A1-11)" +fi + +# (A1-11 placement) the register lives in the per-user data dir (M0 convention), not the plugin +if grep -qF "LINKEDIN_STUDIO_DATA" "$REG_SRC" && grep -qF '"editions"' "$REG_SRC" && grep -qF '"register.json"' "$REG_SRC"; then + pass "editions register resolves under the per-user data dir - it survives plugin reinstalls (M0/A1-11)" +else + fail "$REG_SRC does not resolve the register under the data dir - WIP state placed wrong (M0/A1-11)" +fi + +# (A1-12 telemetry) the phase log is appended by code, not by hand +if grep -qF "export function appendPhase" "$EST_SRC"; then + pass "scripts/editions exports appendPhase - phaseLog entries are written deterministically (A1-12)" +else + fail "$EST_SRC does not export appendPhase (A1-12)" +fi + +# (A1-12 schema) edition-state documents AND carries the phaseLog slot +if grep -qF '"phaseLog": "Per-article phase-transition log' "$EST_TPL"; then + pass "edition-state template documents phaseLog (A1-12 schema)" +else + fail "$EST_TPL does not document phaseLog - the telemetry field is undefined (A1-12)" +fi + +if grep -qF '"phaseLog": []' "$EST_TPL"; then + pass "edition-state template carries the per-article phaseLog slot (A1-12 schema)" +else + fail "$EST_TPL has no per-article phaseLog slot - new editions start unmeasurable (A1-12)" +fi + +echo "" + +# --- Section 16t: Publishing Slots + WIP Surfacing (N13 / A1-10) --- +echo "--- Publishing Slots (N13) ---" + +# N13 answers the question a queue cannot: is the WEEK covered? The grid of publishing +# slots is operator config, coverage is queue + editions register, and the surfaces that +# report it (session start, router, calendar) plus the one that consumes it (newsletter +# Step 10 slot default) must all read the SAME module - two implementations of "next free +# slot" would disagree within a week, and the operator would learn to trust neither. +# (A1-10 wiring) a WIP surface loads slots.mjs and asks it for BOTH vacancy and +# in-flight editions - a compound predicate, so a non-vacuity +# self-test guards it (mirrors Sections 16p/16q/16r/16s). +# (A1-10 consume) Step 10 defaults to the next free slot AND claims it on the row. +# (A1-10 placement) the grid + register resolve under the per-user data dir (M0). +# (D-5) the shipped scheduling times are marked low/directional and defer +# to the operator's own grid. +# (de-niche) the plugin ships NO publishing times in the slot code path and no +# operator grid - the times are user config, always. +SLOTS_SRC="hooks/scripts/slots.mjs" +SS_SRC="hooks/scripts/session-start.mjs" +SLOTS_TPL="config/publishing-slots.template.json" +NL_N13="commands/newsletter.md" +SCHED_REF="references/scheduling-strategy.md" + +slots_surface_wired() { # $1 = text; wired iff it LOADS the shared module and asks for BOTH vacancy and in-flight + echo "$1" | grep -qF "slots.mjs" \ + && echo "$1" | grep -qE "slotVacancies|nextFreeSlot" \ + && echo "$1" | grep -qF "activeEditions" +} + +SL_SELFTEST_OK=1 +if ! slots_surface_wired "import { slotVacancies, activeEditions } from hooks/scripts/slots.mjs and print both"; then + SL_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired slot-surface probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if slots_surface_wired "$probe"; then + SL_SELFTEST_OK=0; echo " false-positive FAIL: under-wired slot-surface probe accepted -> $probe" + fi +done <<'NEGATIVE16T' +import activeEditions from slots.mjs and list what is in production +call slotVacancies and activeEditions from somewhere, no module named +import slotVacancies from slots.mjs and report open slots only +NEGATIVE16T +if [ "$SL_SELFTEST_OK" -eq 1 ]; then + pass "slot-surface self-test: predicate needs slots.mjs + vacancy + activeEditions (1 accepted, 3 under-wired rejected)" +else + fail "slot-surface self-test failed - the N13 wiring lint is vacuous or over-eager" +fi + +# (A1-10 wiring) the two WIP surfaces report capacity, not just a queue +if slots_surface_wired "$(cat commands/linkedin.md)"; then + pass "router surfaces work-in-progress + open slots from the shared module (A1-10)" +else + fail "commands/linkedin.md does not surface in-flight editions and open slots - the week stays invisible (A1-10)" +fi + +if slots_surface_wired "$(cat commands/calendar.md)"; then + pass "calendar surfaces the slot grid + editions in flight from the shared module (A1-10)" +else + fail "commands/calendar.md does not read slots.mjs for vacancy + in-flight editions (A1-10)" +fi + +# (A1-10 opt-in) the grid is offered, never written for the user +if grep -qF "publishing-slots.template.json" commands/calendar.md; then + pass "calendar documents the opt-in grid copy - the plugin imposes no schedule (A1-10/de-niche)" +else + fail "commands/calendar.md never points at the slot-grid template - the feature is undiscoverable (A1-10)" +fi + +# (A1-10 session start) both blocks render at session start, from the same module +if grep -qF "from './slots.mjs'" "$SS_SRC" \ + && grep -qF "## Editions in Flight" "$SS_SRC" \ + && grep -qF "## Publishing Slots" "$SS_SRC"; then + pass "session start renders in-flight editions + publishing slots from slots.mjs (A1-10)" +else + fail "$SS_SRC does not render the week picture from slots.mjs - the operator must open files again (A1-10)" +fi + +# (A1-10 coverage) vacancy counts BOTH short-form queue and long-form register +if grep -qF "export function coverageByDate" "$SLOTS_SRC" && grep -qF "readRegisterRows" "$SLOTS_SRC"; then + pass "slot coverage counts the queue AND the editions register - long-form cannot be double-booked (A1-10)" +else + fail "$SLOTS_SRC does not derive coverage from both the queue and the editions register (A1-10)" +fi + +# (A1-10 placement) grid + register resolve under the per-user data dir (M0 convention) +if grep -qF "getDataRoot('profile')" "$SLOTS_SRC" && grep -qF "getDataRoot('editions')" "$SLOTS_SRC"; then + pass "slot grid + register resolve under the per-user data dir - they survive reinstalls (M0/A1-10)" +else + fail "$SLOTS_SRC does not resolve the grid and register under the data dir (M0/A1-10)" +fi + +# (A1-10 consume) Step 10 defaults to the next free slot instead of interviewing +STEP10_N13=$(awk '/^## Step 10:/{f=1} /^## Distribution channel/{f=0} f' "$NL_N13") +if echo "$STEP10_N13" | grep -qF "nextFreeSlot"; then + pass "newsletter Step 10 defaults to the next free slot - scheduling is one confirmation (A1-10)" +else + fail "newsletter Step 10 still asks for the slot from scratch - the grid buys nothing (A1-10)" +fi + +if echo "$STEP10_N13" | grep -qF -- "--slot"; then + pass "newsletter Step 10 claims the slot on the register row - the next session stops offering it (A1-10)" +else + fail "newsletter Step 10 never writes --slot - a scheduled edition still reads as an open slot (A1-10)" +fi + +# (D-5) the shipped times are marked as what they are, and defer to the operator's grid +if grep -qF "low / directional" "$SCHED_REF"; then + pass "scheduling reference carries the low/directional epistemic marking (D-5)" +else + fail "$SCHED_REF presents practitioner lore as fact - mark it low / directional (D-5)" +fi + +if grep -qF "publishing-slots.json" "$SCHED_REF"; then + pass "scheduling reference defers to the operator's own grid (D-5)" +else + fail "$SCHED_REF does not point at the operator grid - the tables read as the authority (D-5)" +fi + +# (de-niche) the template exists and states that nothing is written for the user +if [ -f "$SLOTS_TPL" ] && grep -qF '"schemaVersion": 1' "$SLOTS_TPL" && grep -qF "OPT-IN" "$SLOTS_TPL"; then + pass "slot-grid template ships with a schema and an explicit opt-in (A1-10/de-niche)" +else + fail "$SLOTS_TPL missing, unversioned, or does not state the opt-in (A1-10/de-niche)" +fi + +# (de-niche) no publishing times hardcoded in the slot code path, no operator grid committed +SLOT_TIME_LITERALS=$(grep -cE "['\"][0-2][0-9]:[0-5][0-9]['\"]" "$SLOTS_SRC" "$SS_SRC" 2>/dev/null | awk -F: '{s+=$2} END {print s+0}') +COMMITTED_GRIDS=$(find . -name "publishing-slots.json" -not -path "*/node_modules/*" 2>/dev/null | wc -l | tr -d ' ') +if [ "$SLOT_TIME_LITERALS" -eq 0 ] && [ "$COMMITTED_GRIDS" -eq 0 ]; then + pass "no publishing times hardcoded in the slot path and no operator grid committed (de-niche)" +else + fail "hardcoded slot times in the code path ($SLOT_TIME_LITERALS) or a committed operator grid ($COMMITTED_GRIDS) - times are user config (de-niche)" +fi + +echo "" + +# --- Section 16u: Fold-In Loop + Retro Step (N14 / A2-F6,F7,F8,F9, C-10) --- +echo "--- Fold-In Loop + Retro (N14) ---" + +# N14 closes the loop the whole pipeline was missing: a correction the operator makes in +# edition N is ENFORCED in edition N+1 instead of being re-discovered. The machinery already +# existed (foldIns schema + promote->ratify); what was absent was the wiring - nothing wrote +# to the queue and no step ever emptied it. So the bindings worth linting are: +# (A2-F7 capture) every step where the operator corrects prose appends to the queue - a +# compound predicate (queue named + left open + routed), so a non-vacuity +# self-test guards it (mirrors Sections 16p/16q/16r/16s/16t). +# (A2-F8 retro) Step 11 exists and does all three jobs: promote the queue, summarize the +# measured effort (phaseLog, N12), ask ONE friction question. +# (A2-F6 headless) the cold package runs as a background agent writing to a file - the +# maximum-independence path with no manual copy-paste seam. +# (C-10 language) confirmed language corrections accumulate in a USER file the reviewer +# and the drafting step both read - the shipped ban-list stays the baseline. +# (A2-F9 adopter) the fang->klassifiser->promoter->handhev loop is documented in-tree, so +# an adopter without the author's contract still has the whole loop. +NL_N14="commands/newsletter.md" +HR_N14="commands/headless-review.md" +LR_N14="agents/language-reviewer.md" +EST_N14="config/edition-state.template.json" +LANG_TPL="config/language-rules.template.md" +FOLDIN_REF="references/fold-in-loop.md" + +foldin_capture_wired() { # $1 = text; wired iff it NAMES the queue, leaves it OPEN, and ROUTES it + echo "$1" | grep -qF "foldIns" \ + && echo "$1" | grep -qF "pending" \ + && echo "$1" | grep -qF "Step 11" +} + +FI_SELFTEST_OK=1 +if ! foldin_capture_wired "append the correction to articles.NN.foldIns with decision pending; Step 11 routes it"; then + FI_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired fold-in-capture probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if foldin_capture_wired "$probe"; then + FI_SELFTEST_OK=0; echo " false-positive FAIL: under-wired fold-in-capture probe accepted -> $probe" + fi +done <<'NEGATIVE16U' +append the correction to articles.NN.foldIns with decision pending, routed by nobody +capture the correction as pending and let Step 11 route it, queue left unnamed +append it to articles.NN.foldIns and Step 11 promotes it, never marked open +NEGATIVE16U +if [ "$FI_SELFTEST_OK" -eq 1 ]; then + pass "fold-in-capture self-test: predicate needs foldIns + pending + Step 11 (1 accepted, 3 under-wired rejected)" +else + fail "fold-in-capture self-test failed - the N14 wiring lint is vacuous or over-eager" +fi + +# (A2-F7 capture) each of the four fold-in steps feeds the queue - a step that folds a +# correction in without capturing it is exactly how a rule gets re-discovered next edition +STEP25_N14=$(awk '/^## Step 2.5:/{f=1} /^## Step 3a:/{f=0} f' "$NL_N14") +STEP3A_N14=$(awk '/^## Step 3a:/{f=1} /^## Step 3b:/{f=0} f' "$NL_N14") +STEP55_N14=$(awk '/^## Step 5.5:/{f=1} /^## Step 6:/{f=0} f' "$NL_N14") +STEP65_N14=$(awk '/^## Step 6.5:/{f=1} /^## Step 7:/{f=0} f' "$NL_N14") +STEP11_N14=$(awk '/^## Step 11:/{f=1} /^## Distribution channel/{f=0} f' "$NL_N14") + +if foldin_capture_wired "$STEP25_N14"; then + pass "Step 2.5 captures operator corrections into the fold-in queue (A2-F7)" +else + fail "newsletter Step 2.5 folds annotations in without capturing them - the rule is lost (A2-F7)" +fi + +if foldin_capture_wired "$STEP3A_N14"; then + pass "Step 3a captures operator corrections into the fold-in queue (A2-F7)" +else + fail "newsletter Step 3a folds annotations in without capturing them - the rule is lost (A2-F7)" +fi + +if foldin_capture_wired "$STEP55_N14"; then + pass "Step 5.5 captures approved editorial flags into the fold-in queue (A2-F7)" +else + fail "newsletter Step 5.5 folds flags in without capturing them - the rule is lost (A2-F7)" +fi + +if foldin_capture_wired "$STEP65_N14"; then + pass "Step 6.5 captures approved cold-review flags into the fold-in queue (A2-F7)" +else + fail "newsletter Step 6.5 folds flags in without capturing them - the rule is lost (A2-F7)" +fi + +# (A2-F8 retro) the step exists and empties the queue with an explicit operator verdict +if echo "$STEP11_N14" | grep -qF "foldIns" && echo "$STEP11_N14" | grep -qF "promoted"; then + pass "Step 11 promotes the fold-in queue with an operator verdict (A2-F8)" +else + fail "newsletter has no Step 11 fold-in promotion - the queue fills up and is never emptied (A2-F8)" +fi + +# (A2-F8 effort) the effort summary reads the MEASURED log (N12), not the model's memory +if echo "$STEP11_N14" | grep -qF "phaseLog"; then + pass "Step 11 summarizes effort from the measured phaseLog - no new measuring apparatus (A2-F8/A1-12)" +else + fail "newsletter Step 11 does not read phaseLog - effort is re-estimated from memory (A2-F8)" +fi + +# (A2-F8 friction) exactly one friction question, and the operator's own register stays theirs +if echo "$STEP11_N14" | grep -qF "process friction" && echo "$STEP11_N14" | grep -qF "never write"; then + pass "Step 11 asks one friction question and never writes the operator's register (A2-F8)" +else + fail "newsletter Step 11 lacks the single friction question or claims write access to the operator's register (A2-F8)" +fi + +# (A2-F8 transition) retro is a real phase - logged like every other, and it re-closes the row +if echo "$STEP11_N14" | grep -qF "register-complete" && echo "$STEP11_N14" | grep -qF "${BT}retro${BT}"; then + pass "Step 11 runs the retro transition and re-closes the register row (A1-11/A2-F8)" +else + fail "newsletter Step 11 leaves the edition re-opened in the register or logs no retro phase (A1-11/A2-F8)" +fi + +# (A2-F8 schema) edition-state documents AND carries the retro slot +if grep -qF '"retro": "Per-article retro record' "$EST_N14" && grep -qF '"retro": null' "$EST_N14"; then + pass "edition-state documents and carries the per-article retro slot (A2-F8 schema)" +else + fail "$EST_N14 has no documented retro slot - the retro leaves no durable record (A2-F8)" +fi + +# (A2-F8 resumption) an edition aborted after scheduling resumes INTO the retro, not past it +RESUME_N14=$(awk '/^### Deterministic resumption/{f=1} /^### Phase-transition protocol/{f=0} f' "$NL_N14") +if echo "$RESUME_N14" | grep -qF "Step 11" && echo "$RESUME_N14" | grep -qF "${BT}retro${BT}"; then + pass "resumption table routes scheduling -> Step 11 and retro -> complete (A2-F8)" +else + fail "newsletter resumption table skips the retro - an aborted session drops the fold-in queue (A2-F8)" +fi + +# (A2-F6 headless) the cold package can run as a background agent writing its report to a file +if grep -qF "run_in_background" "$HR_N14" && grep -qF -- "--background" "$HR_N14"; then + pass "headless-review offers a background run - cold isolation without a fresh session (A2-F6)" +else + fail "$HR_N14 has no background mode - maximum independence still costs a manual session switch (A2-F6)" +fi + +if grep -qF "no copy-paste" "$HR_N14"; then + pass "headless-review states the seam it removes - the report is read from disk, not pasted (A2-F6)" +else + fail "$HR_N14 still relies on pasting the report back into the drafting session (A2-F6)" +fi + +# (A2-F6 wiring) Step 6.5 actually offers that path instead of the paste-back one +if echo "$STEP65_N14" | grep -qF -- "--background"; then + pass "newsletter Step 6.5 routes maximum-independence through the background run (A2-F6)" +else + fail "newsletter Step 6.5 still sends the operator to a fresh session with a paste-back (A2-F6)" +fi + +# (C-10 language) the accumulating user file is READ by the reviewer and by the drafting step +if grep -qF "language-rules/" "$LR_N14"; then + pass "language-reviewer reads the accumulated user language rules (C-10)" +else + fail "$LR_N14 grades only against the shipped list - confirmed corrections never bind (C-10)" +fi + +STEP4_N14=$(awk '/^## Step 4: /{f=1} /^## Step 4.5:/{f=0} f' "$NL_N14") +if echo "$STEP4_N14" | grep -qF "language-rules/"; then + pass "newsletter Step 4 reads the accumulated user language rules while drafting (C-10)" +else + fail "newsletter Step 4 ignores the accumulated language rules - they only catch, never prevent (C-10)" +fi + +if echo "$STEP11_N14" | grep -qF "language-rules/"; then + pass "Step 11 appends confirmed language corrections to the user rules file (C-10)" +else + fail "newsletter Step 11 never writes the language-rules file - judgment corrections evaporate (C-10)" +fi + +# (C-10 opt-in) the file is offered as a template, never written for the user (slots pattern) +if [ -f "$LANG_TPL" ] && grep -qF "OPT-IN" "$LANG_TPL"; then + pass "language-rules template ships with an explicit opt-in - nothing is imposed (C-10/de-niche)" +else + fail "$LANG_TPL missing or does not state the opt-in (C-10/de-niche)" +fi + +# (A2-F9 adopter) the whole loop is documented in-tree, all four stages named +FOLDIN_LOOP_OK=1 +for stage in "Capture" "Classify" "Promote" "Enforce"; do + grep -qF "$stage" "$FOLDIN_REF" 2>/dev/null || FOLDIN_LOOP_OK=0 +done +if [ -f "$FOLDIN_REF" ] && [ "$FOLDIN_LOOP_OK" -eq 1 ]; then + pass "fold-in loop documented in-tree with all four stages (A2-F9)" +else + fail "$FOLDIN_REF missing or does not name capture/classify/promote/enforce (A2-F9)" +fi + +# (A2-F9 self-contained) an adopter without the author's private contract still has the loop, +# and the four accumulation silos are named as one collection point +if grep -qF "not a gap" "$FOLDIN_REF" 2>/dev/null \ + && grep -qF "specifics-bank" "$FOLDIN_REF" 2>/dev/null \ + && grep -qF "ab-test" "$FOLDIN_REF" 2>/dev/null; then + pass "fold-in loop is adopter-self-contained and names the accumulation silos (A2-F9)" +else + fail "$FOLDIN_REF reads as requiring the author's private contract, or omits the silos (A2-F9)" +fi + +echo "" + +# --- Section 16v: Do-Next Contract (N15 / A2-F10,F12,F13,F14) --- +echo "--- Do-Next Contract (N15) ---" + +# Measurement that does not change the next piece is theatre. Before N15 every +# analytics surface ENDED in chat: report/analyze printed recommendations, ab-test +# printed a verdict, the 48h monitor printed a learning - and the next drafting +# session, a fresh context, never saw any of it (grep do.next across the repo = 0). +# N15 makes the channel a contract, so what is worth linting is BOTH ends of it plus +# the lifetime rule that keeps it honest: +# (A2-F10 writer) every measurement surface persists via recordDoNext with a source +# tag and an evidence pointer - a compound predicate (mutator named + +# source declared + evidence carried), so a non-vacuity self-test +# guards it (mirrors Sections 16p-16u). +# (A2-F12/F13) the A/B Adopt verdict and the 48h learning route in the SAME +# channel - a second private channel would re-fragment the loop. +# (reader) all four create surfaces read the section at Step 0, else the +# directives are written into a file nobody opens. +# (A2-F14) newsletter Step 1 calibrates on the PREVIOUS edition's measured +# numbers via the queue-id match, with an explicit honest-miss branch +# (an unmeasured edition must never be estimated). +# (lifetime) replace-by-source + age floor, in code - a section that only grows +# stops being a steering signal and becomes a backlog. +SU_N15="hooks/scripts/state-updater.mjs" +RPT_N15="commands/report.md" +ANL_N15="commands/analyze.md" +ABT_N15="commands/ab-test.md" +PFM_N15="agents/post-feedback-monitor.md" +AIN_N15="agents/analytics-interpreter.md" +NL_N15="commands/newsletter.md" +STATE_TPL_N15="config/state-file.template.md" + +donext_write_wired() { # $1 = text; wired iff it NAMES the mutator, DECLARES a source, and CARRIES evidence + echo "$1" | grep -qF "recordDoNext" \ + && echo "$1" | grep -qF "source:" \ + && echo "$1" | grep -qF "evidence" +} + +DN_SELFTEST_OK=1 +if ! donext_write_wired "call recordDoNext with source: 'report' and directives carrying an evidence pointer"; then + DN_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired do-next write probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if donext_write_wired "$probe"; then + DN_SELFTEST_OK=0; echo " false-positive FAIL: under-wired do-next write probe accepted -> $probe" + fi +done <<'NEGATIVE16V' +call recordDoNext with source: 'report' and two directives, no pointer to what was measured +persist the directives with an evidence pointer under source: 'report', mutator left unnamed +call recordDoNext with an evidence pointer for each directive, source never declared +NEGATIVE16V +if [ "$DN_SELFTEST_OK" -eq 1 ]; then + pass "do-next write self-test: predicate needs recordDoNext + source + evidence (1 accepted, 3 under-wired rejected)" +else + fail "do-next write self-test failed - the N15 wiring lint is vacuous or over-eager" +fi + +# (A2-F10 writers) the two analytics front-doors persist instead of printing-and-forgetting +if donext_write_wired "$(cat "$RPT_N15" 2>/dev/null)"; then + pass "/linkedin:report persists its recommendations as do-next directives (A2-F10)" +else + fail "report.md still ends its recommendations in chat - nothing reaches the next draft (A2-F10)" +fi + +if donext_write_wired "$(cat "$ANL_N15" 2>/dev/null)"; then + pass "/linkedin:analyze persists its recovery plan as do-next directives (A2-F10)" +else + fail "analyze.md diagnoses without persisting the corrections - re-diagnosed next session (A2-F10)" +fi + +# (A2-F12) an Adopt verdict is a decision the next post inherits, not a filed test result +if donext_write_wired "$(cat "$ABT_N15" 2>/dev/null)" && grep -qF "Adopt" "$ABT_N15" 2>/dev/null; then + pass "/linkedin:ab-test routes an Adopt verdict into the same do-next channel (A2-F12)" +else + fail "ab-test.md leaves an Adopt verdict in the test file only (A2-F12)" +fi + +# (A2-F13) the 48h learning is the one measurement taken closest to the next post +if donext_write_wired "$(cat "$PFM_N15" 2>/dev/null)" && grep -qF "48h-monitor" "$PFM_N15" 2>/dev/null; then + pass "post-feedback-monitor routes its 48h learning into the same do-next channel (A2-F13)" +else + fail "post-feedback-monitor.md keeps the 48h learning in chat (A2-F13)" +fi + +# (A2-F10 shape) the interpreter must EMIT directives the caller can persist verbatim +if grep -qF "do-next" "$AIN_N15" 2>/dev/null && grep -qF "evidence" "$AIN_N15" 2>/dev/null; then + pass "analytics-interpreter emits recommendations in do-next form (imperative + evidence)" +else + fail "analytics-interpreter.md emits observations, not persistable directives (A2-F10)" +fi + +# (reader) all four create surfaces read the section - a write nobody reads is still theatre +for READER_N15 in commands/post.md commands/quick.md commands/batch.md commands/create.md; do + if grep -qF "Do-Next Directives" "$READER_N15" 2>/dev/null; then + pass "$(basename "$READER_N15" .md) reads the do-next directives at Step 0" + else + fail "$READER_N15 drafts without reading the do-next directives - the loop is open" + fi +done + +# (A2-F14) long-form calibrates on the previous edition's MEASURED numbers, and says so +# out loud when there are none rather than estimating them +STEP1_N15=$(awk '/^## Step 1: Brief/{f=1} /^## Step 1.5:/{f=0} f' "$NL_N15") +if echo "$STEP1_N15" | grep -qF "Do-Next Directives" \ + && echo "$STEP1_N15" | grep -qF "queue.json" \ + && echo "$STEP1_N15" | grep -qF "analytics/posts" \ + && echo "$STEP1_N15" | grep -qF "Never estimate"; then + pass "newsletter Step 1 calibrates on the previous edition via queue-id match, with an honest-miss branch (A2-F14)" +else + fail "newsletter Step 1 does not read the previous edition's measured numbers (or may estimate them) (A2-F14)" +fi + +# (contract) the mutator exists and is reachable from a command line +if grep -qF "export function recordDoNext" "$SU_N15" 2>/dev/null; then + pass "state-updater exports recordDoNext (deterministic, testable, no AI in the write)" +else + fail "$SU_N15 has no recordDoNext export - the channel has no writer" +fi + +if grep -qF -- "--record-do-next" "$SU_N15" 2>/dev/null; then + pass "state-updater exposes the --record-do-next standalone verb" +else + fail "$SU_N15 has no --record-do-next CLI verb" +fi + +# (lifetime) both halves in code: a source replaces its OWN rows, and every row expires. +# Only one half and the section either grows forever or evicts other sources' directives. +if grep -qF "superseded by this write" "$SU_N15" 2>/dev/null \ + && grep -qF "past the age floor" "$SU_N15" 2>/dev/null; then + pass "do-next lifetime is replace-by-source AND age-bounded (the section cannot become a backlog)" +else + fail "$SU_N15 do-next lifetime rule is incomplete - a growing or self-evicting section" +fi + +# (contract, shipped) a fresh install carries the section and the scalar, so the very +# first report writes into a documented shape instead of inventing one +if grep -qF "## Do-Next Directives" "$STATE_TPL_N15" 2>/dev/null \ + && grep -qF "last_donext_date" "$STATE_TPL_N15" 2>/dev/null; then + pass "state-file template ships the Do-Next Directives section + last_donext_date scalar" +else + fail "$STATE_TPL_N15 does not ship the do-next section/scalar (fresh installs start off-contract)" +fi + +echo "" + +# --- Section 16w: Measure-Truth - Reach + Boundary Map (N16 / D-3, A2-F11, E#9) --- +echo "--- Measure-Truth: Reach + Boundary Map (N16) ---" + +# LinkedIn split a post's impressions into in-network and out-of-network in June +# 2026 - the first native number that says whether a post ACQUIRED audience or +# only resonated with the one already there. It is shown as a PERCENTAGE split in +# the Discovery panel and is absent from the CSV export, so the ingest is a manual +# percent column, exactly like saves. What is worth linting is the honesty of that +# ingest, because every failure mode here is silent: +# (D-3 contract) percent parsing refuses what it cannot know: unknown is never 0, +# a value above 100 is not a share (a count and a share are +# indistinguishable in one column), and a comma is a DECIMAL mark +# here - parseOptionalCount's US-thousands rule would read "36,5" +# as 365. +# (D-3 shape) one stored half, not two. The halves describe one split, so +# keeping both would let a record contradict itself; a transcribed +# pair is cross-checked and DISCARDED when it does not sum to ~100. +# (D-3 roll-up) the aggregate is impressions-WEIGHTED. A flat mean lets a +# 50-impression post at 90% outvote a 10,000-impression post. +# (D-3 boundary) reach never touches engagementRate - it is distribution, not +# engagement, and folding it in would break comparability with +# every historical import. +# (A2-F11) the report OFFERS a baseline diff and writes engagement-patterns.md +# only on an explicit operator go, to the per-user data dir - never +# silently, never into the plugin's shipped template. +# (E#9) the boundary map states what is measurable and what is not: +# dwell explicitly unmeasurable, saves partner-gated, reach native +# but CSV-export status UNVERIFIED. An unstated boundary gets +# quietly filled with an estimate. +CSVP_N16="scripts/analytics/src/parsers/csv-parser.ts" +TYPES_N16="scripts/analytics/src/models/types.ts" +STATS_N16="scripts/analytics/src/utils/stats.ts" +RPT_N16="commands/report.md" +IMP_N16="commands/import.md" +README_N16="README.md" +ADATA_N16="assets/analytics/README.md" + +patterns_update_gated() { # $1 = text; gated iff it NAMES the baseline file, REQUIRES a go, and FORBIDS silent writing + echo "$1" | grep -qF "audience-insights/engagement-patterns.md" \ + && echo "$1" | grep -qF "AskUserQuestion" \ + && echo "$1" | grep -qF "never write it silently" +} + +PU_SELFTEST_OK=1 +if ! patterns_update_gated "read audience-insights/engagement-patterns.md, offer a diff and never write it silently - ask with AskUserQuestion first"; then + PU_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-gated patterns-update probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if patterns_update_gated "$probe"; then + PU_SELFTEST_OK=0; echo " false-positive FAIL: under-gated patterns-update probe accepted -> $probe" + fi +done <<'NEGATIVE16W' +update audience-insights/engagement-patterns.md from the report, asking with AskUserQuestion when unsure +read audience-insights/engagement-patterns.md and never write it silently, applying the diff directly +offer the diff with AskUserQuestion and never write it silently, file left unnamed +NEGATIVE16W +if [ "$PU_SELFTEST_OK" -eq 1 ]; then + pass "patterns-update self-test: predicate needs baseline file + explicit go + no-silent-write (1 accepted, 3 under-gated rejected)" +else + fail "patterns-update self-test failed - the N16 A2-F11 lint is vacuous or over-eager" +fi + +# (D-3 contract) the percent ingest is its OWN parser, not saves' count parser +if grep -qF "function parseOptionalPercent" "$CSVP_N16" 2>/dev/null; then + pass "csv-parser has a dedicated percent parser for the reach share (D-3)" +else + fail "$CSVP_N16 has no parseOptionalPercent - a share is being parsed as a count (D-3)" +fi + +# (D-3 contract) a share above 100 is refused, and a comma is a decimal mark +if grep -qF "parsed > 100" "$CSVP_N16" 2>/dev/null \ + && grep -qF 'cleaned.replace(/,/g, ".")' "$CSVP_N16" 2>/dev/null; then + pass "reach parsing refuses >100 and reads a comma as a decimal mark (never 36,5 -> 365)" +else + fail "$CSVP_N16 reach parsing accepts a non-share value or mis-reads a decimal comma (D-3)" +fi + +# (D-3 shape) both halves reduce to ONE stored value, and a contradictory pair is dropped +if grep -qF "resolveOutOfNetworkPct" "$CSVP_N16" 2>/dev/null \ + && grep -qF "REACH_SPLIT_TOLERANCE" "$CSVP_N16" 2>/dev/null; then + pass "the reach split reduces to one stored half, with a bounded rounding tolerance (D-3)" +else + fail "$CSVP_N16 stores the reach split without a cross-check - a record can contradict itself (D-3)" +fi + +# (D-3 shape) the field is optional in the type, and in-network is documented as derived +if grep -qF "outOfNetworkPct?: number" "$TYPES_N16" 2>/dev/null \ + && grep -qF "complement by" "$TYPES_N16" 2>/dev/null; then + pass "PostMetrics carries the reach share as optional, with in-network documented as its complement" +else + fail "$TYPES_N16 reach field is not optional, or stores both halves (D-3)" +fi + +# (D-3 boundary) reach is kept out of the engagement rate +if grep -qF "NOT folded into engagementRate" "$TYPES_N16" 2>/dev/null; then + pass "reach is explicitly kept out of engagementRate (comparability with historical imports)" +else + fail "$TYPES_N16 does not state that reach stays out of engagementRate (D-3)" +fi + +# (D-3 roll-up) the aggregate is weighted, and zero total weight yields unknown - not 0, not NaN +if grep -qF "export function weightedOutOfNetworkPct" "$STATS_N16" 2>/dev/null \ + && grep -qF "totalWeight <= 0" "$STATS_N16" 2>/dev/null; then + pass "the reach roll-up is impressions-weighted and returns unknown on zero weight (never 0/NaN)" +else + fail "$STATS_N16 has no weighted reach roll-up, or can emit 0/NaN for an unweighted set (D-3)" +fi + +# (rendering) the import surface documents the manual column AND its partial coverage +if grep -qF "Reach entered:" "$IMP_N16" 2>/dev/null \ + && grep -qF "Out-of-network" "$IMP_N16" 2>/dev/null; then + pass "/linkedin:import documents the reach column and the coverage-aware output line" +else + fail "$IMP_N16 does not document the manual reach column (the field can never be filled)" +fi + +# (rendering) the report shows the split, reads it as acquisition-vs-resonance, and refuses +# to invent it when it was not entered +if grep -qF "Reach Split" "$RPT_N16" 2>/dev/null \ + && grep -qF "not entered for this period" "$RPT_N16" 2>/dev/null \ + && grep -qF "never estimate it" "$RPT_N16" 2>/dev/null; then + pass "/linkedin:report renders the reach split with an explicit unknown branch (never estimated)" +else + fail "$RPT_N16 renders no reach split, or may estimate an unentered one (D-3)" +fi + +# (do-next) a reach reading steers the next piece instead of being admired in the report +if grep -qF "Never persist a reach directive" "$RPT_N16" 2>/dev/null; then + pass "a reach insight routes into the do-next channel, and only when it was measured (N15 contract)" +else + fail "$RPT_N16 leaves the reach reading in the report - it never reaches the next draft" +fi + +# (A2-F11) the baseline update is real, gated, and aimed at the user's own copy +if patterns_update_gated "$(cat "$RPT_N16" 2>/dev/null)"; then + pass "/linkedin:report offers an operator-gated engagement-patterns update (A2-F11)" +else + fail "report.md updates the patterns baseline silently, or not at all (A2-F11)" +fi + +if grep -qF "never to the plugin's shipped" "$RPT_N16" 2>/dev/null; then + pass "the patterns update targets the per-user data dir, never the shipped template (A2-F11)" +else + fail "$RPT_N16 does not forbid writing the plugin's shipped patterns template (A2-F11)" +fi + +# (E#9) the boundary map is current on all three metrics: reach, saves, dwell +if grep -qF "In-network vs out-of-network reach" "$README_N16" 2>/dev/null \ + && grep -qF "unverified" "$README_N16" 2>/dev/null; then + pass "boundary map states the reach split as native-but-not-exported, export status unverified (E#9)" +else + fail "$README_N16 boundary map does not cover the reach split honestly (E#9)" +fi + +if grep -qF "Explicitly unmeasurable" "$README_N16" 2>/dev/null \ + && grep -qF "partner-gated" "$README_N16" 2>/dev/null; then + pass "boundary map keeps dwell explicitly unmeasurable and saves partner-gated (E#9)" +else + fail "$README_N16 boundary map lost the dwell/saves boundaries (E#9)" +fi + +# (entry rules) the operator-facing rules cover either-half entry and the cross-check +if grep -qF "Either half works" "$ADATA_N16" 2>/dev/null \ + && grep -qF "Both halves are cross-checked" "$ADATA_N16" 2>/dev/null; then + pass "the data README documents either-half entry and the split cross-check" +else + fail "$ADATA_N16 does not document the reach entry rules (the column will be filled wrong)" +fi + +echo "" + # --- Section 18: Assertion-Count Anti-Erosion (SC6) --- # The lint self-modifies its own checks, so a green run could mask a silently dropped # assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count @@ -1185,12 +2724,67 @@ echo "" # UNCONDITIONAL Section-16h checks (trends-capture self-test + cli.ts capture-handler grep + # trend-spotter capture-pointer grep) = 90; +4 for RE-R2b's four UNCONDITIONAL Section-16i checks # (trends-brief self-test + cli.ts brief-handler grep + trend-spotter brief-pointer grep + -# session-start surfacing grep) = 94. +# session-start surfacing grep) = 94; +5 for RE-R3a's five UNCONDITIONAL Section-16j checks +# (trends-score self-test + score.ts TrendScore-iface grep + types.ts score-field grep + +# trend-spotter dimensions grep + brief.ts composite-rank grep) = 99; +6 for RE-R3b's six +# UNCONDITIONAL Section-16k checks (lifecycle self-test + types.ts TrendStatus grep + types.ts +# surfacedCount grep + store.ts markSurfaced grep + brief.ts effectiveStatus grep + cli.ts +# act-verb grep) = 105; +6 for RE-R3c's six UNCONDITIONAL Section-16l checks (scheduler self-test +# + schedule.ts launchdPlist grep + schedule.ts crontabLine grep + cli.ts schedule-verb grep + +# run-daily.sh brief-invocation grep + run-daily.sh data-twin grep) = 111; +6 for RE-R3d's six +# UNCONDITIONAL Section-16m checks (temporal self-test + brief.ts temporalSignal grep + brief.ts +# b.temporal.rank cmp grep + brief.ts "first-mover" tier grep + cli.ts first-mover-days flag grep + +# cli.ts saturation-at flag grep) = 117; +6 for RE-R3e's six UNCONDITIONAL Section-16n checks +# (brief-diff self-test + brief.ts diffSurfaced grep + brief.ts parseSurfacedFrontmatter grep + +# brief.ts 'Nytt siden sist' section grep + cli.ts selectPriorBriefFile wiring grep + brief.ts +# 'surfaced: ' frontmatter grep) = 123; +6 for N8's six UNCONDITIONAL Section-16o checks (voice-chain +# self-test + voice-trainer chronicle-voice-profile grep + newsletter chronicle-voice-profile grep + +# newsletter chronicle-voice-drift-log read grep + newsletter Step-1 language-resolve grep + newsletter +# Step-8 auto-gull compound grep) = 129; +8 for N9's eight UNCONDITIONAL Section-16p checks (randsone-gate +# self-test + Step-9 randsone_gated compound grep + Step-9 title-strength grep + newsletter C-6 trigger-nouns +# grep + newsletter C-6 scoped-fact-reviewer grep + newsletter C-6 determinism-marker grep + fact-checker +# WebFetch grep + fact-reviewer WebFetch grep) = 137; +9 for N10's nine UNCONDITIONAL Section-16q checks +# (targetLevel-consumption self-test + no-hardcoded-line-manager absence grep + references target-level-span +# grep + personas opposite-end obligatorisk-sekundær grep + personas explicit-ceiling-justification grep + +# personas technical-end seed grep + edition-state targetLevel-slot grep + newsletter articles.NN.targetLevel +# persist grep + persona-reviewer targetLevel-consumption compound grep) = 146; +9 for N11's nine +# UNCONDITIONAL Section-16r checks (destillat-gate self-test + Step-2.5 destillat_gated compound grep + +# Step-8 distil-append grep + Step-8 record-usage grep + types.ts usedIn grep + bank.ts recordUsage grep + +# binding.ts boundSpecificIds grep + distillate.ts checkSkeleton grep + distillate.ts series-root +# placement grep) = 155; +11 for N12's eleven UNCONDITIONAL Section-16s checks (transition-protocol +# self-test + protocol-block compound grep + mirror-invariant grep + 16-phase coverage sweep + +# Step-10 register-complete grep + register.ts upsertEdition grep + register.ts completeEdition/ +# listEditions grep + register.ts data-dir placement grep + editionState.ts appendPhase grep + +# template phaseLog-doc grep + template phaseLog-slot grep) = 166; +13 for N13's thirteen +# UNCONDITIONAL Section-16t checks (slot-surface self-test + router wiring grep + calendar +# wiring grep + calendar opt-in grep + session-start two-block compound grep + coverage +# queue+register grep + data-dir placement grep + Step-10 nextFreeSlot grep + Step-10 --slot +# grep + scheduling-ref directional grep + scheduling-ref operator-grid grep + slot-template +# schema/opt-in grep + de-niche no-hardcoded-times/no-committed-grid check) = 179; +19 for +# N14's nineteen UNCONDITIONAL Section-16u checks (fold-in-capture self-test + four capture +# greps for Steps 2.5/3a/5.5/6.5 + Step-11 promotion grep + Step-11 phaseLog grep + Step-11 +# friction/never-write grep + Step-11 retro-transition/re-close grep + edition-state retro +# doc+slot grep + resumption retro-routing grep + headless background-mode grep + headless +# no-copy-paste grep + Step-6.5 background-routing grep + language-reviewer rules-file grep + +# Step-4 rules-file grep + Step-11 rules-file append grep + language-rules template opt-in +# check + fold-in-loop four-stage check + fold-in-loop adopter/silos grep) = 198; +15 for +# N15's fifteen UNCONDITIONAL Section-16v checks (do-next write self-test + report writer + +# analyze writer + ab-test Adopt writer + 48h-monitor writer + analytics-interpreter +# directive-shape grep + four create-surface reader greps + newsletter Step-1 queue-id/ +# honest-miss compound grep + recordDoNext export grep + --record-do-next CLI verb grep + +# lifetime replace-by-source/age-floor grep + state-template section/scalar grep) = 213; +# +15 for N16's fifteen UNCONDITIONAL Section-16w checks (patterns-update self-test + +# parseOptionalPercent grep + >100/decimal-comma refusal grep + resolve/tolerance +# cross-check grep + optional-field/complement grep + engagementRate-exclusion grep + +# weighted roll-up/zero-weight grep + import reach-column grep + report reach-split/ +# never-estimate compound grep + report reach do-next grep + A2-F11 gated-update +# compound grep + shipped-template write-ban grep + boundary-map reach/unverified grep + +# boundary-map dwell/saves grep + data-README entry-rules grep) = 228. # NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop # the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the # deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh # clone). Runs last so TOTAL_CHECKS sees every prior check. -ASSERT_BASELINE_FLOOR=94 +ASSERT_BASELINE_FLOOR=228 TOTAL_CHECKS=$((PASS + FAIL)) if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" diff --git a/scripts/trends/README.md b/scripts/trends/README.md index ed76e81..3b9fa30 100644 --- a/scripts/trends/README.md +++ b/scripts/trends/README.md @@ -39,22 +39,41 @@ interface TrendRecord { publishedAt?: string;// optional source publish date (ISO-8601); distinct from capturedAt, first-sight, never back-filled topics: string[]; // query tags; unioned across re-captures summary?: string; // optional, verbatim + score?: TrendScore; // persisted relevance (RE-R3a): { mode, dimensions, composite, priority } — REFRESHED on re-capture (RE-R3b, last-wins) + status?: TrendStatus; // lifecycle (RE-R3b): "new" | "acted" | "skipped"; absent ⇒ "new"; the brief excludes non-new + surfacedCount?: number; // seen-log (RE-R3b): distinct days surfaced in a brief; absent ⇒ 0; per-day idempotent + lastSurfacedAt?: string; // seen-log (RE-R3b): ISO date of the most recent surfacing } ``` -Fields (relevance score, first-mover timing, status) can be added in a later -slice without breaking the shape. +`score` is the persisted relevance envelope (RE-R3a): a capture **item** carries the +agent's **judgment** — `{ mode, dimensions }` (the five 1–10 dimension scores) — and the +store turns that into the persisted `TrendScore` `{ mode, dimensions, composite, priority }`, +computing the composite + band once via the single scorer owner (`src/score.ts`). It is +**refreshed on re-capture** (RE-R3b, last-wins — the timing dimension decays, so the newer +judgment supersedes the stored one; `score` is the one mutable field, provenance stays +first-sight); the score-free `add` manual path omits it. The morning brief ranks each bucket +on `composite` first (schema v4). + +The **lifecycle** fields (RE-R3b) are the trend's life after first capture: `status` is set by +the `act`/`skip`/`reset` verbs (a freshly-captured trend is implicitly `new`), and the seen-log +`surfacedCount`/`lastSurfacedAt` is recorded by `brief` (per-day idempotent) so the loop can avoid +re-surfacing handled work. ## CLI ```bash # Capture freshly-polled trends — the NORMALIZING BATCH path (the research agent's path): # raw items on stdin → validate+normalize each → dedupe on title+url → union topics on -# re-capture → persist the source's publishedAt. Content-invalid items are reported in the -# summary errors[], never fail the run; the summary is {added, duplicates, merged, errors}. +# re-capture → persist the source's publishedAt → persist the relevance score (when carried). +# Content-invalid items (incl. a malformed/out-of-range score) are reported in the summary +# errors[], never fail the run; the summary is {added, duplicates, merged, errors}. +# An item's "score" carries the agent's judgment (mode + the five 1–10 dimensions); the store +# computes the composite + band and persists the full TrendScore first-sight. echo '[{"source":"tavily","title":"Agentic workflows hit production", "url":"https://example.com/agentic","topics":["agents","engineering"], - "publishedAt":"2026-06-20","summary":"Teams ship multi-step agents past the demo stage."}]' \ + "publishedAt":"2026-06-20","summary":"Teams ship multi-step agents past the demo stage.", + "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}]' \ | node --import tsx src/cli.ts capture [--store ] [--json] # Add a SINGLE trend MANUALLY — raw flags, no normalization, publish-date-free: @@ -70,10 +89,23 @@ node --import tsx src/cli.ts query --topics "agents,engineering" [--json] # Time-scoped history — newest first, optionally windowed/capped node --import tsx src/cli.ts list [--since 2026-06-01] [--limit 10] [--json] -# Dated morning brief — rank the store by pillar-overlap then recency, write a dated -# Markdown file the SessionStart hook surfaces. Pillars come from the caller (user config). +# Dated morning brief — rank the store by composite then pillar-overlap then recency, write a +# dated Markdown file the SessionStart hook surfaces. Pillars come from the caller (user config). +# The brief EXCLUDES acted/skipped trends and RECORDS surfacing on the store (per-day idempotent) +# unless --no-mark. Pillars come from the caller (user config). node --import tsx src/cli.ts brief --pillars "agents,engineering" \ - [--fresh-days 7] [--out ] [--store ] [--json] + [--fresh-days 7] [--out ] [--no-mark] [--store ] [--json] + +# Lifecycle — mark a trend handled so the brief stops re-surfacing it (id shown in the brief / list --json): +node --import tsx src/cli.ts act --id # wrote about it +node --import tsx src/cli.ts skip --id # decided to pass on it +node --import tsx src/cli.ts reset --id # return it to the queue + +# Autonomous trigger (RE-R3c) — emit/install a daily headless brief. PRINT-FIRST: the tool never runs +# launchctl or the cron table; --install writes only the inert launchd plist file. Deterministic +# (no AI capture — a later slice). Default 07:00; --platform auto → launchd on macOS, cron on Linux. +node --import tsx src/cli.ts schedule --pillars "agents,engineering" \ + [--at 07:00] [--fresh-days 7] [--platform auto|launchd|cron] [--install|--uninstall] [--store ] ``` Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend @@ -91,9 +123,80 @@ ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/morning-brief/YYYY ``` The file's YAML frontmatter carries a single-line `summary` the SessionStart hook surfaces -verbatim (zero-tsx — it reads the Markdown, never the TS CLI). Ranking uses only persisted -fields; a persisted relevance score, an autonomous nightly trigger, and a seen-log freshness -model are later slices. +verbatim (zero-tsx — it reads the Markdown, never the TS CLI). As of RE-R3a the brief ranks +each bucket on the persisted relevance **composite first** (then pillar-overlap, then recency); +a scored entry shows `· ()` and the summary names the top entry's band. + +As of **RE-R3b** the brief is a **work queue**: it **excludes** `acted`/`skipped` trends, shows +each entry's `id` in backticks (copy-paste-ready for `act`/`skip --id`), flags a re-surfaced item +with `· sett Nx` (prior-day count, ≥2), and — unless `--no-mark` — **records surfacing** on the +store (`surfacedCount`/`lastSurfacedAt`, per-day idempotent) after the pure render. + +## Autonomous trigger + headless entry (RE-R3c) + +`schedule` makes the daily loop **closed**: it emits — print-first — a launchd plist (macOS) or cron +line (Linux) firing the brief every morning, plus the exact activation command. `--install` writes only +the **inert** launchd plist file under `~/Library/LaunchAgents/`; the tool **never** runs `launchctl` +or the cron table — you run the one printed command. `--uninstall` prints the removal recipe (and +removes the plist file if present). + +Both schedulers invoke one tested headless wrapper, `run-daily.sh`, which runs the **deterministic** +brief from a scheduler's profile-less environment: it resolves node (baked `NODE_BIN`, else +`command -v`, else common locations), `cd`s into the package so `tsx` resolves, and appends one compact +line ` exit= ` to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log`. + +The nightly run is **deterministic-brief-only (C1)**: it re-renders the brief from the current store +— freshness-aging drops stale trends, `surfacedCount` accumulates day-over-day — but does **not** poll +new sources. A double-fire on the same day is a safe no-op (RE-R3b per-day idempotency: byte-identical +`.md`, `surfacedCount` not double-counted). The autonomous AI capture step (poll → score → capture +before the brief) plugs into the documented seam in `run-daily.sh` as a later slice (e); a +brief-history diff is also a later slice. + +## Temporal overlay (RE-R3d) + +The brief applies a **derived temporal overlay** when it ranks — two signals computed at render time +from already-persisted fields (`publishedAt`/`capturedAt` → age, `surfacedCount` → self-exposure), so +**nothing new is stored** (`SCHEMA_VERSION` stays 4) and the signal can never go stale: + +- **first-mover** — recent (`ageDays ≤ --first-mover-days`, default **2**) AND never surfaced on a + prior day. Ranked up; badge `· 🥇 først ute`. A future-dated trend (`ageDays < 0`) is excluded. +- **saturation** — surfaced on `≥ --saturation-at` (default **3**) prior distinct days. Ranked down; + badge `· 🔁 mettet (Nx)`. This is **self-surfacing** ("you keep seeing this") from OUR seen-log — not + market coverage (that needs external polling, a later slice). +- **warming** (surfaced 1..at-1) keeps the RE-R3b `· sett Nx` badge, but **only at ≥2** (that badge + contract is unchanged); **neutral** (no exposure signal) carries no badge. + +Ranking integration: the relevance composite (RE-R3a) stays the **primary** sort key; the temporal +rank (first-mover↑ / saturated↓) is a new key inserted **after** pillar-overlap and **before** the +`effectiveDate` recency tiebreaker — so the overlay only re-orders *within* a (composite, overlap) +tier, never overriding relevance. Prior-day surfacings exclude today (via `lastSurfacedAt`), so a +same-day re-render is byte-identical. Tune per run with `--first-mover-days N` / `--saturation-at N` +(the scheduled nightly run uses the defaults). + +## Brief history + diff (RE-R3e) + +Each brief records the trend ids it showed into its own frontmatter — one `surfaced: ` line += `surfacedIds(ranking)` (the cohort the brief surfaced). This bumps the **artifact** schema +`BRIEF_SCHEMA_VERSION` **1 → 2**; the store's `SCHEMA_VERSION` stays **4** (no store field — the +membership lives in the artifact, the diff is derived at the CLI edge). + +When a brief is written, the CLI discovers the most recent **prior** dated file +(`selectPriorBriefFile`: the greatest `YYYY-MM-DD.md` strictly `< today`, so a same-day re-run diffs +against the true previous day and stays byte-identical), parses its `surfaced:` line +(`parseSurfacedFrontmatter`, degrading to "empty prior" on any absent/blank/malformed/pre-R3e file), +and computes the symmetric set difference (`diffSurfaced`): + +- **added** — in today, not in the prior brief: the headline "what's new", rendered with titles + resolved from the ranking under a `## 🆕 Nytt siden sist ()` section that **leads** the + ranked list. +- **carried** / **dropped** — in both / in the prior only: a one-line count (`N båret over, M ikke + vist i dag`). Framed "ikke vist i dag" (not "resolved") — a count cannot prove why a trend left. + +A ` N nye siden sist.` marker is appended to the one-line `summary:` the SessionStart hook surfaces, +so the delta shows **without opening the file** (no hook change). The three helpers are **pure** +(string/array in, value out); the directory + file reads live at the `cli.ts` edge, so `brief.ts` +stays fs-free. `--no-mark` is unaffected (it governs only the store seen-log; `surfaced:` always +records what the brief showed). ## Tests diff --git a/scripts/trends/run-daily.sh b/scripts/trends/run-daily.sh new file mode 100755 index 0000000..c6dc401 --- /dev/null +++ b/scripts/trends/run-daily.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# RE-R3c headless entry: runs the DETERMINISTIC morning brief from a scheduler's +# profile-less env (launchd/cron inherit no shell profile). No AI. +# +# The (e) slice will insert a pre-brief AI capture step here (poll -> score -> capture) +# before the `brief` call below; R3c builds ONLY the deterministic store -> artifact path. +# +# Data-path: the FOURTH sanctioned twin of store.ts:defaultStorePath / data-root.mjs:getDataRoot +# / analytics/src/utils/storage.ts:getDataRoot (the shell form of references/data-path-convention.md +# rule 1). Keep in sync. Bash 3.2-compatible: ASCII-only, all expansions quoted, no bash-4 features. +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 + if [ -x "$c" ]; then NODE_BIN="$c"; break; fi + 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 + +# `brief --json` is pretty-printed (cli.ts JSON.stringify(…, null, 2)); collapse it to ONE line. +OUT="$(printf '%s' "$OUT" | tr '\n' ' ' | tr -s ' ')" +printf '%s exit=%s %s\n' "$TS" "$CODE" "$OUT" >> "$LOG" +exit "$CODE" diff --git a/scripts/trends/src/arc.ts b/scripts/trends/src/arc.ts new file mode 100644 index 0000000..3c24bd1 --- /dev/null +++ b/scripts/trends/src/arc.ts @@ -0,0 +1,255 @@ +/** + * The arc (åre) layer — MR-F9 «innenfra og ut» output contract (N7.5). + * + * Discovery ranks candidates on SENDER fit ("does this suit my pillars"). This layer inverts the + * view: it reasons over the demand-sweep's READER-side judgment — which questions are actually asked + * (etterspørsel), whether the sender can answer them (kan-svare), and whether they are already + * answered elsewhere (ikke-besvart) — and groups related records into VEINS (arcs), the unit a warm + * topic really is. Each question in a vein is one actionable edition. + * + * Pure + deterministic by contract (like brief.ts): no fs, no clock, no AI, no network. The reader + * judgments (readerQuestion / painPoint / saturation / demand / verdict / actionability) are the + * demand-spotter agent's output, persisted on the record; this module only combines + classifies + + * ranks + renders them. GENERIC BY ARCHITECTURE: no niche calibration here — what counts as demand + * or as a grip is the sweep's judgment, carried by the fields, never hard-coded. + */ + +import type { TrendRecord, TrendVerdict, DemandSignal } from "./types.js"; + +/** + * The honest market verdict for a question (§4 "ærlig markedsdom"). The asymmetry the engine MUST + * express: a gap in the SUPPLY (many ask, none answer = gold) is not a gap in the DEMAND (none ask, + * but the sender can answer = highest value, lowest guaranteed audience). `saturated` = answered + * already (don't write). `unknown` = the sweep did not measure enough to judge (honest null). + */ +export type MarketGap = "supply-gap" | "demand-gap" | "saturated" | "unknown"; + +/** A reader-side view of the demand inputs, redeclared structurally so callers pass a plain shape. */ +interface GapInputs { + demand?: DemandSignal; + verdict?: TrendVerdict; + actionability?: { formulated: boolean }; +} + +/** + * Classify the honest market verdict (§4). Answered ⇒ saturated. Unanswered-and-asked (strong/ + * moderate) ⇒ supply-gap. Unanswered-but-thin, yet the sender can carry it (BÆRENDE verdict or a + * formulated grip) ⇒ demand-gap. Everything else (no demand, or thin with no way to answer) ⇒ + * unknown — never invented into a gap. Pure. + */ +export function classifyMarketGap(r: GapInputs): MarketGap { + const d = r.demand; + if (!d) return "unknown"; + if (d.answered) return "saturated"; + if (d.strength === "strong" || d.strength === "moderate") return "supply-gap"; + // thin, unanswered — a demand-gap only when the sender can actually answer it. + if (r.verdict === "BÆRENDE" || r.actionability?.formulated === true) return "demand-gap"; + return "unknown"; +} + +/** One reader question inside a vein — the record projected onto its demand-side facets, plus the derived gap + rank. */ +export interface ArcQuestion { + id: string; + /** The reader's OWN words (pass 3) — falls back to the record title when no readerQuestion is set. */ + question: string; + /** The field's words (the record title) — kept alongside so the "begge, side om side" contract holds. */ + fieldQuestion: string; + painPoint?: string; + saturation?: string; + verdict?: TrendVerdict; + /** actionability.formulated — whether a reader-actionable grip is formulated. */ + gripFormulated?: boolean; + demand?: DemandSignal; + /** The honest market verdict (§4). */ + gap: MarketGap; + /** Descending demand rank: `etterspørsel × kan-svare × ikke-besvart` (higher = write this first). */ + rank: number; +} + +/** etterspørsel — how strongly asked. Absent (unmeasured) ⇒ 0, so an unmeasured question never floats up. */ +function demandWeight(d: DemandSignal | undefined): number { + if (!d) return 0; + return d.strength === "strong" ? 3 : d.strength === "moderate" ? 2 : 1; +} + +/** ikke-besvart — false (open) outweighs unmeasured outweighs answered (saturated ⇒ 0, sinks). */ +function openWeight(d: DemandSignal | undefined): number { + if (!d) return 1; // unmeasured openness — between answered and known-open + return d.answered ? 0 : 2; +} + +/** kan-avsenderen-svare — the verdict (BÆRENDE > STØTTE > NYHET) plus a bump for a formulated grip. Min 1. */ +function answerWeight(verdict: TrendVerdict | undefined, grip: boolean | undefined): number { + const base = verdict === "BÆRENDE" ? 3 : verdict === "STØTTE" ? 2 : 1; + return base + (grip ? 1 : 0); +} + +/** + * Project a record onto a reader question: reader's words with a title fallback, the demand-side + * facets, the derived gap, and the multiplicative rank. Multiplicative by design (§4's `×`): a zero + * on any axis kills the rank — answered ⇒ saturated ⇒ 0, unmeasured demand ⇒ 0 — so saturated and + * unmeasured questions sink beneath any genuinely-asked, unanswered one. Pure. + */ +export function toArcQuestion(t: TrendRecord): ArcQuestion { + const grip = t.actionability?.formulated; + const rank = demandWeight(t.demand) * openWeight(t.demand) * answerWeight(t.verdict, grip); + return { + id: t.id, + question: t.readerQuestion && t.readerQuestion.trim().length > 0 ? t.readerQuestion : t.title, + fieldQuestion: t.title, + ...(t.painPoint !== undefined ? { painPoint: t.painPoint } : {}), + ...(t.saturation !== undefined ? { saturation: t.saturation } : {}), + ...(t.verdict !== undefined ? { verdict: t.verdict } : {}), + ...(grip !== undefined ? { gripFormulated: grip } : {}), + ...(t.demand !== undefined ? { demand: t.demand } : {}), + gap: classifyMarketGap(t), + rank, + }; +} + +/** rank desc, then id asc — a total order (id is the store's unique dedupe hash), so the output is fixed. */ +function byRankThenId(a: ArcQuestion, b: ArcQuestion): number { + return b.rank - a.rank || a.id.localeCompare(b.id); +} + +/** Rank a set of records as arc questions on `etterspørsel × kan-svare × ikke-besvart`. Pure, total order. */ +export function rankArcQuestions(records: TrendRecord[]): ArcQuestion[] { + return records.map(toArcQuestion).sort(byRankThenId); +} + +/** A vein: related records clustered, their questions ranked, and split into bearing (BÆRENDE) vs support. */ +export interface Arc { + /** Stable arc id — the lexicographically-smallest member id (deterministic, independent of input order). */ + id: string; + /** The vein's questions, ranked (rankArcQuestions order). */ + questions: ArcQuestion[]; + /** The bearing questions (verdict BÆRENDE) — what an edition is built on. */ + bearing: ArcQuestion[]; + /** The supporting questions (STØTTE / NYHET / unjudged) — context around the bearing ones. */ + support: ArcQuestion[]; + /** The vein's honest market verdict — the top-ranked question's gap (the dominant signal). */ + gap: MarketGap; +} + +/** + * Cluster records into veins by relatedIds TRANSITIVE closure (A→B, B→C ⇒ {A,B,C}), undirected: an + * edge exists if either record names the other. A record with no link and none pointing at it is its + * own singleton vein. Union-find over the record set; ids naming absent records are ignored. Pure. + */ +export function groupIntoArcs(records: TrendRecord[]): Arc[] { + const index = new Map(records.map((r) => [r.id, r])); + const parent = new Map(records.map((r) => [r.id, r.id])); + + const find = (x: string): string => { + let root = x; + while (parent.get(root) !== root) root = parent.get(root)!; + // Path-compress for stability across repeated finds. + let cur = x; + while (parent.get(cur) !== root) { + const next = parent.get(cur)!; + parent.set(cur, root); + cur = next; + } + return root; + }; + const union = (a: string, b: string): void => { + const ra = find(a); + const rb = find(b); + if (ra === rb) return; + // Attach the lexicographically-greater root under the smaller, so the component root is its min id. + if (ra < rb) parent.set(rb, ra); + else parent.set(ra, rb); + }; + + for (const r of records) { + for (const rel of r.relatedIds ?? []) { + if (index.has(rel)) union(r.id, rel); + } + } + + const groups = new Map(); + for (const r of records) { + const root = find(r.id); + const bucket = groups.get(root); + if (bucket) bucket.push(r); + else groups.set(root, [r]); + } + + const arcs: Arc[] = []; + for (const [root, members] of groups) { + const questions = rankArcQuestions(members); + arcs.push({ + id: root, + questions, + bearing: questions.filter((q) => q.verdict === "BÆRENDE"), + support: questions.filter((q) => q.verdict !== "BÆRENDE"), + gap: questions[0]?.gap ?? "unknown", + }); + } + + // Veins ordered by their hottest question, then arc id — highest-demand vein first, deterministically. + return arcs.sort((a, b) => (b.questions[0]?.rank ?? 0) - (a.questions[0]?.rank ?? 0) || a.id.localeCompare(b.id)); +} + +/** The human-readable market verdict line per gap (§4) — the honest "write here / don't". */ +function marketVerdictLine(gap: MarketGap): string { + switch (gap) { + case "supply-gap": + return "hull i tilbudet (mange spør, ingen svarer) — skriv her"; + case "demand-gap": + return "hull i etterspørselen (få spør, men avsenderen kan svare) — høyest verdi, lavest garantert publikum"; + case "saturated": + return "mettet (allerede besvart) — ikke skriv"; + case "unknown": + return "tynt / umålt signal — ikke fyll ut"; + } +} + +/** The compact demand token for a question line: strength + answered, when measured. */ +function demandToken(q: ArcQuestion): string { + if (!q.demand) return "umålt etterspørsel"; + return `etterspørsel: ${q.demand.strength} · besvart: ${q.demand.answered ? "ja" : "nei"}`; +} + +/** Render one question's inventory entry — the reader's words lead; the field's words follow (both, side by side). */ +function renderQuestion(q: ArcQuestion, n: number): string[] { + const out = [`${n}. ${q.question} · ${demandToken(q)} · [${q.gap}] · rank ${q.rank}`]; + if (q.fieldQuestion !== q.question) out.push(` - feltets ord: ${q.fieldQuestion}`); + if (q.painPoint) out.push(` - 🩹 smertepunkt: ${q.painPoint}`); + const grip = q.gripFormulated === undefined ? "—" : q.gripFormulated ? "ja" : "nei"; + out.push(` - 🎯 dom: ${q.verdict ?? "—"} · leser-grep: ${grip}`); + if (q.saturation) out.push(` - 🌡️ metning: ${q.saturation}`); + return out; +} + +/** A short, jargon-free vein label — the reader's words of its top question (never the field's title). */ +function arcTitle(arc: Arc): string { + return arc.questions[0]?.question ?? arc.id; +} + +/** + * Render the §4 output contract for a set of veins: per arc a jargon-free heading (the reader's + * words), the honest market verdict, the ranked question inventory, and the BÆRENDE-vs-STØTTE reading + * order. Deterministic — same arcs → byte-identical output (pure, no clock/fs). An empty set renders + * an explicit marker rather than nothing. + */ +export function renderArcs(arcs: Arc[]): string { + const lines: string[] = ["# Årer — etterspørsels-kart (innenfra og ut)", ""]; + if (arcs.length === 0) { + lines.push("_Ingen årer — tynt signal i denne kjøringen._", ""); + return lines.join("\n") + "\n"; + } + arcs.forEach((arc, i) => { + lines.push(`## Åre ${i + 1}: ${arcTitle(arc)}`); + lines.push(`- **Ærlig markedsdom:** ${marketVerdictLine(arc.gap)}`); + lines.push(""); + lines.push("### Spørsmålsinventar (rangert: etterspørsel × kan-svare × ikke-besvart)"); + arc.questions.forEach((q, n) => lines.push(...renderQuestion(q, n + 1))); + lines.push(""); + lines.push("### Arc-forslag (leserekkefølge)"); + lines.push(`- BÆRENDE: ${arc.bearing.length > 0 ? arc.bearing.map((q) => q.question).join(" · ") : "—"}`); + lines.push(`- STØTTE: ${arc.support.length > 0 ? arc.support.map((q) => q.question).join(" · ") : "—"}`); + lines.push(""); + }); + return lines.join("\n") + "\n"; +} diff --git a/scripts/trends/src/brief.ts b/scripts/trends/src/brief.ts index 5841e03..231f17c 100644 --- a/scripts/trends/src/brief.ts +++ b/scripts/trends/src/brief.ts @@ -16,11 +16,30 @@ import { join, dirname } from "node:path"; -import { defaultStorePath } from "./store.js"; +import { defaultStorePath, effectiveStatus } from "./store.js"; import type { TrendStore, TrendRecord } from "./types.js"; /** The morning-brief artifact's own format version (distinct from the store's SCHEMA_VERSION). */ -export const BRIEF_SCHEMA_VERSION = 1; +export const BRIEF_SCHEMA_VERSION = 2; + +/** + * The live temporal overlay (RE-R3d): two derived signals — first-mover (recent AND + * never surfaced on a prior day) and saturation (surfaced on >= saturationAt prior + * days). DERIVED at brief time from already-persisted fields (publishedAt/capturedAt + * -> ageDays, surfacedCount); never stored (no SCHEMA_VERSION bump), so it can never go + * stale. Mirrors ageDays: a function of the record + today, computed per rank, not persisted. + */ +export type TemporalTier = "first-mover" | "neutral" | "warming" | "saturated"; +export interface TemporalSignal { + /** recent AND never surfaced on a prior day — "you'd be early". */ + firstMover: boolean; + /** prior-day surfacings (surfacedCount ?? 0) — the self-exposure level saturation reads. */ + surfacings: number; + /** the ordinal class (best -> worst opportunity). */ + tier: TemporalTier; + /** descending sort rank: first-mover 3 > neutral 2 > warming 1 > saturated 0. */ + rank: number; +} /** One ranked trend in the brief, with its pillar overlap + freshness. */ export interface BriefEntry { @@ -33,6 +52,8 @@ export interface BriefEntry { effectiveDate: string; /** Whole days from effectiveDate to the injected `today` (negative if future). */ ageDays: number; + /** The live temporal overlay (RE-R3d) — derived per rank from ageDays + surfacedCount. */ + temporal: TemporalSignal; } /** The full ranking the brief renders from. */ @@ -46,11 +67,21 @@ export interface BriefRanking { singleMatches: BriefEntry[]; /** overlap >= 1 AND NOT fresh. */ olderMatched: BriefEntry[]; + /** + * The "I produksjon" board (N6, A1-7): records the operator has pulled out of the work queue — + * status `selected` (valgt, in progress) or `acted` (skrevet, done). Pillar-independent (already + * chosen), so they bypass the overlap filter entirely; sorted selected-before-acted, then title/url. + */ + inProduction: TrendRecord[]; } export interface RankOptions { /** Freshness window in days (effectiveDate within N days of today). Default 7. */ freshDays?: number; + /** first-mover recency window in days (ageDays <= N AND unsurfaced). Default 2 (RE-R3d). */ + firstMoverDays?: number; + /** surfacedCount at/above which a trend is "saturated". Default 3 (RE-R3d). */ + saturationAt?: number; } /** @@ -62,6 +93,32 @@ function ageDaysBetween(effectiveDate: string, today: string): number { return Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000); } +/** + * The live temporal overlay (RE-R3d): classify a trend's opportunity window from its age + + * self-exposure. Pure — every input is injected. first-mover = recent AND never surfaced on a + * prior day (the `ageDays >= 0` guard keeps a future-dated glitch out of "act now"); saturated = + * surfaced on >= saturationAt prior days; warming = surfaced 1..at-1; neutral = the no-signal + * baseline. `at` is clamped to >= 1 so a stray `saturationAt 0` cannot mark every trend saturated. + */ +export function temporalSignal( + ageDays: number, + surfacedCount: number | undefined, + opts: { firstMoverDays: number; saturationAt: number }, +): TemporalSignal { + const surfacings = surfacedCount ?? 0; + const at = Math.max(1, opts.saturationAt); + const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0; + const tier: TemporalTier = firstMover + ? "first-mover" + : surfacings >= at + ? "saturated" + : surfacings >= 1 + ? "warming" + : "neutral"; + const rank = tier === "first-mover" ? 3 : tier === "neutral" ? 2 : tier === "warming" ? 1 : 0; + return { tier, firstMover, surfacings, rank }; +} + /** * Rank the store against the user's pillars. Off-pillar trends (overlap 0) are * dropped; the rest bucket into top (>=2 & fresh), single (==1 & fresh), and older @@ -76,10 +133,21 @@ export function rankForBrief( opts: RankOptions = {}, ): BriefRanking { const freshDays = opts.freshDays ?? 7; + const firstMoverDays = opts.firstMoverDays ?? 2; + const saturationAt = opts.saturationAt ?? 3; const wantedLower = pillars.map((p) => p.toLowerCase()); const entries: BriefEntry[] = []; + const inProduction: TrendRecord[] = []; for (const trend of store.trends) { + const st = effectiveStatus(trend); + // N6 (A1-7): selected/acted are "in production" — collected for their own board, out of the queue. + if (st === "selected" || st === "acted") { + inProduction.push(trend); + continue; + } + // RE-R3b (A3): skipped is handled — drop from the work queue (the brief is a queue, not an archive). + if (st !== "new") continue; const have = new Set(trend.topics.map((t) => t.toLowerCase())); const matchedPillars: string[] = []; for (let i = 0; i < pillars.length; i++) { @@ -88,11 +156,38 @@ export function rankForBrief( const overlap = matchedPillars.length; if (overlap === 0) continue; // off-pillar noise const effectiveDate = trend.publishedAt ?? trend.capturedAt; - entries.push({ trend, overlap, matchedPillars, effectiveDate, ageDays: ageDaysBetween(effectiveDate, today) }); + const ageDays = ageDaysBetween(effectiveDate, today); + // Prior-DAY surfacings: the seen-log count EXCLUDING today. The brief renders BEFORE the CLI + // records today's surfacing, so on the first run of a day surfacedCount is already prior-day — + // but a same-day RE-RUN loads a count that already includes today (lastSurfacedAt === today), + // so subtract it back out. This keeps a same-day re-render byte-identical (RE-R3c SC7) and makes + // "first-mover" (prior surfacings === 0) stable across the render → mark step. + const surfaced = trend.surfacedCount ?? 0; + const priorSurfacings = trend.lastSurfacedAt === today ? Math.max(0, surfaced - 1) : surfaced; + entries.push({ + trend, + overlap, + matchedPillars, + effectiveDate, + ageDays, + temporal: temporalSignal(ageDays, priorSurfacings, { firstMoverDays, saturationAt }), + }); } + // Composite is the PRIMARY within-bucket key (RE-R3a / D2): a higher persisted relevance + // composite sorts first; an unscored record uses the sentinel -1 (composite is a weighted + // sum of [1,10] dims, so it is always >= 1.0 — -1 sorts unscored last and subtracts + // cleanly, where -Infinity - -Infinity = NaN would corrupt the comparator). Buckets are + // unchanged; composite only re-orders WITHIN a bucket. + // RE-R3d inserts the temporal-overlay rank (first-mover↑ / saturated↓) AFTER overlap and + // BEFORE effectiveDate: composite + overlap stay primary, and the overlay refines recency + // (a coarse temporal class) ahead of the raw effectiveDate it sits in front of. The existing + // overlap → temporal → effectiveDate → title → url chain still gives a total order (the + // (title,url) pair is the unique dedupe id; rank is a small integer, no NaN risk). const cmp = (a: BriefEntry, b: BriefEntry): number => + (b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) || b.overlap - a.overlap || + b.temporal.rank - a.temporal.rank || b.effectiveDate.localeCompare(a.effectiveDate) || a.trend.title.localeCompare(b.trend.title) || a.trend.url.localeCompare(b.trend.url); @@ -103,6 +198,12 @@ export function rankForBrief( const singleMatches = entries.filter((e) => e.overlap === 1 && isFresh(e)).sort(cmp); const olderMatched = entries.filter((e) => !isFresh(e)).sort(cmp); // overlap>=1 (0 already excluded) + // A total order for the production board: selected (in progress) before acted (done), then title, then url. + const prodRank = (t: TrendRecord): number => (effectiveStatus(t) === "selected" ? 0 : 1); + inProduction.sort( + (a, b) => prodRank(a) - prodRank(b) || a.title.localeCompare(b.title) || a.url.localeCompare(b.url), + ); + return { today, freshDays, @@ -110,6 +211,7 @@ export function rankForBrief( topMatches, singleMatches, olderMatched, + inProduction, }; } @@ -119,29 +221,114 @@ export function rankForBrief( * One line, no embedded double-quote, no newline, so the hook's extractYaml regex * (^summary: *"?([^"\n]*)"?) captures it whole. */ -export function briefSummary(ranking: BriefRanking): string { +export function briefSummary(ranking: BriefRanking, diff?: BriefDiff): string { + // RE-R3e: the day-over-day delta marker — appended only when a prior brief exists AND + // something is new (suppressed on the first brief / when nothing changed). Digits + ASCII + // + a period: no `"`/`\n`, so the hook's ^summary: regex still captures the line whole. + const delta = + diff && diff.priorDate !== null && diff.added.length > 0 ? ` ${diff.added.length} nye siden sist.` : ""; const fresh = ranking.totals.fresh; if (fresh > 0) { const top = ranking.topMatches[0] ?? ranking.singleMatches[0]; const pillar = top.matchedPillars[0]; - return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar} · ${top.ageDays}d).`; + // Band only (no mode) — the mode stays a body-entry detail to keep the one-line headline clean. + const band = top.trend.score ? ` · ${top.trend.score.priority}` : ""; + // RE-R3d: surface the first-mover marker on the one-line headline ("act now, you're early"). + const fm = top.temporal.firstMover ? " · 🥇 først ute" : ""; + return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band}${fm} · ${top.ageDays}d).${delta}`; } - return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).`; + return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).${delta}`; +} + +/** ` · ()` when scored, else "" — the band+mode token shared by both renders (RE-R3a). */ +function scoreToken(e: BriefEntry): string { + const s = e.trend.score; + return s ? ` · ${s.priority} (${s.mode})` : ""; +} + +/** + * The temporal-overlay badge (RE-R3d): promotes the R3b `· sett Nx` hint into a graded set. + * `first-mover` → `· 🥇 først ute`; `saturated` → `· 🔁 mettet (Nx)`; `warming` → `· sett Nx` + * ONLY at surfacings>=2 (preserving the EXACT R3b ≥2 display contract — the warming TIER still + * demotes a surfaced-once trend in rank, but its badge stays suppressed); `neutral` → "". + * The count is PRIOR-DAY (the brief renders before the CLI records today's surfacing). + */ +function temporalToken(e: BriefEntry): string { + const t = e.temporal; + if (t.tier === "first-mover") return " · 🥇 først ute"; + if (t.tier === "saturated") return ` · 🔁 mettet (${t.surfacings}x)`; + if (t.tier === "warming" && t.surfacings >= 2) return ` · sett ${t.surfacings}x`; + return ""; +} + +/** + * The N6 proposal fields as detailed `- ` lines (top entries only) — each emitted ONLY when present, + * so an unproposed trend renders exactly as before (backward-compat). Order is fixed for determinism. + * The verdict + reader-grip share one line (the N7 band-cap gate's two inputs, shown together). + */ +function proposalLines(t: TrendRecord): string[] { + const out: string[] = []; + if (t.angle) out.push(`- 💡 Vinkel: ${t.angle}`); + if (t.targetLevel) out.push(`- 🎚️ Målnivå: ${t.targetLevel}`); + if (t.rationale) out.push(`- 🧭 Hvorfor nå: ${t.rationale}`); + if (t.verdict || t.actionability) { + const grip = t.actionability + ? `${t.actionability.formulated ? "ja" : "nei"}${t.actionability.note ? ` («${t.actionability.note}»)` : ""}` + : "—"; + out.push(`- 🎯 Dom: ${t.verdict ?? "—"} · leser-grep: ${grip}`); + } + if (t.readerQuestion) out.push(`- ❓ Leserspørsmål: ${t.readerQuestion}`); + if (t.painPoint) out.push(`- 🩹 Smertepunkt: ${t.painPoint}`); + if (t.saturation) out.push(`- 🌡️ Metning: ${t.saturation}`); + // N7.5 (MR-F9): the demand-sweep's rankable signal, shown alongside the verbatim saturation above. + if (t.demand) out.push(`- 📊 Etterspørsel: ${t.demand.strength} · besvart: ${t.demand.answered ? "ja" : "nei"}`); + if (t.relatedIds && t.relatedIds.length > 0) out.push(`- ↔️ Relatert: ${t.relatedIds.join(", ")}`); + return out; +} + +/** The compact proposal token for single-line bullets: verdict + a reader-grip flag, when present. */ +function proposalToken(t: TrendRecord): string { + const bits: string[] = []; + if (t.verdict) bits.push(`🎯 ${t.verdict}`); + if (t.actionability) bits.push(`grep: ${t.actionability.formulated ? "ja" : "nei"}`); + return bits.length > 0 ? ` · ${bits.join(" · ")}` : ""; } function renderTopEntry(e: BriefEntry, n: number): string[] { const lines = [ `### ${n}. ${e.trend.title}`, - `- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d) · Pillarer: ${e.matchedPillars.join(", ")}`, + `- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``, ]; if (e.trend.summary) lines.push(`- ${e.trend.summary}`); + lines.push(...proposalLines(e.trend)); lines.push(`- 🔗 ${e.trend.url}`); lines.push(""); return lines; } function renderBulletEntry(e: BriefEntry): string { - return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d) · 🔗 ${e.trend.url}`; + return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)}${proposalToken(e.trend)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``; +} + +/** + * The "I produksjon" board (N6, A1-7): the selected/acted records rankForBrief set aside, each a + * compact line tagged [valgt]/[skrevet]. Pure. The section header is always emitted (stable structure); + * an empty board renders the explicit "_Ingen i produksjon._" marker. + */ +function renderInProduction(records: TrendRecord[]): string[] { + const lines = ["## 🚧 I produksjon (valgt + skrevet)"]; + if (records.length === 0) { + lines.push("_Ingen i produksjon._", ""); + return lines; + } + for (const t of records) { + const tag = t.status === "acted" ? "skrevet" : "valgt"; + const angle = t.angle ? ` · 💡 ${t.angle}` : ""; + const verdict = t.verdict ? ` · 🎯 ${t.verdict}` : ""; + lines.push(`- [${tag}] **${t.title}**${angle}${verdict} · \`${t.id}\``); + } + lines.push(""); + return lines; } /** @@ -149,15 +336,21 @@ function renderBulletEntry(e: BriefEntry): string { * summary, store stats, ranking descriptor, schemaVersion) + a three-section body. * All three section headers are always emitted (stable structure → determinism). */ -export function renderBrief(ranking: BriefRanking): string { +export function renderBrief( + ranking: BriefRanking, + diff: BriefDiff = { priorDate: null, added: [], carried: [], dropped: [] }, +): string { const { totals } = ranking; const lines: string[] = []; lines.push("---"); lines.push(`date: ${ranking.today}`); - lines.push(`summary: ${briefSummary(ranking)}`); + lines.push(`summary: ${briefSummary(ranking, diff)}`); lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`); - lines.push(`ranking: pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}`); + lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped/selected (selected+acted shown in I produksjon)`); + // RE-R3e: the set of ids this brief showed — the record the NEXT day's diff reads. Always + // emitted (even blank for an empty store); independent of --no-mark (a property of the render). + lines.push(`surfaced: ${surfacedIds(ranking).join(",")}`); lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`); lines.push("---"); lines.push(""); @@ -168,6 +361,27 @@ export function renderBrief(ranking: BriefRanking): string { ); lines.push(""); + // RE-R3e: the day-over-day delta leads, then the full ranked list below. + lines.push(diff.priorDate !== null ? `## 🆕 Nytt siden sist (${diff.priorDate})` : "## 🆕 Nytt siden sist"); + if (diff.priorDate === null) { + lines.push(diff.added.length > 0 ? "_Første brief — alt nedenfor er nytt._" : "_Første brief._", ""); + } else if (diff.added.length === 0) { + lines.push( + `_Ingenting nytt siden ${diff.priorDate}._`, + `_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, + "", + ); + } else { + const byId = new Map( + [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched].map((e) => [e.trend.id, e]), + ); + for (const id of diff.added) { + const e = byId.get(id); + if (e) lines.push(renderBulletEntry(e)); + } + lines.push(`_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, ""); + } + lines.push("## 🎯 Topp-treff (2+ pillarer)"); if (ranking.topMatches.length === 0) { lines.push("_Ingen i dag._", ""); @@ -184,12 +398,79 @@ export function renderBrief(ranking: BriefRanking): string { ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e))); lines.push(""); + // N6 (A1-7): the production board — where a triaged candidate lives once it leaves the queue. + lines.push(...renderInProduction(ranking.inProduction)); + lines.push("---"); lines.push("_Neste steg: /linkedin:react · /linkedin:post · /linkedin:newsletter_"); return lines.join("\n") + "\n"; } +/** + * The ids of the entries renderBrief actually shows: topMatches ∪ singleMatches ∪ the first 5 + * olderMatched (mirroring the render's .slice(0,5)). The brief CLI feeds these to markSurfaced so + * the seen-log records exactly what the operator saw. Pure (RE-R3b). + */ +export function surfacedIds(ranking: BriefRanking): string[] { + return [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id); +} + +/** + * RE-R3e — the day-over-day diff of a brief's surfaced cohort against the most recent + * prior brief. `priorDate` null ⇒ no prior (the first brief / a fresh data dir). + * `added`/`carried`/`dropped` are the three DISJOINT partitions of the symmetric set + * difference (an id is in exactly one), each order-stable. Pure: the id lists + the + * prior date are injected by the CLI edge (no fs/clock here — like `today`/`pillars`). + */ +export interface BriefDiff { + priorDate: string | null; + added: string[]; + carried: string[]; + dropped: string[]; +} + +/** The symmetric set difference of today's surfaced ids against the prior brief's. Pure (no fs/clock). */ +export function diffSurfaced(currentIds: string[], priorIds: string[], priorDate: string | null): BriefDiff { + const prior = new Set(priorIds); + const cur = new Set(currentIds); + return { + priorDate, + added: currentIds.filter((id) => !prior.has(id)), + carried: currentIds.filter((id) => prior.has(id)), + dropped: priorIds.filter((id) => !cur.has(id)), + }; +} + +/** + * Extract a brief's `surfaced:` id list from its full text via one line-anchored regex + * (the hook's `extractYaml` idiom). Absent / blank / malformed → [] (a pre-R3e or + * hand-edited brief degrades to "empty prior"); never throws. Ids are comma-free hex. + */ +export function parseSurfacedFrontmatter(md: string): string[] { + const m = md.match(/^surfaced: *([^\n]*)/m); + if (!m) return []; + return m[1] + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** + * The most recent prior brief's filename: the lexicographically greatest `YYYY-MM-DD.md` + * strictly < `${today}.md` (ISO dates sort = date order), else null. Excludes today + any + * future-dated file, so a same-day re-run diffs against the true previous day (determinism). + */ +export function selectPriorBriefFile(filenames: string[], today: string): string | null { + const todayFile = `${today}.md`; + return ( + filenames + .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f) && f < todayFile) + .sort() + .pop() ?? null + ); +} + /** * Default brief directory under the per-user data dir, DERIVED from * defaultStorePath() so root resolution lives in exactly one place: diff --git a/scripts/trends/src/cli.ts b/scripts/trends/src/cli.ts index 8e0618d..ee7f859 100644 --- a/scripts/trends/src/cli.ts +++ b/scripts/trends/src/cli.ts @@ -7,18 +7,32 @@ * node --import tsx src/cli.ts query --topics [--store ] [--json] * node --import tsx src/cli.ts list [--since ] [--limit ] [--store ] [--json] * node --import tsx src/cli.ts status [--store ] [--json] + * node --import tsx src/cli.ts act|skip|reset|select (--id | --ids ) [--store ] * echo '' | node --import tsx src/cli.ts normalize * echo '' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N] * echo '' | node --import tsx src/cli.ts capture [--store ] [--json] - * node --import tsx src/cli.ts brief [--pillars ] [--fresh-days N] [--out ] [--store ] [--json] + * node --import tsx src/cli.ts brief [--pillars ] [--fresh-days N] [--first-mover-days N] [--saturation-at N] + * [--out ] [--no-mark] [--store ] [--json] + * node --import tsx src/cli.ts arcs [--topics ] [--out ] [--store ] [--json] + * node --import tsx src/cli.ts schedule --pillars [--at HH:MM] [--fresh-days N] + * [--platform auto|launchd|cron] [--install|--uninstall] [--store ] * * The capture agent (research-engine) folds freshly-polled trends into the store via * `capture` (the normalizing batch path: stdin → normalizeItem(s) → itemToInput → - * addTrend), and reasons over accumulated history via `query`/`list`. `brief` (RE-R2b) + * addTrend) — which, when an item carries the agent's five judgment scores (RE-R3a), + * persists an optional relevance `score` (the deterministically-computed composite + band, + * one owner) first-sight on the record so the morning brief ranks on it — and reasons over + * accumulated history via `query`/`list`. `brief` (RE-R2b) * renders a dated, pillar-ranked morning brief over the store to a Markdown file the - * SessionStart hook surfaces. `add` is the MANUAL single-trend path (raw flags, no - * normalization, publish-date-free). The polling + relevance-scoring itself lives - * upstream; this is the deterministic store. + * SessionStart hook surfaces; RE-R3d adds a DERIVED temporal overlay (first-mover up / saturated + * down) as a within-tier ranking refinement, tunable via `--first-mover-days`/`--saturation-at`. + * `add` is the MANUAL single-trend path (raw flags, no + * normalization, publish-date-free). `act`/`skip`/`reset`/`select` set a trend's lifecycle status + * (RE-R3b + N6 `selected`), one id (`--id`) or a batch (`--ids a,b,c`, A1-9): the morning brief + * EXCLUDES acted/skipped/selected from the work queue (selected+acted show in "I produksjon" + * instead) and records each surfacing (per-day-idempotent + * `surfacedCount`) so the loop stops re-surfacing handled work; a re-capture refreshes the score + * (timing decays). The polling + relevance-scoring itself lives upstream; this is the deterministic store. * * `normalize` + `score` (RE-R1) and `capture` (RE-R2a) are the deterministic * research-engine seam: all read their JSON PAYLOAD FROM STDIN (so they do not overload @@ -27,25 +41,50 @@ * `capture` normalizes + folds each valid item into the store (persisting `publishedAt`), * reporting content-invalid items in the summary `errors[]`, never via the exit code. * - * Exit code: 0 on success, 2 on usage error (incl. unparseable stdin / bad flag). + * `schedule` (RE-R3c) is PRINT-FIRST: it emits a launchd plist (macOS) / cron line (Linux) firing the + * DETERMINISTIC `brief` daily via the `run-daily.sh` headless wrapper, plus the exact activation command. + * `--install` writes only the inert launchd plist FILE; the tool never runs `launchctl` or the cron table + * (the operator runs the one printed command). No AI capture in the nightly run — that is a later slice. + * + * Exit code: 0 on success, 2 on usage error or a not-found id (act/skip/reset). A wrong --id is an + * argument-class error; capture's content-invalid items stay in errors[] (never via the exit code). + * `schedule` adds no new exit code: an autonomy install never RUNS the system mutation. */ -import { readFileSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; import { addTrend, defaultStorePath, history, loadStore, + markSurfaced, newestCaptureDate, queryByTopic, saveStore, + setStatus, + setStatusMany, } from "./store.js"; +import type { TrendStatus } from "./types.js"; import { normalizeItem, normalizeItems, itemToInput } from "./item.js"; import { triage } from "./score.js"; import type { ScoreMode } from "./score.js"; -import { rankForBrief, renderBrief, briefSummary, defaultBriefDir } from "./brief.js"; +import { + rankForBrief, + renderBrief, + briefSummary, + defaultBriefDir, + surfacedIds, + diffSurfaced, + parseSurfacedFrontmatter, + selectPriorBriefFile, +} from "./brief.js"; +import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js"; +import type { ScheduleSpec } from "./schedule.js"; +import { groupIntoArcs, renderArcs } from "./arc.js"; function parseFlags(args: string[]): Record { const out: Record = {}; @@ -81,10 +120,13 @@ function usage(msg: string): never { " query --topics [--store ] [--json]\n" + " list [--since ] [--limit ] [--store ] [--json]\n" + " status [--store ] [--json]\n" + + " act|skip|reset|select (--id | --ids ) [--store ]\n" + " normalize < raw-item-or-batch.json\n" + " score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" + " capture [--store ] [--json] < raw-item-or-batch.json\n" + - " brief [--pillars ] [--fresh-days N] [--out ] [--store ] [--json]", + " brief [--pillars ] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out ] [--no-mark] [--store ] [--json]\n" + + " arcs [--topics ] [--out ] [--store ] [--json]\n" + + " schedule --pillars [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store ]", ); process.exit(2); } @@ -208,6 +250,31 @@ function main(): void { return; } + if (command === "act" || command === "skip" || command === "reset" || command === "select") { + // Ids from --ids (batch, A1-9) or a single --id ; --ids wins when both are given. + // splitTopics is the generic comma-split+trim+drop-blank helper (ids are case-sensitive, not lowercased). + const ids = + flags.ids && flags.ids !== "true" + ? splitTopics(flags.ids) + : flags.id && flags.id !== "true" + ? [flags.id] + : []; + if (ids.length === 0) usage(`${command} needs --id or --ids `); + const status: TrendStatus = + command === "act" ? "acted" : command === "skip" ? "skipped" : command === "select" ? "selected" : "new"; + const store = loadStore(storePath); + const res = setStatusMany(store, ids, status); + // All-miss is an argument-class error (exit 2, store untouched); any hit saves + reports the misses (exit 0). + if (res.found.length === 0) { + console.error(`error: no trend with id: ${res.notFound.join(", ")}`); + process.exit(2); + } + saveStore(storePath, store); + const miss = res.notFound.length > 0 ? ` (${res.notFound.length} not found: ${res.notFound.join(", ")})` : ""; + console.log(`Marked ${res.found.length} ${status}${miss}`); + return; + } + if (command === "normalize") { const payload = readStdinJson(); const out = Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload); @@ -246,7 +313,7 @@ function main(): void { const { items, errors } = normalizeItems(raw); const store = loadStore(storePath); // Tally derived from AddResult {added, merged} (no `duplicates` field): a fold is - // `added` (new), else `merged` (existing gained topics), else a plain `duplicate`. + // `added` (new), else `merged` (existing gained topics and/or a refreshed score), else a plain `duplicate`. let added = 0; let merged = 0; let duplicates = 0; @@ -276,21 +343,196 @@ function main(): void { if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer"); freshDays = n; } + // RE-R3d temporal-overlay thresholds (defaults mirror brief.ts's RankOptions defaults). + let firstMoverDays = 2; + if (flags["first-mover-days"] && flags["first-mover-days"] !== "true") { + const n = Number.parseInt(flags["first-mover-days"], 10); + if (Number.isNaN(n) || n < 0) usage("--first-mover-days must be a non-negative integer"); + firstMoverDays = n; + } + let saturationAt = 3; + if (flags["saturation-at"] && flags["saturation-at"] !== "true") { + const n = Number.parseInt(flags["saturation-at"], 10); + if (Number.isNaN(n) || n < 1) usage("--saturation-at must be a positive integer"); + saturationAt = n; + } // A bare `--out` yields the string "true" (parseFlags); the guard falls back to // defaultBriefDir() so it never writes to ./true. const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir(); const day = today(); // one wall-clock read for both the ranking and the filename - const ranking = rankForBrief(loadStore(storePath), pillars, day, { freshDays }); - const md = renderBrief(ranking); + const store = loadStore(storePath); // hoisted: also needed for the surfacing write below + const ranking = rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt }); + // RE-R3e: discover the most recent prior brief in outDir and diff today's surfaced cohort + // against its `surfaced:` line. Any fs error (no dir, unreadable file) degrades to the + // empty-prior (first-brief) path. The dir read lives HERE (the edge) — brief.ts stays fs-free. + const todayIds = surfacedIds(ranking); + let priorIds: string[] = []; + let priorDate: string | null = null; + try { + if (existsSync(outDir)) { + const priorFile = selectPriorBriefFile(readdirSync(outDir), day); + if (priorFile) { + priorIds = parseSurfacedFrontmatter(readFileSync(join(outDir, priorFile), "utf8")); + priorDate = priorFile.slice(0, 10); + } + } + } catch { + priorIds = []; + priorDate = null; + } + const diff = diffSurfaced(todayIds, priorIds, priorDate); + const md = renderBrief(ranking, diff); const path = join(outDir, `${day}.md`); mkdirSync(outDir, { recursive: true }); writeFileSync(path, md, "utf8"); - const summary = briefSummary(ranking); // SAME source the frontmatter carries + // RE-R3b: record surfacing on the store AFTER the pure render (per-day idempotent), unless --no-mark. + // The handled (acted/skipped) records were filtered from the ranking but remain in `store`, so the + // resave preserves them; only the surfaced ids' surfacedCount/lastSurfacedAt change. + const mark = flags["no-mark"] !== "true"; + const marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0; + if (mark) saveStore(storePath, store); + const summary = briefSummary(ranking, diff); // SAME source the frontmatter carries (RE-R3e: incl. the delta marker) if (asJson) { - console.log(JSON.stringify({ path, date: ranking.today, totals: ranking.totals, summary }, null, 2)); + console.log( + JSON.stringify( + { + path, + date: ranking.today, + totals: ranking.totals, + summary, + marked, + diff: { + priorDate: diff.priorDate, + added: diff.added.length, + carried: diff.carried.length, + dropped: diff.dropped.length, + }, + }, + null, + 2, + ), + ); return; } - console.log(`Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh)`); + const deltaNote = diff.added.length > 0 && diff.priorDate !== null ? `, ${diff.added.length} nye siden sist` : ""; + console.log( + `Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)${deltaNote}`, + ); + return; + } + + if (command === "arcs") { + // The demand-sweep's §4 output (MR-F9, N7.5): group the demand-swept records into veins (arcs) + // and render the reader-side output contract — question inventory ranked on etterspørsel × kan-svare + // × ikke-besvart, per-vein market verdict (supply-gap ≠ demand-gap ≠ saturated), BÆRENDE/STØTTE order. + // Reads only records that carry a demand-side signal (the sweep populated them); an optional --topics + // narrows the pool to a theme. Print to stdout by default; --out writes a dated arc map; --json + // emits the arc structures. No store mutation (a read-only view, like brief without the surfacing write). + const store = loadStore(storePath); + const wanted = splitTopics(flags.topics).map((t) => t.toLowerCase()); + const swept = store.trends.filter((t) => { + const hasSignal = + t.demand !== undefined || + t.readerQuestion !== undefined || + t.painPoint !== undefined || + t.saturation !== undefined; + if (!hasSignal) return false; + if (wanted.length === 0) return true; + const have = new Set(t.topics.map((x) => x.toLowerCase())); + return wanted.some((w) => have.has(w)); + }); + const arcs = groupIntoArcs(swept); + if (asJson) { + console.log(JSON.stringify(arcs, null, 2)); + return; + } + const md = renderArcs(arcs); + if (flags.out && flags.out !== "true") { + const outDir = flags.out; + mkdirSync(outDir, { recursive: true }); + const path = join(outDir, `${today()}-arcs.md`); + writeFileSync(path, md, "utf8"); + console.log(`Wrote arc map: ${path} (${arcs.length} åre(r), ${swept.length} swept record(s))`); + return; + } + process.stdout.write(md); + return; + } + + if (command === "schedule") { + // RE-R3c — print-first autonomous trigger. Emits (or installs) a launchd plist / cron line firing + // the DETERMINISTIC brief daily via run-daily.sh; never runs launchctl or the cron table (C2). + const pillars = splitTopics(flags.pillars); + if (pillars.length === 0) usage("schedule needs --pillars "); + + const at = flags.at && flags.at !== "true" ? flags.at : "07:00"; + const [hStr, mStr] = at.split(":"); + const hour = Number.parseInt(hStr, 10); + const minute = Number.parseInt(mStr ?? "", 10); + if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { + usage("--at must be HH:MM (00:00-23:59)"); + } + + let freshDays = 7; + if (flags["fresh-days"] && flags["fresh-days"] !== "true") { + const n = Number.parseInt(flags["fresh-days"], 10); + if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer"); + freshDays = n; + } + + const platformFlag = flags.platform && flags.platform !== "true" ? flags.platform : "auto"; + if (platformFlag !== "auto" && platformFlag !== "launchd" && platformFlag !== "cron") { + usage("--platform must be auto|launchd|cron"); + } + const platform: "launchd" | "cron" = + platformFlag === "auto" + ? process.platform === "darwin" + ? "launchd" + : "cron" + : (platformFlag as "launchd" | "cron"); + + // Resolve every absolute path FROM THE RUNTIME (never hard-coded → domain-general). + const here = dirname(fileURLToPath(import.meta.url)); // .../scripts/trends/src + const wrapperPath = join(here, "..", "run-daily.sh"); + const workingDir = join(here, ".."); + const nodeBin = process.execPath; + // logPath is anchored to the DATA ROOT (dirname(defaultStorePath())), NOT the --store override, so a + // custom --store never splits the plist StandardOutPath from the wrapper's own cron.log (sibling of + // morning-brief/, matching brief.ts defaultBriefDir's dirname(defaultStorePath()) idiom). + const logPath = join(dirname(defaultStorePath()), "cron.log"); + const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); + // env is canonical: always NODE_BIN + a resolved-absolute data root — pins the scheduled run to the + // install-time root AND removes the wrapper's $HOME-unset `set -u` edge under a profile-less env. + const env: Record = { NODE_BIN: nodeBin, LINKEDIN_STUDIO_DATA: root }; + + // The wrapper hard-codes the `brief` subcommand → the baked args carry NO leading "brief". + const args = ["--pillars", pillars.join(","), "--fresh-days", String(freshDays)]; + if (flags.store && flags.store !== "true") args.push("--store", storePath); + + const label = defaultLabel(); + const spec: ScheduleSpec = { platform, label, nodeBin, wrapperPath, args, hour, minute, logPath, workingDir, env }; + const plistTarget = join(homedir(), "Library", "LaunchAgents", `${label}.plist`); + + if (flags.uninstall === "true") { + console.log(uninstallInstructions(spec, platform === "launchd" ? plistTarget : undefined)); + if (platform === "launchd" && existsSync(plistTarget)) rmSync(plistTarget); + return; + } + if (flags.install === "true") { + if (platform === "launchd") { + mkdirSync(dirname(plistTarget), { recursive: true }); + writeFileSync(plistTarget, launchdPlist(spec), "utf8"); + console.log(plistTarget); + console.log(installInstructions(spec, plistTarget)); + } else { + console.log(crontabLine(spec)); + console.log(installInstructions(spec)); + } + return; + } + // default / --print — emit the artifact + the activation command. No fs. + console.log(platform === "launchd" ? launchdPlist(spec) : crontabLine(spec)); + console.log(installInstructions(spec, platform === "launchd" ? plistTarget : undefined)); return; } diff --git a/scripts/trends/src/item.ts b/scripts/trends/src/item.ts index 30119c7..ab4b38d 100644 --- a/scripts/trends/src/item.ts +++ b/scripts/trends/src/item.ts @@ -18,6 +18,9 @@ import { normalizeField } from "./store.js"; import type { TrendInput } from "./store.js"; +import { requiredDimensions, scoreEnvelope, capForActionability } from "./score.js"; +import type { ScoreMode, DimensionScores } from "./score.js"; +import type { TrendVerdict, Actionability, DemandSignal } from "./types.js"; export interface TrendItem { /** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */ @@ -36,6 +39,36 @@ export interface TrendItem { topics: string[]; /** Optional short summary, VERBATIM. Absent/blank -> the key is omitted. */ summary?: string; + /** + * The agent's relevance JUDGMENT (RE-R3a): the mode + the five 1–10 dimension scores — + * NOT a precomputed composite (the store computes that, one owner). Validated by + * normalizeItem; turned into the persisted envelope by itemToInput→scoreEnvelope. + * Absent/invalid -> the key is omitted. + */ + score?: { mode: ScoreMode; dimensions: DimensionScores }; + + // ── N6 proposal fields. Free-text ones follow the `summary` idiom (blank -> key omitted, never an + // error); the two TYPED ones (verdict/actionability) hard-fail when present-but-malformed, like score. ── + /** Proposed article angle (A1-4), VERBATIM. Blank/absent -> omitted. */ + angle?: string; + /** Proposed target level on the user profile's own span — free string, never an enum. Blank/absent -> omitted. */ + targetLevel?: string; + /** Why-now / applicability rationale (A1-4), VERBATIM. Blank/absent -> omitted. */ + rationale?: string; + /** Related trend ids (A1-5): normalized to non-empty strings, deduped. Non-array/empty -> omitted. */ + relatedIds?: string[]; + /** Reader-grip signal (MR-F7). Present-but-malformed -> validation error. */ + actionability?: Actionability; + /** Reader-side utility verdict (MR-F7). Present-but-out-of-vocab -> validation error. */ + verdict?: TrendVerdict; + /** Reader's own question (MR-F9, filled downstream by N7.5). Blank/absent -> omitted. */ + readerQuestion?: string; + /** Cost/risk/duty/tool the topic hits (MR-F9). Blank/absent -> omitted. */ + painPoint?: string; + /** Market saturation judgment (MR-F9). Blank/absent -> omitted. */ + saturation?: string; + /** Demand-sweep signal (MR-F9, N7.5). Present-but-malformed -> validation error (like actionability). */ + demand?: DemandSignal; } export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] }; @@ -63,6 +96,103 @@ function isNonEmptyString(v: unknown): v is string { return typeof v === "string" && v.trim().length > 0; } +/** A plain (non-array, non-null) object. */ +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +const SCORE_MODES = ["kortform", "long-form"] as const; + +/** + * Validate a raw `score` structurally — never throws (returns a reason on failure). The + * mode must be known; `dimensions` must be a non-array object carrying every key the mode + * requires (requiredDimensions) as a number in [1,10]. On success returns the VALIDATED + * envelope (the validated dimensions object, not the raw one). + */ +function validateScore( + raw: unknown, +): { ok: true; score: { mode: ScoreMode; dimensions: DimensionScores } } | { ok: false; reason: string } { + if (!isPlainObject(raw)) return { ok: false, reason: "score must be an object" }; + const mode = raw.mode; + if (typeof mode !== "string" || !(SCORE_MODES as readonly string[]).includes(mode)) { + return { ok: false, reason: `mode must be one of ${SCORE_MODES.join(", ")} (got ${String(mode)})` }; + } + const dims = raw.dimensions; + if (!isPlainObject(dims)) return { ok: false, reason: "dimensions must be an object" }; + const validated: DimensionScores = {}; + for (const key of requiredDimensions(mode as ScoreMode)) { + const value = dims[key]; + if (typeof value !== "number" || Number.isNaN(value) || value < 1 || value > 10) { + return { ok: false, reason: `dimension "${key}" must be a number in [1,10] (got ${String(value)})` }; + } + validated[key] = value; + } + return { ok: true, score: { mode: mode as ScoreMode, dimensions: validated } }; +} + +/** The closed reader-side utility vocabulary (MR-F7) — mechanism, not niche calibration. */ +const VERDICTS = ["BÆRENDE", "STØTTE", "NYHET"] as const; + +/** The closed demand-strength vocabulary (MR-F9, N7.5) — the sweep's honest ordinal, not niche calibration. */ +const DEMAND_STRENGTHS = ["strong", "moderate", "thin"] as const; + +/** + * Validate an optional `demand` (MR-F9, N7.5) — never throws. `strength` must be one of the closed + * vocabulary; `answered` is a REQUIRED boolean (the rankable "is it already answered" the arc layer + * reads). Hard-fail when present-but-malformed, like actionability — a bad payload is caught at the + * seam, not persisted silently. + */ +function validateDemand(raw: unknown): { ok: true; value: DemandSignal } | { ok: false; reason: string } { + if (!isPlainObject(raw)) return { ok: false, reason: "demand must be an object" }; + if (typeof raw.strength !== "string" || !(DEMAND_STRENGTHS as readonly string[]).includes(raw.strength)) { + return { ok: false, reason: `demand.strength must be one of ${DEMAND_STRENGTHS.join(", ")} (got ${String(raw.strength)})` }; + } + if (typeof raw.answered !== "boolean") { + return { ok: false, reason: `demand.answered must be a boolean (got ${String(raw.answered)})` }; + } + return { ok: true, value: { strength: raw.strength as DemandSignal["strength"], answered: raw.answered } }; +} + +/** The free-text proposal fields, all validated by the `summary` idiom (non-empty string -> kept verbatim, else omitted). */ +const FREE_TEXT_FIELDS = ["angle", "targetLevel", "rationale", "readerQuestion", "painPoint", "saturation"] as const; + +/** + * Validate an optional `actionability` (MR-F7) — never throws. `formulated` is a REQUIRED boolean + * (the yes/no the N7 band-cap gate reads); `note` is an optional string (blank -> omitted). Returns + * the validated value (note carried verbatim only when non-blank). + */ +function validateActionability(raw: unknown): { ok: true; value: Actionability } | { ok: false; reason: string } { + if (!isPlainObject(raw)) return { ok: false, reason: "actionability must be an object" }; + if (typeof raw.formulated !== "boolean") { + return { ok: false, reason: `actionability.formulated must be a boolean (got ${String(raw.formulated)})` }; + } + const value: Actionability = { formulated: raw.formulated }; + if (raw.note !== undefined && raw.note !== null) { + if (typeof raw.note !== "string") return { ok: false, reason: "actionability.note must be a string" }; + if (raw.note.trim().length > 0) value.note = raw.note; + } + return { ok: true, value }; +} + +/** + * Normalize related ids (A1-5): keep non-empty trimmed strings, first-seen dedupe. A non-array -> []. + * Ids are carried VERBATIM (not lowercased like topics — they are content hashes, not tags), so a + * caller-supplied id matches the store's `trendId` exactly. + */ +function normalizeIds(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const out: string[] = []; + const seen = new Set(); + for (const v of raw) { + if (typeof v !== "string") continue; + const s = v.trim(); + if (s.length === 0 || seen.has(s)) continue; + seen.add(s); + out.push(s); + } + return out; +} + /** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */ function normalizeTopics(raw: unknown): string[] { if (!Array.isArray(raw)) return []; @@ -105,8 +235,47 @@ export function normalizeItem(raw: unknown): NormalizeResult { } } + let score: { mode: ScoreMode; dimensions: DimensionScores } | undefined; + if (r.score !== undefined && r.score !== null) { + const res = validateScore(r.score); + if (!res.ok) errors.push(`invalid score: ${res.reason}`); + else score = res.score; + } + + // N6 typed fields: hard-fail when present-but-malformed (like score), so a bad payload is caught + // at the seam, not persisted silently. + let actionability: Actionability | undefined; + if (r.actionability !== undefined && r.actionability !== null) { + const res = validateActionability(r.actionability); + if (!res.ok) errors.push(`invalid actionability: ${res.reason}`); + else actionability = res.value; + } + + let verdict: TrendVerdict | undefined; + if (r.verdict !== undefined && r.verdict !== null) { + if (typeof r.verdict !== "string" || !(VERDICTS as readonly string[]).includes(r.verdict)) { + errors.push(`invalid verdict: must be one of ${VERDICTS.join(", ")} (got ${String(r.verdict)})`); + } else { + verdict = r.verdict as TrendVerdict; + } + } + + let demand: DemandSignal | undefined; + if (r.demand !== undefined && r.demand !== null) { + const res = validateDemand(r.demand); + if (!res.ok) errors.push(`invalid demand: ${res.reason}`); + else demand = res.value; + } + if (errors.length > 0) return { ok: false, errors }; + // N6 free-text fields: the summary idiom (non-empty string kept verbatim, else the key is omitted). + const freeText: Partial> = {}; + for (const f of FREE_TEXT_FIELDS) { + if (isNonEmptyString(r[f])) freeText[f] = r[f] as string; + } + const relatedIds = normalizeIds(r.relatedIds); + const item: TrendItem = { source: r.source as string, title: r.title as string, @@ -114,6 +283,12 @@ export function normalizeItem(raw: unknown): NormalizeResult { topics: normalizeTopics(r.topics), ...(publishedAt !== undefined ? { publishedAt } : {}), ...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}), + ...(score !== undefined ? { score } : {}), + ...freeText, + ...(relatedIds.length > 0 ? { relatedIds } : {}), + ...(actionability !== undefined ? { actionability } : {}), + ...(verdict !== undefined ? { verdict } : {}), + ...(demand !== undefined ? { demand } : {}), }; return { ok: true, item }; } @@ -123,8 +298,13 @@ export function normalizeItem(raw: unknown): NormalizeResult { * Pure: injects `capturedAt` (the store's "when WE saw it", supplied by the caller — * never derived here) and carries the rest verbatim. Does NOT re-validate (the item is * already validated by normalizeItem) and does NOT derive an `id` (the store owns id via - * addTrend→trendId). `publishedAt`/`summary` are carried only when present (key omitted - * otherwise), mirroring the store's conditional-spread idiom. + * addTrend→trendId). `publishedAt`/`summary`/`score` are carried only when present (key + * omitted otherwise), mirroring the store's conditional-spread idiom. The `score` is turned + * into the persisted envelope here (judgment → composite, via scoreEnvelope), then run through + * the F7 band-cap gate (`capForActionability`) so a candidate with no formulated reader-grip is + * persisted at most `High`, not `Immediate` (MR-F7, N7). On the capture path the dims are + * pre-validated by normalizeItem, so scoreEnvelope→composite cannot throw; called DIRECTLY with + * bad dims it throws by contract (defense-in-depth — SC2). */ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput { return { @@ -135,6 +315,20 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput { topics: [...item.topics], ...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}), ...(item.summary !== undefined ? { summary: item.summary } : {}), + ...(item.score !== undefined + ? { score: capForActionability(scoreEnvelope(item.score.mode, item.score.dimensions), item.actionability) } + : {}), + // N6 proposal fields carried through to the store input (validated already; key omitted when absent). + ...(item.angle !== undefined ? { angle: item.angle } : {}), + ...(item.targetLevel !== undefined ? { targetLevel: item.targetLevel } : {}), + ...(item.rationale !== undefined ? { rationale: item.rationale } : {}), + ...(item.relatedIds !== undefined ? { relatedIds: [...item.relatedIds] } : {}), + ...(item.actionability !== undefined ? { actionability: item.actionability } : {}), + ...(item.verdict !== undefined ? { verdict: item.verdict } : {}), + ...(item.readerQuestion !== undefined ? { readerQuestion: item.readerQuestion } : {}), + ...(item.painPoint !== undefined ? { painPoint: item.painPoint } : {}), + ...(item.saturation !== undefined ? { saturation: item.saturation } : {}), + ...(item.demand !== undefined ? { demand: item.demand } : {}), }; } diff --git a/scripts/trends/src/schedule.ts b/scripts/trends/src/schedule.ts new file mode 100644 index 0000000..7366c11 --- /dev/null +++ b/scripts/trends/src/schedule.ts @@ -0,0 +1,111 @@ +/** + * RE-R3c — pure string emitters for the autonomous-trigger artifacts (research-engine). + * + * No clock, no fs, no env, no AI: every value the emitters render is injected via `ScheduleSpec` + * (the CLI is the only edge that reads `process.execPath` / `import.meta.url` / `defaultStorePath`). + * Mirrors `brief.ts`'s `renderBrief` purity → byte-deterministic given inputs, fully testable. + * + * Print-first (C2): these are STRINGS. `launchdPlist`/`crontabLine` emit the artifact; the install/ + * uninstall instructions emit the exact command the operator runs. Nothing here ever executes + * `launchctl` or the cron table — the emitted recipes are surfaced for the operator to run. + */ + +export interface ScheduleSpec { + platform: "launchd" | "cron"; + label: string; + nodeBin: string; + wrapperPath: string; + args: string[]; + hour: number; + minute: number; + logPath: string; + workingDir: string; + /** Canonical injected environment — always { NODE_BIN, LINKEDIN_STUDIO_DATA(resolved-absolute) }. */ + env: Record; +} + +/** Defensive XML escaping for well-formedness (paths are normally safe, but escape regardless). */ +function xmlEscape(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +/** + * The launchd plist (macOS). A pinned, well-formed `` template firing + * the wrapper daily via StartCalendarInterval. `RunAtLoad` is false (a calendar job, not boot-time). + */ +export function launchdPlist(spec: ScheduleSpec): string { + const programArguments = ["/bin/bash", spec.wrapperPath, ...spec.args] + .map((a) => ` ${xmlEscape(a)}`) + .join("\n"); + const environment = Object.entries(spec.env) + .map(([k, v]) => ` ${xmlEscape(k)}\n ${xmlEscape(v)}`) + .join("\n"); + return ` + + + + Label + ${xmlEscape(spec.label)} + ProgramArguments + +${programArguments} + + StartCalendarInterval + + Hour + ${spec.hour} + Minute + ${spec.minute} + + EnvironmentVariables + +${environment} + + WorkingDirectory + ${xmlEscape(spec.workingDir)} + StandardOutPath + ${xmlEscape(spec.logPath)} + StandardErrorPath + ${xmlEscape(spec.logPath)} + RunAtLoad + + + +`; +} + +/** + * The cron schedule line (Linux). Returns the line as a STRING; never executes the cron table. + * ` * * * /bin/bash >> 2>&1 #