Compare commits

..

39 commits

Author SHA1 Message Date
bace49f8c2 docs(linkedin-studio): N6 — bånd-fordelings-kalibrering (MR-F6) i trend-scoring-modes.md
Ukalibrert modell-dømmekraft inflaterte topp-båndet (observert 13/20 → Immediate).
Legger til en eksplisitt fordelings-forventning som scoring-SSOT: de fleste kandidatene
hører hjemme i Medium–High (4.0–7.9); Immediate (≥8.0) er unntaket (arbeidsmål ≤~3/20).
Score relativt over batchen, ikke sjenerøst i isolasjon.

Fordelings-formen er mekanisme; terskelen for hva som teller som eksepsjonelt bor i
brukerprofilen/dimensjons-rubrikkene, aldri hardkodet (domene-generelt). Etterprøvbart nå
fordi composite+band persisteres (RE-R3a); MR-F7-bånd-cap-gaten (N7) bygger på samme signal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7SQpXJpBSNvpWaTNq1kWZ
2026-07-21 09:10:06 +02:00
65b60337fe feat(linkedin-studio): N6 — forslags-lag i trends (angle/targetLevel/rationale/relatedIds + selected + --ids + F7/F9-felt actionability/verdict/readerQuestion/painPoint/saturation) [skip-docs]
Artikkelforslaget blir en persistert entitet (steg 2, A1-4) og godkjenningen får
et hjem (steg 3, A1-7) — før dette genererte agenten vinkel/begrunnelse som ble kastet.

Åtte additivt-valgfrie felt på TrendRecord/TrendInput/TrendItem:
- Forslag (A1-4/A1-5): angle · targetLevel (fritekst, brukerdefinert spenn — aldri enum) ·
  rationale · relatedIds[] (flerkilde).
- F7 leser-side: actionability {formulated, note?} (N7-bånd-cap-gaten leser `formulated`) ·
  verdict BÆRENDE/STØTTE/NYHET (lukket mekanisme-vokab).
- F9 leser-side: readerQuestion · painPoint · saturation (N7.5-sveipet fyller dem).

Ingen schema-bump: feltene er additivt-valgfrie, ingen record trenger migrering →
SCHEMA_VERSION forblir 4 (loadStore Math.max håndterer det). First-sight-persistering
(re-capture unionerer topics + re-scorer kun; klobrer aldri en triagert vinkel).

Validering: typede felt (verdict/actionability) feiler hardt ved malformert input;
fritekst/id-liste normaliseres lempelig (summary/topics-idiomet).

Livssyklus: TrendStatus += "selected" → new→selected→acted|skipped. Ny select-verb +
--ids-batch (act/skip/reset/select); partiell suksess = exit 0 + miss-rapport, all-miss = exit 2.
setStatusMany(): ren batch-mutasjon, per-id found/notFound.

Brief: forslagsfeltene rendres per kandidat (detaljert i topp-treff, kompakt token i bullets) +
ny «🚧 I produksjon»-seksjon (selected=valgt + acted=skrevet), pillar-uavhengig, deterministisk
sortert. selected/acted forlater arbeidskøen men vises i produksjons-boardet.

commands/trends.md Step 5 oppgradert: triage → Velg (select) / Skip, batchet per verb (--ids).

Trends-suite 245→266 (ny floor, +21 N6-tester). To eksisterende tester oppdatert for den
endrede brief-kontrakten (acted vises nå i I produksjon; ranking-descriptoren ekskluderer selected).
tsc rent. Roundtrip bevist: capture m/ alle felt → query → brief rendrer feltene → select → I produksjon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7SQpXJpBSNvpWaTNq1kWZ
2026-07-21 09:09:51 +02:00
bb0725af66 docs(linkedin-studio): bevar trinn E-posisjoner i okf-ingestion/plan.md før postkasse-teardown
OKF-runden lukket ved trinn F (konsensus). Vår trinn E-svarfil bodde i den
interne postkassen (~/repos/_okf-interim/), som nå rives. Løfter den varige
substansen som kun fantes der inn i planen (ny §10): vår §7-registerrad
(status `planned`, ingen dør-A-stempling, uforfalskbarhet utenfor fila),
D1-posisjon (ratifisert D1 matcher vår innvending), D3 `partial`-posisjon
(ratifisert def = samme dør; vi er `planned`, ikke `partial`), samt tre
fremadrettede tekniske krav som ellers gikk tapt: tomt-vokabular-ikke-snap
(+ okrs egen RECOMMENDED-konstant som støtte), index preserve-unowned-grensen,
og lag-1-som-sett (delte golden-fixtures over fire resolve_link-akser).

Alt verifisert mot kode 2026-07-20. Ingen kodeendring i pluginen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7SQpXJpBSNvpWaTNq1kWZ
2026-07-21 07:58:11 +02:00
4f95331801 docs(linkedin-studio): OKF trinn E — Q3 besvart + korriger uverifisert vokabular-tilslutning
To selvkorreksjoner verifisert mot kode:
- type-vokabularet er tre verdier, ikke fem (JournalEntry/TributarySummary
  finnes kun i designnotat, aldri i kode)
- tilslutningen til lukket vokabular ble gitt uten aa sjekke innholdet;
  okf-check --strict-ingest exit 1 paa vaart konforme bundle, og safe
  default kollapser alle tre typene til Dokument

Nytt empirisk argument mot obligatorisk noekkel-prefiks: referanse-checkerens
egen konstant heter RECOMMENDED, ikke REQUIRED (exit 0 med advarsler).

Q3 (fase 4-omfang) besvart av llm-ingestion-okf: lag 1-primitivene, ikke
materialize_bundle-paritet. Status forblir planned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uzhvLjCm39mNQXVFwG7w2
2026-07-20 21:38:55 +02:00
f9ff2bc6ee docs(linkedin-studio): snevre inn newline-korreksjonen etter kryssjekk mot andres svar
Forrige commit hevdet at delt fixture-korpus MÅ bære et verbatim,
ikke-normalisert tilfelle. Kryssjekk mot claude-playlist-corpus' trinn
D-svar viser at det var overdrevet: vår newline-frie serializer gjelder
ingest/published/, som er ekskludert fra OKF-bundelen ved design.
Filene write_concept faktisk ville skrevet (brain/) far trailing newline
fra var egen serializer. Pa konseptstien er vi enige med dem.

Det som overlever: write_concept skal ogsa tjene som generell verbatim-
writer utenfor en bundle, sa ramme-normalisering bor vaere en dokumentert
parameter, ikke en ubetinget garanti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uzhvLjCm39mNQXVFwG7w2
2026-07-20 09:46:17 +02:00
fa5c8cf35a docs(linkedin-studio): OKF trinn D-review — korriger avkreftede R5-påstander
Trinn D-review besvart i _okf-interim (postkasse, ikke tracked her).
Verifisering mot egen kode avkreftet to påstander i plan §4 R5:

- Ingen stabile err.code i noen av de fire CLI-ene (fritekst-Error +
  numerisk process.exit). err.code er et krav TIL biblioteket, ikke en
  disiplin vi speiler.
- "Nøyaktig én avsluttende newline" er ikke vår invariant;
  serializePublishedRecord utelater den bevisst for byte-eksakt
  round-trip (SC2). Delt fixture-korpus må bære et verbatim,
  ikke-normalisert tilfelle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uzhvLjCm39mNQXVFwG7w2
2026-07-20 09:31:57 +02:00
c4cd974e9c docs(linkedin-studio): OKF-runde trinn C — bundle-inventar + taksonomi-innsigelse
Legger det varige innholdet fra adopsjonsrunden i eiende repo (postkassens
regel 2: beslutninger bor i eiende repos docs, kopien i _okf-interim er
varselet).

Nytt par. 6: bundle-inventar + plassering. Vi har EN OKF-bundle (brain/),
og alt bruker-eid ligger allerede utenfor repo-treet via data-dir-seamen
(M0/v0.6.0) - beslutningen krever ingen migrasjon hos oss.

Innsigelse mot taksonomien: beslutningstabellen diskriminerer paa hvem som
skriver. Vaar brain/ skrives av pluginen og ville derfor klassifisert som
plugin-eid og hoert hjemme i repoet - som er akkurat feil for det mest
personlige vi har, i et offentlig distribuert repo. Riktig akse er hvilken
livssyklus innholdet foelger. Maskinskrevet + bruker-eid er ikke et hjoerne,
det er hva ethvert laerende system produserer.

Par. 7-9 renummerert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uzhvLjCm39mNQXVFwG7w2
2026-07-20 08:51:20 +02:00
443cfa6160 docs(linkedin-studio): OKF-ingestion kartlegging + kravgrunnlag mot llm-ingestion-okf fase 4
Kartlegger repoets inntaksflater mot dor A i llm-ingestion-okf v0.3.1 og
leverer kravgrunnlaget som avgjor F1 for dette repoet.

Kjernefunn: F1 (manglende fritekst-connector) er ikke var blokker. All
ekstern henting i pluginen skjer i en modell-tur (trend-spotter WebSearch/
WebFetch, /linkedin:react WebFetch) - det finnes ingen rah HTTP-fetch i
plugin-kode. Dor A forutsetter at kode kan hente bytene; for var storste
og minst betrodde flate kan den ikke det. Kravet vart er derfor R1: en
dor som tar imot allerede-hentet, modellprodusert payload og
materialiserer den deterministisk. trends capture er den kontrakten
allerede, og donerbar som presedens.

Fire verifiserte kollisjoner mot dor A: fast 7-nokkels frontmatter uten
extension keys; rewrite-on-run-eierskap mot inkrementell akkresjon;
slug-ids mot content-adresserte ids; tabell-render som kollapser
linjeskift.

ingest/published/ forblir plugin-lokal ved design - na med teknisk gulv,
ikke bare beslutning: byte-eksakt round-trip (SC2), un-normalisert
mintContentId(body), YAML-fri grammatikk (samme grunn som Stage-1-bundlen
ekskluderte ingest/), og model-collapse-guard-semantikk.

Markorlinje satt i STATE.md (local-only): planned.
Separat fra docs/ingestion-guard/plan.md - grensen er beskrevet i par. 6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uzhvLjCm39mNQXVFwG7w2
2026-07-20 07:28:14 +02:00
a8e3cacee3 feat(linkedin-studio): N5 — /linkedin:trends discovery-kommando + trend-spotter pin-fjerning + triage [skip-docs]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: b69d02cd-d30d-478b-95a5-bd06113c648a
2026-07-17 04:00:17 +02:00
95b1521ae5 chore(linkedin-studio): release v0.6.0 — figur-pipeline (MR-F4/MR-F8) + RE-R3 + OKF Stage 1 + kald-review 29/29 + sannhetspass
Versjonssync plugin.json + CLAUDE.md-header + README-badge -> 0.6.0.
CHANGELOG [0.6.0]-catchup: 28 commits siden v0.5.3-taggen (git log som fasit)
+ catch-up-note for aldri-changelogget arbeid inne i eldre tags (Fix #1
contract-gate + Fix #2 specifics-bank i v0.5.1; SB-S3a-e + RE-R1-R2b i
v0.5.3).

Suiter groenne paa fasit-gulv: test-runner 138/0, trends 245/0, brain 134/0,
hooks 140/0, tests 35/0, render 60/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: df0a1ca3-78dd-455e-99a2-e7c133fcb5f6
2026-07-17 03:39:12 +02:00
5c6393f2f7 docs(linkedin-studio): N4 sannhetspass — GR-modellkorreksjon + maturity/saves/kø/SB-header + refs-badge 28
Del 1 (RE-verifisert mot ground truth): README maturity-note (herding 29/29 +
kald-review 29/29; gjenstår = GUI), CLAUDE.md maturity-linje (B-F10),
hardening-plan-køen t.o.m. S31a/b/c, second-brain-header (SB-S3a-e landet,
kun S4 gjenstår).

