From 1adb7f4361f6750fad27e7256420efc52a125406 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 23 Jul 2026 20:38:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20N7=20=E2=80=94=20trend?= =?UTF-8?q?->newsletter-bro=20(sourceTrendId=20+=20Step=201-inntak=20+=20a?= =?UTF-8?q?uto-act)=20+=20F7=20band-cap-gate=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Del 2 (broen): commands/newsletter.md Step 1 trend-intake pre-fills brief (angle/targetLevel/key-points/source-URLs) from a /linkedin:trends candidate via the trends CLI; sourceTrendId persisted at Step 1.5; Step 10 auto-act flips the source trend to acted (closes the discovery->production loop deterministically); Step 2 external fact-package intake path (kilde-så-draft with finished research). edition-state.template.json: per-article sourceTrendId (additive-optional, no schemaVersion bump). A1-8: grep -ci trend commands/newsletter.md 0 -> 18. Del 2.5 (F7 gate, TDD): score.ts capForActionability — a candidate whose reader-grip is explicitly not formulated (actionability.formulated=false) caps to at most High regardless of composite. Pure; overrides the composite->band derivation downward only (composite/dimensions/mode preserved, score stays etterprøvbar); absent actionability left uncapped (legacy-safe). Wired into the item.ts capture path so the PERSISTED priority is gated; store carries the envelope verbatim (re-capture safe). No KTG values hardcoded — the N6 field carries the verdict, the gate only enforces it. Suites (all green): trends 276/0 (was 266, +10), test-runner 139/0, brain 134/0, hooks 140/0, tests 35/0, render 60/0; tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014bE7VbkmR3cqHFEeGfzgwb --- commands/newsletter.md | 67 +++++++++++++++++++++++++++++- config/edition-state.template.json | 6 ++- scripts/trends/src/item.ts | 14 ++++--- scripts/trends/src/score.ts | 37 +++++++++++++++++ scripts/trends/tests/item.test.ts | 33 +++++++++++++++ scripts/trends/tests/score.test.ts | 55 ++++++++++++++++++++++++ 6 files changed, 204 insertions(+), 8 deletions(-) diff --git a/commands/newsletter.md b/commands/newsletter.md index a43187c..109e994 100644 --- a/commands/newsletter.md +++ b/commands/newsletter.md @@ -271,6 +271,33 @@ Establish the edition brief with **at most ~3 calibration questions**. Infer everything you can from Step 0 (series brief, STATE.md, prior edition); only ask what genuinely changes the work. +> **Trend intake (optional bridge — N7).** If this edition is being started from a +> `/linkedin:trends` candidate — the operator supplies a **trend id** (shown in the +> trends brief and via `CLI list --json`, where +> `CLI` = `cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && node --import tsx src/cli.ts`) +> — read that record and **pre-fill the brief from it** instead of asking from +> scratch. Without a trend id, skip this block entirely: the flow below is +> unchanged (the bridge is an offer, never a requirement). +> +> 1. **Read the candidate.** Run `CLI list --json` (or `CLI query --topics topics> --json`) and select the entry whose `id` equals the supplied trend id. +> 2. **Pre-fill from the record's proposal layer (N6 fields)** — each is a *starting +> point the operator confirms or edits* in the dimensions below, not a bypass of +> calibration: +> - `angle` → the edition **Angle**. +> - `targetLevel` → the audience/tone calibration (**Audience** / **Tone**). +> - `summary` + `rationale` + `readerQuestion` / `painPoint` → seed the **Key +> points** and the **Leader-takeaway** (the reader-side framing this bridge +> exists to carry: what the reader would DO with it). +> - `url` (+ any URLs in `rationale`) → seed **source URLs** for Step 2 research / +> the `NN-kilder.md` ledger (they land as `ekstern` slots at Step 1.5). +> - `actionability` / `verdict` (BÆRENDE/STØTTE/NYHET) → frame the takeaway; a +> candidate the operator selected already cleared the F7 reader-grip gate. +> 3. **Bind the provenance.** Note the trend id — it is persisted as +> `articles.NN.sourceTrendId` at the **Step 1.5 checkpoint** (below) and closes the +> discovery→production loop automatically at Step 10 (scheduling flips the trend to +> `acted`). + Settle these dimensions (most should come from context, not questions): - **Angle** — the one premise this edition argues. @@ -416,6 +443,9 @@ Edition brief - Persist the resolved **brief** (Step 1) and **personas** (the set + primær) into `articles.NN` — this is the first durable write of both (Step 2 then adds the research notes). + - If Step 1 ran the **trend-intake bridge**, persist `articles.NN.sourceTrendId` + (the trend id) here too — the durable provenance link Step 10 reads to auto-`act` + the trend. Omit it (leave `null`) for a manually-started edition. - Write `articles.NN.livedSpecifics` (the slot-map above, `status: "pending"`). - Set `currentPhase: "lived-specifics"` in `edition-state.json`. - Write a "lived-specifics bound → next: research (scoped by the binding)" line @@ -451,6 +481,26 @@ Next: Step 2 — Research (scoped by the binding: fill ekstern/unresolved, verif against a primary source (regel 6/7). This is what makes research *fill* the draft instead of re-deriving what the operator already knows. +0.5. **External fact-package intake (optional — kilde-så-draft with finished + research).** If the operator already has a **deep-research report** — a file the + operator supplies by path (e.g. a Gemini Deep Research export, a consultant brief, + a prior research pass; markdown / text / PDF) — ingest it as a research input + instead of researching those slots from zero: + - **Read the file** and extract the same structure the fan-out returns: findings + (each with its source), data points (figures + source + date), and anything the + report itself flags as uncertain. + - **Map onto the binding.** Attribute each finding to the `ekstern`/`unresolved` + slot (or unverified number) it resolves; its sources become the `source` on + those slots and land in `NN-kilder.md` at persist (step 5). + - **Same guilty-until-disproven discipline.** A packaged claim is NOT + pre-verified: label anything you cannot corroborate `UNVERIFIED` and carry it to + the Step 5 fact-check sweep as 🟡 — a polished report is not a primary source. + - **Then scope the fan-out to the GAP only.** Slots the package covers need no + `Task` call; run steps 1–3 below **only for the slots the package leaves open**. + If the package covers every `ekstern`/`unresolved` slot, the fan-out may reduce + to zero calls — note that explicitly (don't fabricate a fan-out that added + nothing). With no package supplied, this sub-step is a no-op: proceed to step 1. + 1. **Decompose** the binding's `ekstern`/`unresolved` slots + unverified numbers (step 0) into 2–5 *scoped, orthogonal* research sub-questions. Each sub-question must be answerable independently so the calls can run in parallel without @@ -1749,7 +1799,22 @@ now a first-class scheduled post. The function appends to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json` with `status: "scheduled"` and returns the new entry. -3. **Persist + close the edition.** Set the article's `status: "scheduled"`, +3. **Close the discovery loop (auto-`act` — N7).** If `articles.NN.sourceTrendId` + is set (this edition was started from a `/linkedin:trends` candidate), flip that + trend's store status to `acted` so it drops out of future discovery triage — the + discovery→production loop closes deterministically, not from memory: + + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && node --import tsx src/cli.ts act --id "" + ``` + + A present id flips to `acted` (exit 0). If the id is no longer in the store the + call errors (all-miss → exit 2, store untouched) — that is **not fatal** to + scheduling (the edition is already registered in step 2): note it and proceed. + Skip this step entirely when `sourceTrendId` is `null` (a manually-started + edition has no trend to close). + +4. **Persist + close the edition.** Set the article's `status: "scheduled"`, `scheduled: ""`, and `currentPhase: "scheduling"` in `edition-state.json`. Write a closing "edition scheduled → mark live via `/linkedin:calendar` on " line to `/STATE.md` (overwrite). The diff --git a/config/edition-state.template.json b/config/edition-state.template.json index 1db186e..bfa3262 100644 --- a/config/edition-state.template.json +++ b/config/edition-state.template.json @@ -32,7 +32,8 @@ "headlessReview": "Per-article headless-review record written by Step 6.5 (headless-review phase). Runs AFTER the in-session persona sweep (Step 6) and BEFORE lock (Step 8), on a FROZEN snapshot of the publish-ready (or pivoted) draft, fanned out from the command layer (foreground) or invoked standalone via /linkedin:headless-review in a fresh/cold session. Five archetypes judge independently with NO drafting-session context: content-reviewer (argument integrity), language-reviewer (Norwegian language), fact-reviewer (cold re-verification incl. claims a late pivot bolted on), persona-reviewer mode=resonans (per active persona), persona-reviewer mode=konverter (primær, hook only). The consolidated report is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { frozenDraft, reviewers: { content, language, fact, personaResonance, personaConversion } (each { reportPath, summary, status }), consolidatedReport, foldedIn, waived, status }. status ladder: pending → run → folded. null until Step 6.5 runs. This is the adversarial-independence companion to the in-session gates (editorialReview, personaSweep, factcheckLog) — deliberately redundant: a cold reader catches what the framing-biased in-session pass missed.", "pivots": "Per-article pivot log (Endring 9c). A pivot is a substantive change to a draft AFTER a gate had already cleared — e.g. a new argument anchor / section added late (the Del 4 Security Champions case: +~530 words, 2 new sections, +42 %). Each /linkedin:pivot invocation appends one entry and moves currentPhase back so the cleared gates (Steps 5–6.5) re-run on the pivoted version before lock. Heuristic (documented, checked at the Step 8 lock precondition): if the current draft's word count differs > 20 % from the version that last cleared Step 6, OR it has > 2 new sections, a pivot-reopen is suggested/required. Each entry: { timestamp, reason, fromPhase, toPhase, wordCountBefore, wordCountAfter, deltaPct, newSections, gatesToRerun: [phase…] }. Default [].", "foldIns": "Per-article accumulation queue (slice 2 of fix #1 — «rettelser fester seg»). Each correction KTG makes during an edition that is NOT yet a contract rule is captured here, then routed by the JA-promoter (maskinrommet/docs/skrivekontrakt.md §E) to a permanent home so it never has to be re-discovered: a MECHANICAL correction → a rules.ts gate rule (BLOCK/WARN) + a §B-row/§C1/§C2-box + a §E-manifest row (the contract-gate `ratify` check then asserts rules.ts ↔ §E-manifest stay in bijection); a JUDGMENT correction → a §C2-box only (stays with editorial-reviewer, no gate rule). Capture is per-article (provenance = which article surfaced it); promotion is series/contract-wide. Each entry: { id, date (ISO-8601), correction (what KTG corrected, near-verbatim), trigger (where/why it surfaced), classification: \"mechanical-block\" | \"mechanical-warn\" | \"judgment\" | null (set at the classify step), decision: \"pending\" | \"promoted\" | \"rejected\" (the JA-promoter outcome), ruleId: | null (set on promote for mechanical), note?: where a judgment/rejected fold-in landed }. Default []. Rejected fold-ins are kept for traceability, never deleted.", - "language": "Review language for this series/edition (additive, default \"en\"). Threads into the long-form review agents so they grade against THIS language's rules: language-reviewer applies Norwegian-specific checks (anglicism→Norwegian idiom, «kanselli-stil») only when language == \"no\"; voice-scrubber's gold standard is the approved editions IN this language; any other value → the agents apply that language's equivalents and never grade prose against Norwegian idiom. \"no\" = Norwegian (the author's case). Resolved at Step 1 / load-context and passed to the language-dependent agents." + "language": "Review language for this series/edition (additive, default \"en\"). Threads into the long-form review agents so they grade against THIS language's rules: language-reviewer applies Norwegian-specific checks (anglicism→Norwegian idiom, «kanselli-stil») only when language == \"no\"; voice-scrubber's gold standard is the approved editions IN this language; any other value → the agents apply that language's equivalents and never grade prose against Norwegian idiom. \"no\" = Norwegian (the author's case). Resolved at Step 1 / load-context and passed to the language-dependent agents.", + "sourceTrendId": "Per-article provenance link to the trend this edition was started from (N7 trend→newsletter bridge, MR-F3). Additive-optional (default null, NO schemaVersion bump — an edition started manually never sets it, and Step 10 reads its absence as \"no source trend\"). Set at the Step 1.5 checkpoint when Step 1's trend-intake read a candidate from the trends store (scripts/trends) to prefill the brief (angle / targetLevel / key-points / source-URLs). Its ONE runtime consumer is Step 10 (scheduling): when the article reaches scheduling AND sourceTrendId is set, the command flips that trend's store status to \"acted\" (trends CLI `act --id `), closing the discovery→production loop deterministically instead of by hand. Value = the trend's store id (normalized title+url, as shown in the /linkedin:trends brief and `CLI list --json`)." }, "schemaVersion": 1, "series": { @@ -90,7 +91,8 @@ "pivots": [], "foldIns": [], "locked": false, - "scheduled": null + "scheduled": null, + "sourceTrendId": null } } } diff --git a/scripts/trends/src/item.ts b/scripts/trends/src/item.ts index 64d8ad9..b62b41e 100644 --- a/scripts/trends/src/item.ts +++ b/scripts/trends/src/item.ts @@ -18,7 +18,7 @@ import { normalizeField } from "./store.js"; import type { TrendInput } from "./store.js"; -import { requiredDimensions, scoreEnvelope } from "./score.js"; +import { requiredDimensions, scoreEnvelope, capForActionability } from "./score.js"; import type { ScoreMode, DimensionScores } from "./score.js"; import type { TrendVerdict, Actionability } from "./types.js"; @@ -270,9 +270,11 @@ export function normalizeItem(raw: unknown): NormalizeResult { * already validated by normalizeItem) and does NOT derive an `id` (the store owns id via * addTrend→trendId). `publishedAt`/`summary`/`score` are carried only when present (key * omitted otherwise), mirroring the store's conditional-spread idiom. The `score` is turned - * into the persisted envelope here (judgment → composite, via scoreEnvelope). On the capture - * path the dims are pre-validated by normalizeItem, so scoreEnvelope→composite cannot throw; - * called DIRECTLY with bad dims it throws by contract (defense-in-depth — SC2). + * into the persisted envelope here (judgment → composite, via scoreEnvelope), then run through + * the F7 band-cap gate (`capForActionability`) so a candidate with no formulated reader-grip is + * persisted at most `High`, not `Immediate` (MR-F7, N7). On the capture path the dims are + * pre-validated by normalizeItem, so scoreEnvelope→composite cannot throw; called DIRECTLY with + * bad dims it throws by contract (defense-in-depth — SC2). */ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput { return { @@ -283,7 +285,9 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput { topics: [...item.topics], ...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}), ...(item.summary !== undefined ? { summary: item.summary } : {}), - ...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}), + ...(item.score !== undefined + ? { score: capForActionability(scoreEnvelope(item.score.mode, item.score.dimensions), item.actionability) } + : {}), // N6 proposal fields carried through to the store input (validated already; key omitted when absent). ...(item.angle !== undefined ? { angle: item.angle } : {}), ...(item.targetLevel !== undefined ? { targetLevel: item.targetLevel } : {}), diff --git a/scripts/trends/src/score.ts b/scripts/trends/src/score.ts index 604f8b4..887bb03 100644 --- a/scripts/trends/src/score.ts +++ b/scripts/trends/src/score.ts @@ -128,6 +128,43 @@ export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): Tre return { mode, dimensions, composite: c, priority: band(c).priority }; } +/** + * A structural view of the reader-grip signal the F7 gate reads — the `formulated` boolean + * of types.ts `Actionability`, redeclared here so score.ts stays free of internal imports + * (types.ts imports TrendScore from here; importing back would form a cycle). The real + * `Actionability` is assignable to this by structure. + */ +export interface ActionabilitySignal { + formulated: boolean; +} + +/** Index of the `High` band in the SSOT ordering (BANDS is priority-descending — lower index outranks). */ +const HIGH_BAND_INDEX = BANDS.findIndex((b) => b.priority === "High"); + +/** True when `p` sits strictly above the `High` band — the only bands the F7 gate can cap down. */ +function aboveHigh(p: Priority): boolean { + const i = BANDS.findIndex((b) => b.priority === p); + return i !== -1 && i < HIGH_BAND_INDEX; +} + +/** + * F7 band-cap gate (MR-F7, N7). A candidate whose reader-grip is explicitly NOT formulated + * (`actionability.formulated === false`) cannot occupy the top `Immediate` band regardless of + * its composite — its priority is capped to at most `High`. This is the ONE place the + * deterministic composite->band derivation is overridden, and only downward: composite, + * dimensions, and mode are left untouched (the real score stays etterprøvbar; only the priority + * label changes). Absent actionability (`undefined`) is left uncapped — legacy / un-annotated + * records keep their derived band (the N6 fields are additive-optional, no migration). Pure; no + * KTG values here — what counts as a formulated grip is the operator's reader-side judgment + * carried by the N6 field, which this gate merely enforces. + */ +export function capForActionability(score: TrendScore, actionability?: ActionabilitySignal): TrendScore { + if (actionability?.formulated === false && aboveHigh(score.priority)) { + return { ...score, priority: "High" }; + } + return score; +} + export interface TriageOptions { mode: ScoreMode; threshold: number; diff --git a/scripts/trends/tests/item.test.ts b/scripts/trends/tests/item.test.ts index 6c0fe3f..25dfa50 100644 --- a/scripts/trends/tests/item.test.ts +++ b/scripts/trends/tests/item.test.ts @@ -337,4 +337,37 @@ describe("trends item normalizer (RE-R1 / B1)", () => { assert.throws(() => itemToInput(item, "2026-06-24")); }); }); + + // The F7 gate (score.ts capForActionability) is applied on the capture path so the PERSISTED + // priority reflects the reader-side verdict — an Immediate composite with no formulated grip + // is stored as High, not Immediate. Proven end-to-end through itemToInput (unit-tested purely + // in score.test.ts; here it must actually bite the store input). + describe("itemToInput — F7 band-cap gate (MR-F7, N7)", () => { + const immediateDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }; // composite 8.15 -> Immediate + const mkScored = (extra: Partial): TrendItem => ({ + source: "tavily", + title: "T", + url: "https://example.com/t", + topics: ["ai"], + score: { mode: "kortform", dimensions: immediateDims }, + ...extra, + }); + + test("Immediate score + actionability.formulated=false -> persisted priority capped to High", () => { + assert.equal(scoreEnvelope("kortform", immediateDims).priority, "Immediate"); // precondition + const input = itemToInput(mkScored({ actionability: { formulated: false } }), "2026-06-24"); + assert.equal(input.score?.priority, "High"); + assert.equal(input.score?.composite, scoreEnvelope("kortform", immediateDims).composite); // real score unchanged + }); + + test("Immediate score + actionability.formulated=true -> priority stays Immediate", () => { + const input = itemToInput(mkScored({ actionability: { formulated: true } }), "2026-06-24"); + assert.equal(input.score?.priority, "Immediate"); + }); + + test("Immediate score + no actionability -> uncapped (equals scoreEnvelope, legacy-safe)", () => { + const input = itemToInput(mkScored({}), "2026-06-24"); + assert.deepEqual(input.score, scoreEnvelope("kortform", immediateDims)); + }); + }); }); diff --git a/scripts/trends/tests/score.test.ts b/scripts/trends/tests/score.test.ts index 98eb0c6..213391f 100644 --- a/scripts/trends/tests/score.test.ts +++ b/scripts/trends/tests/score.test.ts @@ -9,6 +9,7 @@ import { triage, requiredDimensions, scoreEnvelope, + capForActionability, } from "../src/score.js"; const r1 = (x: number) => Math.round(x * 10) / 10; @@ -188,4 +189,58 @@ describe("trends scorer (RE-R1 / B2)", () => { assert.throws(() => scoreEnvelope("kortform", dims)); }); }); + + // MR-F7 (N7): a candidate whose reader-grip is explicitly NOT formulated cannot occupy the + // top `Immediate` band regardless of composite — the gate caps it to at most `High`. The + // reader-side verdict lives in the N6 `actionability` field; the gate only enforces it, it + // never invents a value. Absent actionability is left uncapped (additive-optional, no migration). + describe("capForActionability — F7 band-cap gate (MR-F7)", () => { + const immediateDims = { pillar: 8, audience: 8, timing: 8, angle: 8, authority: 8 }; // composite 8.0 -> Immediate + const highDims = { pillar: 6, audience: 6, timing: 6, angle: 6, authority: 6 }; // composite 6.0 -> High + const mediumDims = { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 }; // composite 5.0 -> Medium + + test("Immediate + no reader-grip (formulated:false) -> capped to High, regardless of composite", () => { + const env = scoreEnvelope("kortform", immediateDims); + assert.equal(env.priority, "Immediate"); // precondition: composite 8.0 lands Immediate + assert.equal(capForActionability(env, { formulated: false }).priority, "High"); + }); + + test("Immediate + reader-grip formulated (true) -> stays Immediate", () => { + const env = scoreEnvelope("kortform", immediateDims); + assert.equal(capForActionability(env, { formulated: true }).priority, "Immediate"); + }); + + test("Immediate + actionability undefined -> uncapped (legacy / un-annotated records not penalized)", () => { + const env = scoreEnvelope("kortform", immediateDims); + assert.equal(capForActionability(env, undefined).priority, "Immediate"); + }); + + test("already High + formulated:false -> stays High (caps TO High, never below)", () => { + const env = scoreEnvelope("kortform", highDims); + assert.equal(env.priority, "High"); + assert.equal(capForActionability(env, { formulated: false }).priority, "High"); + }); + + test("Medium + formulated:false -> untouched (gate bites only the top band)", () => { + const env = scoreEnvelope("kortform", mediumDims); + assert.equal(env.priority, "Medium"); + assert.equal(capForActionability(env, { formulated: false }).priority, "Medium"); + }); + + test("caps priority only — composite/dimensions/mode preserved (score stays etterprøvbar)", () => { + const dims = { pillar: 9, depth: 9, angle: 9, authority: 9, currency: 9 }; // 9.0 -> Immediate + const env = scoreEnvelope("long-form", dims); + const gated = capForActionability(env, { formulated: false }); + assert.equal(gated.priority, "High"); + assert.equal(gated.composite, env.composite); // real score unchanged + assert.deepEqual(gated.dimensions, env.dimensions); + assert.equal(gated.mode, env.mode); + }); + + test("does not mutate the input envelope (returns a fresh object)", () => { + const env = scoreEnvelope("kortform", immediateDims); + capForActionability(env, { formulated: false }); + assert.equal(env.priority, "Immediate"); // original untouched + }); + }); });