From 65b60337fea33cefca357ad4febb4247152db5bf Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 21 Jul 2026 09:09:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20N6=20=E2=80=94=20forsl?= =?UTF-8?q?ags-lag=20i=20trends=20(angle/targetLevel/rationale/relatedIds?= =?UTF-8?q?=20+=20selected=20+=20--ids=20+=20F7/F9-felt=20actionability/ve?= =?UTF-8?q?rdict/readerQuestion/painPoint/saturation)=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01S7SQpXJpBSNvpWaTNq1kWZ --- commands/trends.md | 20 +- scripts/trends/src/brief.ts | 84 +++- scripts/trends/src/cli.ts | 37 +- scripts/trends/src/item.ts | 104 +++++ scripts/trends/src/store.ts | 60 ++- scripts/trends/src/types.ts | 66 ++- scripts/trends/tests/brief.test.ts | 4 +- scripts/trends/tests/cli.test.ts | 9 +- .../trends/tests/n6-proposal-layer.test.ts | 381 ++++++++++++++++++ 9 files changed, 732 insertions(+), 33 deletions(-) create mode 100644 scripts/trends/tests/n6-proposal-layer.test.ts diff --git a/commands/trends.md b/commands/trends.md index 008bf92..8e2bee1 100644 --- a/commands/trends.md +++ b/commands/trends.md @@ -4,7 +4,7 @@ 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 (act/skip). Default scoring mode + 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", @@ -107,19 +107,23 @@ Resolve the top of the queue now instead of leaving it as homework. For the cand top bands (Immediate + High; cap at 8), use AskUserQuestion — one question per candidate, up to 4 candidates per call, options: -- **Act** — writing about it now/soon: mark handled so the brief stops re-surfacing it -- **Skip** — not for me: same effect, opposite verdict +- **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 each decision through the store CLI, one call per resolved id: +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 act --id # or: skip --id +CLI select --ids # everything chosen this pass +CLI skip --ids # everything rejected this pass ``` -"Leave" means no call. Finish with a one-line summary: N acted, N skipped, N left in queue. -(The CLI currently takes one `--id` per call; a `selected` status and `--ids` batching are -planned upgrades — keep decisions per-id so this step absorbs them without contract change.) +Single-id form (`--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`) diff --git a/scripts/trends/src/brief.ts b/scripts/trends/src/brief.ts index 3bbe4d8..e234983 100644 --- a/scripts/trends/src/brief.ts +++ b/scripts/trends/src/brief.ts @@ -67,6 +67,12 @@ export interface BriefRanking { singleMatches: BriefEntry[]; /** overlap >= 1 AND NOT fresh. */ olderMatched: BriefEntry[]; + /** + * The "I produksjon" board (N6, A1-7): records the operator has pulled out of the work queue — + * status `selected` (valgt, in progress) or `acted` (skrevet, done). Pillar-independent (already + * chosen), so they bypass the overlap filter entirely; sorted selected-before-acted, then title/url. + */ + inProduction: TrendRecord[]; } export interface RankOptions { @@ -132,9 +138,16 @@ export function rankForBrief( const wantedLower = pillars.map((p) => p.toLowerCase()); const entries: BriefEntry[] = []; + const inProduction: TrendRecord[] = []; for (const trend of store.trends) { - // RE-R3b (A3): acted/skipped are handled — drop from the work queue (the brief is a queue, not an archive). - if (effectiveStatus(trend) !== "new") continue; + const st = effectiveStatus(trend); + // N6 (A1-7): selected/acted are "in production" — collected for their own board, out of the queue. + if (st === "selected" || st === "acted") { + inProduction.push(trend); + continue; + } + // RE-R3b (A3): skipped is handled — drop from the work queue (the brief is a queue, not an archive). + if (st !== "new") continue; const have = new Set(trend.topics.map((t) => t.toLowerCase())); const matchedPillars: string[] = []; for (let i = 0; i < pillars.length; i++) { @@ -185,6 +198,12 @@ export function rankForBrief( const singleMatches = entries.filter((e) => e.overlap === 1 && isFresh(e)).sort(cmp); const olderMatched = entries.filter((e) => !isFresh(e)).sort(cmp); // overlap>=1 (0 already excluded) + // A total order for the production board: selected (in progress) before acted (done), then title, then url. + const prodRank = (t: TrendRecord): number => (effectiveStatus(t) === "selected" ? 0 : 1); + inProduction.sort( + (a, b) => prodRank(a) - prodRank(b) || a.title.localeCompare(b.title) || a.url.localeCompare(b.url), + ); + return { today, freshDays, @@ -192,6 +211,7 @@ export function rankForBrief( topMatches, singleMatches, olderMatched, + inProduction, }; } @@ -241,19 +261,72 @@ function temporalToken(e: BriefEntry): string { 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[] { const lines = [ `### ${n}. ${e.trend.title}`, `- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``, ]; if (e.trend.summary) lines.push(`- ${e.trend.summary}`); + lines.push(...proposalLines(e.trend)); lines.push(`- 🔗 ${e.trend.url}`); lines.push(""); return lines; } function renderBulletEntry(e: BriefEntry): string { - return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``; + 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; } /** @@ -272,7 +345,7 @@ export function renderBrief( lines.push(`date: ${ranking.today}`); lines.push(`summary: ${briefSummary(ranking, diff)}`); lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`); - 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`); + 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(",")}`); @@ -323,6 +396,9 @@ export function renderBrief( ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e))); lines.push(""); + // N6 (A1-7): the production board — where a triaged candidate lives once it leaves the queue. + lines.push(...renderInProduction(ranking.inProduction)); + lines.push("---"); lines.push("_Neste steg: /linkedin:react · /linkedin:post · /linkedin:newsletter_"); diff --git a/scripts/trends/src/cli.ts b/scripts/trends/src/cli.ts index d864419..6b93baa 100644 --- a/scripts/trends/src/cli.ts +++ b/scripts/trends/src/cli.ts @@ -7,7 +7,7 @@ * node --import tsx src/cli.ts query --topics [--store ] [--json] * node --import tsx src/cli.ts list [--since ] [--limit ] [--store ] [--json] * node --import tsx src/cli.ts status [--store ] [--json] - * node --import tsx src/cli.ts act|skip|reset --id [--store ] + * node --import tsx src/cli.ts act|skip|reset|select (--id | --ids ) [--store ] * echo '' | node --import tsx src/cli.ts normalize * echo '' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N] * echo '' | node --import tsx src/cli.ts capture [--store ] [--json] @@ -26,8 +26,10 @@ * SessionStart hook surfaces; RE-R3d adds a DERIVED temporal overlay (first-mover up / saturated * down) as a within-tier ranking refinement, tunable via `--first-mover-days`/`--saturation-at`. * `add` is the MANUAL single-trend path (raw flags, no - * normalization, publish-date-free). `act`/`skip`/`reset --id` set a trend's lifecycle status - * (RE-R3b): the morning brief EXCLUDES acted/skipped and records each surfacing (per-day-idempotent + * 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. * @@ -63,6 +65,7 @@ import { queryByTopic, saveStore, setStatus, + setStatusMany, } from "./store.js"; import type { TrendStatus } from "./types.js"; import { normalizeItem, normalizeItems, itemToInput } from "./item.js"; @@ -115,7 +118,7 @@ function usage(msg: string): never { " query --topics [--store ] [--json]\n" + " list [--since ] [--limit ] [--store ] [--json]\n" + " status [--store ] [--json]\n" + - " act|skip|reset --id [--store ]\n" + + " act|skip|reset|select (--id | --ids ) [--store ]\n" + " normalize < raw-item-or-batch.json\n" + " score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" + " capture [--store ] [--json] < raw-item-or-batch.json\n" + @@ -244,18 +247,28 @@ function main(): void { return; } - if (command === "act" || command === "skip" || command === "reset") { - const id = flags.id; - if (!id || id === "true") usage(`${command} needs --id `); - const status: TrendStatus = command === "act" ? "acted" : command === "skip" ? "skipped" : "new"; + if (command === "act" || command === "skip" || command === "reset" || command === "select") { + // Ids from --ids (batch, A1-9) or a single --id ; --ids wins when both are given. + // splitTopics is the generic comma-split+trim+drop-blank helper (ids are case-sensitive, not lowercased). + const ids = + flags.ids && flags.ids !== "true" + ? splitTopics(flags.ids) + : flags.id && flags.id !== "true" + ? [flags.id] + : []; + if (ids.length === 0) usage(`${command} needs --id or --ids `); + const status: TrendStatus = + command === "act" ? "acted" : command === "skip" ? "skipped" : command === "select" ? "selected" : "new"; const store = loadStore(storePath); - const res = setStatus(store, id, status); - if (!res.found) { - console.error(`error: no trend with id: ${id}`); + 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); - console.log(`Marked ${id} ${status}`); + const miss = res.notFound.length > 0 ? ` (${res.notFound.length} not found: ${res.notFound.join(", ")})` : ""; + console.log(`Marked ${res.found.length} ${status}${miss}`); return; } diff --git a/scripts/trends/src/item.ts b/scripts/trends/src/item.ts index 59cef75..64d8ad9 100644 --- a/scripts/trends/src/item.ts +++ b/scripts/trends/src/item.ts @@ -20,6 +20,7 @@ import { normalizeField } 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 { /** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */ @@ -45,6 +46,27 @@ export interface TrendItem { * 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[] }; @@ -106,6 +128,49 @@ function validateScore( 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(); + for (const v of raw) { + if (typeof v !== "string") continue; + const s = v.trim(); + if (s.length === 0 || seen.has(s)) continue; + seen.add(s); + out.push(s); + } + return out; +} + /** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */ function normalizeTopics(raw: unknown): string[] { if (!Array.isArray(raw)) return []; @@ -155,8 +220,33 @@ export function normalizeItem(raw: unknown): NormalizeResult { 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 }; + // N6 free-text fields: the summary idiom (non-empty string kept verbatim, else the key is omitted). + const freeText: Partial> = {}; + for (const f of FREE_TEXT_FIELDS) { + if (isNonEmptyString(r[f])) freeText[f] = r[f] as string; + } + const relatedIds = normalizeIds(r.relatedIds); + const item: TrendItem = { source: r.source as string, title: r.title as string, @@ -165,6 +255,10 @@ export function normalizeItem(raw: unknown): NormalizeResult { ...(publishedAt !== undefined ? { publishedAt } : {}), ...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}), ...(score !== undefined ? { score } : {}), + ...freeText, + ...(relatedIds.length > 0 ? { relatedIds } : {}), + ...(actionability !== undefined ? { actionability } : {}), + ...(verdict !== undefined ? { verdict } : {}), }; return { ok: true, item }; } @@ -190,6 +284,16 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput { ...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}), ...(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 } : {}), }; } diff --git a/scripts/trends/src/store.ts b/scripts/trends/src/store.ts index d1c42cf..52f5ba5 100644 --- a/scripts/trends/src/store.ts +++ b/scripts/trends/src/store.ts @@ -18,7 +18,7 @@ import { homedir } from "node:os"; import { createHash } from "node:crypto"; import { SCHEMA_VERSION } from "./types.js"; -import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus } 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"; @@ -35,6 +35,25 @@ export interface TrendInput { 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 { @@ -131,8 +150,9 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult { 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) and lifecycle - // (status/surfacedCount/lastSurfacedAt) are untouched. + // 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; @@ -149,6 +169,16 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult { topics: [...input.topics], ...(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); return { store, added: true, merged: false }; @@ -177,6 +207,30 @@ export function setStatus( 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(); + 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 diff --git a/scripts/trends/src/types.ts b/scripts/trends/src/types.ts index 53e6f9f..88533b6 100644 --- a/scripts/trends/src/types.ts +++ b/scripts/trends/src/types.ts @@ -26,8 +26,35 @@ import type { TrendScore } from "./score.js"; -/** The lifecycle state of a trend (RE-R3b). Absent on a record ⇒ "new" (see effectiveStatus). */ -export type TrendStatus = "new" | "acted" | "skipped"; +/** + * 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 { /** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */ @@ -76,6 +103,34 @@ export interface TrendRecord { 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. praktiker→beslutter) — + * 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 { @@ -90,4 +145,11 @@ export interface TrendQueryHit { topicOverlap: number; } +/** + * 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; diff --git a/scripts/trends/tests/brief.test.ts b/scripts/trends/tests/brief.test.ts index 37108c9..9346de6 100644 --- a/scripts/trends/tests/brief.test.ts +++ b/scripts/trends/tests/brief.test.ts @@ -416,11 +416,11 @@ describe("RE-R3b — render id + surfaced marker + descriptor (D4/D5)", () => { 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 ends with '; excludes acted/skipped'", () => { + 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\n/, + /\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/, ); }); }); diff --git a/scripts/trends/tests/cli.test.ts b/scripts/trends/tests/cli.test.ts index 6397ac6..02b4f22 100644 --- a/scripts/trends/tests/cli.test.ts +++ b/scripts/trends/tests/cli.test.ts @@ -401,7 +401,7 @@ describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)", } }); - test("RED: the brief .md omits an acted record", () => { + 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"); @@ -409,7 +409,12 @@ describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)", 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"); - assert.ok(!md.includes("Handled"), "an acted record must not appear in the brief"); + // 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 }); } diff --git a/scripts/trends/tests/n6-proposal-layer.test.ts b/scripts/trends/tests/n6-proposal-layer.test.ts new file mode 100644 index 0000000..f0647ab --- /dev/null +++ b/scripts/trends/tests/n6-proposal-layer.test.ts @@ -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 & { 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 }); + } + }); +});