Del 2: D-1 BLOCKER — algorithm-signals GR-seksjonen omskrevet mot primærkilde
(LinkedIn engineering-blogg 2026-03-12, Hristo Danchev: Generative Recommender
(GR) offisielt navn + LLM-retrieval + utrulling annonsert); fabrikasjonsflagget
avviste en ekte primærkilde og er trukket med korreksjonsnote; 360Brew-skepsis
beholdt. D-2 — saves-begrunnelse: Marketing API v202604 har POST_SAVE på
/memberCreatorPostAnalytics (partner-gated; manuell inntasting forblir riktig
UX). B-F11 — README refs-badge 26->28 + 25-document->28-document (ls
references/*.md = 28). CLAUDE.md Architecture faar specifics-bank +
contract-gate-linjer.

CHANGELOG-catchup kommer i release-committen (0.6.0) for aa holde
versjonsdeklarasjonene konsistente per commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: df0a1ca3-78dd-455e-99a2-e7c133fcb5f6
2026-07-17 03:35:40 +02:00
81510297db feat(linkedin-studio): N3.5 — MR-F8 build-html-forsoning (blockquote+lenker+FIGUR->SVG port + paritetstest) [skip-docs]
Plugin eier hele render/ (KTG-go 16.07, eierskapsvalg a). Portet fra
maskinrommets tools/build-html.mjs (read-only): flerlinjers blockquote
(avsnitt i quote), [tekst](url)-lenker med http/https/mailto-whitelist,
FIGUR-markoer -> inline SVG med figcaption + fallback til blockquote.
CSS for de tre konstruktene. 7 nye paritetstester (repoets testtilfeller
kopiert + med-SVG-case); paritetsbevis: samme fixture gjennom begge
motorer = byte-identisk parser-HTML. Render-floor 53 -> 60.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: c61b308e-e4fa-4a49-9ff0-a3ce725cf703
2026-07-17 03:22:20 +02:00
1a67bd2cb8 feat(linkedin-studio): N3 — Step 7.5 kodet-figur-rute + figure-design-guidelines [skip-docs]
MR-F4 S2: (1) commands/newsletter.md Step 7.5 — generate er nå tre ruter;
kodet rute (render/build-figur.mjs) er PRIMÆR for data-figurer
(presisjon/reproduserbarhet), mcp-image beholdes for illustrative,
external uendret. Fasetabell + ressursliste + rute-statuslinje oppdatert.
(2) NY references/figure-design-guidelines.md — designregler (24pt,
fargebudsjett, whitespace), token-konvensjon (profile/brand-tokens.json,
--figur-*, nøytrale defaults), tre render-mål. Refs 27->28.
(3) CLAUDE.md Architecture-linje for rendereren.
scripts/test-runner.sh: EXPECT_REFS 27->28 + fila navngitt i POSTM0_REFS
(named-additions-vakta). Suiter grønne på floor: 138/0 - 140/0 - 35/0 -
53/0 - 245/0 - 134/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: e70c619c-419b-4229-acaa-b5291f5e75d6
2026-07-16 20:47:59 +02:00
c4434ed789 feat(linkedin-studio): N2 — build-figur renderer (SVG/HTML->PNG, 3 mål, token-seam) [skip-docs]
TDD (33 nye tester, render-suite 20->53/0 = ny floor, 4 ekte e2e-renders):
- render/build-figur.mjs: SVG/HTML -> PNG via headless Chrome; tre mål
  (article 1200xauto, carousel 1080x1350, single 1200x1200), overstyrbare
  via --width/--height; CLI + importerbar modul.
- Token-seam: design-tokens leses fra
  LINKEDIN_STUDIO_DATA/profile/brand-tokens.json (brukerdata), nøytrale
  defaults i modulen — merkevare aldri hardkodet i repoet. Injiseres som
  CSS-variabler (--figur-*).
- Warn-validering (aldri hard fail): min 24pt tekst, fargebudsjett
  (maks 2 ikke-nøytrale farger).
- Robust Chrome-oppdagelse: app-bundle-stier -> PATH -> install-hint
  (prober injiserbare for test).
- macOS-quirk (verifisert lokalt, Chrome 150): headless skriver PNG-en men
  avslutter aldri -> stderr-watchdog («written to file») + kill + verifisering
  av output. Søk-først utført (kjent headless-ustabilitet på macOS).
- Fixture: nøytral demo-SVG (placeholder-søylediagram) under
  render/__tests__/fixtures/.

Alle seks suiter grønne: test-runner 138/0, trends 245/0, brain 134/0,
hooks 140/0, tests 35/0, render 53/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: 26f26c3d-be91-47c4-bbdf-4694ba063b67
2026-07-16 20:26:19 +02:00
776d728d7d fix(linkedin-studio): N1 — prune-regex no-op + dato-uavhengige kalender-fixtures (TDD) [skip-docs]
B-F1 (prod-bug, prune de facto no-op): fjernet `m`-flagget fra Recent Posts-
regexen i `state-updater.mjs`. Med `/m` matchet `$`-alternativet i lookahead
slutten av hver linje, så den late capturen stoppet etter FØRSTE entry-linje;
gamle entries under en fersk (nye prepender øverst) ble aldri skannet/prunet.
Ny regresjonstest (old-under-fresh) rød->grønn beviser mekanismen.

B-F2 (kalender-flake): `pruneContentHistory` fikk valgfri `today = new Date()`-
param (deterministisk rotårsak-fix; CLI/hook-kall uendret via default). Prune-
testene injiserer fast syntetisk today og asserter kun på Recent Posts-seksjonen
(ikke helinnhold/frontmatter), så today-100d aldri kolliderer med SAMPLE_STATE-
datoer. Samme grep i `scripts/check-replace-safety.mjs`.

Suiter (alle grønne): test-runner 138/0 · hooks 140/0 (+1 regresjonstest) ·
trends 245/0 · brain 134/0 · tests 35/0 · render 20/0.

Utført av Fable 5 (high) subagent, orkestrert + verifisert av Opus-hovedkontekst.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: a814039f-da8b-41b1-8d7c-60abe1e03c5a
2026-07-16 20:03:05 +02:00
d67552eab1 docs(linkedin-studio): ingestion-guard adoption plan — persist-gate integration map (status: planned)
Read `llm-ingestion-guard` v0.2 adoption brief; mapped this repo's untrusted-ingest
surface (brief §7 checklist) against 882f6ee via two independent read-only surveys.

- New `docs/ingestion-guard/plan.md`: when/where to wire the guard at the deterministic
  persist gates (`screen_output` at trends `capture`, `brain ingest`, specifics-bank
  `ekstern` bindings, analytics CSV). Ranked by automated-reinjection risk; the trends ->
  `session-start.mjs` reinjection is the one live poison->trusted-read loop (priority 1).
- Honest scope: only the `screen_output` half maps cleanly (fetch/transform happen inside
  the model turn — no `your_model()` code seam); `prepare_input` has no clean wiring point.
- Python<->Node interop is the blocker (plan §7); no code wired (brief = plan-only).
- OKF `import_bundle` has no seam today (brain is export-only); relevant only when SB-S4
  connector or a cross-plugin shared skill lands.

Machine-readable marker line for the guard repo's roll-up lives in STATE.md
(LOCAL-ONLY / gitignored, so the roll-up is machine-local).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: 57adea42-c8f1-4b88-acb6-2453e1239c79
2026-07-16 10:54:09 +02:00
882f6eee5e fix(linkedin-studio): Oppgave 1 fix-pass — clipboard heredoc + report refs + calendar queue-felter [skip-docs]
Primær-sti-fixer fra cold-review:
- clipboard (10 cmds): printf '%s' '<text>' → quoted heredoc (apostrof/%/$/backtick
  korrumperte stdin); «Copied» betinget på COPIED, FAILED → be om manuell kopi
- report.md: heatmap-gren pekte til ikke-eksisterende «Step 6c» → «Step 2c»;
  Step 8b export skriver .md → la til Write i allowed-tools
- calendar.md: Step 1 emitter ENTRY RECORDS (id/draft_path/character_count) fra
  returnerte entry-objekter; publish + reschedule resolver id derfra
  (queueFormatSummary droppet feltene → handlingene var brutt)

test-runner: 138 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GqEHp4uDiivfrAUjw4BdE
2026-06-30 13:35:17 +02:00
4fd038ad1d docs(linkedin-studio): cold-review R5 (Grow+Router) — independent kald-review of 6 surfaces -> 29/29 coverage
Final cold-review round: strategy, competitive, monetize, outreach, profile, linkedin.
2 independent cold Opus reviewers (intent + correctness), no cross-feed, every claim
tool-grounded; divergences re-grounded by main before registration.

Result: verdict MINOR, 0 MAJOR (cleanest batch of the sweep). Resolution integrity
PASS across all 6 (2/2 subagent_type, 28/28 routes, 11/11 router-suggested agents,
helper-script exports all resolve; 0 under-declared tools; 0 dead executable refs).

Findings (all advisory, no REWORK): thought-leader terminology cluster (5 surfaces,
9 instances; profile :79/:101 are correct negative examples, NOT violations);
monetize :6 description<->body scope self-contradiction; bare ref-paths in
strategy+profile (folds into systemic #3); monetize Audience-Size scorecard +40 vs
/25 cap (suggestion).

Cold-review sweep COMPLETE: 29/29 coverage (S1 + R2a + R2b + R3 + R4 + R5).
v1.0.0 review-blocker lifted. test-runner 138/0 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GqEHp4uDiivfrAUjw4BdE
2026-06-30 10:28:57 +02:00
4109fe7fd0 docs(linkedin-studio): cold-review R4 (Measure surfaces) — independent kald-review of 6 surfaces
Independent two-lens cold review of the 6 Measure-journey surfaces (import,
report, analyze, audit, ab-test, measure) on frozen HEAD 69f37ba. Largest
batch + only analytics-class batch; analytics-honesty predicate carried
alongside the standard intent/correctness lenses.

Verdict: REWORK (0 BLOCKER · 1 MAJOR · 4 MINOR · 6 SUGGESTION).
- report.md MAJOR: heatmap report type (:72) routes to a nonexistent "Step 6c";
  real handler is Step 2c (:106) — provably-wrong cross-ref on a primary menu
  branch. Caught by the correctness lens alone (intent lens never traced
  step-jump arithmetic); confirmed by main grounding the step inventory.
- 5 of 6 surfaces ALLOW (measure notably clean — delegate-only enforced by the
  allowed-tools whitelist, not just asserted).

Analytics-class predicate PASSES on all 6: saves framed as manual/count-only/
no-API and never folded into engagementRate; dwell called unmeasurable;
parseOptionalCount (csv-parser.ts:71) + getAnalyticsRoot seam described
accurately wherever quoted; graceful degradation present everywhere.

Independence: 2 convergences (import Step 6a invalid trends flags; ab-test
ER-omits-clicks) + 3 divergences resolved by main grounding in both directions
(intent over-rated import 6a MAJOR->MINOR; correctness uniquely caught report
6c + analyze twin severity scales). Two-lens method earned its keep again.

New R4 finding clusters (operator-gated fix, not done here): sibling
interface/metric drift (import stale trends flags vs report; ab-test ER vs CLI
engagementRate), one true under-declaration (report Step 8b Write). No code
changed; cold review finds only.

Cumulative cold-review coverage: 23/29 (S1 + R2a + R2b + R3 + R4). Remaining:
R5 (Grow+Router, 6 surfaces) -> 29/29. test-runner 138/0 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GqEHp4uDiivfrAUjw4BdE
2026-06-30 09:58:40 +02:00
69f37ba2b3 docs(linkedin-studio): cold-review R3 (Engage surfaces) — independent kald-review of 4 surfaces
Reproduces the S1/R2a/R2b non-fabricating method: 2 blind cold Opus lenses for
the round (intent + correctness), each covering all 4 surfaces, no cross-feed,
every mechanical claim tool-grounded. Surfaces: firsthour, calendar,
headless-review, pivot.

Verdict REWORK — 1 of 4 surfaces, 1 MAJOR; 0 BLOCKER:
- calendar: the queue load (queueFormatSummary) 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 (those fields are never shown) -> MAJOR
- firsthour: ALLOW (1 MINOR bare ref paths; clipboard printf pointer)
- headless-review: ALLOW (2 MINOR: SendUserFile absent from allowed-tools on the
  primary surfacing path; dead v3.1.0 reload anchor post version-reset)
- pivot: ALLOW (clean — heuristic, worked example, off-by-one phase map all
  reconcile)

Independence axis earned its keep again: convergence on headless SendUserFile
(both lenses) + one divergence resolved by main 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,
same shape as R2b's newsletter resumption table).

Connects to existing systemic findings (no new cross-cutting): clipboard printf
(firsthour confirmed, folds into R2a's 10-file finding) and bare reference paths
(firsthour adds 3 sites to R2b's pattern). New SUGGESTION-class pattern:
allowed-tools over-declaration on 3 of 4 surfaces.

Counts: 0 BLOCKER, 1 MAJOR, 3 MINOR, 5 SUGGESTION. Cumulative cold-review
coverage 17/29. Review finds; changes no code. test-runner 138/0 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GqEHp4uDiivfrAUjw4BdE
2026-06-30 09:29:47 +02:00
2b706609bd docs(linkedin-studio): cold-review R2b (Create orchestrators) — independent kald-review of 4 surfaces
Reproduces the S1/R2a non-fabricating method: 2 blind cold Opus lenses per
surface (intent + correctness), no cross-feed, every mechanical claim
tool-grounded. Surfaces: create, batch, pipeline, newsletter.

Verdict REWORK — 3 of 4 surfaces, each 1 MAJOR; 0 BLOCKER:
- batch: 3a/3b component scaffold (format-blind) contradicts the format-aware
  band gate
- pipeline: Step 2 scaffold (960-1640) cannot satisfy Step 3 total band
  (1200-1800)
- newsletter: resumption table omits the contract-gate phase (Step 4.5) ->
  breaks deterministic resume between Step 4 and Step 5
- create: ALLOW (1 MINOR, 8-option AskUserQuestion vs documented 2-4 cap)

Two systemic patterns surfaced (main-grounded, not single-reviewer):
- 5-component draft scaffold 960-1640 != 1200-1800 band, in exactly 3 files
  (post.md [R2a], batch.md, pipeline.md)
- bare reference paths vs ${CLAUDE_PLUGIN_ROOT}/ in batch + pipeline

Independence axis earned its keep: on newsletter the intent-lens asserted the
resumption table complete; the correctness-lens counted the gap; main confirmed
the correctness-lens (a divergence resolved by grounding, not just convergence).

Review finds; changes no code. Fixes are separate operator-gated decisions.
test-runner 138/0 unchanged. Hardening-class artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GqEHp4uDiivfrAUjw4BdE
2026-06-30 08:58:44 +02:00
5474df50e6 docs(linkedin-studio): cold-review R2a (Create emitters) — independent kald-review of 5 surfaces
R2a of the cold-review sweep: post/react/carousel/video/multiplatform, 2 independent cold
Opus reviewers per surface (intent + correctness lenses), no cross-feed, every mechanical
claim tool-grounded (anti-fabrication mandate). Verdict REWORK — 2 MAJOR (systemic
clipboard-printf apostrophe corruption across all 10 content commands; post personal-stories
band 1,000-1,400 vs its own Step 5 gate + canonical SSOT 1,200-1,800), 0 BLOCKER. Findings
only — no code changed. Local-only hardening artifact, not pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GqEHp4uDiivfrAUjw4BdE
2026-06-30 07:18:43 +02:00
9567689c4a docs(claude-md): trim CLAUDE.md to invariants (−2,266 always-tok)
CLAUDE.md is loaded every turn while working in this repo (measured 4,846
always-loaded tokens — the entire per-repo delta, since the repo has no
.claude/rules or .mcp.json). The bulk was a version-history narrative: the
2026-05-31 re-baseline note plus a single paragraph recounting the full
v2.0.0 → v4.1.0 journey (per-version motivation, gate-by-gate evolution).
That is CHANGELOG material, not an invariant for working on the plugin —
CHANGELOG.md (18 version headings) already owns it.

Trimmed to what is invariant: a terse current-maturity intro (v0.5.3, M0
done, v1.0.0 remainder) pointing to CHANGELOG/docs; the architecture,
hooks, command (29) and agent (19) tables, and content-quality rules. The
verbose per-row prose (newsletter phase-list, agent (vX.Y)/Step-tag
motivation) is compressed to one-liners. Agent name/model/color cells,
all counts, and the version stay byte-exact so the structure lint holds.

Verified with the repo's own gates: scripts/test-runner.sh 138/0 ("All
structural checks passed!" — counts, version-consistency, stat-consistency,
model-consistency, render-chain all green); scripts/check-model-consistency.mjs
OK (19 agents, all surface declarations match frontmatter). CLAUDE.md
127→109 lines, 19,572→10,457 B, 4,846→2,580 tok (−47%). Docs-only — no
version bump, no catalog ref change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01683eAqVecv9VZfQzL8CQ9h
2026-06-29 10:14:23 +02:00
da0a16a17c docs(linkedin-studio): OKF brief — record Stage 1 finish (recommended fields + pending-diff)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 21:02:27 +02:00
e9e183ebb0 feat(linkedin-studio): brain Stage 1 finish — title/description + pending-diff typed [skip-docs]
Completes the linkedin-studio in-scope Stage 1 (docs/okf-convergence-brief.md):
- serializeProfile + operations.md seed gain the cheap recommended OKF fields
  `title` + `description` (timestamp/resource intentionally omitted — a timestamp
  would break the pure/deterministic serializer; resource is N/A internally).
- renderDiffMd leads the transient pending-diff.md with `type: PendingDiff`, so
  the brain/ bundle passes okf-check even mid-propose.

Verified: 2 new tests (okf-conform + consolidate-cli); full brain suite 134/134
(0 regressions); cross-tool — okr/scripts/okf-check.mjs validates brain/ exit 0
WITH a pending-diff present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 21:02:26 +02:00
db8cb8c7e3 docs(linkedin-studio): OKF convergence brief — reference design, premise corrections, Stage 1 outcome
Cross-plugin second-brain convergence on OKF-compatible form. The brain is the
reference design (most mature of the three); OKF is a thin interop veneer.

Records: three verified premise corrections (mdcode != OKF; OKF has no
document-folder ingest; classify/convert is build-yourself and the sibling
docs never asked for it); the three-consumer landscape (okr built, architect
designed, linkedin-studio richest); the staged plan (shared spec -> measure ->
conditional shared skill); per-repo scope boundaries (each its own go); and the
landed Stage-1 outcome with its premise refinement (bundle=brain/, ingest/
excluded as round-trip-critical tributary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 20:57:35 +02:00
9e95222d12 feat(linkedin-studio): brain emits OKF-compatible form — Stage 1 (bundle=brain/, ingest/ excluded) [skip-docs]
Cross-plugin OKF convergence, Stage 1 (docs/okf-convergence-brief.md): the
second-brain hub now conforms to OKF-compatible form so a shared retrieval
skill (and a sibling agent) can traverse it.

- serializeProfile leads with a constant `type: Profile` frontmatter block
  (round-trip-safe: parseProfile skips it, parse-serialize identity holds).
- operations.md seed -> `type: Operations`; index.md seed -> `okf_version: 0.1`
  marker (markdown text; index files carry no frontmatter per OKF); new
  brain/journal/index.md (per-level index).

Premise correction: the brain is deliberately YAML-free and ingest/published
has a byte-exact round-trip invariant (SC2) a frontmatter block would break,
so the concept-bundle is scoped to brain/ ONLY; the ingest/ tributary is
excluded and pointed to from the hub. We emit frontmatter, add no parser.

Verified: new tests/okf-conform.test.ts (5/5); full brain suite 132/132 (0
regressions); cross-tool — okr/scripts/okf-check.mjs validates brain/ (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 20:52:41 +02:00
001d76ce99 docs(linkedin-studio): correct v1.0.0-maturity status — hardening landed, GUI + cold-review remain
CLAUDE.md:3 was stale (it predated the hardening campaign). Corrected to the
tool-verified reality:
- Hardening complete (29/29): every command through the interactive quality-gate
  (docs/hardening/log.md: HARDENED/PASS/FIXED) + the S27-S30 reference/terminology/
  magnitude scrubs.
- Command testing is effectively that campaign (persona-sim + 4-axis eval + lint),
  backed by the script-level suites.
- Independent cold /trekreview adjudication persists for S1 only (4/29); S2-S26 were
  gated by the operator-in-the-loop v2 method after the reviewer swarm was dropped
  following the S2 fabrication incident -> brief SC-H not met as written.
- GUI is the one workstream not yet begun.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 15:17:04 +02:00
5b51b4baeb feat(linkedin-studio): RE-R3e — brief history + day-over-day diff (surfaced: frontmatter + Nytt siden sist) [skip-docs]
Closes hull #7 ("ingen brief-historikk"). Each morning brief now records the
trend ids it showed into its own YAML frontmatter (surfaced: <id-csv> =
surfacedIds(ranking)) and renders a day-over-day diff against the most recent
prior brief — a "## Nytt siden sist (<prior-date>)" section that leads the
ranked list, plus a " N nye siden sist." marker on the one-line summary the
SessionStart hook surfaces (no hook change).

- brief.ts: BRIEF_SCHEMA_VERSION 1->2 (artifact frontmatter gained surfaced:;
  the store's SCHEMA_VERSION stays 4 — no store field). Three PURE helpers
  (diffSurfaced / parseSurfacedFrontmatter / selectPriorBriefFile) + the
  surfaced: emit + the section + the summary marker. No fs/clock in brief.ts.
- cli.ts: the brief handler discovers the prior dated file (existsSync-guarded
  readdirSync -> selectPriorBriefFile, strict < today so a same-day re-run is
  byte-identical), parses its surfaced: line, computes the diff, threads it into
  renderBrief AND the shared briefSummary(ranking, diff) (one-source: file
  frontmatter == --json summary, cli.test one-source invariant). --json gains a
  diff:{priorDate,added,carried,dropped} counts object; the console line appends
  the delta. Any fs error degrades to the empty-prior (first-brief) path.

TDD two-phase: stubs -> 17 value-RED (no module-not-found) -> GREEN. Trends suite
216 -> 245 (brief +27, cli +2), 0 fail. New unconditional gate Section 16n (6
checks); ASSERT_BASELINE_FLOOR 117 -> 123; TRENDS_TESTS_FLOOR -> 245. Full gate
FAIL=0; hook suite 139/139 + R3c schedule/run-daily green untouched. Behavioural:
real two-day rename-real-write diff + same-day byte-identity confirmed. Counts
29/19/27 unchanged; no version bump (additive, v0.5.2 dev).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 14:40:09 +02:00
ddedb3d1de docs(linkedin-studio): RE-R3e — Nytt-siden-sist header carries the prior date (plan/brief fidelity)
The shipped render dates the section header (## 🆕 Nytt siden sist (<prior-date>))
when a prior brief exists — the form SC9, Phase-B, and the behavioural step already
specify. The plan Step 3 snippet showed a bare header and the brief S-history prose
was silent on the date; both are corrected to match the shipped code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 14:39:35 +02:00
e0b191db0c docs(linkedin-studio): RE-R3e brief + plan — brief history + day-over-day diff (hull #7), light-Voyage folded
Closes hull #7 ("ingen brief-historikk"): each morning brief records the trend
ids it showed (surfaced: frontmatter, BRIEF_SCHEMA_VERSION 1->2; store schema
stays 4) and renders "Nytt siden sist" against the most recent prior brief.
Pure render-time diff (brief.ts stays store/fs-free; the dir+file reads live at
the cli.ts edge). Zero new source/test files — all EDITs.

Light-Voyage (3 Opus reviewers — scope-guardian MIXED, brief-reviewer
PROCEED_WITH_RISKS, plan-critic REWORK 0.88) folded into brief #9 / plan
Plan-critic. Converged on 2 MAJOR + 4 MINOR, all re-verified against live code:
- MAJOR-1: brief.test.ts:574 assert.equal(BRIEF_SCHEMA_VERSION, 1) is a hard
  literal outside the frontmatter set -> Step 3 flips it to 2 with the bump.
- MAJOR-2: cli.ts:350 const summary = briefSummary(ranking) left unthreaded ->
  day-2 --json.summary would lose the marker the file carries (breaks the
  cli.test.ts:268 one-source invariant); Step 4 threads diff -> briefSummary.
- M1 import type for the BriefDiff interface; M2 SC9 rename-real-write (no
  hand-fixture); M3 SC1 cross-partition disjointness wording; M4 empty
  surfaced: contradiction reworded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 14:16:54 +02:00
2a8459c674 feat(linkedin-studio): RE-R3d — temporal overlay (first-mover + saturation) [skip-docs]
R3 slice (b): the rest of hull #3. The morning brief now reads the temporal axis
the R3b seen-log records but the ranking ignored. Two DERIVED signals, computed at
brief time from already-persisted fields (publishedAt/capturedAt -> ageDays,
surfacedCount), never stored:

- first-mover: recent (ageDays <= --first-mover-days, default 2) AND never surfaced
  on a prior day -> ranked up, badge "first ute". Future-dated (ageDays<0) excluded.
- saturation: surfaced on >= --saturation-at (default 3) prior days -> ranked down,
  badge "mettet (Nx)". Self-surfacing (our seen-log), not market coverage.
- warming (1..at-1) keeps the R3b "sett Nx" badge but only at >=2 (contract intact);
  neutral carries no badge.

SB1 derived (no schema bump: SCHEMA_VERSION 4 / BRIEF_SCHEMA_VERSION 1 untouched).
SB2 the R3a relevance composite stays the PRIMARY sort key; the temporal rank is a
new cmp key after pillar-overlap, before effectiveDate -> re-orders only WITHIN a
(composite, overlap) tier. temporalSignal is pure (saturationAt clamped >=1).

Prior-day surfacings exclude today (via lastSurfacedAt), so a same-day re-render is
byte-identical (caught by the R3c run-daily SC7 regression; fixes a latent R3b
prior-day imprecision too). brief CLI gains --first-mover-days / --saturation-at;
schedule untouched (nightly uses defaults).

Wiring: trend-spotter.md (prose), trend-scoring-modes.md (one-line consumer note),
README (## Temporal overlay), gate Section 16m (+6 unconditional -> ASSERT floor
111->117), TRENDS_TESTS_FLOOR 192->216. Counts 29/19/27 unchanged. Zero new files.

Gate: Passed 132 / Failed 0; trends 216/216; hook suite 139/139 untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 12:10:42 +02:00
5aa7187243 docs(linkedin-studio): RE-R3d brief + plan — temporal overlay (first-mover + saturation), light-Voyage hardened
R3 slice (b): the rest of hull #3. Two derived brief-time signals — first-mover
(recent + unsurfaced -> ranked up) and saturation (surfaced >= N prior days ->
ranked down) — computed from already-persisted fields. SB1 derived (no schema
bump, SCHEMA_VERSION stays 4); SB2 R3a composite stays the primary sort key, the
overlay is a within-tier cmp refinement. Zero new source/test files; counts
29/19/27; ASSERT floor 111 -> 117.

Three Opus reviewers (scope-guardian / brief-reviewer / plan-critic) folded:
warming badge gated at >=2 (preserves the R3b contract), disagreement ordering
fixture (true RED), saturationAt clamp, ageDays>=0 guard, fresh->neutral rename,
SSOT one-line note, nightly-thresholds known limitation, cite fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 11:43:22 +02:00
3276e44dbf feat(linkedin-studio): RE-R3c — autonomous trigger (scheduler + headless entry) [skip-docs]
Closes research-engine hulls (1) no autonomous trigger + (6) no headless entry.
Makes the daily research loop closed + headless: deterministic-brief-only (C1),
print-first (C2 — the tool never runs launchctl or the cron table; --install writes
only the inert launchd plist file).

- NEW scripts/trends/src/schedule.ts — pure string emitters (launchd plist + cron-line +
  install/uninstall instructions + defaultLabel). No clock/fs/env/AI; byte-deterministic.
- NEW scripts/trends/run-daily.sh — bash-3.2 headless wrapper: resolves node, cd's into the
  package so tsx resolves, logs via the data-path twin seam; runs the deterministic brief and
  appends one compact cron.log line per fire. The (e) AI-capture seam is documented, not built.
- EDIT cli.ts — schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
  [--platform auto|launchd|cron] [--install|--uninstall] [--store <p>]; print-first, no new
  exit code; logPath anchored to dirname(defaultStorePath()) (not the --store override).
- WIRE trend-spotter.md (one prose line) + README (scheduler + wrapper + the C1 boundary).
- Gate: TRENDS_TESTS_FLOOR 171->192, ASSERT_BASELINE_FLOOR 105->111, new UNCONDITIONAL
  Section 16l (6 deps-absent greps + non-vacuity self-test), header-enum + floor-history append.

TDD two-phase RED -> GREEN. trends 192/192, gate 126/0, hook-suite 139/0 (untouched), plutil
-lint OK. No schema change (SCHEMA_VERSION 4 / BRIEF_SCHEMA_VERSION 1). Counts 29/19/27 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 11:00:59 +02:00
b43757462b docs(linkedin-studio): RE-R3c brief + plan — autonomous trigger (scheduler + headless entry), light-Voyage hardened
Slice (c) of the R3 build-out: a `schedule` CLI verb (print-first launchd
plist / cron-table line) + `run-daily.sh`, a bash-3.2 headless wrapper that
runs the DETERMINISTIC morning brief from a profile-less scheduler env.
Closes hulls #1 (no autonomous trigger) + #6 (no headless entry point).

Operator-confirmed (AskUserQuestion 2026-06-26): C1 deterministic brief-only
(no AI capture — that is slice e, which plugs into the documented pre-brief
seam); C2 print-first installer (the tool emits the artifact + the install
command; `--install` writes only the inert launchd plist file; never runs the
scheduler activation itself).

Light-Voyage hardened — three Opus reviewers, each verifying against live
code: scope-guardian ALIGNED (0 creep/0 gaps), brief-reviewer
PROCEED_WITH_RISKS, plan-critic REVISE. All findings folded, incl. the
pretty-printed `brief --json` log-line compaction, the `cd "$DIR"` cron fix,
the logPath base pinned to `dirname(defaultStorePath())`, the canonical
`ScheduleSpec.env`, and the `ASSERT_BASELINE_FLOOR` :1259->:1329 cite. No
schema/count change (29/19/27, store v4). Tracked feature-design.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 10:13:39 +02:00
b185db9a12 feat(linkedin-studio): RE-R3b — trend lifecycle (re-score on re-capture · status · seen-log) [skip-docs]
The lifecycle layer over the trend store: what happens to a trend AFTER first capture.
- re-score on re-capture (last-wins; addTrend duplicate branch, score the one mutable
  field; provenance + lifecycle untouched; no false-merge via JSON compare). Reverses
  R3a's first-sight D3 — that R3a test reconciled to the new behaviour.
- status new/acted/skipped (effectiveStatus/setStatus + act/skip/reset CLI verbs);
  rankForBrief EXCLUDES handled trends (a work queue, not an archive).
- seen-log surfacedCount/lastSurfacedAt (markSurfaced, per-day idempotent); the brief
  CLI records surfacing on the store AFTER the pure render, unless --no-mark.
- render: entry id in backticks (copy-paste for act/skip) + · sett Nx prior-day hint.
- schema v3→v4 (additive lossless); the R3a migration block reconciled to the bump,
  the new R3b block committed against SCHEMA_VERSION (breaks the reconcile cycle).

score.ts + item.ts untouched (re-score reuses the R3a capture path). RED-first (two
phase: 16 logic-RED + 4 stub-RED). Gate: Section 16k (6 emitters), TRENDS_TESTS_FLOOR
146→171, ASSERT_BASELINE_FLOOR 99→105. trends 171/171, gate 120/0/0, hook suite 139/139.

Plan: docs/research-engine/{brief,plan}-re-r3b.md (light-Voyage hardened @ c40b937).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 01:08:43 +02:00
c40b937856 docs(linkedin-studio): RE-R3b brief + plan — trend lifecycle (re-score · status · seen-log), light-Voyage hardened
Slice (a) of the full-R3 build-out: the lifecycle layer over the trend store.
- re-score on re-capture (last-wins; R3a's explicit deferral)
- status new/acted/skipped (act/skip/reset CLI; brief excludes handled)
- seen-log surfacedCount/lastSurfacedAt (per-day idempotent, brief-recorded)

Architecture confirmed via AskUserQuestion: on-record seen-log + brief records
surfacing (rankForBrief stays pure, --no-mark dry-run) · last-score-wins ·
exclude acted/skipped. score.ts + item.ts untouched (re-score reuses the R3a
capture path); touched: types/store/brief/cli + schema v3->v4.

Light-Voyage hardened (3 Opus reviewers vs live code): scope-guardian ALIGNED;
brief-reviewer PROCEED_WITH_RISKS; plan-critic PROCEED_WITH_RISKS (78/B). All
folded — incl. the MAJOR (the brief CLI store binding hoist) + the v3->v4
migration-block reconcile (premise-verified before drafting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
2026-06-26 00:44:47 +02:00
e169c78710 feat(linkedin-studio): RE-R3a — persist relevance score on the store record + rank the morning brief on it [skip-docs]
R3 slice 1 (research-deepening). Stop discarding the relevance judgment the
trend-spotter already computes: persist a 4-field TrendScore {mode, dimensions,
composite, priority} on TrendRecord (schema v2->v3, additive lossless migrate),
computed by the existing score.ts composite()+band() (one owner, no new arithmetic),
threaded item->store; then rankForBrief sorts each bucket composite-first (sentinel
-1 for unscored) and renderBrief surfaces "· <priority> (<mode>)" per body entry
(briefSummary shows the band only). First-sight only; mode-blind ranking with the mode
shown so the operator can disambiguate instruments.

- score.ts: TrendScore + requiredDimensions(mode) (ordered) + scoreEnvelope (composes
  composite+band; throws on bad dim by contract)
- types.ts: SCHEMA_VERSION 2->3; TrendRecord.score?
- store.ts: TrendInput.score?; addTrend persists first-sight (duplicate keeps it);
  migrate comment v1->v2->v3 (logic unchanged, JSON.stringify preserves the field)
- item.ts: TrendItem.score?; normalizeItem validates (non-array score/dimensions + the
  mode's five dims in [1,10]) -> structured error never throw, carries validated dims;
  itemToInput -> scoreEnvelope (no throw on the capture path; direct call throws by contract)
- brief.ts: composite-primary comparator; band+mode render; exact ranking: descriptor
- cli.ts: capture persists score via itemToInput (doc-only); add/score paths unchanged
- agents/trend-spotter.md Step 4.5: capture batch carries the Step-2 dimensions
- gate: TRENDS_TESTS_FLOOR 104->146; new unconditional Section 16j; ASSERT floor 94->99

Tests: trends 146/146 (RED two-phase: logic-RED store/brief/cli; stub-first then
assertion-RED score/item). Gate green (Passed 114 / Failed 0; 113 checks >= 99).
Hook suite 139/139 untouched. Counts 27/19/29 unchanged. No new source file/agent/command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
2026-06-24 14:05:27 +02:00
4d3b9f4711 docs(linkedin-studio): RE-R3a brief + plan — persist relevance score + rank morning brief on it (light-Voyage hardened)
R3 slice 1 (research-deepening): persist a 4-field TrendScore {mode, dimensions,
composite, priority} on the store record (schema v2->v3, additive lossless), computed
by the already-built score.ts (composite+band, one owner), threaded item->store, and
rank rankForBrief on composite first + surface band+mode in renderBrief.

Go-gate confirmed (operator "Go"): D1 4-field envelope · D2 composite primary within
bucket · D3 first-sight only · D4 one slice · D6 mode shown per body entry.

Light-Voyage: scope-guardian ALIGNED (0) / brief-reviewer PROCEED_WITH_RISKS (6 MINOR)
/ plan-critic REVISE (1 BLOCKER, 4 MAJOR, 4 MINOR) — all folded. Headline fold: the RED
proof is now explicitly two-phase (logic-RED for store/brief/cli; stub-first then
assertion-RED for score/item, since a missing named import throws at module-load under
Node16 ESM, not on assertion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmHCQjJHUyWwxGAVVjNLgp
2026-06-24 13:43:02 +02:00
79 changed files with 11147 additions and 241 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "linkedin-studio", "name": "linkedin-studio",
"version": "0.5.3", "version": "0.6.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 03): 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:<agent>`, 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).", "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 03): 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:<agent>`, 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": { "author": {
"name": "Kjell Tore Guttormsen" "name": "Kjell Tore Guttormsen"

View file

@ -5,6 +5,43 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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 `<p>` 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-R3ae
- **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-S3ae. 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 13). Together these took the newsletter pipeline 16 → 18 phases.
- **Inside v0.5.3:** **SB-S3ae** (profile.md reader-wiring, supersede arm, cross-silo id-threading, operations.md ops centre, content-history retirement + read-side reconcile) and **RE-R1R2b** (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 ## [0.5.3] - 2026-06-24
### Changed — registration hygiene: agent fasit fixtures moved out of `agents/` ### Changed — registration hygiene: agent fasit fixtures moved out of `agents/`

File diff suppressed because one or more lines are too long

View file

@ -6,12 +6,12 @@
*AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)* *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.6.0-blue)
![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) ![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) ![Agents](https://img.shields.io/badge/agents-19-orange)
![Hooks](https://img.shields.io/badge/hooks-9-red) ![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) ![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. 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). > New here? Run `/linkedin:onboarding` — it walks you through profile optimization, personalization, and your first published post in one guided flow (~10 minutes).
> [!NOTE] > [!NOTE]
> **Pre-1.0 (v0.5.0).** The earlier 1.0.04.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.04.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 ## 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 ### Onboarding & Setup
@ -179,7 +179,7 @@ All 29 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc. Th
| `content-planner` | Sonnet | Weekly/monthly content calendars | | `content-planner` | Sonnet | Weekly/monthly content calendars |
| `network-builder` | Sonnet | Strategic networking + outreach | | `network-builder` | Sonnet | Strategic networking + outreach |
| `content-repurposer` | Sonnet | Format conversion + evergreen refresh | | `content-repurposer` | Sonnet | Format conversion + evergreen refresh |
| `trend-spotter` | Sonnet | Trending topics + opportunity scores | | `trend-spotter` | (inherits session) | Trending topics + opportunity scores |
| `voice-trainer` | Sonnet | Voice profile building + drift detection | | `voice-trainer` | Sonnet | Voice profile building + drift detection |
| `differentiation-checker` | Sonnet | Originality scoring + commodity detection | | `differentiation-checker` | Sonnet | Originality scoring + commodity detection |
| `video-scripter` | Sonnet | Video scripts with pacing + visual cues | | `video-scripter` | Sonnet | Video scripts with pacing + visual cues |
@ -245,7 +245,7 @@ The README is the front door. The detail lives alongside it:
| For… | See | | For… | See |
|------|-----| |------|-----|
| Architecture — agent pipeline & selection, 9 hooks, 6 skills, personalization scoring, configuration, analytics internals | [CLAUDE.md](CLAUDE.md) | | 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) | | Full version history and known gaps | [CHANGELOG.md](CHANGELOG.md) |
| Maintenance model, fork-and-own, what upstream provides | [GOVERNANCE.md](GOVERNANCE.md) | | Maintenance model, fork-and-own, what upstream provides | [GOVERNANCE.md](GOVERNANCE.md) |

View file

@ -14,7 +14,6 @@ description: |
Triggers on: "trending", "what should I post about", "scan for trends", "content opportunities", 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". "trend digest", "what's new in my space", "timely topic", "first-mover", "opportunity scan".
model: sonnet
color: white color: white
# No `tools:` allowlist by design (research-engine slice 2b). An explicit allowlist would # No `tools:` allowlist by design (research-engine slice 2b). An explicit allowlist would
# block every research MCP unless its `mcp__<server>__<tool>` name were hardcoded here — # block every research MCP unless its `mcp__<server>__<tool>` 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 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 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 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 re-capture (so re-capturing an existing trend just enriches the tags), persists the source's
`publishedAt` for later freshness ranking — one call, not one per trend: `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 ```bash
cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \
echo '[ echo '[
{"source":"<tavily|websearch|manual|…>","title":"<verbatim headline>","url":"<source url>", {"source":"<tavily|websearch|manual|…>","title":"<verbatim headline>","url":"<source url>",
"topics":["<pillar-tag1>","<pillar-tag2>"],"publishedAt":"<YYYY-MM-DD if known>", "topics":["<pillar-tag1>","<pillar-tag2>"],"publishedAt":"<YYYY-MM-DD if known>",
"summary":"<one-line what-happened>"} "summary":"<one-line what-happened>",
"score":{"mode":"kortform","dimensions":{"pillar":N,"audience":N,"timing":N,"angle":N,"authority":N}}}
]' | node --import tsx src/cli.ts capture ]' | node --import tsx src/cli.ts capture
``` ```
`source` is the tool you actually fetched with (**Research Routing**); `publishedAt` is the `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 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 110 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 `{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 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. 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 <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)** **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 After capturing, render today's dated morning brief over the store so the **next session surfaces
@ -321,6 +342,21 @@ written to `<data-dir>/trends/morning-brief/YYYY-MM-DD.md` and ranks only on per
(pillar overlap + `publishedAt`/`capturedAt` freshness, default 7-day window — tune with (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. `--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** **Step 5: Compile digest**
- Format using output template below - Format using output template below

View file

@ -39,8 +39,15 @@ console.log('=== OVERDUE ===');
console.log(queueFormatSummary(queueOverdue())); console.log(queueFormatSummary(queueOverdue()));
console.log('=== COUNTS ==='); console.log('=== COUNTS ===');
console.log(JSON.stringify(queueCount(), null, 2)); 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: Also read state for context:
- `~/.claude/linkedin-studio.local.md` for weekly goal and current progress - `~/.claude/linkedin-studio.local.md` for weekly goal and current progress
@ -110,7 +117,7 @@ No posts scheduled for today.
- Run /linkedin:quick for an unplanned quick post - 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:** **3c. Update queue status:**
```bash ```bash
@ -168,8 +175,8 @@ If they choose to reschedule:
2. Ask for the new date and time 2. Ask for the new date and time
3. Re-add the entry with the **same id** and new date/time — `queueAdd` replaces any 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 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 unchanged fields (id, draft_path, pillar, format, hook preview, char count) from the
entry shown in Step 2: **ENTRY RECORDS** block emitted in Step 1:
```bash ```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]));" 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]));"
``` ```

View file

@ -208,9 +208,11 @@ CAPTION
Then auto-copy the full deck to clipboard silently: Then auto-copy the full deck to clipboard silently:
```bash ```bash
printf '%s' '<FULL_DECK_PAYLOAD>' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__'
<FULL_DECK_PAYLOAD>
__LINKEDIN_CLIP_EOF__
``` ```
Substitute `<FULL_DECK_PAYLOAD>` with the assembled deck above — all slides' copy + the caption. Then confirm: "Full deck — [N] slides + caption — copied to clipboard." Substitute `<FULL_DECK_PAYLOAD>` 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:<platform>`, 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): 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." "Want to refine? Options: adjust slide text / change visual style / regenerate specific slide / different hook / ready for publishing."

View file

@ -138,10 +138,12 @@ Show the post with:
Auto-copy the post text to clipboard silently: Auto-copy the post text to clipboard silently:
```bash ```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 present: "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`, present: "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.
## Step 7: State Update ## Step 7: State Update

View file

@ -69,10 +69,12 @@ Show, in this order:
Auto-copy the self-comments + draft replies to clipboard silently (so they're one paste away): Auto-copy the self-comments + draft replies to clipboard silently (so they're one paste away):
```bash ```bash
printf '%s' '<DRAFT_COMMENTS_BLOCK>' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__'
<DRAFT_COMMENTS_BLOCK>
__LINKEDIN_CLIP_EOF__
``` ```
Then confirm: "Copied your draft comments to clipboard." Substitute `<DRAFT_COMMENTS_BLOCK>` 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:<platform>`, 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 ## Step 4: Persist the Plan to State

View file

@ -84,6 +84,7 @@ directly when you do.
| `/linkedin:multiplatform` | Adapt content for Twitter/X, slides, YouTube (long-form → newsletter) | | `/linkedin:multiplatform` | Adapt content for Twitter/X, slides, YouTube (long-form → newsletter) |
| `/linkedin:batch` | Create a full week of content in one session | | `/linkedin:batch` | Create a full week of content in one session |
| `/linkedin:pipeline` | End-to-end single-post workflow (idea → draft → schedule → analyze) | | `/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: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: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 | | `/linkedin:pivot` | Re-open a long-form edition after a late change so cleared gates re-run |
@ -161,6 +162,7 @@ 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 "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 "quick" or "fast" → Route to `/linkedin:quick`
- Mentions "pipeline" or "end to end" → Route to `/linkedin:pipeline` - 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 "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 "calendar" or "schedule" or "queue" or "upcoming posts" or "what's scheduled" → Route to `/linkedin:calendar`
- Mentions "publish" or "mark as published" or "posted today" or "just published" or "post is live" → Route to `/linkedin:calendar` (publish action) - Mentions "publish" or "mark as published" or "posted today" or "just published" or "post is live" → Route to `/linkedin:calendar` (publish action)

View file

@ -118,9 +118,11 @@ After creating the adaptation:
- Save to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/multiplatform/[platform]-[slug].md` - Save to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/multiplatform/[platform]-[slug].md`
- Auto-copy the adapted content to clipboard silently: - Auto-copy the adapted content to clipboard silently:
```bash ```bash
printf '%s' '<ADAPTED_CONTENT>' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__'
<ADAPTED_CONTENT>
__LINKEDIN_CLIP_EOF__
``` ```
- Present the content and confirm: "Copied to clipboard." - Substitute `<ADAPTED_CONTENT>` 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:<platform>`, 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 - Note platform-specific publishing tips
## Reference Files ## Reference Files

View file

@ -109,7 +109,7 @@ 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 | **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` | | 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 | **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` | | 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" | `render/build-linkedin.mjs` | | 8 | **LOCK → delivery** | POST.html "all in one place" | `render/build-linkedin.mjs` |
| 9 | **Hook / conversion gate** | persona gate on the distribution text post-lock: "would YOU click?" | **`persona-reviewer`** (conversion mode) | | 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 | `hooks/scripts/queue-manager.mjs` |
@ -1466,13 +1466,25 @@ operator declares carousel format for it. Branch accordingly:
in `edition-state.json``articles.NN.visualAssets.cover.brief` and in `edition-state.json``articles.NN.visualAssets.cover.brief` and
`…figures[].brief`. `…figures[].brief`.
3. **Generate — two routes, no lock-in.** The interface is pluggable (path-in / 3. **Generate — three routes, no lock-in.** The interface is pluggable (path-in /
path-out); `mcp-image` is the default, not a hard dependency: path-out). Route by what the image *is*:
- **Default route — `mcp__mcp-image__generate_image`** (Nano Banana Pro / - **Coded route — PRIMARY for data figures** (charts, diagrams, before/after
Gemini 3 Pro Image). Write candidates to comparisons — anything whose content is real numbers or real structure).
`linkedin/NN/cover-v<N>-kandidat.png` (and `fig<N>-kandidat.png` for Author the figure as SVG/HTML per
figures). Candidate naming lets several attempts sit side by side without `${CLAUDE_PLUGIN_ROOT}/references/figure-design-guidelines.md` (design
overwriting an approved file. Record route `"mcp-image"`. 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. - **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 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 tool is mandated. Record route `"external"`. (The raw file may then be
@ -1560,7 +1572,7 @@ Visual assets (BEFORE lock).
- Cover: linkedin/NN/cover.png approved (after <N> candidates) (or: N/A — carousel) - Cover: linkedin/NN/cover.png approved (after <N> candidates) (or: N/A — carousel)
- Figures: <N> approved → linkedin/NN/figN.png (or: none) - Figures: <N> approved → linkedin/NN/figN.png (or: none)
- Carousel deck: linkedin/NN/carousel.pdf rendered + approved (or: N/A — standard) - 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] - Operator gate: approved (candidates surfaced via SendUserFile) [OPERATØR]
Next: Step 8 — LOCK → delivery. Next: Step 8 — LOCK → delivery.
``` ```
@ -1841,3 +1853,5 @@ the honest decision surface; it sells nothing.
- `${CLAUDE_PLUGIN_ROOT}/render/build-linkedin.mjs` — POST.html delivery; reads `linkedin/NN/cover.png` + credit/caption (Step 8) - `${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-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-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

View file

@ -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: Show the post with its character count, the hook highlighted, and one alternative hook. Auto-copy the post text to clipboard silently:
```bash ```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 ### 3.5 — Record it

View file

@ -136,7 +136,9 @@ Offer to help identify target profiles and draft comments.
Auto-copy the final post text to clipboard silently before presenting: Auto-copy the final post text to clipboard silently before presenting:
```bash ```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: Present the final post as copy-paste ready content:

View file

@ -150,9 +150,11 @@ Present ONE draft with:
Auto-copy the final post text to clipboard silently: Auto-copy the final post text to clipboard silently:
```bash ```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. Do NOT proactively offer alternative versions. Only generate alternatives if the user asks for them.

View file

@ -159,9 +159,11 @@ Show the post with:
Auto-copy the final post text to clipboard silently: Auto-copy the final post text to clipboard silently:
```bash ```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. Do NOT proactively offer alternative versions. Only generate alternatives if the user asks.

View file

@ -146,9 +146,11 @@ Show:
Auto-copy the main draft text to clipboard silently: Auto-copy the main draft text to clipboard silently:
```bash ```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: Do NOT use AskUserQuestion for refinement. Simply state:

View file

@ -10,6 +10,7 @@ allowed-tools:
- Bash - Bash
- Read - Read
- Glob - Glob
- Write
- AskUserQuestion - AskUserQuestion
- Task - 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 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. **If weekly (option 1 or default):** Continue below.
### Weekly: Determine Week ### Weekly: Determine Week

142
commands/trends.md Normal file
View file

@ -0,0 +1,142 @@
---
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 |
| `--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.
## 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 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, 23 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]
[23 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

View file

@ -172,9 +172,11 @@ Style: [minimal / branded / text-heavy]
Auto-copy the POST CAPTION text to clipboard silently: Auto-copy the POST CAPTION text to clipboard silently:
```bash ```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 ## Step 7: Refinement Cycle

View file

@ -13,7 +13,7 @@
| content-planner | Sonnet | Cyan | Weekly/monthly content calendars | | content-planner | Sonnet | Cyan | Weekly/monthly content calendars |
| network-builder | Sonnet | Teal | Strategic networking and outreach | | network-builder | Sonnet | Teal | Strategic networking and outreach |
| content-repurposer | Sonnet | Purple | Format conversion and evergreen refresh | | 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 |
| voice-trainer | Sonnet | Pink | Voice profile building and drift detection | | voice-trainer | Sonnet | Pink | Voice profile building and drift detection |
| differentiation-checker | Sonnet | Gray | Originality scoring and commodity detection | | differentiation-checker | Sonnet | Gray | Originality scoring and commodity detection |
| video-scripter | Sonnet | Violet | Video script creation with pacing and visual cues | | video-scripter | Sonnet | Violet | Video script creation with pacing and visual cues |

View file

@ -93,6 +93,7 @@ that exercises the command's real path:
| S7 batch | S14 import | S21 monetize | S28 ref-consistency B | | S7 batch | S14 import | S21 monetize | S28 ref-consistency B |
| S8 pipeline | S15 report | S22 outreach | S29 terminology-scrub | | S8 pipeline | S15 report | S22 outreach | S29 terminology-scrub |
| | | | S30 magnitude-scrub | | | | | S30 magnitude-scrub |
| | | | S31a/b/c multiplier-scrub |
*S9 newsletter (16-phase) may split into S9a/S9b. Otherwise one command = one session. *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 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. 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 ✅ → S29ae ✅ → 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). are hardening-class (commit local, no push).
## End-of-session ritual (every session — STATE.md handoff baked in) ## End-of-session ritual (every session — STATE.md handoff baked in)

View file

@ -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
(S2S26, `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,0001,400) contradicts the file's own Step 5 gate + canonical SSOT (1,2001,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 150500).
- **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,2001,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 (58 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 200400 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` "~8085% watch muted" — same claim, two figures in one frozen file.
- **Both lenses flagged this.** 85% sits inside 8085% so not a hard contradiction, but reads as unreviewed precision in a quality-gate checklist.
- **disposition:** pick one figure (recommend "~8085%") in both places.
### MINOR — post caption (200400) is a third length band vs canonical quick (150500)
- `video.md:121,158` specify "200-400 chars"; `content-quality-gate.md:18` quick = 150500. 200400 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 200400 is intentional or drift.
- **disposition:** accept if intentional (captions deliberately shorter), but add a one-line note that 200400 is a deliberate sub-band of the 150500 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 ~110140 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 110140) 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 110140 / 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.

View file

@ -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 (9601,640) cannot satisfy the 1,2001,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,2001,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 (9601,640)
also blows the quick band (150500). 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 24
- `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 24 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, 9601,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 150500. **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 "35 posts" headline
- `batch.md:5,65` fix the output at "35 posts"; `batch.md:52` schedules against `weekly_goal` slot
templates (2x/3x/4x/5x). At `weekly_goal=2x`, 35 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" + "35" 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 08 map to every named stage. All post-emitting predicates present (hook 110140 `: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 9601,640; the very next step's scorecard asserts
"Total 1,2001,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 58
(`: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 24 ("if scheduled/queued, end here; Steps
58 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,2001,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.

218
docs/hardening/review-R3.md Normal file
View file

@ -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 14 sequential; sub-markers 3a3f
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% = (19921400)/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).

293
docs/hardening/review-R4.md Normal file
View file

@ -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.

258
docs/hardening/review-R5.md Normal file
View file

@ -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 (04) 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 110× 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 06 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 112`); "4 talk
templates" → AD; "5 phases" → PHASE 15; 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 17 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.

View file

@ -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 15. 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 12) 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 (N1N32). 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.

View file

@ -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`

341
docs/okf-ingestion/plan.md Normal file
View file

@ -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 (R1R5) 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.

View file

@ -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 · A1A4 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 110 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 / A1A4 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 16, **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 A1A4** (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
D1D4 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 SC1SC8 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.

View file

@ -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 16, **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 A1A4** (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 SC1SC9 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).

View file

@ -0,0 +1,424 @@
# Brief — RE-R3c: autonomous trigger — scheduler + headless entry point
> **Slice:** RE-R3c (research-engine rung-2, R3 slice 3 — the **autonomy** slice: the trigger that makes the
> daily loop *closed* and the headless entry that runs the deterministic morning brief with **no interactive
> session**). R3 ("deepen the research engine") is an **arc** of 5 open hulls (substrate §1). R3a took relevance,
> R3b took the lifecycle (status + seen-log + re-score). R3c takes hulls **(1) no autonomous trigger** + **(6) no
> headless entry point** — the *mechanism* that runs the existing deterministic brief on a schedule, built and
> tested deterministically **before** the autonomous AI fan-out (slice e) plugs into it.
> **Predecessor:** RE-R3b (`status` exclusion + per-day-idempotent `surfacedCount`/`lastSurfacedAt` — the
> dedup-state a nightly loop **depends on** so it never re-surfaces handled work) + RE-R2b (`brief.ts` dated
> artifact + the SessionStart surfacing the nightly run feeds) + RE-R3a (composite ranking).
> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (1) (*"ingen autonom trigger … zero cron/launchd/
> scheduler i hele repoet"*) + (6) (*"ingen headless entry point"*) + §B4 (*"behavioral scheduling … a push/delivery
> window that gates delivery separately from the sweep"*) + §B3 (the dated digest as a flat plain-text artifact
> *"skrevet av Stop-hook eller cron-trigget headless-sesjon"*). R3c builds the cron-triggered headless path B3
> anticipated and the scheduling-window discipline B4 names.
> **TDD-order:** RED before code, **two phases** (light-Voyage discipline, inherited): Phase A — assertion-RED via
> subprocess against the **existing** CLI (`schedule` is an unknown command today → `usage` exit 2; the wrapper
> file is absent → exit 127) — true assertion-RED on the exit-code/stdout assertions, not module-not-found. Phase B
> — `schedule.ts` is a NEW module whose exports the tests import; under Node16 ESM a missing named import throws at
> module-load, so land **non-throwing stubs** (`launchdPlist → ""`, etc.) first, then record value-assertion RED
> against them. See plan Step 1.
> **Architectural decisions — CONFIRMED (operator, AskUserQuestion 2026-06-26; baked into the plan):**
> - **C1 — deterministic brief-only.** The nightly headless run regenerates the dated brief from the **current
> store** (freshness-aging drops stale trends; `surfacedCount` accumulates per distinct day → feeds slice b).
> **NO AI capture.** Polling stays operator-driven; the autonomous AI fan-out is **slice (e)**, which plugs into
> (c)'s headless seam. Faithful to the operator's `(a)→(c)→(b)→(d)→(e)` sequence: build the trigger mechanism +
> headless plumbing (deterministic, testable) **before** the AI sweep it will eventually drive. *Honest framing:
> the visible autonomous-research payoff lands with (e); (c) is the mechanism.*
> - **C2 — print-first installer.** `schedule` **emits** the launchd plist (macOS) / crontab line (Linux) + the
> exact install command; the operator runs it. `--install` writes only the inert launchd plist FILE (never runs
> `launchctl`; never touches `crontab`). Matches the global `[voyage]` cron-persistence guard, the push-policy's
> operator-authorization, and the "confirm outward-facing/persistent actions" rule.
## 1. Operator decision context (2026-06-26)
The research engine is **Tier-1** (operator, 2026-06-23). R1→R3b built the deterministic spine **and** the
trend's life after capture: item-schema + triage (R1) → capture bridge (R2a) → dated morning brief + surfacing
(R2b) → persisted relevance + composite ranking (R3a) → status lifecycle + seen-log + re-score (R3b). The spine is
complete and the lifecycle is correct — **but nothing runs it on its own.** The morning brief exists only when the
operator interactively invokes the `brief` CLI (via the `trend-spotter` agent or by hand); the SessionStart hook
*surfaces* the latest dated brief (`session-start.mjs:534`) but **never generates one**. There is **zero
scheduler** in the repo (verified live: only `scripts/test-runner.sh` exists; no plist, no cron, no launchd in any
`.ts`/`.mjs`/`.sh`/config). The loop is open: a brief is only as fresh as the last time the operator remembered to
ask for one.
R3c closes hulls **(1)** and **(6)** — the **autonomous trigger** and the **headless entry point** — which the
operator chose as slice (c) of the full-R3 build-out (2026-06-24, *"ALLE gjenstående R3-slices … i rekkefølge
(a) → (c) → (b) → (d) → (e)"*). It is sequenced **after** R3b for a load-bearing reason the operator named: an
autonomous loop **must never re-surface handled work**, so it depends on R3b's status-exclusion (acted/skipped
dropped from the brief) and its **per-day-idempotent** surfacing (a double-fire doesn't double-count). R3b made
the nightly regeneration *safe to automate*; R3c automates it.
**What R3c is — and is not (C1).** R3c is the **mechanism**, not the AI sweep. The nightly run is the *existing
deterministic* `brief` generation — load store → rank → write the dated `.md` → record surfacing — run with no
interaction by a scheduler. It does **not** poll new sources (that is the AI fan-out, slice e). Its honest value
without (e): the brief is regenerated every morning from the current store, so SessionStart surfacing is always
fresh; freshness-aging drops trends past the window automatically; and `surfacedCount` accumulates day-over-day —
the temporal signal slice (b) reads — **without the operator running anything**. (e) later plugs an AI capture
step into the documented pre-brief seam to close the full `poll→score→capture→brief` loop.
## 2. The gap — grounded in code
- **No autonomous trigger (hull 1).** Repo-wide there is no scheduler: no launchd plist, no crontab artifact, no
`launchctl`/`cron` reference in any source or config (verified). Every brief is born of an interactive session.
- **No headless entry point (hull 6) — *almost*.** The `brief` subcommand (`cli.ts:297-328`) is **already
non-interactive**: it reads flags, writes `<outDir>/<day>.md`, records surfacing, and exits 0 — no prompts. What
is missing is a **robust invocation wrapper** that makes it runnable from a scheduler's *minimal* environment:
a launchd/cron job inherits **no shell profile** (no `PATH` from `~/.zshenv`, so a bare `node` is unresolvable),
has **no working directory** set to the repo (tsx resolves modules only from `scripts/trends/`), and has **no
logging**. Today nothing bridges that gap.
- **The brief is operator-pulled, never machine-pushed.** `session-start.mjs:60-77`/`:534` *reads* the latest
dated brief (`date`+`summary`, zero-tsx) and surfaces it — it is a pure consumer. Generation lives only in the
CLI, invoked by a human. B3's *"cron-trigget headless-sesjon"* writer does not exist.
- **The CLI has no scheduling verb.** `cli.ts` exposes `add`/`query`/`list`/`status`/`act`/`skip`/`reset`/
`normalize`/`score`/`capture`/`brief` (`cli.ts:5-14`, `:134-330`) — capture/read/lifecycle, all interactive.
There is **no way to emit or install a daily schedule** for the brief.
- **The data-dir seam is solved, but only for two runtimes.** `store.ts:252` (`defaultStorePath`) and
`hooks/scripts/data-root.mjs:24` (`getDataRoot`) are *twins* of the one seam (`LINKEDIN_STUDIO_DATA ?? ~/.claude/
linkedin-studio`). A scheduler entry running in **shell** needs the same seam for its log path — a **third
sanctioned twin**, exactly the inline `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/…` form
`references/data-path-convention.md` rule 1 prescribes. It does not exist yet.
## 3. Scope — what is IN (RE-R3c)
### S-schedule — `scripts/trends/src/schedule.ts` (NEW, pure module)
Pure string emitters for the schedule artifacts — no clock, no fs, no env, no AI (the CLI injects every resolved
value). Mirrors `brief.ts`'s `renderBrief` purity → fully testable, byte-deterministic given inputs.
- **`export interface ScheduleSpec`** — `{ platform: "launchd" | "cron"; label: string; nodeBin: string;
wrapperPath: string; args: string[]; hour: number; minute: number; logPath: string; workingDir: string;
env: Record<string, string>; }`. All paths are absolute, resolved by the CLI at generation time on the
operator's machine. **`env`** is the injected environment map (the CLI builds it — always `NODE_BIN` +
a **resolved-absolute** `LINKEDIN_STUDIO_DATA`); the emitter only *renders* it, so it reads no env itself
(folded — brief-reviewer #4 / plan-critic #3: the field is canonical, not mid-step).
- **`export function launchdPlist(spec: ScheduleSpec): string`** — the plist XML: `Label`, `ProgramArguments`
(`["/bin/bash", wrapperPath, ...args]`), `StartCalendarInterval` (`{ Hour: spec.hour, Minute: spec.minute }`),
`EnvironmentVariables` (rendered from `spec.env` only — purity), `WorkingDirectory` (`spec.workingDir`),
`StandardOutPath`/`StandardErrorPath` (`spec.logPath`), `RunAtLoad` false. A pinned, well-formed template
(`<?xml … !DOCTYPE plist …>`); `schedule.test` asserts both key-completeness **and** well-formedness
(balanced-tag/parse), `plutil -lint` is the deps-present manual check (folded — brief-reviewer #7).
- **`export function crontabLine(spec: ScheduleSpec): string`** — one line:
`<minute> <hour> * * * <env-prefix> /bin/bash <wrapperPath> <args…> >> <logPath> 2>&1 # <label>` where
`<env-prefix>` is `spec.env` rendered as cron's inline `K=V K=V` form. **The function returns the line as a
STRING; it never executes `crontab`** (the execution guard + C2; the literal `crontabLine` does not match the
guard's `\bcrontab\b` word-boundary pattern — §5).
- **`export function installInstructions(spec: ScheduleSpec, plistTargetPath?: string): string`** — the exact
operator commands. launchd: *"written to `<plistTargetPath>` — activate with `launchctl bootstrap gui/$(id -u)
<plistTargetPath>`"*. cron: *"add the line above with `(crontab -l 2>/dev/null; echo '<line>') | crontab -`"*.
Print-first surfaces these so the operator runs them.
- **`export function uninstallInstructions(spec, plistTargetPath?): string`** — symmetric removal (launchd:
`launchctl bootout …` + `rm <plist>`; cron: the line-removal `grep -v` recipe).
- **`export function defaultLabel(): string`** — `"com.linkedin-studio.trends.daily"` (the **plugin namespace**,
domain-general — not the user's domain; no vendor/sector token).
### S-wrapper — `scripts/trends/run-daily.sh` (NEW, headless invocation wrapper)
The single tested headless entry, invoked identically by **both** the launchd plist and the crontab line (one
entry → one test). Bash 3.2-compatible (operator's macOS: no `declare -A`, no `mapfile`, all expansions quoted,
ASCII-only).
- Resolves **its own directory** (`DIR="$(cd "$(dirname "$0")" && pwd)"`) so it is relocatable — no hard-coded
repo path — and **`cd "$DIR"`** so `--import tsx` resolves `node_modules` from the package even under cron's
`$HOME` CWD (folded — brief-reviewer #1: the plist sets `WorkingDirectory`, but cron does not — `cd` makes the
one wrapper scheduler-agnostic).
- Resolves **node** from a minimal scheduler env: `NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null)}"`;
if still empty, fall back to common locations; exit 127 with a logged error if none. (The scheduler bakes
`NODE_BIN=<process.execPath>` so resolution always succeeds; the fallback is for a manual invocation.)
- Resolves the **log path** from the canonical inline seam — `LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/
linkedin-studio}/trends/cron.log"` — the **fourth sanctioned data-path twin** (shell), the exact form
`references/data-path-convention.md` rule 1 prescribes (documented as such, like `data-root.mjs`'s twin comment).
The scheduler **always bakes a resolved-absolute `LINKEDIN_STUDIO_DATA`** into the artifact env, so a scheduled
run never evaluates `$HOME` (sidesteps the `set -u` `HOME`-unset edge — folded — plan-critic #9 / brief-reviewer
#9); the `$HOME` fallback is only for a manual invocation, where `HOME` is set. `mkdir -p` its dir.
- Runs the **deterministic** brief: `OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)";
CODE=$?` then **compacts** `OUT` to one line (`tr '\n' ' ' | tr -s ' '`) — `brief --json` is **pretty-printed**
(`cli.ts:323` `JSON.stringify(…, null, 2)`), so the structured log line must collapse the newlines (folded —
plan-critic #1). The scheduler bakes `--pillars … --fresh-days N` into `"$@"`; the wrapper hard-codes the
`brief` subcommand and adds `--json`. Appends **one** line `<ISO-ts> exit=<CODE> <compact-OUT>` to `$LOG`;
`exit $CODE`. **No AI**`brief` is the deterministic store→artifact path (C1).
- **The (e) seam (documented, not built):** a one-line comment marks where slice (e) will insert a pre-brief
capture step (`claude -p … trend-spotter | cli.ts capture`) before the `brief` call. R3c builds **only** the
deterministic path.
### S-cli — `scripts/trends/src/cli.ts` (EDIT) — the `schedule` subcommand
- **`schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install]
[--uninstall] [--store <path>]`**:
- Resolves **platform**: `auto` (default) → `process.platform === "darwin" ? "launchd" : "cron"`.
- Resolves **time** from `--at` (default `07:00`); validates `HH ∈ 023`, `MM ∈ 059``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 15, **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 SC1SC10 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.

View file

@ -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 SC1SC10 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).

View file

@ -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 `<`.

View file

@ -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 16 — 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 SC1SC8 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
A1A4, 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.

View file

@ -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 D1D9 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** | SC1SC4 |
| `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 16 — 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 SC1SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held (no
saturation scoring, no scheduler, no brief-diff, no A1A4, 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).

View file

@ -0,0 +1,376 @@
# Plan — RE-R3c: autonomous trigger — scheduler + headless entry point
> **Brief:** `docs/research-engine/brief-re-r3c.md`. **Slice:** RE-R3c (research-engine rung-2 — R3 slice 3, the
> **autonomy** slice: the scheduler trigger + the headless entry that runs the deterministic morning brief with no
> interactive session). Closes hulls (1) no autonomous trigger + (6) no headless entry point.
> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)**
> subprocess assertion-RED against the **existing** CLI (`schedule` is an unknown command → `usage` exit 2; the
> `run-daily.sh` wrapper file is absent → exit 127) on the exit-0/stdout assertions; **(B)** for the NEW
> `schedule.ts` exports the tests import, land **non-throwing stubs** first (Node16 ESM throws a missing named
> import at module-load, not on assertion), then record value-assertion RED against them. Then GREEN: `schedule.ts`
> stubs→real → `run-daily.sh` wrapper → `cli.ts` `schedule` verb → wire `trend-spotter.md` (prose) + README →
> gate floors + Section 16l → behavioural → land.
> **Counts recounted live at land, never pinned/guessed.**
> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-26):** C1 deterministic brief-only (no AI capture —
> that is slice e) · C2 print-first installer (emit + the operator runs the system mutation; `--install` writes only
> the inert launchd plist file; the tool never runs `launchctl`/`crontab`). Go-gate D1D9 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** | SC1SC6, 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 | SC3SC6, 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:0023: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 15 — 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 SC1SC10 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.

View file

@ -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 D1D10 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 | SC1SC5, 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) | SC1SC5, SC7SC9 |
| `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 23).
- `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 15 — 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 13, 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 24 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).

View file

@ -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 D1D9 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` | SC1SC8 |
| `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) | SC1SC8 |
| `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 15 — 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.

View file

@ -1,6 +1,6 @@
# Second Brain — Architecture Design # 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); S3S4 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-S3ae 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). > **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`. > **Research inputs (three parallel threads, 2026-06-23):** `research/connector-egress.md` · `research/secondbrain-sota.md` · `research/silo-inventory.md`.

View file

@ -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', () => { describe('pruneContentHistory', () => {
test('removes entries older than 90 days', () => { test('prunes an old entry that sits BELOW a fresh entry (regression: /m flag truncated the capture to the first line)', () => {
const today = new Date(); // Production prepends new entries at the top, so old entries always sit
const old = new Date(today); // BELOW fresh ones. With the /m flag, the $ alternative in the lookahead
old.setDate(old.getDate() - 100); // matched the end of the FIRST entry line, so only that line was captured
const oldDate = old.toISOString().slice(0, 10); // and older entries below were never scanned -> never pruned.
const oldDate = daysBeforeFixedToday(400);
const freshDate = daysBeforeFixedToday(10);
const recent = new Date(today); const state = SAMPLE_STATE.replace(
recent.setDate(recent.getDate() - 10); /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
const recentDate = recent.toISOString().slice(0, 10); `## Recent Posts\n\n- [${freshDate}] "Fresh post..." (1200) - fresh topic\n- [${oldDate}] "Old post..." (1000) - old topic\n\n`
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 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.notEqual(result, null);
assert.equal(result.pruned, 1); assert.equal(result.pruned, 1);
assert.ok(!result.content.includes(oldDate)); const section = recentPostsSection(result.content);
assert.ok(result.content.includes(recentDate)); assert.ok(!section.includes(oldDate));
assert.ok(section.includes(recentDate));
}); });
test('preserves entries within 90 days', () => { test('preserves entries within 90 days', () => {
const today = new Date(); const recentDate = daysBeforeFixedToday(30);
const recent = new Date(today);
recent.setDate(recent.getDate() - 30);
const recentDate = recent.toISOString().slice(0, 10);
const stateWithRecent = SAMPLE_STATE.replace( const stateWithRecent = SAMPLE_STATE.replace(
'## Recent Posts\n\n', /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
`## Recent Posts\n\n- [${recentDate}] "Recent post..." (1200) - topic\n` `## 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 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[\s\S]*?(?=## Session Notes)/,
'## Recent Posts\n\n' '## Recent Posts\n\n'
); );
const result = pruneContentHistory(emptyRecent, 90); const result = pruneContentHistory(emptyRecent, 90, FIXED_TODAY);
assert.equal(result, null); assert.equal(result, null);
}); });
test('handles custom maxAgeDays', () => { test('handles custom maxAgeDays', () => {
const today = new Date(); const oldDate = daysBeforeFixedToday(40);
const old = new Date(today);
old.setDate(old.getDate() - 40);
const oldDate = old.toISOString().slice(0, 10);
const stateWithOld = SAMPLE_STATE.replace( const stateWithOld = SAMPLE_STATE.replace(
'## Recent Posts\n\n', /## Recent Posts\n\n[\s\S]*?(?=## Session Notes)/,
`## Recent Posts\n\n- [${oldDate}] "Somewhat old..." (1000) - topic\n` `## 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.notEqual(result, null);
assert.equal(result.pruned, 1); assert.equal(result.pruned, 1);
}); });
@ -352,25 +381,23 @@ describe('pruneContentHistory', () => {
// string search has no $1 group, but `$&` still expands to the whole matched // string search has no $1 group, but `$&` still expands to the whole matched
// section and `$$` collapses to `$`, so a kept post like "$$ and $& budget" // section and `$$` collapses to `$`, so a kept post like "$$ and $& budget"
// corrupted state. The replacement function inserts newSection verbatim. // corrupted state. The replacement function inserts newSection verbatim.
const today = new Date(); const oldDate = daysBeforeFixedToday(100);
const old = new Date(today); old.setDate(old.getDate() - 100); const recentDate = daysBeforeFixedToday(10);
const oldDate = old.toISOString().slice(0, 10);
const recent = new Date(today); recent.setDate(recent.getDate() - 10);
const recentDate = recent.toISOString().slice(0, 10);
// Build the fixture with a replacement FUNCTION too — a string replacement here // 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 // would itself interpret the `$$`/`$&` we are trying to plant (the very bug under
// test), corrupting the fixture before pruneContentHistory ever sees it. // test), corrupting the fixture before pruneContentHistory ever sees it.
const stateWithMix = SAMPLE_STATE.replace( const stateWithMix = SAMPLE_STATE.replace(
'## Recent Posts\n\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` () => `## 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.notEqual(result, null);
assert.equal(result.pruned, 1); assert.equal(result.pruned, 1);
assert.ok(!result.content.includes(oldDate), 'old entry pruned'); const section = recentPostsSection(result.content);
assert.ok(result.content.includes(`- [${recentDate}] "Saved $&100" (1200) - $$ and $& budget`), 'kept $-bearing entry survives verbatim'); 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) || []; const headings = result.content.match(/^## Recent Posts$/gm) || [];
assert.equal(headings.length, 1, 'section must not be duplicated by a $& expansion'); assert.equal(headings.length, 1, 'section must not be duplicated by a $& expansion');
}); });

View file

@ -133,17 +133,21 @@ export function updatePostTracking(stateContent, { postDate, postTopic, hookText
* Remove Recent Posts entries older than maxAgeDays. * Remove Recent Posts entries older than maxAgeDays.
* @param {string} stateContent - Full state file content * @param {string} stateContent - Full state file content
* @param {number} [maxAgeDays=90] * @param {number} [maxAgeDays=90]
* @param {Date} [today=new Date()] - Injectable clock (tests pass a fixed date)
* @returns {{ content: string, pruned: number } | null} * @returns {{ content: string, pruned: number } | null}
*/ */
export function pruneContentHistory(stateContent, maxAgeDays = 90) { export function pruneContentHistory(stateContent, maxAgeDays = 90, today = new Date()) {
const today = new Date();
const cutoff = new Date(today); const cutoff = new Date(today);
cutoff.setDate(cutoff.getDate() - maxAgeDays); cutoff.setDate(cutoff.getDate() - maxAgeDays);
const cutoffStr = cutoff.toISOString().slice(0, 10); const cutoffStr = cutoff.toISOString().slice(0, 10);
// Find all Recent Posts entries // Find all Recent Posts entries
const entryPattern = /^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm; 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; if (!recentSection || !recentSection[1].trim()) return null;
const sectionContent = recentSection[1]; const sectionContent = recentSection[1];

View file

@ -113,14 +113,25 @@ improves writing); do not justify it as "reduces reach."
## The deployed ranking model — what we can and cannot say ## The deployed ranking model — what we can and cannot say
> **An LLM-based relevance-ranking system is live on LinkedIn in 2026.** > **LinkedIn's feed ranking model has an official name: the Generative Recommender (GR).**
> **No public name. No deployment date.** > 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 | | Claim | Statement | Source | Confidence |
|-------|-----------|--------|------------| |-------|-----------|--------|------------|
| A live LLM relevance system exists | Confirmed in direction by LinkedIn's 2026 communications. | LinkedIn comms (2026) | high | | Production name | **Generative Recommender (GR)** — official, primary-source. | LinkedIn engineering blog, 2026-03-12 | 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) | | 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 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 | | 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) ## Operational heuristics (directional — test per account)
@ -177,10 +188,11 @@ source.)
--- ---
*Last updated: 2026-05. Maintained as the single canonical algorithm statement; cite, do *Last updated: 2026-07-17 (GR-model correction). Maintained as the single canonical
not restate.* 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 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 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); Product (2026-02-16); Matt Navarra relaying LinkedIn Sr. Director Product (Aug 2025);

