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
This commit is contained in:
Kjell Tore Guttormsen 2026-07-21 09:09:51 +02:00
commit 65b60337fe
9 changed files with 732 additions and 33 deletions

View file

@ -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 <id> # or: skip --id <id>
CLI select --ids <id1,id2,id3> # everything chosen this pass
CLI skip --ids <id4,id5> # 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 <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`)

View file

@ -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 <url> · /linkedin:post · /linkedin:newsletter_");

View file

@ -7,7 +7,7 @@
* 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 status [--store <path>] [--json]
* node --import tsx src/cli.ts act|skip|reset --id <id> [--store <path>]
* 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 '<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]
@ -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 <a,b> [--store <path>] [--json]\n" +
" list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]\n" +
" status [--store <path>] [--json]\n" +
" act|skip|reset --id <id> [--store <path>]\n" +
" act|skip|reset|select (--id <id> | --ids <a,b,c>) [--store <path>]\n" +
" normalize < raw-item-or-batch.json\n" +
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
" capture [--store <path>] [--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 <id>`);
const status: TrendStatus = command === "act" ? "acted" : command === "skip" ? "skipped" : "new";
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 = 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;
}

View file

@ -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<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). */
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<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 = {
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 } : {}),
};
}

View file

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

View file

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

View file

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

View file

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

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