View file

@ -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.

View file

@ -92,9 +92,44 @@ The same priority bands apply to both modes (the composite is on the same 010
| 2.03.9 | **Low** | Note, skip for now | Park unless the angle sharpens | | 2.03.9 | **Low** | Note, skip for now | Park unless the angle sharpens |
| 01.9 | **Skip** | Off positioning | Off positioning | | 01.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 **MediumHigh (4.07.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 910 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 ## Consumers
- `agents/trend-spotter.md` — reads the requested mode and applies the matching rubric - `agents/trend-spotter.md` — reads the requested mode and applies the matching rubric
instead of inlining a matrix (wired in research-engine slice 2b). 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 - Any future research-engine pass that scores candidates before writing them to the trend
store (`scripts/trends/`). 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.

View file

@ -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 1623, 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</title></head><body></body></html>', DEFAULT_TOKENS);
assert.match(out, /<head>\s*<style>[^<]*--figur-ink/);
});
test('injectTokens prepender style når <head> mangler', () => {
const out = injectTokens('<p>bare et fragment</p>', DEFAULT_TOKENS);
assert.ok(out.trimStart().startsWith('<style>'));
assert.match(out, /<p>bare et fragment<\/p>/);
});
});
describe('parseSvgMeta — aspekt fra SVG-kilden', () => {
test('viewBox gir aspekt', () => {
const aspect = parseSvgMeta('<svg viewBox="0 0 1200 675"></svg>');
assert.ok(Math.abs(aspect - 1200 / 675) < 1e-9);
});
test('width/height-attributter gir aspekt', () => {
const aspect = parseSvgMeta('<svg width="800px" height="400"></svg>');
assert.equal(aspect, 2);
});
test('uten dimensjonsinfo: null', () => {
assert.equal(parseSvgMeta('<svg><rect/></svg>'), null);
});
});
describe('validateFigure — designregler som warn, aldri hard fail', () => {
test('font-size under 24 gir warning', () => {
const w = validateFigure('<svg><text font-size="14">liten</text></svg>');
assert.ok(w.some((x) => /24/.test(x)));
});
test('font-size ≥ 24 gir ingen tekst-warning', () => {
const w = validateFigure('<svg><text font-size="28">ok</text><text style="font-size: 32px">ok</text></svg>');
assert.ok(!w.some((x) => /24/.test(x)));
});
test('mer enn 2 ikke-nøytrale farger gir fargebudsjett-warning', () => {
const w = validateFigure('<svg><rect fill="#9A3324"/><rect fill="#2F6F9F"/><rect fill="#22AA55"/></svg>');
assert.ok(w.some((x) => /farge/i.test(x)));
});
test('nøytraler (gråtoner/hvit/sort) telles ikke mot fargebudsjettet', () => {
const w = validateFigure(
'<svg><rect fill="#FFFFFF"/><rect fill="#1A1A1A"/><rect fill="#888888"/><rect fill="#9A3324" font-size="30"/></svg>'
);
assert.ok(!w.some((x) => /farge/i.test(x)));
});
});
describe('resolveChrome — robust binær-oppdagelse (mockbar)', () => {
test('finner app-bundle-sti via exists-probe', () => {
const r = resolveChrome({
exists: (p) => p.includes('Google Chrome.app'),
which: () => null,
});
assert.equal(r.available, true);
assert.match(r.path, /Google Chrome/);
});
test('faller tilbake til PATH via which-probe', () => {
const r = resolveChrome({
exists: () => false,
which: (cmd) => (cmd === 'chromium' ? '/usr/local/bin/chromium' : null),
});
assert.equal(r.available, true);
assert.equal(r.path, '/usr/local/bin/chromium');
});
test('ingen kandidater: available=false med install-hint', () => {
const r = resolveChrome({ exists: () => false, which: () => null });
assert.equal(r.available, false);
assert.match(r.hint, /Chrome/);
});
});
describe('parseArgs — CLI-kontrakt', () => {
test('defaults: target=article, out avledes senere (null)', () => {
const r = parseArgs(['fig.svg']);
assert.equal(r.error, undefined);
assert.equal(r.input, 'fig.svg');
assert.equal(r.target, 'article');
assert.equal(r.out, null);
});
test('--target/--out/--width/--height parses (tall som tall)', () => {
const r = parseArgs(['fig.svg', '--target', 'carousel', '--out', '/tmp/x.png', '--width', '900', '--height', '1200']);
assert.equal(r.target, 'carousel');
assert.equal(r.out, '/tmp/x.png');
assert.equal(r.width, 900);
assert.equal(r.height, 1200);
});
test('ugyldig target gir error med gyldige mål', () => {
const r = parseArgs(['fig.svg', '--target', 'poster']);
assert.match(r.error, /article/);
});
test('manglende input gir usage-error', () => {
const r = parseArgs([]);
assert.match(r.error, /[Bb]ruk/);
});
});
// ---------------------------------------------------------------------------
// Ende-til-ende (krever ekte Chrome — skippes CI-vennlig når binær mangler)
// ---------------------------------------------------------------------------
const chrome = resolveChrome();
describe('renderFigure — ende-til-ende via headless Chrome', { skip: !chrome.available && 'Chrome ikke funnet' }, () => {
test('article: fixture-SVG → PNG med 1200px bredde og aspekt-avledet høyde', async () => {
const out = path.join(tmpdir(), 'fig-article.png');
const r = await renderFigure({ input: FIXTURE_SVG, target: 'article', out, dataDir: tmpdir() });
assert.equal(r.out, out);
const dim = pngDimensions(out);
assert.equal(dim.width, 1200);
assert.equal(dim.height, 675); // fixture er 16:9 (viewBox 1200×675)
});
test('carousel: fixture-SVG → PNG 1080×1350', async () => {
const out = path.join(tmpdir(), 'fig-carousel.png');
await renderFigure({ input: FIXTURE_SVG, target: 'carousel', out, dataDir: tmpdir() });
const dim = pngDimensions(out);
assert.equal(dim.width, 1080);
assert.equal(dim.height, 1350);
});
test('render uten brand-tokens.json lykkes med nøytrale defaults', async () => {
const out = path.join(tmpdir(), 'fig-default-tokens.png');
const r = await renderFigure({ input: FIXTURE_SVG, target: 'single', out, dataDir: tmpdir() });
assert.equal(r.tokensSource, 'default');
assert.ok(fs.existsSync(out));
// PNG-magic
const buf = fs.readFileSync(out);
assert.equal(buf.readUInt32BE(0), 0x89504e47);
});
test('CLI: node build-figur.mjs <fixture> --target carousel --out <png>', () => {
const out = path.join(tmpdir(), 'fig-cli.png');
const script = path.join(__dirname, '..', 'build-figur.mjs');
execFileSync(process.execPath, [script, FIXTURE_SVG, '--target', 'carousel', '--out', out], {
stdio: ['ignore', 'pipe', 'pipe'],
});
const dim = pngDimensions(out);
assert.equal(dim.width, 1080);
assert.equal(dim.height, 1350);
});
});

View file

@ -1,7 +1,11 @@
import { describe, test } from 'node:test'; import { describe, test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { markdownToHtml, inline } from '../build-html.mjs'; import { markdownToHtml, inline } from '../build-html.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
describe('markdownToHtml — tables (beslutning H)', () => { describe('markdownToHtml — tables (beslutning H)', () => {
test('converts a pipe table to <table>/<tr>/<td>', () => { test('converts a pipe table to <table>/<tr>/<td>', () => {
const md = [ const md = [
@ -63,3 +67,66 @@ describe('inline — backtick code span (beslutning H)', () => {
assert.match(inline('**fet** og *kursiv*'), /<em>kursiv<\/em>/); assert.match(inline('**fet** og *kursiv*'), /<em>kursiv<\/em>/);
}); });
}); });
// Paritetstilfeller portet fra maskinrommets tools/__tests__/build-html.test.mjs
// (N3.5/MR-F8): lenker, flerlinjers blockquote, FIGUR→SVG. Samme markdown skal
// gi samme parser-HTML i begge motorer.
describe('inline — lenker [tekst](url) (N3.5 MR-F8)', () => {
test('renders [tekst](url) as a link with target/rel', () => {
const out = inline('se [Reuters](https://www.reuters.com/x) for detaljer');
assert.match(out, /<a href="https:\/\/www\.reuters\.com\/x"[^>]*>Reuters<\/a>/);
assert.match(out, /target="_blank"/);
assert.match(out, /rel="noopener"/);
});
test('mailto is allowed', () => {
assert.match(inline('[skriv](mailto:x@y.no)'), /<a href="mailto:x@y\.no"/);
});
test('drops non-http(s)/mailto schemes — link text survives, href does not', () => {
const out = inline('[klikk](javascript:alert(1))');
assert.doesNotMatch(out, /<a /);
assert.match(out, /klikk/);
});
});
describe('markdownToHtml — flerlinjers blockquote (N3.5 MR-F8)', () => {
test('a blockquote keeps its paragraph breaks', () => {
const md = ['> Første avsnitt.', '>', '> Andre avsnitt.'].join('\n');
const html = markdownToHtml(md);
assert.match(html, /<blockquote>/);
// two separate <p> inside the quote — the old parser collapsed these into one
const paras = html.match(/<blockquote>(.*?)<\/blockquote>/s)[1].match(/<p>/g) || [];
assert.equal(paras.length, 2);
});
test('a single-paragraph blockquote still renders one <p>', () => {
const html = markdownToHtml('> Bare ett avsnitt her.');
const paras = html.match(/<blockquote>(.*?)<\/blockquote>/s)[1].match(/<p>/g) || [];
assert.equal(paras.length, 1);
});
});
describe('markdownToHtml — FIGUR→SVG (N3.5 MR-F8)', () => {
test('a FIGUR blockquote with no matching SVG falls back to a plain blockquote', () => {
const html = markdownToHtml('> **[FIGUR 9 — «finnes ikke»]**');
assert.match(html, /<blockquote>/);
assert.doesNotMatch(html, /<figure/);
});
test('a FIGUR blockquote with matching figurer/figN*.svg inlines the SVG as <figure>', () => {
const prev = process.cwd();
process.chdir(path.join(__dirname, 'fixtures', 'figur-serie'));
try {
const html = markdownToHtml('> **[FIGUR 1 — «Demo-figur»]**');
assert.match(html, /<figure class="fig">/);
assert.match(html, /<svg/);
assert.match(html, /<figcaption>Figur 1 — Demo-figur<\/figcaption>/);
// xml-deklarasjonen i SVG-fila skal strippes før inlining
assert.doesNotMatch(html, /<\?xml/);
assert.doesNotMatch(html, /<blockquote>/);
} finally {
process.chdir(prev);
}
});
});

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 675" role="img" aria-label="Demo: soylediagram med placeholder-data">
<!-- Noytral demo-figur (placeholder-data) for build-figur-testene.
Bruker token-CSS-variabler med noytrale fallbacks. -->
<rect width="1200" height="675" fill="var(--figur-background, #FFFFFF)"/>
<text x="80" y="90" font-family="var(--figur-fontFamily, sans-serif)" font-size="40" font-weight="700" fill="var(--figur-ink, #1A1A1A)">Kvartalsvis utvikling (demo)</text>
<text x="80" y="135" font-family="var(--figur-fontFamily, sans-serif)" font-size="26" fill="var(--figur-muted, #5B5B5B)">Placeholder-data — ikke reelle tall</text>
<g>
<rect x="120" y="380" width="140" height="180" fill="var(--figur-accent, #2F6F9F)"/>
<rect x="360" y="320" width="140" height="240" fill="var(--figur-accent, #2F6F9F)"/>
<rect x="600" y="250" width="140" height="310" fill="var(--figur-accent, #2F6F9F)"/>
<rect x="840" y="180" width="140" height="380" fill="var(--figur-accent, #2F6F9F)"/>
</g>
<line x1="80" y1="560" x2="1120" y2="560" stroke="var(--figur-rule, #D9D9D9)" stroke-width="2"/>
<g font-family="var(--figur-fontFamily, sans-serif)" font-size="26" fill="var(--figur-muted, #5B5B5B)">
<text x="160" y="605">Q1</text>
<text x="400" y="605">Q2</text>
<text x="640" y="605">Q3</text>
<text x="880" y="605">Q4</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100" role="img" aria-label="Demo-figur for FIGUR-markørtesten">
<rect x="10" y="10" width="180" height="80" fill="none" stroke="#333" stroke-width="2"/>
<text x="100" y="55" text-anchor="middle" font-size="14">fixture</text>
</svg>

After

Width:  |  Height:  |  Size: 333 B

365
render/build-figur.mjs Normal file
View file

@ -0,0 +1,365 @@
#!/usr/bin/env node
// build-figur.mjs — render en kodet figur (SVG/HTML) til PNG via headless Chrome.
// Bruk: node build-figur.mjs <figur.svg|figur.html> [--target article|carousel|single]
// [--out <fil.png>] [--width N] [--height N]
// Tre render-mål: article (~1200px bred, innholdsstyrt høyde) · carousel (1080×1350)
// · single (1200×1200). Design-tokens leses fra brukerens data-dir
// (${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/brand-tokens.json)
// og injiseres som CSS-variabler (--figur-*); uten fil brukes nøytrale defaults —
// brukerens merkevare er DATA, aldri hardkodet her. Designregler håndheves som
// warn-validering (aldri hard fail). Ingen nettverkstilgang under render (lokal
// fil-input; Chrome kjøres med nettverksdempende flagg). CLI + importerbar modul
// (samme dobbeltrolle som hooks/scripts/ical-generator.mjs). Ingen npm-avhengigheter.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawn } from "node:child_process";
// ---------------------------------------------------------------------------
// Render-mål. height:null = innholdsstyrt (avledes av kildens aspekt, ellers
// fallback). Overstyrbare via --width/--height.
// ---------------------------------------------------------------------------
export const TARGETS = {
article: { width: 1200, height: null },
carousel: { width: 1080, height: 1350 },
single: { width: 1200, height: 1200 },
};
const ARTICLE_FALLBACK_ASPECT = 16 / 9;
export function deriveDimensions(target, aspect, overrides = {}) {
const t = TARGETS[target];
if (!t) {
throw new Error(`Ukjent mål «${target}». Gyldige mål: ${Object.keys(TARGETS).join(", ")}`);
}
const warnings = [];
const width = overrides.width ?? t.width;
let height = overrides.height ?? t.height;
if (height == null) {
if (aspect) {
height = Math.round(width / aspect);
} else {
height = Math.round(width / ARTICLE_FALLBACK_ASPECT);
warnings.push(
`Kilden oppgir ingen dimensjoner — bruker fallback-høyde ${height}px (overstyr med --height).`
);
}
}
return { width, height, warnings };
}
// ---------------------------------------------------------------------------
// Design-tokens: BRUKERDATA fra data-dir, nøytrale defaults ellers.
// ---------------------------------------------------------------------------
export const DEFAULT_TOKENS = {
background: "#FFFFFF",
ink: "#1A1A1A",
muted: "#5B5B5B",
accent: "#2F6F9F",
rule: "#D9D9D9",
fontFamily: "system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif",
};
function defaultDataDir() {
return process.env.LINKEDIN_STUDIO_DATA || path.join(os.homedir(), ".claude", "linkedin-studio");
}
export function loadBrandTokens(dataDir = defaultDataDir()) {
const file = path.join(dataDir, "profile", "brand-tokens.json");
const warnings = [];
if (!fs.existsSync(file)) {
return { tokens: { ...DEFAULT_TOKENS }, source: "default", warnings };
}
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("forventet et JSON-objekt");
}
return { tokens: { ...DEFAULT_TOKENS, ...parsed }, source: "user", warnings };
} catch (err) {
warnings.push(`Kunne ikke lese brand-tokens.json (${err.message}) — bruker nøytrale defaults.`);
return { tokens: { ...DEFAULT_TOKENS }, source: "default", warnings };
}
}
export function tokensToCss(tokens) {
const vars = Object.entries(tokens)
.map(([key, value]) => `--figur-${key}:${value};`)
.join("");
return `:root{${vars}}`;
}
// ---------------------------------------------------------------------------
// Kilde → render-klar HTML. SVG pakkes i en side med eksakt mål-flate;
// HTML får token-variablene injisert (etter <head>, ellers prependet).
// ---------------------------------------------------------------------------
export function wrapSvg(svgContent, { width, height }, tokens) {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>
${tokensToCss(tokens)}
*{margin:0;padding:0;box-sizing:border-box;}
html,body{width: ${width}px;height: ${height}px;background:var(--figur-background);overflow:hidden;}
svg{display:block;width:100%;height:100%;}
</style></head>
<body>
${svgContent}
</body>
</html>
`;
}
export function injectTokens(html, tokens) {
const style = `<style>${tokensToCss(tokens)}</style>`;
if (/<head[^>]*>/i.test(html)) {
return html.replace(/<head[^>]*>/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(/<svg[^>]*\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/);
const hm = svgContent.match(/<svg[^>]*\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 <figur.svg|figur.html> [--target article|carousel|single] [--out <fil.png>] [--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();
}

View file

@ -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. // Tar uescapet tekst, returnerer escaped HTML med inline-tagger.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function inline(text) { export function inline(text) {
let out = esc(text); let out = esc(text);
// `kode` først, slik at * og ** inni en kode-span ikke tolkes som fet/kursiv // `kode` først, slik at * og ** inni en kode-span ikke tolkes som fet/kursiv
out = out.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`); out = out.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
// [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)
? `<a href="${url}" target="_blank" rel="noopener">${label}</a>`
: label
);
// **fet** før *kursiv* for å unngå konflikt // **fet** før *kursiv* for å unngå konflikt
out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`); out = out.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`);
out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`); out = out.replace(/\*([^*]+)\*/g, (_, c) => `<em>${c}</em>`);
@ -118,10 +124,25 @@ function isTableLine(t) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Kompakt markdown -> HTML for body. // Kompakt markdown -> HTML for body.
// Håndterer: # .. #### overskrifter, | tabeller |, - punktlister, // Håndterer: # .. #### overskrifter, | tabeller |, - punktlister,
// 1. nummererte lister, > blockquote, --- horisontal linje, `kode`, og // 1. nummererte lister, > blockquote (flerlinjers, med FIGUR-markør),
// avsnitt (blanklinje-separert). // [lenker](url), --- horisontal linje, `kode`, og avsnitt (blanklinje-separert).
// Første avsnitt får drop-cap-klasse. Avsnitt etter det første: .indent. // 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) { export function markdownToHtml(body) {
const lines = body.replace(/\r\n/g, "\n").split("\n"); const lines = body.replace(/\r\n/g, "\n").split("\n");
const blocks = []; const blocks = [];
@ -167,14 +188,41 @@ export function markdownToHtml(body) {
continue; continue;
} }
// Blockquote (sammenhengende > -linjer) // Blockquote (sammenhengende > -linjer; tom > -linje = nytt avsnitt i samme quote)
if (/^>\s?/.test(trimmed)) { if (/^>\s?/.test(trimmed)) {
const qbuf = []; const qlines = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) { while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
qbuf.push(lines[i].trim().replace(/^>\s?/, "")); qlines.push(lines[i].trim().replace(/^>\s?/, ""));
i++; i++;
} }
blocks.push(`<blockquote><p>${inline(qbuf.join(" ").trim())}</p></blockquote>`); 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(
`<figure class="fig">${svg}` +
(caption ? `<figcaption>${inline(caption)}</figcaption>` : "") +
"</figure>"
);
continue;
}
}
blocks.push("<blockquote>" + paras.map((p) => `<p>${inline(p)}</p>`).join("") + "</blockquote>");
continue; 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 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 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 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 { .body code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.86em; font-size: 0.86em;
@ -367,6 +416,23 @@ h1.title {
font-style: italic; font-style: italic;
color: #333; 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 { .body hr {
border: 0; border: 0;
border-top: 1px solid var(--rule); border-top: 1px solid var(--rule);

View file

@ -249,7 +249,7 @@ function runReconcile(_flags: Record<string, string>): void {
} }
function renderDiffMd(diff: ProfileDiff): string { 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[]) => { const section = (title: string, items: string[]) => {
lines.push(`## ${title} (${items.length})`, ""); lines.push(`## ${title} (${items.length})`, "");
for (const i of items) lines.push(`- ${i}`); for (const i of items) lines.push(`- ${i}`);

View file

@ -36,9 +36,20 @@ function serializeFact(f: ProfileFact): string {
return f.value === "" ? `- [${bracket}]` : `- [${bracket}] ${f.value}`; 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 { export function serializeProfile(doc: ProfileDoc): string {
const lines: string[] = [ const lines: string[] = [
"---",
"type: Profile",
"title: Profile",
"description: Two-layer semantic profile (static and dynamic) - the distilled who-you-are.",
"---",
"",
"# Profile", "# Profile",
"", "",
`schemaVersion: ${doc.schemaVersion}`, `schemaVersion: ${doc.schemaVersion}`,

View file

@ -35,6 +35,8 @@ function indexSeed(): string {
> Map of Content one screen pointing at every tributary, with a freshness flag. > 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). > Generated by \`brain init\`; safe to hand-edit (a re-run never clobbers it).
okf_version: 0.1
| Tributary | What it holds | Freshness | | Tributary | What it holds | Freshness |
|-----------|---------------|-----------| |-----------|---------------|-----------|
| voice-samples | writing style | | | voice-samples | writing style | |
@ -50,7 +52,12 @@ function indexSeed(): string {
} }
function operationsSeed(): 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 > 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 > 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:_ <one or two sentences on your current direction — replace
`; `;
} }
function journalIndexSeed(): string {
return `# Journal — episodic log
> 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 { function profileSeed(): string {
const templateText = readFileSync(TEMPLATE_PATH, "utf8"); const templateText = readFileSync(TEMPLATE_PATH, "utf8");
const instancePath = dataRoot(INSTANCE_SUB); const instancePath = dataRoot(INSTANCE_SUB);
@ -111,6 +127,7 @@ export function initBrain(): InitResult {
{ sub: "brain/profile.md", seed: profileSeed }, { sub: "brain/profile.md", seed: profileSeed },
{ sub: "brain/index.md", seed: indexSeed }, { sub: "brain/index.md", seed: indexSeed },
{ sub: "brain/operations.md", seed: operationsSeed }, { sub: "brain/operations.md", seed: operationsSeed },
{ sub: "brain/journal/index.md", seed: journalIndexSeed },
]; ];
for (const { sub, seed } of files) { for (const { sub, seed } of files) {

View file

@ -59,6 +59,16 @@ describe("brain consolidate CLI (SC5)", () => {
assert.equal(diff.additions.length, 1); 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", () => { test("--propose REJECTS a malformed candidate (missing field), no write", () => {
const { code } = runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [{ key: "x", value: "y" }])]); const { code } = runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [{ key: "x", value: "y" }])]);
assert.notEqual(code, 0, "non-zero exit on malformed candidate"); assert.notEqual(code, 0, "non-zero exit on malformed candidate");

View file

@ -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");
});
});

View file

@ -118,25 +118,34 @@ const BATTERY = [
{ {
path: "section rewrite of KEPT entries (:171)", path: "section rewrite of KEPT entries (:171)",
run: () => { run: () => {
const today = new Date(); // Fixed injectable "today" (third param): with the real clock, on some
const old = new Date(today); old.setDate(old.getDate() - 100); // calendar days today-100d collides with a hardcoded SAMPLE date (e.g.
const recent = new Date(today); recent.setDate(recent.getDate() - 10); // 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 oldDate = old.toISOString().slice(0, 10);
const recentDate = recent.toISOString().slice(0, 10); const recentDate = recent.toISOString().slice(0, 10);
// Plant the payload via a FUNCTION replace so the fixture itself is not // Replace the WHOLE section (heading + hardcoded entry) so no SAMPLE entry
// pre-corrupted by the very expansion under test. // 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( const state = SAMPLE.replace(
"## Recent Posts\n\n", /## Recent Posts\n\n[\s\S]*?(?=## Milestone Log)/,
() => `## Recent Posts\n\n- [${oldDate}] "drop" (1000) - drop me\n- [${recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}\n` () => `## 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 }; return { ...r, _recentDate: recentDate, _oldDate: oldDate };
}, },
assert: (r) => { assert: (r) => {
assert.notEqual(r, null, "prune returned null"); assert.notEqual(r, null, "prune returned null");
assert.equal(r.pruned, 1, "exactly the old entry pruned"); assert.equal(r.pruned, 1, "exactly the old entry pruned");
assert.ok(!r.content.includes(r._oldDate), "old entry pruned"); // Assert on the Recent Posts section only, not the whole content, so
assert.ok(r.content.includes(`- [${r._recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}`), "kept $-entry survives verbatim"); // 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], once: [/^## Recent Posts$/gm],
}, },

View file

@ -46,7 +46,25 @@
# trends-brief wiring guard (RE-R2b: scripts/trends/src/cli.ts dispatches `brief`, # 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 # 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 # 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 # 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 assertion-count anti-erosion floor (SC6) in Section 18. All
# are live below (Sections 818). # are live below (Sections 818).
# #
# Usage: bash scripts/test-runner.sh # Usage: bash scripts/test-runner.sh
@ -73,8 +91,8 @@ warn() { echo -e "${YELLOW}⚠${NC} $1"; WARN=$((WARN + 1)); }
# Source of truth: CLAUDE.md headers + STATE.md Telling. Bump these together # Source of truth: CLAUDE.md headers + STATE.md Telling. Bump these together
# with the files when adding/removing an agent, command, reference, or skill. # with the files when adding/removing an agent, command, reference, or skill.
EXPECT_AGENTS=19 EXPECT_AGENTS=19
EXPECT_COMMANDS=29 EXPECT_COMMANDS=30
EXPECT_REFS=27 EXPECT_REFS=28
EXPECT_SKILLS=6 EXPECT_SKILLS=6
# Pre-M0 references/ baseline was 25. Every ref doc added since is NAMED below, so the # 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 # count bump always maps to an intended, named addition — never an incidental doc masked
@ -83,7 +101,10 @@ EXPECT_SKILLS=6
# AND that every named doc actually exists. bash 3.2-safe: plain indexed array. # AND that every named doc actually exists. bash 3.2-safe: plain indexed array.
REFS_BASELINE_PRE_M0=25 REFS_BASELINE_PRE_M0=25
M0_REF="references/data-path-convention.md" 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)
)
echo "================================================" echo "================================================"
echo "LinkedIn Studio Plugin — Structure Validator" echo "LinkedIn Studio Plugin — Structure Validator"
@ -698,7 +719,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_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_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) 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 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)" pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)"
else else
@ -1170,6 +1191,327 @@ fi
echo "" 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 18: Assertion-Count Anti-Erosion (SC6) --- # --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
# The lint self-modifies its own checks, so a green run could mask a silently dropped # 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 # assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
@ -1185,12 +1527,25 @@ echo ""
# UNCONDITIONAL Section-16h checks (trends-capture self-test + cli.ts capture-handler grep + # 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 # 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 + # (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.
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop # 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 # 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 # 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. # clone). Runs last so TOTAL_CHECKS sees every prior check.
ASSERT_BASELINE_FLOOR=94 ASSERT_BASELINE_FLOOR=123
TOTAL_CHECKS=$((PASS + FAIL)) TOTAL_CHECKS=$((PASS + FAIL))
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"

View file

@ -39,22 +39,41 @@ interface TrendRecord {
publishedAt?: string;// optional source publish date (ISO-8601); distinct from capturedAt, first-sight, never back-filled publishedAt?: string;// optional source publish date (ISO-8601); distinct from capturedAt, first-sight, never back-filled
topics: string[]; // query tags; unioned across re-captures topics: string[]; // query tags; unioned across re-captures
summary?: string; // optional, verbatim 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 `score` is the persisted relevance envelope (RE-R3a): a capture **item** carries the
slice without breaking the shape. agent's **judgment**`{ mode, dimensions }` (the five 110 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 ## CLI
```bash ```bash
# Capture freshly-polled trends — the NORMALIZING BATCH path (the research agent's path): # 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 # 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 # re-capture → persist the source's publishedAt → persist the relevance score (when carried).
# summary errors[], never fail the run; the summary is {added, duplicates, merged, errors}. # 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 110 dimensions); the store
# computes the composite + band and persists the full TrendScore first-sight.
echo '[{"source":"tavily","title":"Agentic workflows hit production", echo '[{"source":"tavily","title":"Agentic workflows hit production",
"url":"https://example.com/agentic","topics":["agents","engineering"], "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 <path>] [--json] | node --import tsx src/cli.ts capture [--store <path>] [--json]
# Add a SINGLE trend MANUALLY — raw flags, no normalization, publish-date-free: # 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 # Time-scoped history — newest first, optionally windowed/capped
node --import tsx src/cli.ts list [--since 2026-06-01] [--limit 10] [--json] 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 # Dated morning brief — rank the store by composite then pillar-overlap then recency, write a
# Markdown file the SessionStart hook surfaces. Pillars come from the caller (user config). # 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" \ node --import tsx src/cli.ts brief --pillars "agents,engineering" \
[--fresh-days 7] [--out <dir>] [--store <path>] [--json] [--fresh-days 7] [--out <dir>] [--no-mark] [--store <path>] [--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 <id> # wrote about it
node --import tsx src/cli.ts skip --id <id> # decided to pass on it
node --import tsx src/cli.ts reset --id <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 <path>]
``` ```
Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend 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 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 verbatim (zero-tsx — it reads the Markdown, never the TS CLI). As of RE-R3a the brief ranks
fields; a persisted relevance score, an autonomous nightly trigger, and a seen-log freshness each bucket on the persisted relevance **composite first** (then pillar-overlap, then recency);
model are later slices. a scored entry shows `· <priority> (<mode>)` 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 `<ISO-ts> exit=<code> <json>` 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: <id-csv>` 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 (<prior-date>)` 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 ## Tests

39
scripts/trends/run-daily.sh Executable file
View file

@ -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"

View file

@ -16,11 +16,30 @@
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { defaultStorePath } from "./store.js"; import { defaultStorePath, effectiveStatus } from "./store.js";
import type { TrendStore, TrendRecord } from "./types.js"; import type { TrendStore, TrendRecord } from "./types.js";
/** The morning-brief artifact's own format version (distinct from the store's SCHEMA_VERSION). */ /** 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. */ /** One ranked trend in the brief, with its pillar overlap + freshness. */
export interface BriefEntry { export interface BriefEntry {
@ -33,6 +52,8 @@ export interface BriefEntry {
effectiveDate: string; effectiveDate: string;
/** Whole days from effectiveDate to the injected `today` (negative if future). */ /** Whole days from effectiveDate to the injected `today` (negative if future). */
ageDays: number; ageDays: number;
/** The live temporal overlay (RE-R3d) — derived per rank from ageDays + surfacedCount. */
temporal: TemporalSignal;
} }
/** The full ranking the brief renders from. */ /** The full ranking the brief renders from. */
@ -46,11 +67,21 @@ export interface BriefRanking {
singleMatches: BriefEntry[]; singleMatches: BriefEntry[];
/** overlap >= 1 AND NOT fresh. */ /** overlap >= 1 AND NOT fresh. */
olderMatched: BriefEntry[]; 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 { export interface RankOptions {
/** Freshness window in days (effectiveDate within N days of today). Default 7. */ /** Freshness window in days (effectiveDate within N days of today). Default 7. */
freshDays?: number; 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); 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 * 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 * dropped; the rest bucket into top (>=2 & fresh), single (==1 & fresh), and older
@ -76,10 +133,21 @@ export function rankForBrief(
opts: RankOptions = {}, opts: RankOptions = {},
): BriefRanking { ): BriefRanking {
const freshDays = opts.freshDays ?? 7; const freshDays = opts.freshDays ?? 7;
const firstMoverDays = opts.firstMoverDays ?? 2;
const saturationAt = opts.saturationAt ?? 3;
const wantedLower = pillars.map((p) => p.toLowerCase()); const wantedLower = pillars.map((p) => p.toLowerCase());
const entries: BriefEntry[] = []; const entries: BriefEntry[] = [];
const inProduction: TrendRecord[] = [];
for (const trend of store.trends) { 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 have = new Set(trend.topics.map((t) => t.toLowerCase()));
const matchedPillars: string[] = []; const matchedPillars: string[] = [];
for (let i = 0; i < pillars.length; i++) { for (let i = 0; i < pillars.length; i++) {
@ -88,11 +156,38 @@ export function rankForBrief(
const overlap = matchedPillars.length; const overlap = matchedPillars.length;
if (overlap === 0) continue; // off-pillar noise if (overlap === 0) continue; // off-pillar noise
const effectiveDate = trend.publishedAt ?? trend.capturedAt; 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 => const cmp = (a: BriefEntry, b: BriefEntry): number =>
(b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) ||
b.overlap - a.overlap || b.overlap - a.overlap ||
b.temporal.rank - a.temporal.rank ||
b.effectiveDate.localeCompare(a.effectiveDate) || b.effectiveDate.localeCompare(a.effectiveDate) ||
a.trend.title.localeCompare(b.trend.title) || a.trend.title.localeCompare(b.trend.title) ||
a.trend.url.localeCompare(b.trend.url); 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 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) 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 { return {
today, today,
freshDays, freshDays,
@ -110,6 +211,7 @@ export function rankForBrief(
topMatches, topMatches,
singleMatches, singleMatches,
olderMatched, olderMatched,
inProduction,
}; };
} }
@ -119,29 +221,112 @@ export function rankForBrief(
* One line, no embedded double-quote, no newline, so the hook's extractYaml regex * One line, no embedded double-quote, no newline, so the hook's extractYaml regex
* (^summary: *"?([^"\n]*)"?) captures it whole. * (^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; const fresh = ranking.totals.fresh;
if (fresh > 0) { if (fresh > 0) {
const top = ranking.topMatches[0] ?? ranking.singleMatches[0]; const top = ranking.topMatches[0] ?? ranking.singleMatches[0];
const pillar = top.matchedPillars[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}`;
}
/** ` · <priority> (<mode>)` 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}`);
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[] { function renderTopEntry(e: BriefEntry, n: number): string[] {
const lines = [ const lines = [
`### ${n}. ${e.trend.title}`, `### ${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}`); if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
lines.push(...proposalLines(e.trend));
lines.push(`- 🔗 ${e.trend.url}`); lines.push(`- 🔗 ${e.trend.url}`);
lines.push(""); lines.push("");
return lines; return lines;
} }
function renderBulletEntry(e: BriefEntry): string { 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 +334,21 @@ function renderBulletEntry(e: BriefEntry): string {
* summary, store stats, ranking descriptor, schemaVersion) + a three-section body. * summary, store stats, ranking descriptor, schemaVersion) + a three-section body.
* All three section headers are always emitted (stable structure determinism). * 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 { totals } = ranking;
const lines: string[] = []; const lines: string[] = [];
lines.push("---"); lines.push("---");
lines.push(`date: ${ranking.today}`); 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(`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(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`);
lines.push("---"); lines.push("---");
lines.push(""); lines.push("");
@ -168,6 +359,27 @@ export function renderBrief(ranking: BriefRanking): string {
); );
lines.push(""); 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)"); lines.push("## 🎯 Topp-treff (2+ pillarer)");
if (ranking.topMatches.length === 0) { if (ranking.topMatches.length === 0) {
lines.push("_Ingen i dag._", ""); lines.push("_Ingen i dag._", "");
@ -184,12 +396,79 @@ export function renderBrief(ranking: BriefRanking): string {
ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e))); ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e)));
lines.push(""); 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("---");
lines.push("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_"); lines.push("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_");
return lines.join("\n") + "\n"; 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 * Default brief directory under the per-user data dir, DERIVED from
* defaultStorePath() so root resolution lives in exactly one place: * defaultStorePath() so root resolution lives in exactly one place:

View file

@ -7,18 +7,31 @@
* node --import tsx src/cli.ts query --topics <a,b> [--store <path>] [--json] * node --import tsx src/cli.ts query --topics <a,b> [--store <path>] [--json]
* node --import tsx src/cli.ts list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json] * node --import tsx src/cli.ts list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]
* node --import tsx src/cli.ts status [--store <path>] [--json] * node --import tsx src/cli.ts status [--store <path>] [--json]
* node --import tsx src/cli.ts act|skip|reset|select (--id <id> | --ids <a,b,c>) [--store <path>]
* echo '<raw item|batch>' | node --import tsx src/cli.ts normalize * echo '<raw item|batch>' | node --import tsx src/cli.ts normalize
* echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N] * echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N]
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json] * echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--store <path>] [--json] * node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N]
* [--out <dir>] [--no-mark] [--store <path>] [--json]
* node --import tsx src/cli.ts schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
* [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]
* *
* The capture agent (research-engine) folds freshly-polled trends into the store via * The capture agent (research-engine) folds freshly-polled trends into the store via
* `capture` (the normalizing batch path: stdin normalizeItem(s) itemToInput * `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 * 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 * SessionStart hook surfaces; RE-R3d adds a DERIVED temporal overlay (first-mover up / saturated
* normalization, publish-date-free). The polling + relevance-scoring itself lives * down) as a within-tier ranking refinement, tunable via `--first-mover-days`/`--saturation-at`.
* upstream; this is the deterministic store. * `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 * `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 * research-engine seam: all read their JSON PAYLOAD FROM STDIN (so they do not overload
@ -27,25 +40,49 @@
* `capture` normalizes + folds each valid item into the store (persisting `publishedAt`), * `capture` normalizes + folds each valid item into the store (persisting `publishedAt`),
* reporting content-invalid items in the summary `errors[]`, never via the exit code. * 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 { readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
import { join } from "node:path"; import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath } from "node:url";
import { import {
addTrend, addTrend,
defaultStorePath, defaultStorePath,
history, history,
loadStore, loadStore,
markSurfaced,
newestCaptureDate, newestCaptureDate,
queryByTopic, queryByTopic,
saveStore, saveStore,
setStatus,
setStatusMany,
} from "./store.js"; } from "./store.js";
import type { TrendStatus } from "./types.js";
import { normalizeItem, normalizeItems, itemToInput } from "./item.js"; import { normalizeItem, normalizeItems, itemToInput } from "./item.js";
import { triage } from "./score.js"; import { triage } from "./score.js";
import type { ScoreMode } 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";
function parseFlags(args: string[]): Record<string, string> { function parseFlags(args: string[]): Record<string, string> {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
@ -81,10 +118,12 @@ function usage(msg: string): never {
" query --topics <a,b> [--store <path>] [--json]\n" + " query --topics <a,b> [--store <path>] [--json]\n" +
" list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]\n" + " list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]\n" +
" status [--store <path>] [--json]\n" + " status [--store <path>] [--json]\n" +
" act|skip|reset|select (--id <id> | --ids <a,b,c>) [--store <path>]\n" +
" normalize < raw-item-or-batch.json\n" + " normalize < raw-item-or-batch.json\n" +
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" + " score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" + " capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
" brief [--pillars <a,b>] [--fresh-days N] [--out <dir>] [--store <path>] [--json]", " brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out <dir>] [--no-mark] [--store <path>] [--json]\n" +
" schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]",
); );
process.exit(2); process.exit(2);
} }
@ -208,6 +247,31 @@ function main(): void {
return; return;
} }
if (command === "act" || command === "skip" || command === "reset" || command === "select") {
// Ids from --ids <a,b,c> (batch, A1-9) or a single --id <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 <id> or --ids <a,b,c>`);
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") { if (command === "normalize") {
const payload = readStdinJson(); const payload = readStdinJson();
const out = Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload); const out = Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload);
@ -246,7 +310,7 @@ function main(): void {
const { items, errors } = normalizeItems(raw); const { items, errors } = normalizeItems(raw);
const store = loadStore(storePath); const store = loadStore(storePath);
// Tally derived from AddResult {added, merged} (no `duplicates` field): a fold is // 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 added = 0;
let merged = 0; let merged = 0;
let duplicates = 0; let duplicates = 0;
@ -276,21 +340,158 @@ function main(): void {
if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer"); if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer");
freshDays = n; 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 // A bare `--out` yields the string "true" (parseFlags); the guard falls back to
// defaultBriefDir() so it never writes to ./true. // defaultBriefDir() so it never writes to ./true.
const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir(); 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 day = today(); // one wall-clock read for both the ranking and the filename
const ranking = rankForBrief(loadStore(storePath), pillars, day, { freshDays }); const store = loadStore(storePath); // hoisted: also needed for the surfacing write below
const md = renderBrief(ranking); 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`); const path = join(outDir, `${day}.md`);
mkdirSync(outDir, { recursive: true }); mkdirSync(outDir, { recursive: true });
writeFileSync(path, md, "utf8"); 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) { 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; 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 === "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 <a,b>");
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<string, string> = { 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; return;
} }

View file

@ -18,6 +18,9 @@
import { normalizeField } from "./store.js"; import { normalizeField } from "./store.js";
import type { TrendInput } from "./store.js"; import type { TrendInput } from "./store.js";
import { requiredDimensions, scoreEnvelope } from "./score.js";
import type { ScoreMode, DimensionScores } from "./score.js";
import type { TrendVerdict, Actionability } from "./types.js";
export interface TrendItem { export interface TrendItem {
/** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */ /** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */
@ -36,6 +39,34 @@ export interface TrendItem {
topics: string[]; topics: string[];
/** Optional short summary, VERBATIM. Absent/blank -> the key is omitted. */ /** Optional short summary, VERBATIM. Absent/blank -> the key is omitted. */
summary?: string; summary?: string;
/**
* The agent's relevance JUDGMENT (RE-R3a): the mode + the five 110 dimension scores
* NOT a precomputed composite (the store computes that, one owner). Validated by
* normalizeItem; turned into the persisted envelope by itemToInputscoreEnvelope.
* 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;
} }
export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] }; export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] };
@ -63,6 +94,83 @@ function isNonEmptyString(v: unknown): v is string {
return typeof v === "string" && v.trim().length > 0; return typeof v === "string" && v.trim().length > 0;
} }
/** A plain (non-array, non-null) object. */
function isPlainObject(v: unknown): v is Record<string, unknown> {
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 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<string>();
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). */ /** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */
function normalizeTopics(raw: unknown): string[] { function normalizeTopics(raw: unknown): string[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
@ -105,8 +213,40 @@ 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;
}
}
if (errors.length > 0) return { ok: false, errors }; 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<Record<(typeof FREE_TEXT_FIELDS)[number], string>> = {};
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 = { const item: TrendItem = {
source: r.source as string, source: r.source as string,
title: r.title as string, title: r.title as string,
@ -114,6 +254,11 @@ export function normalizeItem(raw: unknown): NormalizeResult {
topics: normalizeTopics(r.topics), topics: normalizeTopics(r.topics),
...(publishedAt !== undefined ? { publishedAt } : {}), ...(publishedAt !== undefined ? { publishedAt } : {}),
...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}), ...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}),
...(score !== undefined ? { score } : {}),
...freeText,
...(relatedIds.length > 0 ? { relatedIds } : {}),
...(actionability !== undefined ? { actionability } : {}),
...(verdict !== undefined ? { verdict } : {}),
}; };
return { ok: true, item }; return { ok: true, item };
} }
@ -123,8 +268,11 @@ export function normalizeItem(raw: unknown): NormalizeResult {
* Pure: injects `capturedAt` (the store's "when WE saw it", supplied by the caller * 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 * 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 * already validated by normalizeItem) and does NOT derive an `id` (the store owns id via
* addTrendtrendId). `publishedAt`/`summary` are carried only when present (key omitted * addTrendtrendId). `publishedAt`/`summary`/`score` are carried only when present (key
* otherwise), mirroring the store's conditional-spread idiom. * omitted otherwise), mirroring the store's conditional-spread idiom. The `score` is turned
* into the persisted envelope here (judgment composite, via scoreEnvelope). On the capture
* path the dims are pre-validated by normalizeItem, so scoreEnvelopecomposite cannot throw;
* called DIRECTLY with bad dims it throws by contract (defense-in-depth SC2).
*/ */
export function itemToInput(item: TrendItem, capturedAt: string): TrendInput { export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
return { return {
@ -135,6 +283,17 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
topics: [...item.topics], topics: [...item.topics],
...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}), ...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}),
...(item.summary !== undefined ? { summary: item.summary } : {}), ...(item.summary !== undefined ? { summary: item.summary } : {}),
...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}),
// 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 } : {}),
}; };
} }

View file

@ -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<string, string>;
}
/** Defensive XML escaping for well-formedness (paths are normally safe, but escape regardless). */
function xmlEscape(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/**
* The launchd plist (macOS). A pinned, well-formed `<?xml … !DOCTYPE plist …>` 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) => ` <string>${xmlEscape(a)}</string>`)
.join("\n");
const environment = Object.entries(spec.env)
.map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${xmlEscape(spec.label)}</string>
<key>ProgramArguments</key>
<array>
${programArguments}
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>${spec.hour}</integer>
<key>Minute</key>
<integer>${spec.minute}</integer>
</dict>
<key>EnvironmentVariables</key>
<dict>
${environment}
</dict>
<key>WorkingDirectory</key>
<string>${xmlEscape(spec.workingDir)}</string>
<key>StandardOutPath</key>
<string>${xmlEscape(spec.logPath)}</string>
<key>StandardErrorPath</key>
<string>${xmlEscape(spec.logPath)}</string>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
`;
}
/**
* The cron schedule line (Linux). Returns the line as a STRING; never executes the cron table.
* `<min> <hour> * * * <env-prefix> /bin/bash <wrapper> <args…> >> <log> 2>&1 # <label>`.
*/
export function crontabLine(spec: ScheduleSpec): string {
const envPrefix = Object.entries(spec.env)
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const prefix = envPrefix.length > 0 ? `${envPrefix} ` : "";
return (
`${spec.minute} ${spec.hour} * * * ${prefix}/bin/bash ${spec.wrapperPath} ` +
`${spec.args.join(" ")} >> ${spec.logPath} 2>&1 # ${spec.label}`
);
}
/** The exact activation command for the operator to run (print-first; the tool never runs it). */
export function installInstructions(spec: ScheduleSpec, plistTargetPath?: string): string {
if (spec.platform === "launchd") {
return `Wrote ${plistTargetPath}. Activate it with:\n launchctl bootstrap gui/$(id -u) ${plistTargetPath}`;
}
return `Add the line above to your schedule with:\n (crontab -l 2>/dev/null; echo '${crontabLine(spec)}') | crontab -`;
}
/** The symmetric removal command (print-first). */
export function uninstallInstructions(spec: ScheduleSpec, plistTargetPath?: string): string {
if (spec.platform === "launchd") {
return `Deactivate + remove with:\n launchctl bootout gui/$(id -u)/${spec.label} && rm ${plistTargetPath}`;
}
return `Remove the scheduled line with:\n crontab -l | grep -vF '# ${spec.label}' | crontab -`;
}
/** The reverse-DNS plugin namespace — domain-general, no vendor/sector token. */
export function defaultLabel(): string {
return "com.linkedin-studio.trends.daily";
}

View file

@ -96,6 +96,38 @@ export function band(composite: number): Band {
return toBand(BANDS[BANDS.length - 1]); return toBand(BANDS[BANDS.length - 1]);
} }
/**
* The persist-ready relevance envelope (RE-R3a): the agent's judgment (mode + the five
* dimension scores) plus the deterministically-derived composite + priority band. Lives
* in score.ts (the score domain owns it); types.ts imports it (one-way score.ts imports
* nothing internal, so no cycle).
*/
export interface TrendScore {
mode: ScoreMode;
dimensions: DimensionScores;
composite: number;
priority: Priority;
}
/**
* The mode's five dimension keys, in SSOT weight-literal order. `normalizeItem` consumes
* this as a membership set; score.test pins the order so a silent SSOT reorder fails.
*/
export function requiredDimensions(mode: ScoreMode): string[] {
return Object.keys(WEIGHTS[mode]);
}
/**
* Compose the persist-ready envelope from the agent's judgment: the composite is
* `composite(dimensions, mode)` and the priority is `band(composite).priority` the
* existing pure functions stay the single owners (no new arithmetic). Throws via
* `composite` on an out-of-range dimension (its contract).
*/
export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore {
const c = composite(dimensions, mode);
return { mode, dimensions, composite: c, priority: band(c).priority };
}
export interface TriageOptions { export interface TriageOptions {
mode: ScoreMode; mode: ScoreMode;
threshold: number; threshold: number;

View file

@ -18,7 +18,8 @@ import { homedir } from "node:os";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { SCHEMA_VERSION } from "./types.js"; import { SCHEMA_VERSION } from "./types.js";
import type { TrendStore, TrendRecord, TrendQueryHit } from "./types.js"; import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus, TrendVerdict, Actionability } from "./types.js";
import type { TrendScore } from "./score.js";
export { SCHEMA_VERSION } from "./types.js"; export { SCHEMA_VERSION } from "./types.js";
@ -32,13 +33,34 @@ export interface TrendInput {
publishedAt?: string; publishedAt?: string;
topics: string[]; topics: string[];
summary?: string; summary?: string;
/** The persisted relevance envelope (RE-R3a), if the caller computed one. First-sight, never updated on re-capture. */
score?: TrendScore;
// ── N6 proposal fields (all first-sight, like source/capturedAt/publishedAt/summary). ──
/** Proposed article angle (A1-4). */
angle?: string;
/** Proposed target level on the user profile's own span (free string, never a hard-coded enum). */
targetLevel?: string;
/** Why-now / applicability rationale (A1-4). */
rationale?: string;
/** Related trend ids — multi-source candidate (A1-5). */
relatedIds?: string[];
/** Reader-grip signal the N7 band-cap gate reads (MR-F7). */
actionability?: Actionability;
/** Reader-side utility verdict BÆRENDE/STØTTE/NYHET (MR-F7). */
verdict?: TrendVerdict;
/** Reader's own question, filled by the N7.5 demand-sweep (MR-F9). */
readerQuestion?: string;
/** Cost/risk/duty/tool the topic hits (MR-F9). */
painPoint?: string;
/** Market saturation judgment (MR-F9). */
saturation?: string;
} }
export interface AddResult { export interface AddResult {
store: TrendStore; store: TrendStore;
/** true iff a new trend was appended (false = duplicate title+url). */ /** true iff a new trend was appended (false = duplicate title+url). */
added: boolean; added: boolean;
/** true iff an existing duplicate gained new topic tags via union. */ /** true iff an existing duplicate was mutated — topic tags unioned and/or its score refreshed (RE-R3b). */
merged: boolean; merged: boolean;
} }
@ -76,10 +98,12 @@ export function emptyStore(): TrendStore {
export function loadStore(path: string): TrendStore { export function loadStore(path: string): TrendStore {
if (!existsSync(path)) return emptyStore(); if (!existsSync(path)) return emptyStore();
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TrendStore>; const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TrendStore>;
// Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2 is // Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2→v3→v4 are
// purely additive-optional (an old record is already a valid v2 record that simply // all purely additive-optional (an old record is already a valid v4 record that simply
// lacks the optional publishedAt), so the migration is the version stamp alone — // lacks the optional publishedAt [v2] / score [v3] / status+surfacedCount+lastSurfacedAt
// records pass through untouched (lossless + idempotent for any well-formed store). // [v4]), so the migration is the version stamp alone — records pass through untouched
// (lossless + idempotent for any well-formed store; new optional fields survive
// JSON.stringify on resave).
// A string / NaN / absent version coerces to the current version (never crashes); the // A string / NaN / absent version coerces to the current version (never crashes); the
// non-array `trends` coercion below is unchanged and out of the losslessness claim. // non-array `trends` coercion below is unchanged and out of the losslessness claim.
const onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION; const onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION;
@ -123,7 +147,17 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
if (existing) { if (existing) {
const { topics, changed } = unionTopics(existing.topics, input.topics); const { topics, changed } = unionTopics(existing.topics, input.topics);
existing.topics = topics; existing.topics = topics;
return { store, added: false, merged: changed }; let mutated = changed;
// RE-R3b: re-score on re-capture (last-wins). `score` is the ONE mutable field — a fresh
// judgment (timing decays) replaces the stored one; the JSON compare avoids a false-merge
// on an identical re-score. Provenance (source/capturedAt/publishedAt), lifecycle
// (status/surfacedCount/lastSurfacedAt), AND the N6 proposal fields are all first-sight —
// untouched here, so re-capture never clobbers an operator's triaged angle/verdict.
if (input.score !== undefined && JSON.stringify(existing.score) !== JSON.stringify(input.score)) {
existing.score = input.score;
mutated = true;
}
return { store, added: false, merged: mutated };
} }
const trend: TrendRecord = { const trend: TrendRecord = {
id, id,
@ -134,11 +168,93 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult {
...(input.publishedAt !== undefined ? { publishedAt: input.publishedAt } : {}), ...(input.publishedAt !== undefined ? { publishedAt: input.publishedAt } : {}),
topics: [...input.topics], topics: [...input.topics],
...(input.summary !== undefined ? { summary: input.summary } : {}), ...(input.summary !== undefined ? { summary: input.summary } : {}),
...(input.score !== undefined ? { score: input.score } : {}),
// N6 proposal fields — conditional-spread (key omitted when absent), mirroring the idiom above.
...(input.angle !== undefined ? { angle: input.angle } : {}),
...(input.targetLevel !== undefined ? { targetLevel: input.targetLevel } : {}),
...(input.rationale !== undefined ? { rationale: input.rationale } : {}),
...(input.relatedIds !== undefined ? { relatedIds: [...input.relatedIds] } : {}),
...(input.actionability !== undefined ? { actionability: input.actionability } : {}),
...(input.verdict !== undefined ? { verdict: input.verdict } : {}),
...(input.readerQuestion !== undefined ? { readerQuestion: input.readerQuestion } : {}),
...(input.painPoint !== undefined ? { painPoint: input.painPoint } : {}),
...(input.saturation !== undefined ? { saturation: input.saturation } : {}),
}; };
store.trends.push(trend); store.trends.push(trend);
return { store, added: true, merged: false }; return { store, added: true, merged: false };
} }
// ── RE-R3b lifecycle helpers (the trend's life AFTER first capture) ──
/** The record's lifecycle status, defaulting absent → "new" (the single reader of that convention). Pure. */
export function effectiveStatus(t: TrendRecord): TrendStatus {
return t.status ?? "new";
}
/**
* Set a trend's lifecycle status by id (the act/skip/reset verbs). Mutates the matched
* record in place and returns the same store; an unknown id is a no-op reported as
* { found: false } (never throws). Pure (no fs).
*/
export function setStatus(
store: TrendStore,
id: string,
status: TrendStatus,
): { 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 };
}
/**
* Set the same status on a batch of ids in one pass (N6, A1-9 ten candidates, one call).
* Partitions the ids into `found` (matched + mutated) and `notFound` (no such record), each
* order-stable on the input. Mutates the matched records in place; unknown ids are skipped,
* never an error. Pure (no fs) the CLI decides the exit code from the partition (all-miss
* usage error; any hit success + report). Duplicate input ids collapse to one mutation.
*/
export function setStatusMany(
store: TrendStore,
ids: string[],
status: TrendStatus,
): { store: TrendStore; found: string[]; notFound: string[] } {
const found: string[] = [];
const notFound: string[] = [];
const seen = new Set<string>();
for (const id of ids) {
if (seen.has(id)) continue;
seen.add(id);
const res = setStatus(store, id, status);
(res.found ? found : notFound).push(id);
}
return { store, found, notFound };
}
/**
* Record that the given trends were surfaced in a brief on `today` (the seen-log, B4).
* PER-DAY IDEMPOTENT: a record already surfaced on `today` is skipped, so re-running the
* same day's brief does not double-count. Increments surfacedCount (absent 0) and stamps
* lastSurfacedAt; returns how many records were actually incremented. Pure `today` is
* injected by the caller (the CLI edge), like the store's capturedAt.
*/
export function markSurfaced(
store: TrendStore,
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; // per-day idempotent
t.surfacedCount = (t.surfacedCount ?? 0) + 1;
t.lastSurfacedAt = today;
marked++;
}
return { store, marked };
}
/** /**
* Trends whose topics overlap the query, ranked by overlap (desc) then recency * Trends whose topics overlap the query, ranked by overlap (desc) then recency
* (capturedAt desc). Topic matching is case-insensitive. Non-matches are * (capturedAt desc). Topic matching is case-insensitive. Non-matches are

View file

@ -19,10 +19,43 @@
* a typed store in the per-user data dir (`${LINKEDIN_STUDIO_DATA}`), so the * a typed store in the per-user data dir (`${LINKEDIN_STUDIO_DATA}`), so the
* trend history survives plugin upgrades/reinstalls via the M0 data-path seam. * trend history survives plugin upgrades/reinstalls via the M0 data-path seam.
* The minimal core here (title, url, source, capturedAt, topics, optional * The minimal core here (title, url, source, capturedAt, topics, optional
* summary) can gain fields (relevance score, first-mover timing, status) in a * summary) can gain fields (first-mover timing, status) in a later slice without
* later slice without breaking the shape. * breaking the shape the relevance `score` field (RE-R3a) is the first such
* realized addition.
*/ */
import type { TrendScore } from "./score.js";
/**
* The lifecycle state of a trend. Absent on a record "new" (see effectiveStatus).
* RE-R3b introduced new/acted/skipped; N6 (A1-7) inserts `selected` the operator's
* triage pick so the full arc is new selected acted | skipped. `selected` and
* `acted` are the two "in production" states the morning brief surfaces separately.
*/
export type TrendStatus = "new" | "selected" | "acted" | "skipped";
/**
* The reader-side utility verdict (N6 / MR-F7, mechanism from `nytteloftet.md`): a
* candidate is BÆRENDE (carries an edition on its own), STØTTE (supports one), or NYHET
* (news only no reader grip yet). A CLOSED mechanism vocabulary avsender-neutral, so
* the three names are the mechanism, NOT niche calibration (what counts as a grip lives in
* the user profile / data dir, never here). The N7 band-cap gate reads verdict+actionability.
*/
export type TrendVerdict = "BÆRENDE" | "STØTTE" | "NYHET";
/**
* The reader-grip signal (N6 / MR-F7): whether a reader-actionable grip is formulated, plus
* an optional one-line statement of it. The five relevance dimensions all measure SENDER fit;
* this is the missing reader side. The N7 band-cap gate caps a candidate whose grip is not
* formulated (`formulated: false`) to at most `High`, regardless of composite.
*/
export interface Actionability {
/** Is a reader-actionable grip formulated? The yes/no the N7 gate reads. */
formulated: boolean;
/** Optional short statement of the grip (or of why none). */
note?: string;
}
export interface TrendRecord { export interface TrendRecord {
/** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */ /** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */
id: string; id: string;
@ -45,6 +78,59 @@ export interface TrendRecord {
topics: string[]; topics: string[];
/** Optional short summary of the trend, stored VERBATIM. */ /** Optional short summary of the trend, stored VERBATIM. */
summary?: string; summary?: string;
/**
* The persisted relevance assessment (RE-R3a): the agent's judgment (mode + the
* five 110 dimension scores) plus the deterministically-derived composite + band,
* computed once at first sight by the store's single scorer owner. First-sight,
* never updated on re-capture (re-score pairs with the R3b status slice). Absent on
* pre-R3a records and on the score-free `add` manual path (key omitted).
*
* RE-R3b makes `score` the one MUTABLE field: a re-capture carrying a fresh judgment
* refreshes it (last-wins, timing decays), via addTrend's duplicate branch.
*/
score?: TrendScore;
/**
* The trend's lifecycle status (RE-R3b). Absent "new" (effectiveStatus). Set only by
* the act/skip/reset CLI verbs, never on capture a freshly-captured trend is implicitly
* new. The morning brief excludes anything not "new" (a work queue, not an archive).
*/
status?: TrendStatus;
/**
* The seen-log count (RE-R3b, B4): distinct days this trend has appeared in a generated
* brief. Absent 0. Incremented (per-day-idempotent) by the brief CLI after the pure
* ranking the temporal foundation slices (c)+(b) read.
*/
surfacedCount?: number;
/** ISO date of the most recent surfacing (RE-R3b). Absent ⇒ never. The per-day idempotency key. */
lastSurfacedAt?: string;
// ── N6 proposal layer (A1-4/A1-5): the discovery agent's proposal, persisted (was discarded).
// All first-sight (like source/capturedAt) — a re-capture unions topics + refreshes score only.
/** The proposed article angle — the agent's take, stored VERBATIM. Absent on the score-free `add` path. */
angle?: string;
/**
* The proposed target level, on a span DEFINED BY THE USER PROFILE (e.g. praktikerbeslutter)
* a free string, NEVER a hard-coded enum. The plugin owns the field; the profile owns the span's values.
*/
targetLevel?: string;
/** Why now / practical applicability — the agent's timeliness rationale, VERBATIM. */
rationale?: string;
/** Related trend ids (A1-5, multi-source candidate): other records this trend is a facet of. */
relatedIds?: string[];
// ── N6 reader-side (MR-F7): the reader-grip signal the N7 band-cap gate reads. ──
/** Whether a reader-actionable grip is formulated (+ optional note). See {@link Actionability}. */
actionability?: Actionability;
/** The reader-side utility verdict (BÆRENDE/STØTTE/NYHET). See {@link TrendVerdict}. */
verdict?: TrendVerdict;
// ── N6 reader-side (MR-F9): schema only here — the demand-sweep that FILLS these is N7.5. ──
/** The reader's OWN formulation of the question, in the reader's words (not the field's jargon). */
readerQuestion?: string;
/** The cost/risk/duty/tool the topic hits for the reader (what it competes against). */
painPoint?: string;
/** The market saturation judgment — is this already answered by others? (metningsdom), VERBATIM. */
saturation?: string;
} }
export interface TrendStore { export interface TrendStore {
@ -59,4 +145,11 @@ export interface TrendQueryHit {
topicOverlap: number; topicOverlap: number;
} }
export const SCHEMA_VERSION = 2; /**
* The store schema version. Still 4 after N6: the eight N6 proposal fields
* (angle/targetLevel/rationale/relatedIds/actionability/verdict/readerQuestion/painPoint/saturation)
* are additive-optional a pre-N6 v4 record is already a valid post-N6 v4 record that simply lacks
* them, so NO record needs migrating and a version bump would be a marker with no migration behind it.
* (Earlier slices bumped because they were the first to add fields at all; the choice is deliberate.)
*/
export const SCHEMA_VERSION = 4;

View file

@ -7,14 +7,39 @@ import {
renderBrief, renderBrief,
briefSummary, briefSummary,
defaultBriefDir, defaultBriefDir,
surfacedIds,
diffSurfaced,
parseSurfacedFrontmatter,
selectPriorBriefFile,
temporalSignal,
BRIEF_SCHEMA_VERSION, BRIEF_SCHEMA_VERSION,
} from "../src/brief.js"; } from "../src/brief.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendRecord, TrendStore } from "../src/types.js"; import type { TrendRecord, TrendStore } from "../src/types.js";
const TODAY = "2026-06-24"; const TODAY = "2026-06-24";
type TestScore = {
mode: "kortform" | "long-form";
dimensions: Record<string, number>;
composite: number;
priority: "Immediate" | "High" | "Medium" | "Low" | "Skip";
};
function mkTrend( function mkTrend(
p: { title: string; url: string; topics: string[]; capturedAt: string; publishedAt?: string; source?: string; summary?: string }, p: {
title: string;
url: string;
topics: string[];
capturedAt: string;
publishedAt?: string;
source?: string;
summary?: string;
score?: TestScore;
status?: "new" | "acted" | "skipped";
surfacedCount?: number;
lastSurfacedAt?: string;
},
): TrendRecord { ): TrendRecord {
return { return {
id: p.title + "|" + p.url, id: p.title + "|" + p.url,
@ -25,8 +50,17 @@ function mkTrend(
...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}), ...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}),
topics: p.topics, topics: p.topics,
...(p.summary !== undefined ? { summary: p.summary } : {}), ...(p.summary !== undefined ? { summary: p.summary } : {}),
...(p.score !== undefined ? { score: p.score } : {}),
...(p.status !== undefined ? { status: p.status } : {}),
...(p.surfacedCount !== undefined ? { surfacedCount: p.surfacedCount } : {}),
...(p.lastSurfacedAt !== undefined ? { lastSurfacedAt: p.lastSurfacedAt } : {}),
}; };
} }
/** A composite-bearing score for the rank tests (mode/priority kept consistent for render asserts). */
function mkScore(composite: number, priority: TestScore["priority"], mode: TestScore["mode"] = "kortform"): TestScore {
return { mode, dimensions: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 }, composite, priority };
}
function mkStore(trends: TrendRecord[]): TrendStore { function mkStore(trends: TrendRecord[]): TrendStore {
return { schemaVersion: 2, trends }; return { schemaVersion: 2, trends };
} }
@ -64,13 +98,15 @@ describe("rankForBrief — grouping (SC1)", () => {
}); });
describe("rankForBrief — within-group total order (SC1)", () => { describe("rankForBrief — within-group total order (SC1)", () => {
test("effectiveDate desc orders before title", () => { test("effectiveDate desc orders before title (both neutral so the RE-R3d temporal key ties)", () => {
// RE-R3d: both entries are neutral (surfaced 0, ageDays 4-6 > firstMoverDays 2), so the temporal
// key ties and effectiveDate is the deciding key; titles disagree with date to isolate effectiveDate.
const store = mkStore([ const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-18", capturedAt: "2026-06-18" }),
mkTrend({ title: "Bravo", url: "https://e/b1", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), mkTrend({ title: "Bravo", url: "https://e/b1", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
]); ]);
const r = rankForBrief(store, ["a", "b"], TODAY); const r = rankForBrief(store, ["a", "b"], TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Alpha", "Bravo"]); assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Bravo", "Alpha"]);
}); });
test("same title+effectiveDate+overlap -> url asc tie-break (total order)", () => { test("same title+effectiveDate+overlap -> url asc tie-break (total order)", () => {
const store = mkStore([ const store = mkStore([
@ -159,6 +195,163 @@ describe("renderBrief + briefSummary (SC3)", () => {
}); });
}); });
describe("rankForBrief — composite primary within bucket (RE-R3a / SC5)", () => {
const pillars = ["a", "b"];
test("higher composite sorts first at the same overlap + freshness", () => {
const store = mkStore([
mkTrend({ title: "Low", url: "https://e/low", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
mkTrend({ title: "High", url: "https://e/high", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["High", "Low"], "composite 9 before composite 6");
});
test("an unscored record sorts after every scored record in its bucket (sentinel -1)", () => {
const store = mkStore([
mkTrend({ title: "Unscored", url: "https://e/u", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
mkTrend({ title: "Scored low", url: "https://e/s", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(2.0, "Low") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Scored low", "Unscored"], "any scored beats unscored");
});
test("both-unscored same-title/diff-url pair falls back to url asc (total order intact)", () => {
const store = mkStore([
mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]);
});
test("composite overrides effectiveDate within the bucket (composite is the leading key)", () => {
const store = mkStore([
mkTrend({ title: "Fresher lower", url: "https://e/fl", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(5.0, "Medium") }),
mkTrend({ title: "Older higher", url: "https://e/oh", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Older higher", "Fresher lower"]);
});
test("deterministic: identical scored input -> identical ranking order", () => {
const trends = [
mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }),
];
const r1 = rankForBrief(mkStore(trends), pillars, TODAY);
const r2 = rankForBrief(mkStore(trends), pillars, TODAY);
assert.deepEqual(r1.topMatches.map((e) => e.trend.title), r2.topMatches.map((e) => e.trend.title));
});
});
describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => {
const pillars = ["AI", "gov"];
test("scored top-entry meta line is the full pinned shape (· <priority> (<mode>) between age and Pillarer)", () => {
// neutral age (4d > firstMoverDays 2) so the RE-R3d temporal overlay adds no badge — isolates the score token.
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- Kilde: tavily · Publisert: 2026-06-20 (4d) · Immediate (kortform) · Pillarer: AI, gov"),
"scored top-entry meta line must carry · <priority> (<mode>) between (<age>d) and · Pillarer",
);
});
test("unscored top-entry meta line is UNCHANGED (no token)", () => {
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- Kilde: tavily · Publisert: 2026-06-20 (4d) · Pillarer: AI, gov"),
"unscored neutral top-entry meta line carries no score and no temporal token",
);
});
test("scored bullet (single match) is the full pinned shape (· <priority> (<mode>) before · 🔗)", () => {
const store = mkStore([
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · High (kortform) · 🔗 https://e/b"),
"scored bullet must carry · <priority> (<mode>) before · 🔗",
);
});
test("unscored bullet (single match) is UNCHANGED (no token)", () => {
const store = mkStore([
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY));
assert.ok(
md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · 🔗 https://e/b"),
"unscored bullet must be unchanged",
);
});
test("briefSummary names the band (no mode) on a scored top", () => {
// neutral age (4d) so the RE-R3d first-mover marker stays off — isolates the band token.
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
]);
const s = briefSummary(rankForBrief(store, pillars, TODAY));
assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 4d)."), `summary should carry the band: ${s}`);
assert.ok(!s.includes("kortform"), "summary must not carry the mode");
});
test("briefSummary omits the band token on an unscored top", () => {
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
]);
const s = briefSummary(rankForBrief(store, pillars, TODAY));
assert.ok(s.includes("Topp: «Alpha» (AI · 4d)."), `unscored summary should omit the band: ${s}`);
});
test("briefSummary stays one line, no double-quote, even when the top title contains a guillemet", () => {
const store = mkStore([
mkTrend({ title: "«Quoted» take", url: "https://e/q", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
]);
const s = briefSummary(rankForBrief(store, pillars, TODAY));
assert.ok(!s.includes('"'), "summary must not contain a double-quote");
assert.ok(!s.includes("\n"), "summary must be a single line");
assert.ok(s.includes("Immediate"), "summary still carries the band");
});
test("single-pillar unscored top -> summary renders with no · <priority> token, one line", () => {
const store = mkStore([
mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }),
]);
const s = briefSummary(rankForBrief(store, ["AI"], TODAY));
assert.ok(!s.includes("· ·"), "no empty priority slot");
assert.ok(s.includes("Topp: «Solo» (AI · 4d)."), `single-pillar unscored summary: ${s}`);
assert.ok(!s.includes("\n"));
});
test("ranking: descriptor equals the exact pinned string", () => {
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
]);
const md = renderBrief(rankForBrief(store, pillars, TODAY, { freshDays: 7 }));
assert.ok(
md.includes("ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays 7"),
"the ranking descriptor must carry the RE-R3d temporal key (RE-R3a base + RE-R3d)",
);
});
test("deterministic: identical scored input -> identical bytes", () => {
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }),
]);
const r = rankForBrief(store, pillars, TODAY);
assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY)));
});
});
describe("defaultBriefDir", () => { describe("defaultBriefDir", () => {
test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => { test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => {
const prev = process.env.LINKEDIN_STUDIO_DATA; const prev = process.env.LINKEDIN_STUDIO_DATA;
@ -171,3 +364,371 @@ describe("defaultBriefDir", () => {
} }
}); });
}); });
describe("RE-R3b — exclude acted/skipped (A3)", () => {
const pillars = ["AI", "gov"];
const store = mkStore([
mkTrend({ title: "New top", url: "https://e/n", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
mkTrend({ title: "Acted top", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", status: "acted" }),
mkTrend({ title: "Skipped single", url: "https://e/s", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", status: "skipped" }),
]);
const r = rankForBrief(store, pillars, TODAY);
test("RED: acted/skipped are dropped from every bucket", () => {
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["New top"]);
assert.deepEqual(r.singleMatches.map((e) => e.trend.title), []);
assert.deepEqual(r.olderMatched.map((e) => e.trend.title), []);
});
test("RED: totals.trends counts the full inventory (incl. handled); matched is post-filter", () => {
assert.equal(r.totals.trends, 3, "full store count");
assert.equal(r.totals.matched, 1, "only the new record is matched");
});
test("RED: a store whose only matches are handled → no fresh + the empty summary", () => {
const s = mkStore([
mkTrend({ title: "A", url: "https://e/aa", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", status: "acted" }),
mkTrend({ title: "B", url: "https://e/bb", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", status: "skipped" }),
]);
const rr = rankForBrief(s, pillars, TODAY);
assert.equal(rr.totals.fresh, 0);
assert.match(briefSummary(rr), /Ingen ferske tema-signaler/);
});
});
describe("RE-R3b — render id + surfaced marker + descriptor (D4/D5)", () => {
const pillars = ["AI", "gov"];
test("RED: a top entry carries the id in backticks (copy-paste-ready for act/skip)", () => {
const s = mkStore([mkTrend({ title: "Top", url: "https://e/t", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" })]);
const md = renderBrief(rankForBrief(s, pillars, TODAY));
assert.ok(md.includes("· `Top|https://e/t`"), "top entry meta line must end with the id in backticks");
});
test("RED: a single-match bullet carries the id in backticks", () => {
const s = mkStore([mkTrend({ title: "Single", url: "https://e/sg", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" })]);
const md = renderBrief(rankForBrief(s, pillars, TODAY));
assert.ok(md.includes("· `Single|https://e/sg`"), "bullet must end with the id in backticks");
});
test("· sett Nx appears only when surfacedCount >= 2 (warming badge, RE-R3b contract preserved by RE-R3d)", () => {
const s = mkStore([
mkTrend({ title: "Seen", url: "https://e/seen", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 2 }),
mkTrend({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1 }),
]);
const md = renderBrief(rankForBrief(s, pillars, TODAY));
assert.ok(md.includes("· sett 2x"), "surfacedCount 2 → warming → · sett 2x");
assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker (the preserved ≥2 gate)");
});
test("RED: the ranking: descriptor names the acted/skipped/selected exclusion (N6)", () => {
const md = renderBrief(rankForBrief(mkStore([]), pillars, TODAY));
assert.match(
md,
/\nranking: composite desc, then pillar-overlap desc, then temporal \(first-mover↑\/saturated↓\), then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\/selected \(selected\+acted shown in I produksjon\)\n/,
);
});
});
describe("RE-R3b — surfacedIds (D7, Phase B)", () => {
test("RED: surfacedIds = topMatches singleMatches olderMatched.slice(0,5)", () => {
const olders = Array.from({ length: 7 }, (_, i) =>
mkTrend({ title: "O" + i, url: "https://e/o" + i, topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }),
);
const s = mkStore([
mkTrend({ title: "Top", url: "https://e/t", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }),
mkTrend({ title: "Sg", url: "https://e/sg", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }),
...olders,
]);
const r = rankForBrief(s, ["AI", "gov"], TODAY);
const expected = [...r.topMatches, ...r.singleMatches, ...r.olderMatched.slice(0, 5)].map((e) => e.trend.id);
assert.deepEqual(surfacedIds(r), expected);
assert.equal(surfacedIds(r).length, 1 + 1 + 5, "older capped at 5");
});
});
// ── RE-R3d: temporal overlay (first-mover + saturation) ──
describe("temporalSignal — first-mover detection (RE-R3d / SC1)", () => {
const opts = { firstMoverDays: 2, saturationAt: 3 };
test("recent + unsurfaced is first-mover (rank 3); undefined surfacedCount counts as 0", () => {
for (const a of [0, 1, 2]) {
const s = temporalSignal(a, 0, opts);
assert.equal(s.tier, "first-mover", `ageDays ${a} surfaced 0`);
assert.equal(s.firstMover, true);
assert.equal(s.rank, 3);
}
assert.equal(temporalSignal(1, undefined, opts).tier, "first-mover");
});
test("past the window is neutral (rank 2)", () => {
const s = temporalSignal(3, 0, opts);
assert.equal(s.tier, "neutral");
assert.equal(s.firstMover, false);
assert.equal(s.rank, 2);
});
test("recent but already surfaced is NOT first-mover (warming)", () => {
const s = temporalSignal(1, 1, opts);
assert.equal(s.tier, "warming");
assert.equal(s.firstMover, false);
});
test("future publishedAt (negative ageDays) is NOT first-mover (>=0 guard)", () => {
const s = temporalSignal(-1, 0, opts);
assert.equal(s.tier, "neutral");
assert.equal(s.firstMover, false);
});
});
describe("temporalSignal — saturation grading + clamp (RE-R3d / SC2, SC9)", () => {
const opts = { firstMoverDays: 2, saturationAt: 3 };
test("surfacings >= saturationAt is saturated (rank 0, inclusive)", () => {
for (const c of [3, 4]) {
const s = temporalSignal(5, c, opts);
assert.equal(s.tier, "saturated", `surfaced ${c}`);
assert.equal(s.rank, 0);
assert.equal(s.surfacings, c);
}
});
test("1..saturationAt-1 is warming (rank 1)", () => {
for (const c of [1, 2]) {
const s = temporalSignal(5, c, opts);
assert.equal(s.tier, "warming", `surfaced ${c}`);
assert.equal(s.rank, 1);
}
});
test("surfaced 0 (not recent) is neutral (rank 2)", () => {
assert.equal(temporalSignal(5, 0, opts).tier, "neutral");
});
test("defensive clamp: saturationAt 0 does NOT mark every non-first-mover saturated", () => {
assert.equal(temporalSignal(5, 5, { firstMoverDays: 2, saturationAt: 0 }).tier, "saturated");
assert.equal(temporalSignal(5, 0, { firstMoverDays: 2, saturationAt: 0 }).tier, "neutral");
});
test("pure: same inputs -> same output", () => {
assert.deepEqual(temporalSignal(2, 1, opts), temporalSignal(2, 1, opts));
});
});
describe("rankForBrief — temporal overlay re-orders within tier, composite dominates (RE-R3d / SC3)", () => {
const pillars = ["a", "b"];
// DISAGREEMENT fixture: temporal.rank and effectiveDate-desc disagree, so the new key is what decides.
// (surfacedCount correlates with age, so a naive first-mover-vs-saturated fixture would already be ordered
// correctly by the existing effectiveDate key — that test would pass WITHOUT the feature.)
const store = mkStore([
// A: neutral (surfaced 0, ageDays 5 > firstMoverDays), OLDER date, composite 7.0
mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-19", capturedAt: "2026-06-19", score: mkScore(7.0, "High") }),
// B: warming (surfaced 2), NEWER date, composite 7.0
mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 2, score: mkScore(7.0, "High") }),
// Z: saturated (surfaced 4) but HIGHER composite 8.5
mkTrend({ title: "Z", url: "https://e/z", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", surfacedCount: 4, score: mkScore(8.5, "Immediate") }),
]);
test("[Z, A, B]: composite primary (Z); then temporal (A neutral > B warming) over newer-date B", () => {
const r = rankForBrief(store, pillars, TODAY);
assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Z", "A", "B"]);
});
test("total order: deterministic regardless of store insertion order", () => {
const reordered = mkStore([store.trends[2], store.trends[0], store.trends[1]]);
assert.deepEqual(rankForBrief(reordered, pillars, TODAY).topMatches.map((e) => e.trend.title), ["Z", "A", "B"]);
});
});
describe("renderBrief — temporal badges + the >=2 boundary (RE-R3d / SC4)", () => {
const pillars = ["AI", "gov"];
const render = (p: Parameters<typeof mkTrend>[0]): string => renderBrief(rankForBrief(mkStore([mkTrend(p)]), pillars, TODAY));
test("first-mover entry carries · 🥇 først ute", () => {
const md = render({ title: "FM", url: "https://e/fm", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" });
assert.ok(md.includes("· 🥇 først ute"), "ageDays 1 + unsurfaced -> first-mover badge");
});
test("saturated entry carries · 🔁 mettet (3x)", () => {
const md = render({ title: "Sat", url: "https://e/sat", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 3 });
assert.ok(md.includes("· 🔁 mettet (3x)"), "surfacedCount 3 -> saturated badge");
});
test("warming entry with surfacedCount 2 carries · sett 2x", () => {
const md = render({ title: "Warm", url: "https://e/warm", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 2 });
assert.ok(md.includes("· sett 2x"), "surfacedCount 2 -> warming badge (>=2)");
});
test("warming entry with surfacedCount 1 carries NO badge (preserves the R3b >=2 contract)", () => {
const md = render({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 1 });
assert.ok(!md.includes("sett 1x"), "surfacedCount 1 -> no marker");
assert.ok(!md.includes("mettet"), "surfacedCount 1 is not saturated");
});
test("neutral entry carries none of the temporal badges", () => {
const md = render({ title: "Neu", url: "https://e/neu", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" });
assert.ok(!md.includes("først ute") && !md.includes("mettet") && !md.includes("· sett "), "ageDays 4 + unsurfaced -> neutral");
});
});
describe("briefSummary — first-mover marker (RE-R3d / SC5)", () => {
const pillars = ["AI", "gov"];
test("first-mover top carries · 🥇 først ute and stays regex-safe", () => {
const s = briefSummary(rankForBrief(mkStore([
mkTrend({ title: "Fresh", url: "https://e/f", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(9.0, "Immediate") }),
]), pillars, TODAY));
assert.ok(s.includes("· 🥇 først ute"), `first-mover summary marker: ${s}`);
assert.ok(!s.includes('"') && !s.includes("\n"), "summary stays single-line, no double-quote");
});
test("non-first-mover top omits the marker", () => {
const s = briefSummary(rankForBrief(mkStore([
mkTrend({ title: "Old", url: "https://e/o", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }),
]), pillars, TODAY));
assert.ok(!s.includes("først ute"), `neutral top, no marker: ${s}`);
});
});
describe("rankForBrief — no schema/score mutation (RE-R3d / SC8)", () => {
test("ranking does not mutate score.composite; schema versions unchanged", () => {
const t = mkTrend({ title: "X", url: "https://e/x", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(8.0, "Immediate") });
const before = t.score!.composite;
rankForBrief(mkStore([t]), ["AI"], TODAY);
assert.equal(t.score!.composite, before, "rankForBrief must not mutate the stored composite");
assert.equal(BRIEF_SCHEMA_VERSION, 2); // RE-R3e: artifact frontmatter gained surfaced: (1->2); store SCHEMA_VERSION stays 4
assert.equal(SCHEMA_VERSION, 4);
});
});
describe("rankForBrief — prior-day surfacings exclude today (RE-R3d / same-day re-run idempotency)", () => {
const pillars = ["AI", "gov"];
test("a first-mover trend already surfaced TODAY stays first-mover (today excluded from the prior count)", () => {
// surfacedCount 1 but lastSurfacedAt === today -> prior-day count 0 -> still first-mover, so a
// same-day re-render (which loads the post-mark count) is byte-identical to the first run (RE-R3c SC7).
const s = mkStore([
mkTrend({ title: "FM", url: "https://e/fm", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1, lastSurfacedAt: TODAY }),
]);
const e = rankForBrief(s, pillars, TODAY).topMatches[0];
assert.equal(e.temporal.tier, "first-mover", "today's own surfacing must not demote it out of first-mover");
assert.equal(e.temporal.surfacings, 0, "prior-day surfacings exclude today");
});
test("the same count surfaced on a PRIOR day is warming (today not excluded)", () => {
const s = mkStore([
mkTrend({ title: "W", url: "https://e/w", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1, lastSurfacedAt: "2026-06-23" }),
]);
const e = rankForBrief(s, pillars, TODAY).topMatches[0];
assert.equal(e.temporal.tier, "warming", "a prior-day surfacing counts");
assert.equal(e.temporal.surfacings, 1);
});
});
// --- RE-R3e: brief history + day-over-day diff ---
describe("diffSurfaced — partitions + order + empty prior (RE-R3e / SC1)", () => {
test("symmetric set difference, order-stable", () => {
const d = diffSurfaced(["a", "b", "c"], ["b", "c", "d"], "2026-06-25");
assert.deepEqual(d, { priorDate: "2026-06-25", added: ["a"], carried: ["b", "c"], dropped: ["d"] });
});
test("empty prior ⇒ everything added, nothing dropped", () => {
const d = diffSurfaced(["a", "b"], [], null);
assert.deepEqual(d, { priorDate: null, added: ["a", "b"], carried: [], dropped: [] });
});
test("the three partitions are mutually disjoint (cross-partition exclusivity)", () => {
const d = diffSurfaced(["a", "b", "c"], ["b", "c", "d"], "2026-06-25");
const all = [...d.added, ...d.carried, ...d.dropped];
assert.equal(new Set(all).size, all.length, "no id appears in two buckets");
});
test("pure: same inputs → same output", () => {
assert.deepEqual(diffSurfaced(["x"], ["y"], "2026-06-20"), diffSurfaced(["x"], ["y"], "2026-06-20"));
});
});
describe("parseSurfacedFrontmatter — read + degrade (RE-R3e / SC2)", () => {
const block = (surfaced: string) =>
`---\ndate: 2026-06-26\nsummary: hi\nsurfaced: ${surfaced}\nstore: { trends: 3 }\nschemaVersion: 2\n---\nbody`;
test("parses a csv of ids", () => {
assert.deepEqual(parseSurfacedFrontmatter(block("1a2b,3c4d,5e6f")), ["1a2b", "3c4d", "5e6f"]);
});
test("trims whitespace around ids", () => {
assert.deepEqual(parseSurfacedFrontmatter(block(" a , b ,c ")), ["a", "b", "c"]);
});
test("a blank surfaced: line → []", () => {
assert.deepEqual(parseSurfacedFrontmatter(block("")), []);
});
test("an absent surfaced: line (a pre-R3e brief) → []", () => {
assert.deepEqual(parseSurfacedFrontmatter(`---\ndate: 2026-06-26\nsummary: hi\nschemaVersion: 1\n---\nbody`), []);
});
test("line-anchored: a summary: with commas does not leak in", () => {
assert.deepEqual(parseSurfacedFrontmatter(`---\ndate: 2026-06-26\nsummary: a,b,c\nschemaVersion: 1\n---`), []);
});
test("never throws on malformed input", () => {
assert.doesNotThrow(() => parseSurfacedFrontmatter("not a brief at all"));
});
});
describe("selectPriorBriefFile — strict-prior selection (RE-R3e / SC3)", () => {
const files = ["2026-06-24.md", "2026-06-25.md", "2026-06-26.md", "README.md", "2026-06-30.md"];
test("greatest dated file strictly < today (excludes today + future, ignores non-dated)", () => {
assert.equal(selectPriorBriefFile(files, "2026-06-26"), "2026-06-25.md");
});
test("no file < today → null", () => {
assert.equal(selectPriorBriefFile(["2026-06-26.md", "2026-06-30.md"], "2026-06-26"), null);
});
test("empty list → null", () => {
assert.equal(selectPriorBriefFile([], "2026-06-26"), null);
});
});
describe("renderBrief/briefSummary — surfaced: + diff section + marker (RE-R3e / SC4SC8)", () => {
const pillars = ["AI", "gov"];
const store = mkStore([
mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }),
mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(7.0, "High") }),
]);
const r = rankForBrief(store, pillars, TODAY);
const ids = surfacedIds(r);
const emptyDiff = { priorDate: null, added: [], carried: [], dropped: [] };
test("SC4: one surfaced: line = surfacedIds(r).join(','), before schemaVersion: 2", () => {
const md = renderBrief(r, emptyDiff);
const m = md.match(/^surfaced: (.*)$/m);
assert.ok(m, "a surfaced: frontmatter line exists");
assert.equal(m![1], ids.join(","));
assert.ok(md.indexOf("\nsurfaced:") < md.indexOf("\nschemaVersion:"), "surfaced: precedes schemaVersion:");
assert.match(md, /\nschemaVersion: 2\n/);
});
test("SC4: round-trips via parseSurfacedFrontmatter", () => {
assert.deepEqual(parseSurfacedFrontmatter(renderBrief(r, emptyDiff)), ids);
});
test("SC4: an empty store → a blank surfaced: line", () => {
const empty = rankForBrief(mkStore([]), pillars, TODAY);
assert.match(renderBrief(empty, emptyDiff), /\nsurfaced: \n/);
});
test("SC5: section header present + precedes Topp-treff (delta leads)", () => {
const md = renderBrief(r, emptyDiff);
assert.ok(md.includes("## 🆕 Nytt siden sist"), "section header present");
assert.ok(md.indexOf("## 🆕 Nytt siden sist") < md.indexOf("## 🎯 Topp-treff"), "delta precedes the ranked list");
});
test("SC5: first brief with added → 'Første brief — alt nedenfor er nytt'", () => {
assert.ok(renderBrief(r, { priorDate: null, added: ids, carried: [], dropped: [] }).includes("Første brief — alt nedenfor er nytt"));
});
test("SC5: empty first brief → 'Første brief.'", () => {
assert.ok(renderBrief(r, emptyDiff).includes("_Første brief._"));
});
test("SC5: prior + added → the added title + the carried/dropped tally", () => {
const md = renderBrief(r, { priorDate: "2026-06-23", added: [ids[0]], carried: [ids[1]], dropped: ["gone"] });
assert.ok(md.includes("Alpha"), "the added entry's title is rendered");
assert.ok(md.includes("1 båret over, 1 ikke vist i dag"), "the carried/dropped tally");
});
test("SC5: prior + no added → 'Ingenting nytt siden <date>' + tally", () => {
const md = renderBrief(r, { priorDate: "2026-06-23", added: [], carried: ids, dropped: [] });
assert.ok(md.includes("Ingenting nytt siden 2026-06-23"));
assert.ok(md.includes("båret over"));
});
test("SC6: marker ' N nye siden sist.' when prior + added", () => {
const s = briefSummary(r, { priorDate: "2026-06-25", added: ["x", "y"], carried: [], dropped: [] });
assert.ok(s.endsWith(" 2 nye siden sist."), `marker appended: ${s}`);
});
test("SC6: no marker on the first brief or when nothing new", () => {
assert.ok(!briefSummary(r, { priorDate: null, added: ["x"], carried: [], dropped: [] }).includes("nye siden sist"));
assert.ok(!briefSummary(r, { priorDate: "2026-06-25", added: [], carried: [], dropped: [] }).includes("nye siden sist"));
});
test("SC6: briefSummary(r) === briefSummary(r, emptyDiff) (keeps the :166 summary-equality test green)", () => {
assert.equal(briefSummary(r), briefSummary(r, emptyDiff));
});
test("SC6: the marker carries no quote/newline", () => {
const s = briefSummary(r, { priorDate: "2026-06-25", added: ["x"], carried: [], dropped: [] });
assert.ok(!s.includes('"') && !s.includes("\n"));
});
test("SC7: BRIEF_SCHEMA_VERSION === 2; SCHEMA_VERSION === 4", () => {
assert.equal(BRIEF_SCHEMA_VERSION, 2);
assert.equal(SCHEMA_VERSION, 4);
});
test("SC8: same (r, diff) → byte-identical render", () => {
const d = { priorDate: "2026-06-23", added: [ids[0]], carried: [ids[1]], dropped: [] };
assert.equal(renderBrief(r, d), renderBrief(r, d));
});
});

View file

@ -2,10 +2,12 @@ import { describe, test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs"; import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, renameSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { SCHEMA_VERSION } from "../src/types.js";
// Resolve the package root (scripts/trends) so the subprocess `src/cli.ts` path + the // Resolve the package root (scripts/trends) so the subprocess `src/cli.ts` path + the
// `tsx` loader resolve regardless of the runner's cwd. // `tsx` loader resolve regardless of the runner's cwd.
const trendsDir = fileURLToPath(new URL("..", import.meta.url)); const trendsDir = fileURLToPath(new URL("..", import.meta.url));
@ -97,7 +99,7 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
"tally must sum to the input size", "tally must sum to the input size",
); );
const persisted = JSON.parse(readFileSync(store, "utf8")); const persisted = JSON.parse(readFileSync(store, "utf8"));
assert.equal(persisted.schemaVersion, 2); assert.equal(persisted.schemaVersion, SCHEMA_VERSION);
assert.equal(persisted.trends.length, 1); assert.equal(persisted.trends.length, 1);
assert.equal(persisted.trends[0].publishedAt, "2026-06-20"); assert.equal(persisted.trends[0].publishedAt, "2026-06-20");
assert.match(persisted.trends[0].capturedAt, /^\d{4}-\d{2}-\d{2}$/); assert.match(persisted.trends[0].capturedAt, /^\d{4}-\d{2}-\d{2}$/);
@ -157,6 +159,73 @@ describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => {
const { status } = run(["capture"], ""); const { status } = run(["capture"], "");
assert.equal(status, 2); assert.equal(status, 2);
}); });
// ── RE-R3a: capture persists the computed relevance score (SC7) ──
test("a valid per-item score -> record carries the computed composite/priority (read back via list --json)", () => {
const store = tmpStore();
try {
const batch = JSON.stringify([
{
source: "tavily",
title: "Scored capture",
url: "https://example.com/sc",
topics: ["ai"],
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 } },
},
]);
const cap = run(["capture", "--store", store, "--json"], batch);
assert.equal(cap.status, 0);
const summary = JSON.parse(cap.stdout);
assert.equal(summary.added, 1);
assert.equal(summary.errors.length, 0);
const ls = run(["list", "--store", store, "--json"], "");
assert.equal(ls.status, 0);
const rows = JSON.parse(ls.stdout);
assert.equal(rows.length, 1);
// 9*.30 + 8*.25 + 9*.20 + 7*.15 + 6*.10 = 8.15 -> round1 8.1 (8.15*10 = 81.4999… in IEEE-754) -> Immediate
assert.equal(rows[0].score.composite, 8.1);
assert.equal(rows[0].score.priority, "Immediate");
assert.equal(rows[0].score.mode, "kortform");
assert.deepEqual(rows[0].score.dimensions, { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 });
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("a batch with one bad score (timing:99) -> that item in errors[], the valid one added, exit 0", () => {
const store = tmpStore();
try {
const batch = JSON.stringify([
{
source: "tavily",
title: "Good scored",
url: "https://example.com/good",
topics: ["ai"],
score: { mode: "kortform", dimensions: { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 } },
},
{
source: "tavily",
title: "Bad scored",
url: "https://example.com/bad",
topics: ["ai"],
score: { mode: "kortform", dimensions: { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 } },
},
]);
const { status, stdout } = run(["capture", "--store", store, "--json"], batch);
assert.equal(status, 0, "a bad score must not fail the run");
const summary = JSON.parse(stdout);
assert.equal(summary.added, 1, "the valid scored item is added");
assert.equal(summary.errors.length, 1, "the bad-score item lands in errors[]");
assert.equal(
summary.added + summary.merged + summary.duplicates + summary.errors.length,
2,
"tally must sum to the input size",
);
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
}); });
}); });
@ -250,3 +319,429 @@ describe("trends CLI — brief subcommand (RE-R2b / Step 3)", () => {
} }
}); });
}); });
describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)", () => {
// One temp dir per fixture holds both the store file and the brief out dir, so the
// real per-user data dir (defaultBriefDir) is never touched.
const fixture = () => {
const dir = mkdtempSync(join(tmpdir(), "trends-r3b-"));
return { dir, store: join(dir, "trends.json"), out: join(dir, "briefs") };
};
const listJson = (store: string): Array<Record<string, any>> =>
JSON.parse(run(["list", "--store", store, "--json"], "").stdout);
const seedScored = (store: string, title: string, url: string, timing = 9): void => {
const batch = JSON.stringify([
{
source: "tavily",
title,
url,
topics: ["ai", "gov"],
publishedAt: "2026-06-23",
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing, angle: 7, authority: 6 } },
},
]);
run(["capture", "--store", store], batch);
};
test("RED: act --id → acted; skip → skipped; reset → new (read back via list --json)", () => {
const { dir, store } = fixture();
try {
seedScored(store, "Lifecycle", "https://e/lc");
const id = listJson(store)[0].id;
assert.equal(run(["act", "--id", id, "--store", store], "").status, 0);
assert.equal(listJson(store)[0].status, "acted");
run(["skip", "--id", id, "--store", store], "");
assert.equal(listJson(store)[0].status, "skipped");
run(["reset", "--id", id, "--store", store], "");
assert.equal(listJson(store)[0].status, "new");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: an unknown id → exit 2 + store unchanged; a missing --id → exit 2", () => {
const { dir, store } = fixture();
try {
seedScored(store, "X", "https://e/x");
const before = readFileSync(store, "utf8");
assert.equal(run(["act", "--id", "nope", "--store", store], "").status, 2);
assert.equal(readFileSync(store, "utf8"), before, "store untouched on a not-found id");
assert.equal(run(["skip", "--store", store], "").status, 2, "missing --id → exit 2");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: brief records surfacing (surfacedCount + lastSurfacedAt + marked); a second same-day run is idempotent", () => {
const { dir, store, out } = fixture();
try {
seedScored(store, "Surf", "https://e/surf");
const o1 = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
assert.equal(o1.marked, 1, "first brief marks 1");
const rec = listJson(store)[0];
assert.equal(rec.surfacedCount, 1);
assert.match(rec.lastSurfacedAt, /^\d{4}-\d{2}-\d{2}$/);
const o2 = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
assert.equal(o2.marked, 0, "second same-day brief is idempotent");
assert.equal(listJson(store)[0].surfacedCount, 1, "count unchanged on the second same-day run");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: brief --no-mark writes no surfacedCount", () => {
const { dir, store, out } = fixture();
try {
seedScored(store, "Dry", "https://e/dry");
const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--no-mark", "--out", out, "--store", store, "--json"], "").stdout);
assert.equal(o.marked, 0);
assert.equal("surfacedCount" in listJson(store)[0], false, "--no-mark must not write surfacedCount");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: an acted record leaves the work queue and moves to the 'I produksjon' board (N6)", () => {
const { dir, store, out } = fixture();
try {
seedScored(store, "Handled", "https://e/handled");
const id = listJson(store)[0].id;
run(["act", "--id", id, "--store", store], "");
const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
const md = readFileSync(o.path, "utf8");
// N6: acted no longer vanishes from the whole brief — it leaves the QUEUE and shows in "I produksjon".
const idx = md.indexOf("## 🚧 I produksjon");
assert.ok(idx >= 0, "the production section is present");
assert.ok(!md.slice(0, idx).includes("Handled"), "an acted record must not appear in the work queue");
assert.ok(md.slice(idx).includes("Handled"), "the acted record appears in I produksjon");
assert.ok(md.slice(idx).includes("skrevet"), "tagged skrevet (acted)");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("RED: a re-capture with a changed score reports merged:1 + refreshes the composite", () => {
const { dir, store } = fixture();
try {
seedScored(store, "Re", "https://e/re", 9);
const c1 = listJson(store)[0].score.composite;
const batch = JSON.stringify([
{
source: "tavily",
title: "Re",
url: "https://e/re",
topics: ["ai", "gov"],
publishedAt: "2026-06-23",
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 2, angle: 7, authority: 6 } },
},
]);
const o = JSON.parse(run(["capture", "--store", store, "--json"], batch).stdout);
assert.equal(o.merged, 1, "a changed score on a duplicate → merged");
const c2 = listJson(store)[0].score.composite;
assert.ok(c2 < c1, "a lower timing → lower composite (re-score last-wins)");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("trends CLI — schedule subcommand (RE-R3c / autonomous trigger, print-first)", () => {
// schedule is flag-driven + print-first. Capture stdout AND stderr (usage → stderr); override HOME
// (the ~/Library/LaunchAgents target) + LINKEDIN_STUDIO_DATA (the cron.log seam) into a temp dir so
// a real HOME is never touched. (os.homedir() respects $HOME on POSIX — verified.)
function runSched(
args: string[],
env: Record<string, string> = {},
): { status: number | null; stdout: string; stderr: string } {
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "schedule", ...args], {
input: "",
encoding: "utf8",
cwd: trendsDir,
env: { ...process.env, ...env },
});
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
}
const tmpHome = () => mkdtempSync(join(tmpdir(), "sched-home-"));
// A dependency-free well-formedness check: tokenize element tags and assert they nest/balance
// (the `<?xml?>` PI and `<!DOCTYPE>` are skipped — neither starts with a letter after `<`).
// SC1 asserts balance + key-completeness; `plutil -lint` is the deps-present manual check (Step 7).
function isBalancedXml(xml: string): boolean {
const stack: string[] = [];
const re = /<(\/?)([a-zA-Z][\w.:-]*)[^>]*?(\/?)>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(xml)) !== null) {
if (m[3] === "/") continue; // self-closing (e.g. <false/>)
if (m[1] === "/") {
if (stack.pop() !== m[2]) return false;
} else {
stack.push(m[2]);
}
}
return stack.length === 0;
}
test("SC1: --platform launchd --at 07:30 --print → key-complete, well-formed plist, exit 0", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(
["--pillars", "ai,gov", "--platform", "launchd", "--at", "07:30", "--print"],
{ HOME: home, LINKEDIN_STUDIO_DATA: home },
);
assert.equal(status, 0);
assert.match(stdout, /<key>Label<\/key>\s*<string>com\.linkedin-studio\.trends\.daily<\/string>/);
assert.ok(stdout.includes("<key>ProgramArguments</key>"), "ProgramArguments present");
assert.ok(stdout.includes("run-daily.sh"), "invokes the wrapper");
assert.ok(stdout.includes("--pillars") && stdout.includes("ai,gov"), "carries the pillars");
assert.match(stdout, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7");
assert.match(stdout, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30");
assert.ok(stdout.includes("<key>StandardOutPath</key>") && stdout.includes("<key>StandardErrorPath</key>"), "Std*Path present");
assert.ok(stdout.includes("cron.log"), "Std*Path point at cron.log");
assert.ok(stdout.includes("<key>EnvironmentVariables</key>"), "EnvironmentVariables present");
assert.ok(stdout.includes("NODE_BIN") && stdout.includes("LINKEDIN_STUDIO_DATA"), "env carries NODE_BIN + data root");
assert.ok(isBalancedXml(stdout), "the plist XML is balanced / well-formed");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC1: two --print runs (same args) are byte-identical (the emitter is pure)", () => {
const home = tmpHome();
try {
const a = runSched(["--pillars", "ai,gov", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
const b = runSched(["--pillars", "ai,gov", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
assert.equal(a.status, 0);
assert.equal(a.stdout, b.stdout);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC2: --platform cron --at 07:30 --print → cron line + install recipe (string only), exit 0", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(
["--pillars", "ai,gov", "--platform", "cron", "--at", "07:30", "--print"],
{ HOME: home, LINKEDIN_STUDIO_DATA: home },
);
assert.equal(status, 0);
assert.match(stdout, /^30 7 \* \* \* /m, "the cron line fires at 07:30 daily");
assert.ok(stdout.includes("run-daily.sh"), "invokes the wrapper");
assert.ok(stdout.includes("--pillars ai,gov"), "carries the pillars");
assert.ok(stdout.includes(">> ") && stdout.includes("2>&1"), "redirects stdout+stderr to the log");
assert.ok(stdout.includes("com.linkedin-studio.trends.daily"), "the label comment");
assert.ok(stdout.includes("crontab -"), "the install recipe is printed (a string the operator runs)");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC3: platform auto (no --platform) → launchd on darwin, cron elsewhere", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(["--pillars", "ai", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
assert.equal(status, 0);
if (process.platform === "darwin") {
assert.ok(stdout.includes("<key>Label</key>"), "darwin auto → launchd plist");
} else {
assert.match(stdout, /^\d+ \d+ \* \* \* /m, "non-darwin auto → cron line");
}
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC4: --print writes nothing (temp-HOME LaunchAgents stays absent), exit 0", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
assert.equal(status, 0);
assert.ok(stdout.includes("<key>Label</key>"), "the plist is on stdout");
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "--print creates no LaunchAgents dir");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC5: --install launchd → inert plist FILE written + launchctl printed (never run), exit 0", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "launchd", "--install"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
assert.equal(status, 0);
const plist = join(home, "Library", "LaunchAgents", "com.linkedin-studio.trends.daily.plist");
assert.ok(existsSync(plist), "the inert plist file is written");
const content = readFileSync(plist, "utf8");
assert.ok(content.includes("<key>Label</key>") && content.includes("com.linkedin-studio.trends.daily"), "the file holds the plist");
assert.ok(stdout.includes("launchctl bootstrap"), "the activation command is PRINTED (the tool never runs launchctl)");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC6: --install cron → line + install recipe printed, writes no file, exit 0", () => {
const home = tmpHome();
try {
const { status, stdout } = runSched(["--pillars", "ai", "--platform", "cron", "--install"], { HOME: home, LINKEDIN_STUDIO_DATA: home });
assert.equal(status, 0);
assert.match(stdout, /^\d+ \d+ \* \* \* /m, "the cron line is printed");
assert.ok(stdout.includes("crontab -"), "the install recipe is printed (never executed)");
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "cron --install writes no plist");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("SC9: no --pillars / bad --at / bad --platform → exit 2; the fs is untouched", () => {
const home = tmpHome();
try {
assert.equal(runSched([], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "no --pillars → exit 2");
assert.equal(runSched(["--pillars", "ai", "--at", "25:00"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at 25:00 → exit 2");
assert.equal(runSched(["--pillars", "ai", "--at", "7:99"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at 7:99 → exit 2");
assert.equal(runSched(["--pillars", "ai", "--at", "noon"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at noon → exit 2");
assert.equal(runSched(["--pillars", "ai", "--platform", "bogus"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--platform bogus → exit 2");
assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "a validation error writes nothing");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe("trends CLI — brief temporal flags (RE-R3d / SC6)", () => {
// brief is flag-driven; spawn into a temp store/out so the real per-user data dir is never touched.
function runBrief(args: string[], env: Record<string, string> = {}): { status: number | null; stdout: string } {
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "brief", ...args], {
input: "",
encoding: "utf8",
cwd: trendsDir,
env: { ...process.env, ...env },
});
return { status: res.status, stdout: res.stdout };
}
const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10);
function seedStore(trends: unknown[]): string {
const store = join(mkdtempSync(join(tmpdir(), "trends-r3d-")), "trends.json");
writeFileSync(store, JSON.stringify({ schemaVersion: 2, trends }));
return store;
}
// --no-mark so sequential runs on the same store do NOT accumulate surfacedCount (which would
// confound the threshold assertions); tier badges live in the .md body, not in --json.
function briefMd(args: string[]): { status: number | null; md: string } {
const out = mkdtempSync(join(tmpdir(), "r3d-out-"));
const { status, stdout } = runBrief([...args, "--out", out, "--no-mark", "--json"]);
const path = status === 0 ? (JSON.parse(stdout).path as string) : "";
return { status, md: path ? readFileSync(path, "utf8") : "" };
}
test("--saturation-at 2 escalates a surfacedCount-2 trend from warming (sett 2x) to saturated (mettet 2x)", () => {
const store = seedStore([
{ id: "s", title: "Seen", url: "https://e/s", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"], surfacedCount: 2 },
]);
try {
const dflt = briefMd(["--pillars", "ai", "--store", store]);
assert.equal(dflt.status, 0);
assert.ok(dflt.md.includes("· sett 2x"), "default saturationAt 3 -> surfacedCount 2 is warming");
const tuned = briefMd(["--pillars", "ai", "--store", store, "--saturation-at", "2"]);
assert.equal(tuned.status, 0);
assert.ok(tuned.md.includes("· 🔁 mettet (2x)"), "--saturation-at 2 -> surfacedCount 2 is saturated");
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("--first-mover-days 1 drops a 2-day-old unsurfaced trend out of first-mover", () => {
const store = seedStore([
{ id: "f", title: "Fresh", url: "https://e/f", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"] },
]);
try {
const dflt = briefMd(["--pillars", "ai", "--store", store]);
assert.ok(dflt.md.includes("· 🥇 først ute"), "default firstMoverDays 2 -> a 2-day unsurfaced trend is first-mover");
const tuned = briefMd(["--pillars", "ai", "--store", store, "--first-mover-days", "1"]);
assert.ok(!tuned.md.includes("først ute"), "--first-mover-days 1 -> a 2-day trend is neutral");
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
test("invalid threshold flags -> exit 2", () => {
const store = seedStore([]);
try {
assert.equal(runBrief(["--pillars", "ai", "--store", store, "--first-mover-days", "x"]).status, 2, "--first-mover-days x");
assert.equal(runBrief(["--pillars", "ai", "--store", store, "--first-mover-days", "-1"]).status, 2, "--first-mover-days -1");
assert.equal(runBrief(["--pillars", "ai", "--store", store, "--saturation-at", "0"]).status, 2, "--saturation-at 0");
assert.equal(runBrief(["--pillars", "ai", "--store", store, "--saturation-at", "x"]).status, 2, "--saturation-at x");
} finally {
rmSync(join(store, ".."), { recursive: true, force: true });
}
});
});
describe("trends CLI — brief history + day-over-day diff (RE-R3e / SC9)", () => {
const fixture = () => {
const dir = mkdtempSync(join(tmpdir(), "trends-r3e-"));
return { dir, store: join(dir, "trends.json"), out: join(dir, "briefs") };
};
// freshIso keeps the seeds inside the freshness window regardless of when the test runs.
const fresh = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10);
const seedOnPillar = (store: string, title: string, url: string): void => {
const batch = JSON.stringify([
{
source: "tavily",
title,
url,
topics: ["ai", "gov"],
publishedAt: fresh,
score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 } },
},
]);
run(["capture", "--store", store], batch);
};
const briefJson = (store: string, out: string) =>
JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout);
test("two-day (rename-real-write): day-2 shows the new trend; --json carries the diff", () => {
const { dir, store, out } = fixture();
try {
// Day 1 — two on-pillar trends. The brief records its surfaced cohort + a null-prior diff.
seedOnPillar(store, "Alpha", "https://e/a");
seedOnPillar(store, "Beta", "https://e/b");
const o1 = briefJson(store, out);
assert.equal(o1.diff.priorDate, null, "first brief has no prior (empty dir)");
assert.match(readFileSync(o1.path, "utf8"), /^surfaced: .+/m, "day-1 records a non-empty surfaced: line");
// Rename the REAL day-1 brief to a fixed past date — its surfaced: ids are genuine store
// ids, so the day-2 diff's carried/added are id-exact, clock-free (no today() advance).
const prior = "2026-06-20";
renameSync(o1.path, join(out, `${prior}.md`));
// Day 2 — capture one NEW on-pillar trend, re-run. It is the sole "added".
seedOnPillar(store, "Gamma", "https://e/g");
const o2 = briefJson(store, out);
assert.equal(o2.diff.priorDate, prior, "day-2 discovers the renamed strict-prior");
assert.ok(o2.diff.added >= 1, "at least the new trend is added");
assert.equal(o2.diff.carried, 2, "the two day-1 trends carry over");
assert.equal(o2.diff.dropped, 0, "nothing dropped");
const md2 = readFileSync(o2.path, "utf8");
assert.ok(md2.includes(`## 🆕 Nytt siden sist (${prior})`), "the diff section names the prior date");
assert.ok(md2.includes("Gamma"), "the new trend's title is in the Nytt-siden-sist section");
// The non-JSON console line appends the delta marker.
const console2 = run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store], "").stdout;
assert.ok(console2.includes("nye siden sist"), `console line carries the delta: ${console2}`);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a first run (empty dir) → diff.priorDate === null, no Nytt-siden-sist date", () => {
const { dir, store, out } = fixture();
try {
seedOnPillar(store, "Solo", "https://e/s");
const o = briefJson(store, out);
assert.equal(o.diff.priorDate, null);
const md = readFileSync(o.path, "utf8");
assert.ok(md.includes("Første brief"), "a first brief says 'Første brief'");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -2,7 +2,9 @@ import { describe, test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js"; import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js";
import type { TrendItem } from "../src/item.js";
import { normalizeField } from "../src/store.js"; import { normalizeField } from "../src/store.js";
import { scoreEnvelope } from "../src/score.js";
describe("trends item normalizer (RE-R1 / B1)", () => { describe("trends item normalizer (RE-R1 / B1)", () => {
describe("normalizeItem — well-formed", () => { describe("normalizeItem — well-formed", () => {
@ -224,4 +226,115 @@ describe("trends item normalizer (RE-R1 / B1)", () => {
assert.equal(input.capturedAt, "2026-06-24"); assert.equal(input.capturedAt, "2026-06-24");
}); });
}); });
describe("normalizeItem — score validation (RE-R3a / SC2)", () => {
const base = {
source: "tavily",
title: "Scored item",
url: "https://example.com/s",
topics: ["ai"],
};
const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };
test("a valid kortform score -> carried with the validated dims", () => {
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: validDims } });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.score, { mode: "kortform", dimensions: validDims });
});
test("a valid long-form score -> carried with its five dims", () => {
const longDims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
const res = normalizeItem({ ...base, score: { mode: "long-form", dimensions: longDims } });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.deepEqual(res.item.score, { mode: "long-form", dimensions: longDims });
});
test("absent score -> key omitted", () => {
const res = normalizeItem(base);
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal("score" in res.item, false);
});
test("a bad mode -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: { mode: "bogus", dimensions: validDims } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")), res.errors.join("; "));
});
test("a missing dimension -> structured error (no throw)", () => {
const { authority, ...missing } = validDims;
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: missing } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("a dimension out of [1,10] (0 or 11) -> structured error (no throw)", () => {
const lo = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 0 } } });
assert.equal(lo.ok, false);
const hi = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 11 } } });
assert.equal(hi.ok, false);
});
test("a non-object score -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: "high" });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("an array dimensions -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: [9, 8, 9, 7, 6] } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.includes("invalid score")));
});
test("an array score -> structured error (no throw)", () => {
const res = normalizeItem({ ...base, score: [] });
assert.equal(res.ok, false);
});
});
describe("itemToInput — score bridge + the throw contract (RE-R3a / SC2)", () => {
const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 };
test("a scored item -> input.score equals scoreEnvelope(mode, dimensions)", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
score: { mode: "kortform", dimensions: validDims },
};
const input = itemToInput(item, "2026-06-24");
assert.deepEqual(input.score, scoreEnvelope("kortform", validDims));
});
test("an unscored item -> no score key", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
};
const input = itemToInput(item, "2026-06-24") as Record<string, unknown>;
assert.equal("score" in input, false);
});
test("called directly with an out-of-range dim -> throws by contract (defense-in-depth)", () => {
const item: TrendItem = {
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
score: { mode: "kortform", dimensions: { ...validDims, timing: 99 } },
};
assert.throws(() => itemToInput(item, "2026-06-24"));
});
});
}); });

View file

@ -0,0 +1,381 @@
/**
* N6 the proposal layer: the discovery agent's angle/rationale becomes a PERSISTED
* entity (step 2, A1-4) and the operator's approval gets a home (step 3, A1-7). Eight
* additive-optional fields carry the proposal (angle/targetLevel/rationale/relatedIds),
* the reader-side F7 signal the N7 band-cap gate reads (actionability/verdict), and the
* F9 reader fields the N7.5 demand-sweep fills (readerQuestion/painPoint/saturation).
* Plus the `selected` lifecycle state, the `--ids` batch, and the brief's "I produksjon"
* section. Every field is optional an existing store reads UNCHANGED (backward-compat).
*/
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import {
emptyStore,
loadStore,
saveStore,
addTrend,
effectiveStatus,
setStatus,
setStatusMany,
} from "../src/store.js";
import { normalizeItem, itemToInput } from "../src/item.js";
import { rankForBrief, renderBrief } from "../src/brief.js";
import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendRecord, TrendStore } from "../src/types.js";
const tmp = () => mkdtempSync(join(tmpdir(), "trends-n6-"));
// The full proposal payload, reused across the schema tests.
const FULL_INPUT = {
title: "AI agents reshape public-sector workflows",
url: "https://example.com/agents",
source: "tavily",
capturedAt: "2026-07-20",
topics: ["ai", "public sector"],
angle: "What a caseworker actually does differently on Monday",
targetLevel: "praktiker", // a value from the user's OWN profile span, never a hardcoded enum
rationale: "The procurement window opens in Q3 — timely for buyers deciding now",
relatedIds: ["abc123def456", "0011223344ff"],
actionability: { formulated: true, note: "reader can pilot one workflow this week" },
verdict: "BÆRENDE" as const,
readerQuestion: "How do I start without a platform team?",
painPoint: "competes against the M365 licence they already pay for",
saturation: "thin — two vendor blogs, no independent walkthrough",
};
describe("N6 — schema round-trip (the proposal becomes a persisted entity)", () => {
test("addTrend persists all eight proposal fields, and load/save round-trips them byte-for-byte", () => {
const dir = tmp();
try {
const path = join(dir, "trends.json");
const { store } = addTrend(emptyStore(), FULL_INPUT);
saveStore(path, store);
const reloaded = loadStore(path);
const t = reloaded.trends[0];
assert.equal(t.angle, FULL_INPUT.angle);
assert.equal(t.targetLevel, FULL_INPUT.targetLevel);
assert.equal(t.rationale, FULL_INPUT.rationale);
assert.deepEqual(t.relatedIds, FULL_INPUT.relatedIds);
assert.deepEqual(t.actionability, FULL_INPUT.actionability);
assert.equal(t.verdict, "BÆRENDE");
assert.equal(t.readerQuestion, FULL_INPUT.readerQuestion);
assert.equal(t.painPoint, FULL_INPUT.painPoint);
assert.equal(t.saturation, FULL_INPUT.saturation);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("the new fields are additive-optional — schema stays v4, no bump (no record needs migrating)", () => {
assert.equal(SCHEMA_VERSION, 4);
});
test("an existing v4 store WITHOUT the new fields loads and re-saves unchanged (backward-compat)", () => {
const dir = tmp();
try {
const path = join(dir, "trends.json");
// A record shaped exactly like a pre-N6 v4 record (no proposal fields at all).
const legacy: TrendStore = {
schemaVersion: 4,
trends: [
{
id: "legacy00",
title: "Old trend",
url: "https://example.com/old",
source: "websearch",
capturedAt: "2026-06-01",
topics: ["ai"],
},
],
};
writeFileSync(path, JSON.stringify(legacy, null, 2) + "\n", "utf8");
const loaded = loadStore(path);
assert.equal(loaded.schemaVersion, 4);
const t = loaded.trends[0];
assert.equal(t.angle, undefined);
assert.equal(t.verdict, undefined);
assert.equal(Object.prototype.hasOwnProperty.call(t, "angle"), false, "no phantom key added on load");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("proposal fields are first-sight — a re-capture with a different angle does NOT overwrite the stored one", () => {
const first = addTrend(emptyStore(), FULL_INPUT).store;
const second = addTrend(first, { ...FULL_INPUT, angle: "A completely different angle", topics: ["ai", "govtech"] });
assert.equal(second.added, false);
// topics still union (existing discipline); angle is provenance — first sight wins.
assert.equal(first.trends[0].angle, FULL_INPUT.angle);
assert.deepEqual(first.trends[0].topics, ["ai", "public sector", "govtech"]);
});
});
describe("N6 — lifecycle: selected + setStatusMany batch", () => {
test("effectiveStatus round-trips the new `selected` state", () => {
const rec: TrendRecord = { id: "x", title: "t", url: "u", source: "s", capturedAt: "2026-07-20", topics: [], status: "selected" };
assert.equal(effectiveStatus(rec), "selected");
});
test("setStatus accepts `selected` (widened TrendStatus)", () => {
const { store } = addTrend(emptyStore(), FULL_INPUT);
const id = store.trends[0].id;
const res = setStatus(store, id, "selected");
assert.equal(res.found, true);
assert.equal(store.trends[0].status, "selected");
});
test("setStatusMany marks every matched id and reports the unknown ones (partial batch)", () => {
let store = emptyStore();
store = addTrend(store, { ...FULL_INPUT, title: "A", url: "https://example.com/a" }).store;
store = addTrend(store, { ...FULL_INPUT, title: "B", url: "https://example.com/b" }).store;
const ids = store.trends.map((t) => t.id);
const res = setStatusMany(store, [ids[0], ids[1], "ghost99"], "selected");
assert.deepEqual(res.found.sort(), [ids[0], ids[1]].sort());
assert.deepEqual(res.notFound, ["ghost99"]);
assert.equal(store.trends[0].status, "selected");
assert.equal(store.trends[1].status, "selected");
});
test("setStatusMany on an all-unknown batch mutates nothing and reports every id not-found", () => {
const { store } = addTrend(emptyStore(), FULL_INPUT);
const res = setStatusMany(store, ["nope1", "nope2"], "acted");
assert.deepEqual(res.found, []);
assert.deepEqual(res.notFound, ["nope1", "nope2"]);
assert.equal(effectiveStatus(store.trends[0]), "new");
});
});
describe("N6 — item validation (typed fields hard-fail, free-text lenient)", () => {
test("a well-formed item with every proposal field normalizes, and itemToInput carries them all", () => {
const res = normalizeItem({
source: "tavily",
title: "T",
url: "https://example.com/t",
topics: ["ai"],
angle: "the angle",
targetLevel: "beslutter",
rationale: "why now",
relatedIds: ["id1", "id2"],
actionability: { formulated: true, note: "do X" },
verdict: "STØTTE",
readerQuestion: "how?",
painPoint: "cost",
saturation: "saturated",
});
assert.equal(res.ok, true);
if (!res.ok) return;
const input = itemToInput(res.item, "2026-07-20");
assert.equal(input.angle, "the angle");
assert.equal(input.targetLevel, "beslutter");
assert.equal(input.verdict, "STØTTE");
assert.deepEqual(input.actionability, { formulated: true, note: "do X" });
assert.deepEqual(input.relatedIds, ["id1", "id2"]);
assert.equal(input.readerQuestion, "how?");
assert.equal(input.painPoint, "cost");
assert.equal(input.saturation, "saturated");
});
test("an out-of-vocabulary verdict is a hard error (closed mechanism vocabulary)", () => {
const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"], verdict: "MEGABÆRENDE" });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.toLowerCase().includes("verdict")), "error names the offending field");
});
test("a malformed actionability (formulated not a boolean) is a hard error", () => {
const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"], actionability: { formulated: "yes" } });
assert.equal(res.ok, false);
if (res.ok) return;
assert.ok(res.errors.some((e) => e.toLowerCase().includes("actionability")));
});
test("blank free-text fields are dropped, not errors (summary-idiom); relatedIds junk is normalized away", () => {
const res = normalizeItem({
source: "s",
title: "t",
url: "https://example.com/t",
topics: ["ai"],
angle: " ",
rationale: "",
relatedIds: ["ok1", "", 42, "ok1"], // blank + non-string dropped, dupe collapsed
});
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal(Object.prototype.hasOwnProperty.call(res.item, "angle"), false);
assert.equal(Object.prototype.hasOwnProperty.call(res.item, "rationale"), false);
assert.deepEqual(res.item.relatedIds, ["ok1"]);
});
test("an item with NONE of the new fields still normalizes (backward-compat)", () => {
const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"] });
assert.equal(res.ok, true);
if (!res.ok) return;
assert.equal(res.item.verdict, undefined);
assert.equal(res.item.actionability, undefined);
});
});
// ── Brief rendering ──────────────────────────────────────────────────────────
const TODAY = "2026-07-20";
function mk(p: Partial<TrendRecord> & { title: string; url: string; topics: string[] }): TrendRecord {
return {
id: p.title + "|" + p.url,
source: "tavily",
capturedAt: "2026-07-20",
...p,
} as TrendRecord;
}
describe("N6 — brief renders the proposal fields + the 'I produksjon' section", () => {
test("a fresh, top-matched candidate renders its angle/målnivå/verdict/reader fields in the brief", () => {
const store: TrendStore = {
schemaVersion: 4,
trends: [
mk({
title: "Agents in casework",
url: "https://example.com/a",
topics: ["ai", "public sector"],
publishedAt: "2026-07-20",
angle: "Monday-morning angle",
targetLevel: "praktiker",
rationale: "timely for Q3 buyers",
actionability: { formulated: true, note: "pilot one workflow" },
verdict: "BÆRENDE",
readerQuestion: "How do I start without a platform team?",
painPoint: "competes with M365",
saturation: "thin market",
}),
],
};
const md = renderBrief(rankForBrief(store, ["ai", "public sector"], TODAY));
assert.ok(md.includes("Monday-morning angle"), "angle rendered");
assert.ok(md.includes("praktiker"), "målnivå rendered");
assert.ok(md.includes("BÆRENDE"), "verdict rendered");
assert.ok(md.includes("How do I start without a platform team?"), "readerQuestion rendered");
assert.ok(md.includes("competes with M365"), "painPoint rendered");
assert.ok(md.includes("thin market"), "saturation rendered");
});
test("the 'I produksjon' section lists selected (valgt) and acted (skrevet) trends", () => {
const store: TrendStore = {
schemaVersion: 4,
trends: [
mk({ title: "Chosen one", url: "https://example.com/s", topics: ["ai"], status: "selected", angle: "selected angle", verdict: "BÆRENDE" }),
mk({ title: "Written one", url: "https://example.com/w", topics: ["ai"], status: "acted" }),
mk({ title: "Fresh queue item", url: "https://example.com/n", topics: ["ai"], publishedAt: "2026-07-20" }),
],
};
const md = renderBrief(rankForBrief(store, ["ai"], TODAY));
assert.ok(md.includes("I produksjon"), "section header present");
assert.ok(md.includes("Chosen one") && md.includes("valgt"), "selected trend shown as valgt");
assert.ok(md.includes("Written one") && md.includes("skrevet"), "acted trend shown as skrevet");
// A selected/acted trend must NOT leak back into the 'new' queue sections.
assert.ok(!md.includes("### 1. Chosen one"), "selected trend is not in the ranked queue");
});
test("an empty in-production board renders the explicit empty marker", () => {
const store: TrendStore = {
schemaVersion: 4,
trends: [mk({ title: "Only new", url: "https://example.com/n", topics: ["ai"], publishedAt: "2026-07-20" })],
};
const md = renderBrief(rankForBrief(store, ["ai"], TODAY));
assert.ok(md.includes("I produksjon"), "section header always present (stable structure)");
assert.ok(md.includes("Ingen i produksjon"), "explicit empty marker");
});
test("the render is deterministic (same store → byte-identical brief)", () => {
const store: TrendStore = {
schemaVersion: 4,
trends: [
mk({ title: "A", url: "https://example.com/a", topics: ["ai"], status: "selected", angle: "x" }),
mk({ title: "B", url: "https://example.com/b", topics: ["ai"], publishedAt: "2026-07-20", verdict: "NYHET" }),
],
};
const a = renderBrief(rankForBrief(store, ["ai"], TODAY));
const b = renderBrief(rankForBrief(store, ["ai"], TODAY));
assert.equal(a, b);
});
});
// ── CLI: select verb + --ids batch ───────────────────────────────────────────
const trendsRoot = fileURLToPath(new URL("..", import.meta.url));
function run(args: string[], input = ""): { status: number | null; stdout: string } {
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
cwd: trendsRoot,
input,
encoding: "utf8",
});
return { status: res.status, stdout: res.stdout + res.stderr };
}
function seed(store: string): string[] {
// capture three trends; return their ids (read via list --json).
const batch = JSON.stringify([
{ source: "tavily", title: "One", url: "https://example.com/1", topics: ["ai"] },
{ source: "tavily", title: "Two", url: "https://example.com/2", topics: ["ai"] },
{ source: "tavily", title: "Three", url: "https://example.com/3", topics: ["ai"] },
]);
run(["capture", "--store", store], batch);
const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string }>;
return rows.map((r) => r.id);
}
describe("N6 — CLI select verb + --ids batch", () => {
test("select --id sets `selected`; read back via list --json", () => {
const dir = tmp();
try {
const store = join(dir, "trends.json");
const [id] = seed(store);
assert.equal(run(["select", "--id", id, "--store", store]).status, 0);
const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string; status?: string }>;
assert.equal(rows.find((r) => r.id === id)!.status, "selected");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("--ids marks a whole batch in one call (ten candidates, one call)", () => {
const dir = tmp();
try {
const store = join(dir, "trends.json");
const ids = seed(store);
const res = run(["act", "--ids", ids.join(","), "--store", store]);
assert.equal(res.status, 0);
const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ status?: string }>;
assert.ok(rows.every((r) => r.status === "acted"), "every seeded trend is acted");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a partial batch (some ids unknown) saves the matches, exits 0, and reports the misses", () => {
const dir = tmp();
try {
const store = join(dir, "trends.json");
const ids = seed(store);
const res = run(["skip", "--ids", `${ids[0]},ghost`, "--store", store]);
assert.equal(res.status, 0, "partial success is exit 0");
assert.ok(res.stdout.toLowerCase().includes("ghost") || res.stdout.toLowerCase().includes("not found"), "misses reported");
const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string; status?: string }>;
assert.equal(rows.find((r) => r.id === ids[0])!.status, "skipped");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("an all-unknown batch changes nothing and exits 2", () => {
const dir = tmp();
try {
const store = join(dir, "trends.json");
seed(store);
assert.equal(run(["select", "--ids", "nope1,nope2", "--store", store]).status, 2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,134 @@
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, readFileSync, existsSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { defaultStorePath } from "../src/store.js";
// The hooks `.mjs` twin of the data-root seam — SC8 binds it into the consistency check.
import { getDataRoot } from "../../../hooks/scripts/data-root.mjs";
// Package root (scripts/trends) so the wrapper path + `tsx` resolution are cwd-independent.
const trendsDir = fileURLToPath(new URL("..", import.meta.url));
const wrapper = join(trendsDir, "run-daily.sh");
const today = new Date().toISOString().slice(0, 10);
// Invoke THROUGH `bash` (never a direct executable spawn): an ABSENT wrapper exits 127
// (a clean assertion-RED), where a direct exec of a missing file would throw ENOENT /
// status:null (a module-not-found-class failure). Folded — plan-critic #7.
function runWrapper(
args: string[],
env: Record<string, string> = {},
cwd: string = trendsDir,
): { status: number | null; stdout: string; stderr: string } {
const res = spawnSync("bash", [wrapper, ...args], {
input: "",
encoding: "utf8",
cwd,
env: { ...process.env, ...env },
});
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
}
describe("trends headless wrapper — run-daily.sh (RE-R3c / SC7, SC8)", () => {
const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10);
// One temp dir is BOTH the LINKEDIN_STUDIO_DATA root (so cron.log lands at <dir>/trends/cron.log)
// AND holds the seeded store + brief out-dir, so the real per-user data dir is never touched.
function fixture(): { dir: string; store: string; out: string } {
const dir = mkdtempSync(join(tmpdir(), "r3c-wrap-"));
const store = join(dir, "s.json");
writeFileSync(
store,
JSON.stringify({
schemaVersion: 2,
trends: [
{
id: "a",
title: "Fresh Match",
url: "https://e/a",
source: "tavily",
capturedAt: freshIso,
publishedAt: freshIso,
topics: ["ai", "gov"],
},
],
}),
);
return { dir, store, out: join(dir, "mb") };
}
test("SC7: seeded store → dated brief .md written + exactly one compact cron.log line + exit 0", () => {
const { dir, store, out } = fixture();
try {
const { status } = runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], {
LINKEDIN_STUDIO_DATA: dir,
});
assert.equal(status, 0, "the deterministic brief exits 0");
assert.ok(existsSync(join(out, `${today}.md`)), "the dated brief .md is written");
const log = readFileSync(join(dir, "trends", "cron.log"), "utf8");
const lines = log.split("\n").filter((l) => l.trim().length > 0);
assert.equal(lines.length, 1, "exactly one log line (the pretty-printed brief json compacted to one line)");
assert.match(lines[0], /^\S+ exit=0 /, "the line is `<ts> exit=0 <compact-json>`");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("SC7: a second same-day run → byte-identical .md, surfacedCount not double-counted, second log line", () => {
const { dir, store, out } = fixture();
try {
assert.equal(runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }).status, 0);
const md1 = readFileSync(join(out, `${today}.md`), "utf8");
assert.equal(runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }).status, 0);
const md2 = readFileSync(join(out, `${today}.md`), "utf8");
assert.equal(md1, md2, "the same-day re-render is byte-identical");
const persisted = JSON.parse(readFileSync(store, "utf8"));
assert.equal(persisted.trends[0].surfacedCount, 1, "surfacedCount stays 1 (R3b per-day idempotency)");
const lines = readFileSync(join(dir, "trends", "cron.log"), "utf8").split("\n").filter((l) => l.trim().length > 0);
assert.equal(lines.length, 2, "two runs → two log lines");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("SC7: CWD-independent — runs from an unrelated cwd (the wrapper's `cd \"$DIR\"` resolves tsx)", () => {
const { dir, store, out } = fixture();
const otherCwd = mkdtempSync(join(tmpdir(), "r3c-cwd-"));
try {
const { status } = runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }, otherCwd);
assert.equal(status, 0, "the wrapper resolves tsx from a cwd that is not the package dir");
assert.ok(existsSync(join(out, `${today}.md`)), "the dated brief is still written");
} finally {
rmSync(dir, { recursive: true, force: true });
rmSync(otherCwd, { recursive: true, force: true });
}
});
test("SC8: data-path twin consistency — wrapper log dir == dirname(defaultStorePath()) == getDataRoot('trends')", () => {
const { dir, store, out } = fixture();
const saved = process.env.LINKEDIN_STUDIO_DATA;
try {
runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir });
const wrapperLogDir = join(dir, "trends"); // where the wrapper actually wrote cron.log
assert.ok(existsSync(join(wrapperLogDir, "cron.log")), "the wrapper wrote into <data>/trends");
// The TS store twin + the hooks `.mjs` twin, resolved under the SAME override.
process.env.LINKEDIN_STUDIO_DATA = dir;
assert.equal(dirname(defaultStorePath()), wrapperLogDir, "TS dirname(defaultStorePath()) == wrapper log dir");
assert.equal(getDataRoot("trends"), wrapperLogDir, "hooks getDataRoot('trends') == wrapper log dir");
// Override-independence: a different root still resolves the two TS/JS twins identically.
const other = mkdtempSync(join(tmpdir(), "r3c-twin-"));
process.env.LINKEDIN_STUDIO_DATA = other;
assert.equal(dirname(defaultStorePath()), getDataRoot("trends"), "the twins agree for any override root");
rmSync(other, { recursive: true, force: true });
} finally {
if (saved === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
else process.env.LINKEDIN_STUDIO_DATA = saved;
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,120 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import {
launchdPlist,
crontabLine,
installInstructions,
uninstallInstructions,
defaultLabel,
type ScheduleSpec,
} from "../src/schedule.js";
// A fully-resolved spec, exactly as the CLI builds it at generation time (every value injected —
// the emitters read no clock/fs/env). `env` is canonical: always { NODE_BIN, LINKEDIN_STUDIO_DATA }.
function launchdSpec(): ScheduleSpec {
return {
platform: "launchd",
label: "com.linkedin-studio.trends.daily",
nodeBin: "/usr/local/bin/node",
wrapperPath: "/repo/scripts/trends/run-daily.sh",
args: ["--pillars", "ai,gov", "--fresh-days", "7"],
hour: 7,
minute: 30,
logPath: "/data/trends/cron.log",
workingDir: "/repo/scripts/trends",
env: { NODE_BIN: "/usr/local/bin/node", LINKEDIN_STUDIO_DATA: "/data" },
};
}
const cronSpec = (): ScheduleSpec => ({ ...launchdSpec(), platform: "cron" });
function isBalancedXml(xml: string): boolean {
const stack: string[] = [];
const re = /<(\/?)([a-zA-Z][\w.:-]*)[^>]*?(\/?)>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(xml)) !== null) {
if (m[3] === "/") continue;
if (m[1] === "/") {
if (stack.pop() !== m[2]) return false;
} else {
stack.push(m[2]);
}
}
return stack.length === 0;
}
describe("schedule.ts — pure launchd plist emitter (SC1)", () => {
test("key-complete: Label, ProgramArguments, StartCalendarInterval(H/M), Std*Path, EnvironmentVariables", () => {
const p = launchdPlist(launchdSpec());
assert.ok(p.includes("<key>Label</key>") && p.includes("com.linkedin-studio.trends.daily"), "Label");
assert.ok(p.includes("<key>ProgramArguments</key>") && p.includes("/bin/bash") && p.includes("run-daily.sh"), "ProgramArguments");
assert.ok(p.includes("--pillars") && p.includes("ai,gov"), "the args are rendered as <string> entries");
assert.ok(p.includes("<key>StartCalendarInterval</key>"), "StartCalendarInterval");
assert.match(p, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7");
assert.match(p, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30");
assert.ok(p.includes("<key>StandardOutPath</key>") && p.includes("<key>StandardErrorPath</key>"), "Std*Path");
assert.ok(p.includes("/data/trends/cron.log"), "Std*Path point at the resolved cron.log");
assert.ok(p.includes("<key>EnvironmentVariables</key>") && p.includes("NODE_BIN") && p.includes("LINKEDIN_STUDIO_DATA"), "env rendered");
assert.ok(p.includes("<key>WorkingDirectory</key>") && p.includes("/repo/scripts/trends"), "WorkingDirectory");
assert.ok(p.includes("RunAtLoad"), "RunAtLoad declared");
});
test("well-formed: balanced tags + plist DOCTYPE wrapper", () => {
const p = launchdPlist(launchdSpec());
assert.ok(p.startsWith("<?xml"), "begins with the XML declaration");
assert.ok(p.includes("<!DOCTYPE plist"), "has the plist DOCTYPE");
assert.ok(p.trimEnd().endsWith("</plist>"), "closes the plist element");
assert.ok(isBalancedXml(p), "every element tag is balanced");
});
test("pure / deterministic: two calls are byte-identical", () => {
assert.equal(launchdPlist(launchdSpec()), launchdPlist(launchdSpec()));
});
});
describe("schedule.ts — pure cron line emitter (SC2, string only)", () => {
test("the line carries time, env prefix, /bin/bash, wrapper, args, log redirect, label comment", () => {
const line = crontabLine(cronSpec());
assert.match(line, /^30 7 \* \* \* /, "fires 07:30 daily");
assert.ok(line.includes("NODE_BIN=/usr/local/bin/node"), "the env prefix carries NODE_BIN");
assert.ok(line.includes("LINKEDIN_STUDIO_DATA=/data"), "the env prefix carries the data root");
assert.ok(line.includes("/bin/bash") && line.includes("run-daily.sh"), "invokes the wrapper via bash");
assert.ok(line.includes("--pillars ai,gov"), "carries the pillars");
assert.ok(line.includes(">> /data/trends/cron.log 2>&1"), "redirects stdout+stderr to the log");
assert.ok(line.trimEnd().endsWith("# com.linkedin-studio.trends.daily"), "ends with the label comment");
assert.ok(!line.includes("\n"), "a single line");
});
});
describe("schedule.ts — install / uninstall instruction strings", () => {
const plistTarget = "/home/u/Library/LaunchAgents/com.linkedin-studio.trends.daily.plist";
test("install launchd: written-path + launchctl bootstrap recipe", () => {
const out = installInstructions(launchdSpec(), plistTarget);
assert.ok(out.includes(plistTarget), "names the written plist path");
assert.ok(out.includes("launchctl bootstrap"), "the activation recipe");
});
test("install cron: the crontab - install recipe carrying the line", () => {
const out = installInstructions(cronSpec());
assert.ok(out.includes("crontab -"), "the install recipe");
assert.ok(out.includes("run-daily.sh"), "carries the line to install");
});
test("uninstall launchd: bootout + rm the plist", () => {
const out = uninstallInstructions(launchdSpec(), plistTarget);
assert.ok(out.includes("launchctl bootout"), "the deactivation recipe");
assert.ok(out.includes(plistTarget), "removes the plist file");
});
test("uninstall cron: the line-removal recipe", () => {
const out = uninstallInstructions(cronSpec());
assert.ok(out.includes("crontab -") && out.includes("com.linkedin-studio.trends.daily"), "removes by the label comment");
});
});
describe("schedule.ts — defaultLabel (SC1/SC2 the plugin namespace)", () => {
test("is the reverse-DNS plugin namespace", () => {
assert.equal(defaultLabel(), "com.linkedin-studio.trends.daily");
});
});

View file

@ -1,7 +1,15 @@
import { describe, test } from "node:test"; import { describe, test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { KORTFORM_WEIGHTS, LONG_FORM_WEIGHTS, composite, band, triage } from "../src/score.js"; import {
KORTFORM_WEIGHTS,
LONG_FORM_WEIGHTS,
composite,
band,
triage,
requiredDimensions,
scoreEnvelope,
} from "../src/score.js";
const r1 = (x: number) => Math.round(x * 10) / 10; const r1 = (x: number) => Math.round(x * 10) / 10;
const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0); const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0);
@ -142,4 +150,42 @@ describe("trends scorer (RE-R1 / B2)", () => {
assert.deepEqual(dropped, []); assert.deepEqual(dropped, []);
}); });
}); });
describe("requiredDimensions (RE-R3a / SC1)", () => {
test("kortform -> the five keys in SSOT weight order", () => {
assert.deepEqual(requiredDimensions("kortform"), ["pillar", "audience", "timing", "angle", "authority"]);
});
test("long-form -> the five keys in SSOT weight order", () => {
assert.deepEqual(requiredDimensions("long-form"), ["pillar", "depth", "angle", "authority", "currency"]);
});
test("order is pinned to the SSOT weight literals (a silent reorder fails)", () => {
assert.deepEqual(requiredDimensions("kortform"), Object.keys(KORTFORM_WEIGHTS));
assert.deepEqual(requiredDimensions("long-form"), Object.keys(LONG_FORM_WEIGHTS));
});
});
describe("scoreEnvelope (RE-R3a / SC1)", () => {
test("composes composite()+band() — composite/priority equal the existing functions (one owner)", () => {
const dims = { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 };
const env = scoreEnvelope("kortform", dims);
assert.equal(env.mode, "kortform");
assert.deepEqual(env.dimensions, dims);
assert.equal(env.composite, composite(dims, "kortform"));
assert.equal(env.priority, band(composite(dims, "kortform")).priority);
});
test("long-form envelope composes the long-form composite/band", () => {
const dims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 };
const env = scoreEnvelope("long-form", dims);
assert.equal(env.composite, composite(dims, "long-form"));
assert.equal(env.priority, band(composite(dims, "long-form")).priority);
});
test("a bad dimension makes scoreEnvelope throw (via composite — defense-in-depth contract)", () => {
const dims = { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 };
assert.throws(() => scoreEnvelope("kortform", dims));
});
});
}); });

View file

@ -14,6 +14,9 @@ import {
queryByTopic, queryByTopic,
history, history,
newestCaptureDate, newestCaptureDate,
effectiveStatus,
setStatus,
markSurfaced,
} from "../src/store.js"; } from "../src/store.js";
import { SCHEMA_VERSION } from "../src/types.js"; import { SCHEMA_VERSION } from "../src/types.js";
import type { TrendStore } from "../src/types.js"; import type { TrendStore } from "../src/types.js";
@ -275,6 +278,67 @@ describe("trends store", () => {
assert.equal(res2.merged, true, "topic union still reported"); assert.equal(res2.merged, true, "topic union still reported");
assert.equal("publishedAt" in res2.store.trends[0], false, "no back-fill of first-sight provenance"); assert.equal("publishedAt" in res2.store.trends[0], false, "no back-fill of first-sight provenance");
}); });
// ── RE-R3a: relevance score first-sight persistence (SC3) ──
const kortScore = {
mode: "kortform" as const,
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate" as const,
};
test("RED: persists score on a new record when present (first-sight)", () => {
const res = addTrend(emptyStore(), {
title: "Scored trend",
url: "https://example.com/sc",
source: "tavily",
capturedAt: "2026-06-24",
topics: ["ai"],
score: kortScore,
});
assert.deepEqual(res.store.trends[0].score, kortScore);
});
test("regression guard: omits score when absent (no undefined-valued key)", () => {
const res = addTrend(emptyStore(), {
title: "Unscored trend",
url: "https://example.com/us",
source: "tavily",
capturedAt: "2026-06-24",
topics: ["ai"],
});
assert.equal("score" in res.store.trends[0], false);
});
test("re-capture REFRESHES the score (last-wins, RE-R3b reverses R3a's D3), unions topics", () => {
let store = emptyStore();
store = addTrend(store, {
title: "Same scored trend",
url: "https://example.com/ssc",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["a"],
score: kortScore,
}).store;
const lowerScore = {
mode: "kortform" as const,
dimensions: { pillar: 6, audience: 5, timing: 6, angle: 5, authority: 5 },
composite: 5.6,
priority: "Medium" as const,
};
const res2 = addTrend(store, {
title: "Same scored trend",
url: "https://example.com/ssc",
source: "gemini",
capturedAt: "2026-06-20",
topics: ["a", "b"],
score: lowerScore,
});
assert.equal(res2.added, false);
assert.equal(res2.merged, true);
assert.deepEqual(res2.store.trends[0].score, lowerScore, "score refreshed last-wins (RE-R3b reverses R3a's D3)");
assert.deepEqual([...res2.store.trends[0].topics].sort(), ["a", "b"]);
});
}); });
describe("queryByTopic", () => { describe("queryByTopic", () => {
@ -429,7 +493,7 @@ describe("trends store", () => {
}); });
withFixture(v1, (path) => { withFixture(v1, (path) => {
const s = loadStore(path); const s = loadStore(path);
assert.equal(s.schemaVersion, 2, "v1 store must migrate to v2"); assert.equal(s.schemaVersion, SCHEMA_VERSION, "v1 store must migrate to the current version");
assert.equal(s.trends.length, 1); assert.equal(s.trends.length, 1);
assert.equal(s.trends[0].title, "Old trend"); assert.equal(s.trends[0].title, "Old trend");
assert.equal(s.trends[0].capturedAt, "2026-05-01"); assert.equal(s.trends[0].capturedAt, "2026-05-01");
@ -438,22 +502,22 @@ describe("trends store", () => {
}); });
}); });
test("RED: round-trip loadStore→saveStore writes schemaVersion:2 to disk", () => { test("RED: round-trip loadStore→saveStore writes the current schemaVersion to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 1, trends: [] }), (path) => { withFixture(JSON.stringify({ schemaVersion: 1, trends: [] }), (path) => {
saveStore(path, loadStore(path)); saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8")); const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, 2); assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
}); });
}); });
test("RED: a non-numeric schemaVersion is coerced to v2 (old code passes the string through)", () => { test("RED: a non-numeric schemaVersion is coerced to the current version (old code passes the string through)", () => {
withFixture(JSON.stringify({ schemaVersion: "weird", trends: [] }), (path) => { withFixture(JSON.stringify({ schemaVersion: "weird", trends: [] }), (path) => {
assert.equal(loadStore(path).schemaVersion, 2); assert.equal(loadStore(path).schemaVersion, SCHEMA_VERSION);
}); });
}); });
// ── GREEN-only regression guards: old code already returns the current version ── // ── GREEN-only regression guards: old code already returns the current version ──
test("regression guard: a v2 store loads as v2, idempotent (records + publishedAt intact)", () => { test("regression guard: a v2 store loads idempotent (records + publishedAt intact)", () => {
const v2 = JSON.stringify({ const v2 = JSON.stringify({
schemaVersion: 2, schemaVersion: 2,
trends: [ trends: [
@ -470,7 +534,7 @@ describe("trends store", () => {
}); });
withFixture(v2, (path) => { withFixture(v2, (path) => {
const s = loadStore(path); const s = loadStore(path);
assert.equal(s.schemaVersion, 2); assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.equal(s.trends[0].publishedAt, "2026-05-30"); assert.equal(s.trends[0].publishedAt, "2026-05-30");
}); });
}); });
@ -493,4 +557,298 @@ describe("trends store", () => {
}); });
}); });
}); });
describe("schema migration (RE-R3a / score v2→v3)", () => {
const withFixture = (contents: string, fn: (path: string) => void) => {
const dir = tmp();
const path = join(dir, "trends.json");
try {
writeFileSync(path, contents, "utf8");
fn(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
// RE-R3a v2→v3 block, reconciled to the v4 bump (RE-R3b): the stamped-version assertions track
// SCHEMA_VERSION (a v2 store now migrates to the current version), the v2 INPUT fixture stays literal.
test("a v2 store (no score) loads stamped as the current version, records intact, no score invented", () => {
const v2 = JSON.stringify({
schemaVersion: 2,
trends: [
{
id: "abc123",
title: "Old dated trend",
url: "https://example.com/o",
source: "tavily",
capturedAt: "2026-05-01",
topics: ["ai"],
publishedAt: "2026-04-30",
},
],
});
withFixture(v2, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v2 store must migrate to the current version");
assert.equal(s.trends.length, 1);
assert.equal(s.trends[0].title, "Old dated trend");
assert.equal(s.trends[0].capturedAt, "2026-05-01");
assert.equal(s.trends[0].publishedAt, "2026-04-30");
assert.deepEqual(s.trends[0].topics, ["ai"]);
assert.equal("score" in s.trends[0], false, "migration must not invent a score");
});
});
test("round-trip loadStore→saveStore writes the current schemaVersion to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 2, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
});
});
test("a v3 store with score migrates to the current version, score preserved", () => {
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{
id: "x",
title: "Scored",
url: "https://example.com/s",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
score: {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
},
},
],
});
withFixture(v3, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.deepEqual(s.trends[0].score, {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
});
});
});
test("RED: a v3 store's score survives load → save → load (field preservation)", () => {
const score = {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
};
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{
id: "x",
title: "Survivor",
url: "https://example.com/sv",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
score,
},
],
});
withFixture(v3, (path) => {
const first = loadStore(path);
saveStore(path, first);
const second = loadStore(path);
assert.deepEqual(second.trends[0].score, score, "score must survive a load+resave (no field stripping)");
});
});
});
// ── RE-R3b: re-score on re-capture (last-wins; A2) ──
describe("addTrend — re-score on re-capture (RE-R3b)", () => {
const mkScore = (composite: number, priority: string, timing = 5) => ({
mode: "kortform",
dimensions: { pillar: 5, audience: 5, timing, angle: 5, authority: 5 },
composite,
priority,
});
const seed = (score?: unknown) => ({
title: "Re-scored trend",
url: "https://example.com/rs",
source: "tavily",
capturedAt: "2026-06-01",
topics: ["ai"],
...(score !== undefined ? { score } : {}),
});
test("RED: a duplicate with a DIFFERENT score replaces the stored score (last-wins), merged:true", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, {
...seed(mkScore(4.0, "Medium", 3)),
capturedAt: "2026-06-08",
topics: ["ai", "rag"],
});
assert.equal(res.added, false);
assert.equal(res.merged, true, "a changed score (or new topics) → merged:true");
assert.deepEqual(res.store.trends[0].score, mkScore(4.0, "Medium", 3), "score must be the fresh one");
assert.equal(res.store.trends[0].source, "tavily", "provenance source unchanged");
assert.equal(res.store.trends[0].capturedAt, "2026-06-01", "provenance capturedAt unchanged (first-sight)");
assert.deepEqual([...res.store.trends[0].topics].sort(), ["ai", "rag"], "topics still unioned");
});
test("RED: a re-capture with a BYTE-IDENTICAL score and no new topics → merged:false (no false-merge)", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, { ...seed(mkScore(8.1, "Immediate", 9)), capturedAt: "2026-06-09" });
assert.equal(res.added, false);
assert.equal(res.merged, false, "identical score + same topics → not a merge");
assert.deepEqual(res.store.trends[0].score, mkScore(8.1, "Immediate", 9));
});
test("RED: a duplicate with NO score leaves the stored score unchanged", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
const res = addTrend(store, { ...seed(), capturedAt: "2026-06-10" });
assert.equal(res.added, false);
assert.deepEqual(res.store.trends[0].score, mkScore(8.1, "Immediate", 9), "no input score → keep the stored one");
});
test("RED: re-scoring an ACTED trend updates the score but never resets status/surfacedCount", () => {
let store = emptyStore();
store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store;
// Simulate a handled, surfaced record (status/surfacedCount are set by act/markSurfaced, not addTrend).
(store.trends[0] as Record<string, unknown>).status = "acted";
(store.trends[0] as Record<string, unknown>).surfacedCount = 3;
const res = addTrend(store, { ...seed(mkScore(4.0, "Medium", 3)), capturedAt: "2026-06-11" });
assert.deepEqual(res.store.trends[0].score, mkScore(4.0, "Medium", 3), "score refreshed");
assert.equal((res.store.trends[0] as Record<string, unknown>).status, "acted", "status must NOT reset on re-score");
assert.equal((res.store.trends[0] as Record<string, unknown>).surfacedCount, 3, "surfacedCount must NOT change on re-score");
});
});
// ── RE-R3b: schema migration v3→v4 (additive-optional lifecycle fields) ──
describe("schema migration (RE-R3b / lifecycle v3→v4)", () => {
const withFixture = (contents: string, fn: (path: string) => void) => {
const dir = tmp();
const path = join(dir, "trends.json");
try {
writeFileSync(path, contents, "utf8");
fn(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
};
const scored = {
mode: "kortform",
dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 },
composite: 8.1,
priority: "Immediate",
};
// ── genuinely RED while SCHEMA_VERSION=3: loadStore(v3).schemaVersion===3 ≠ 4 (hard-4 device) ──
test("RED: a v3 store (no lifecycle fields) loads stamped as v4, records intact, no field invented", () => {
const v3 = JSON.stringify({
schemaVersion: 3,
trends: [
{ id: "x", title: "Scored", url: "https://example.com/s", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], score: scored },
],
});
withFixture(v3, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION, "v3 store must migrate to the current version");
assert.equal(s.trends.length, 1);
assert.deepEqual(s.trends[0].score, scored, "score intact");
assert.equal("status" in s.trends[0], false, "migration must not invent a status");
assert.equal("surfacedCount" in s.trends[0], false, "migration must not invent a surfacedCount");
assert.equal("lastSurfacedAt" in s.trends[0], false, "migration must not invent a lastSurfacedAt");
});
});
test("RED: round-trip loadStore→saveStore writes schemaVersion:4 to disk", () => {
withFixture(JSON.stringify({ schemaVersion: 3, trends: [] }), (path) => {
saveStore(path, loadStore(path));
const onDisk = JSON.parse(readFileSync(path, "utf8"));
assert.equal(onDisk.schemaVersion, SCHEMA_VERSION);
});
});
test("a v4 store with lifecycle fields loads idempotent", () => {
const v4 = JSON.stringify({
schemaVersion: SCHEMA_VERSION,
trends: [
{ id: "y", title: "Handled", url: "https://example.com/h", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], status: "acted", surfacedCount: 3, lastSurfacedAt: "2026-06-25" },
],
});
withFixture(v4, (path) => {
const s = loadStore(path);
assert.equal(s.schemaVersion, SCHEMA_VERSION);
assert.equal(s.trends[0].status, "acted");
assert.equal(s.trends[0].surfacedCount, 3);
assert.equal(s.trends[0].lastSurfacedAt, "2026-06-25");
});
});
test("a v4 store's lifecycle fields survive load → save → load (field preservation)", () => {
const v4 = JSON.stringify({
schemaVersion: 4,
trends: [
{ id: "z", title: "Persist", url: "https://example.com/p", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], status: "skipped", surfacedCount: 2, lastSurfacedAt: "2026-06-24" },
],
});
withFixture(v4, (path) => {
const first = loadStore(path);
saveStore(path, first);
const second = loadStore(path);
assert.equal(second.trends[0].status, "skipped");
assert.equal(second.trends[0].surfacedCount, 2);
assert.equal(second.trends[0].lastSurfacedAt, "2026-06-24");
});
});
});
// ── RE-R3b: lifecycle functions (Phase B — RED against the stubs) ──
describe("lifecycle functions: effectiveStatus / setStatus / markSurfaced (RE-R3b)", () => {
const seedStore = () =>
addTrend(
addTrend(emptyStore(), { title: "A", url: "https://e/a", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"] }).store,
{ title: "B", url: "https://e/b", source: "tavily", capturedAt: "2026-06-02", topics: ["gov"] },
).store;
test("RED: effectiveStatus is the stored status, or 'new' when absent", () => {
assert.equal(effectiveStatus({ status: "acted" } as TrendRecord), "acted");
assert.equal(effectiveStatus({} as TrendRecord), "new");
});
test("RED: setStatus sets a present record's status (found:true); an absent id → found:false", () => {
const store = seedStore();
const idA = store.trends[0].id;
const res = setStatus(store, idA, "acted");
assert.equal(res.found, true);
assert.equal(store.trends[0].status, "acted");
assert.equal(setStatus(store, "missing-id", "skipped").found, false, "absent id → found:false (no throw)");
});
test("RED: markSurfaced increments + sets lastSurfacedAt; per-day idempotent; later day re-increments", () => {
const store = seedStore();
const idA = store.trends[0].id;
const r1 = markSurfaced(store, [idA], "2026-06-25");
assert.equal(r1.marked, 1);
assert.equal(store.trends[0].surfacedCount, 1);
assert.equal(store.trends[0].lastSurfacedAt, "2026-06-25");
assert.equal("surfacedCount" in store.trends[1], false, "an id not in the set is untouched");
const r2 = markSurfaced(store, [idA], "2026-06-25");
assert.equal(r2.marked, 0, "same-day re-mark is idempotent");
assert.equal(store.trends[0].surfacedCount, 1);
const r3 = markSurfaced(store, [idA], "2026-06-26");
assert.equal(r3.marked, 1, "a later day increments again");
assert.equal(store.trends[0].surfacedCount, 2);
assert.equal(store.trends[0].lastSurfacedAt, "2026-06-26");
});
});
}); });

View file

@ -37,7 +37,7 @@ This skill covers everything related to LinkedIn analytics, performance measurem
| Agent | Model | Responsibility | | Agent | Model | Responsibility |
|-------|-------|----------------| |-------|-------|----------------|
| `analytics-interpreter` | Sonnet | Audience pattern analysis + weekly/monthly performance reports (interpret/report modes) | | `analytics-interpreter` | Sonnet | Audience pattern analysis + weekly/monthly performance reports (interpret/report modes) |
| `trend-spotter` | Sonnet | Trending topics + opportunity scores | | `trend-spotter` | (inherits session) | Trending topics + opportunity scores |
| `post-feedback-monitor` | Opus | Post-publish 48h monitoring, anomaly detection | | `post-feedback-monitor` | Opus | Post-publish 48h monitoring, anomaly detection |
--- ---

View file

@ -33,7 +33,7 @@ This skill covers long-term LinkedIn strategy, authority building, competitive i
| Agent | Model | Responsibility | | Agent | Model | Responsibility |
|-------|-------|----------------| |-------|-------|----------------|
| `strategy-advisor` | Sonnet | Growth recommendations based on phase | | `strategy-advisor` | Sonnet | Growth recommendations based on phase |
| `trend-spotter` | Sonnet | Trending topics + opportunity scores | | `trend-spotter` | (inherits session) | Trending topics + opportunity scores |
--- ---

View file

@ -153,7 +153,7 @@ These rules apply to ALL content created by any skill or command:
| `content-planner` | Sonnet | Cyan | Content audit + weekly/monthly plans | | `content-planner` | Sonnet | Cyan | Content audit + weekly/monthly plans |
| `network-builder` | Sonnet | Cyan | Strategic networking + outreach | | `network-builder` | Sonnet | Cyan | Strategic networking + outreach |
| `content-repurposer` | Sonnet | Magenta | Format conversion + evergreen refresh | | `content-repurposer` | Sonnet | Magenta | Format conversion + evergreen refresh |
| `trend-spotter` | Sonnet | Cyan | Trending topics + opportunity scores | | `trend-spotter` | (inherits session) | Cyan | Trending topics + opportunity scores |
| `voice-trainer` | Sonnet | Magenta | Voice profile building + drift detection | | `voice-trainer` | Sonnet | Magenta | Voice profile building + drift detection |
| `differentiation-checker` | Sonnet | Blue | Originality scoring + commodity detection | | `differentiation-checker` | Sonnet | Blue | Originality scoring + commodity detection |
| `post-feedback-monitor` | Opus | Lime | Post-publish 48h monitoring, real-time interventions | | `post-feedback-monitor` | Opus | Lime | Post-publish 48h monitoring, real-time interventions |