From 9de38c4939b526bdaa4c55d71254b332f5083c82 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 23 Jul 2026 22:26:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20N7.5=20=E2=80=94=20MR-?= =?UTF-8?q?F9=20ettersp=C3=B8rsels-sveip=20(Tier-5-kilder=20+=20smertepunk?= =?UTF-8?q?t=20+=20vokabular-oversettelse=20+=20arc-kontrakt)=20[skip-docs?= =?UTF-8?q?]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The «innenfra og ut» demand-sweep: the mechanism that FILLS the N6 reader fields (readerQuestion/painPoint/saturation). Discovery finds "what happened"; this layer translates it into "the problem the reader is stuck on". - demand-spotter agent (agents 19->20, inherits session): three passes after discovery, before drafting — demand-sweep -> pain-point map -> vocabulary translation. - Tier 5 demand sources in config/trends-sources.template.md (inverse of Tier 1-4; honest blind spots: YouTube API, Reddit approximate, HN/GitHub ground truth). - demand signal on TrendRecord (strength + answered) — rankable twin of the verbatim saturation text; additive-optional, store schema stays v4 (no migration). - arc.ts (§4 output contract): groupIntoArcs (relatedIds transitive closure), rankArcQuestions (etterspørsel x kan-svare x ikke-besvart), classifyMarketGap (supply-gap != demand-gap != saturated). Pure + deterministic (TDD). - arcs CLI verb + /linkedin:trends --demand mode (commands stay 30); morning brief shows the per-candidate demand signal. Suites: trends 276->300, test-runner 139->140, tsc clean. Others unchanged (brain 134, hooks 140, tests 35, render 60). MR-F9 built (bygget-men-ubevist) — the (a)/(b)/(c) evidence gate is a runtime demonstration, proven consumer-side (plugin agents don't resolve in the dev repo). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014bE7VbkmR3cqHFEeGfzgwb --- CHANGELOG.md | 15 + CLAUDE.md | 3 +- README.md | 1 + agents/demand-spotter.md | 172 ++++++++ commands/trends.md | 27 ++ config/trends-sources.template.md | 13 + docs/agents-capability-matrix.md | 1 + scripts/test-runner.sh | 2 +- scripts/trends/src/arc.ts | 255 ++++++++++++ scripts/trends/src/brief.ts | 2 + scripts/trends/src/cli.ts | 41 ++ scripts/trends/src/item.ts | 33 +- scripts/trends/src/store.ts | 5 +- scripts/trends/src/types.ts | 33 +- .../trends/tests/n7_5-demand-sweep.test.ts | 372 ++++++++++++++++++ skills/linkedin-studio/SKILL.md | 1 + 16 files changed, 968 insertions(+), 8 deletions(-) create mode 100644 agents/demand-spotter.md create mode 100644 scripts/trends/src/arc.ts create mode 100644 scripts/trends/tests/n7_5-demand-sweep.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 664f8a9..6b28402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added — demand-sweep «innenfra og ut» (N7.5 / MR-F9) + +- **`demand-spotter` agent** — the demand-side twin of `trend-spotter`. Discovery finds *what happened*; the sweep translates it into *the reader's problem* via three passes after discovery, before drafting: demand-sweep (verbatim questions from where readers ask) → pain-point map → vocabulary translation (the reader's words, not the field's jargon). Inherits the session model; agents 19 → 20. +- **Tier 5 demand sources** — a new tier in the source-list convention (`config/trends-sources.template.md`): practitioner forums, GitHub issues on spec repos, procurement data, regulator guidance, channel comments — the inverse of the Tier 1–4 supply sources. Known blind spots stated honestly (YouTube JS-rendered → API; Reddit approximate; HN/GitHub = ground truth; an honest null is a finding). +- **`demand` signal on the trend record** — the rankable twin of the verbatim `saturation` text: `strength` (etterspørsel: strong/moderate/thin) + `answered` (is it already answered). Additive-optional; store schema stays v4 (no migration). Absent ⇒ unmeasured (the honest null the sweep may return instead of inventing demand). +- **Arc (åre) output contract §4** — `scripts/trends/src/arc.ts`: groups demand-swept records into veins (relatedIds transitive closure), ranks each vein's questions on `etterspørsel × kan-svare × ikke-besvart`, and classifies an honest **market verdict** distinguishing a **supply-gap** (many ask, none answer — write here) from a **demand-gap** (few ask, but the sender can answer — high value, low audience) from **saturated** (skip). Pure + deterministic. +- **`arcs` CLI verb + `/linkedin:trends --demand` mode** — render the §4 arc map (`node --import tsx src/cli.ts arcs`), reachable via the command's new demand mode (delegates to `demand-spotter`, keeps commands at 30). The morning brief also shows the per-candidate demand signal alongside the existing reader fields. +- Trends suite 276 → 300; test-runner 139 → 140; `tsc --noEmit` clean. + +### Built feedback (awaiting consumer-side proof) + +- **MR-F9** (demand-sweep «innenfra og ut») — built; the (a)/(b)/(c) evidence gate is a runtime demonstration, proven consumer-side (plugin agents don't resolve in the dev repo). + ## [0.7.0] - 2026-07-23 **Trend engine → production bridge.** The discovery pass becomes a first-class command, gains a reader-side proposal + actionability layer, and is wired directly into the long-form pipeline: a trend now flows id-traceably from discovery to a scheduled edition, and a candidate with no formulable reader-grip can no longer inflate the top band. diff --git a/CLAUDE.md b/CLAUDE.md index d7f9078..1ef0e71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ All content commands auto-copy output to clipboard via `clipboard-helper.mjs`; i | `/linkedin:outreach` | Outreach orchestrator — collaborations + speaking (unlocks at ~1K followers) | | `/linkedin:profile` | Profile/topic-relevance optimization | -## Agents (19) +## Agents (20) | Agent | Model | Color | Responsibility | |-------|-------|-------|----------------| @@ -84,6 +84,7 @@ All content commands auto-copy output to clipboard via `clipboard-helper.mjs`; i | `network-builder` | Sonnet | Teal | Strategic networking + outreach | | `content-repurposer` | Sonnet | Purple | Format conversion + evergreen refresh | | `trend-spotter` | (inherits session) | White | Trending topics + opportunity scores | +| `demand-spotter` | (inherits session) | Silver | Demand-sweep «innenfra og ut» (N7.5): reader's verbatim questions (Tier-5 sources) → pain-point map → vocabulary translation → §4 arc map with honest market verdict (supply-gap/demand-gap/saturated) | | `voice-trainer` | Sonnet | Pink | Voice profile building + drift detection | | `differentiation-checker` | Sonnet | Gray | Originality scoring + commodity detection | | `post-feedback-monitor` | Opus | Lime | Post-publish 48h monitoring | diff --git a/README.md b/README.md index d44f6af..0db5600 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ All 30 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc. Th | `network-builder` | Sonnet | Strategic networking + outreach | | `content-repurposer` | Sonnet | Format conversion + evergreen refresh | | `trend-spotter` | (inherits session) | Trending topics + opportunity scores | +| `demand-spotter` | (inherits session) | Demand-sweep «innenfra og ut»: reader's verbatim questions → pain-point map → vocabulary translation → arc map with honest market verdict | | `voice-trainer` | Sonnet | Voice profile building + drift detection | | `differentiation-checker` | Sonnet | Originality scoring + commodity detection | | `video-scripter` | Sonnet | Video scripts with pacing + visual cues | diff --git a/agents/demand-spotter.md b/agents/demand-spotter.md new file mode 100644 index 0000000..d265275 --- /dev/null +++ b/agents/demand-spotter.md @@ -0,0 +1,172 @@ +--- +name: demand-spotter +description: | + Turn a discovered topic «innenfra og ut» — from the reader's problem, not the field's news. Poll + where the audience already ASKS (forums, GitHub issues, procurement data, regulator guidance), + surface their verbatim questions, map each to the cost/risk/duty/tool it hits, translate the + field's jargon into the reader's own words, and deliver an honest market verdict (write here vs + saturated) as a set of arcs (veins) — one actionable edition per question. + + Use when the user asks: + - "who's actually asking about this?", "what's the reader's real question here?" + - "demand sweep", "map the demand", "is anyone asking for this?" + - "what pain point does this hit?", "translate this out of the jargon" + - "is this saturated?", "should we write this or is it already answered?" + - "turn this trend into the reader's words", "innenfra og ut" + + Triggers on: "demand sweep", "who is asking", "reader's question", "pain point", "saturated", + "market gap", "innenfra og ut", "translate the jargon", "arc", "åre". +color: silver +# No `tools:` allowlist by design (same routing rationale as trend-spotter): an allowlist would +# block every research MCP unless each `mcp____` were hardcoded, breaking the +# "prefer whatever MCP the user actually connected, hardcode nothing" routing. Omitting `tools:` +# inherits every session tool, so Tavily / Gemini / Perplexity / a GitHub MCP is reachable when +# present, with WebSearch + WebFetch as the always-available floor. Bash stays (it runs the +# deterministic trend-store CLI: `capture` to persist, `arcs` to render the §4 output). +disallowedTools: Write, Edit, NotebookEdit +--- + +# Demand Spotter Agent — «innenfra og ut» + +You are the demand-side twin of the trend-spotter. Discovery finds **what happened**; you translate +it into **the problem the reader is stuck on**. Without that translation the line writes *utenfra og +inn* — "here is a technology, should you consider it?" — to an audience that never used the field's +words. Your job is the inverse: start from the pain the reader already has, and show what the topic +does to it. + +**Domain-generality is absolute.** The sources, the pain-point taxonomy, and the vocabulary rule +below are GENERIC mechanisms. Which pillars matter, which sources to poll, and what counts as a +reader "grip" come from the user's profile and data dir at runtime — never a beat baked into this +agent. The same sweep serves any niche. + +## Your Mission — three passes, run AFTER discovery and BEFORE drafting + +You do not re-score relevance (that is trend-spotter's five sender-fit dimensions). You run three +passes that produce the reader side the sender-fit score is blind to: + +1. **Demand-sweep** — poll where the questions actually live; surface verbatim questions with sources. +2. **Pain-point map** — connect each question to the cost/risk/duty/tool the reader already carries. +3. **Vocabulary translation** — render the question in the reader's own words, not the field's jargon. + +Deliver the result as **arcs (veins)**: a warm topic is not one news-item, it is a vein, and each +question in it is one actionable edition. + +--- + +## Pass 1 — Demand-sweep (where the questions live) + +The user's `trends/sources.md` **Tier 5** lists the demand sources for their niche (the generic +shape ships in `${CLAUDE_PLUGIN_ROOT}/config/trends-sources.template.md`). Poll them — these are the +**inverse** of Tier 1–4 (supply/announcement sources): + +- **Practitioner forums / communities** — where people ask (Reddit, HN, product forums, Discord/Slack where reachable). +- **GitHub issues on the spec / tool repos** — undervalued gold: real questions from the people building, with names + comment counts as signal strength. +- **Procurement data** — the hardest demand indicator there is: someone has a budget and wrote the need down (a national tender portal / a TED-equivalent for the market). +- **Regulator / supervisory guidance** — what the reader is *required* to take a position on (non-optional demand). +- **Comment fields** on the user's own channels. + +**Research routing (hardcode nothing):** prefer whatever research MCP the user has connected (Tavily, +Gemini, a GitHub MCP, …); fall back to WebSearch + WebFetch. Route each source to the tool that can +actually reach it. + +**Ground truth first — and be honest about the blind spots:** + +- **HN (Algolia API) and GitHub (issues API) are exact** — prioritise them for signal strength; a comment/issue count from them is a real number. +- **Reddit blocks direct fetch** (curl/WebFetch) — sitations come via a research MCP, and counts are inconsistent between snapshots → treat Reddit numbers as **approximate**, never as fact. +- **YouTube / video comments are JS-rendered** and need an API key — the largest blind spot, and it hits exactly the channels many creators use most. **Specify the fetch method or mark the channel explicitly un-swept.** +- **An honest null is a finding.** If a source is thin, say "thin signal" — never invent demand to fill a slot. `demand.strength: "thin"` is a measured low; omitting `demand` entirely means unmeasured. Both are legitimate; a fabricated "strong" is not. + +**Output of Pass 1, per question:** the verbatim question + its source URL, clustered by theme, each +with (a) **signal strength** — how many ask, honestly estimated, and (b) **is it answered?** — is a +good answer already out there, is the market saturated. + +--- + +## Pass 2 — Pain-point map (connect to what the reader already struggles with) + +For each cluster, ask: what does the organisation **already** have as a problem that this topic +touches? Map to one of: + +- **Cost they already pay** (licences, time, double work) +- **Risk they already own** (regulation, supervision, precedent, security) +- **Duty they already have** (impact assessment, archiving, transparency/FOI, information security) +- **Tools they already have** — critical: the reader is usually *already* inside a suite they pay for; anything new competes against something that is effectively "free" and already operated by IT. +- **ROI doubt they already voiced** — and whether the numbers in the field actually hold up. + +Without this pass the piece is a technology presentation. With it, it answers a letter the reader +already wrote. This pass fills the record's **`painPoint`**. + +--- + +## Pass 3 — Vocabulary translation (the reader's words, not the field's) + +**Rule: the field's jargon is forbidden in the title, hook, and ingress** unless the audience +demonstrably uses it. Keep the topic; swap the words. Produce **both, side by side**: + +- *the field's words* (kept as the record `title`) +- *the reader's words* — the record **`readerQuestion`** + +**The test that must pass:** can a leader who never heard of the topic recognise their own problem in +the title? No → the title is written *utenfra og inn*, and you rewrite it. This pass fills +**`readerQuestion`**. + +--- + +## Output contract §4 — the arc (åre), not the news-item + +Persist each swept question as a store record carrying the reader fields, then render the arc map. + +**Persist (Pass 1–3 results) via the deterministic store CLI** — one batch call, carrying the reader +fields the demand-sweep produced (`readerQuestion`, `painPoint`, `saturation`, `demand`, plus the +reader-side verdict `verdict` and grip `actionability` the F7 gate reads). `relatedIds` links the +records that are facets of one vein: + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ + echo '[ + {"source":"","title":"","url":"", + "topics":[""], + "readerQuestion":"", + "painPoint":"", + "saturation":"", + "demand":{"strength":"strong|moderate|thin","answered":true|false}, + "verdict":"BÆRENDE|STØTTE|NYHET", + "actionability":{"formulated":true|false,"note":""}, + "relatedIds":[""]} + ]' | node --import tsx src/cli.ts capture +``` + +- **`demand`** is the rankable twin of the verbatim `saturation` text: `strength` = etterspørsel (how strongly asked), `answered` = whether a good answer already exists. Omit `demand` when you did not measure it (honest null) — do not fabricate a strength. +- **`verdict`/`actionability`** are the reader-side utility judgment (mechanism from the scoring SSOT); the deterministic F7 band-cap gate reads them. What counts as a formulated grip is the operator's reader-side calibration (profile / data dir), never yours to invent. +- Content-invalid items land in `errors[]`; the valid ones still persist. A malformed `demand` (bad strength, non-boolean `answered`) is a hard error at the seam — fix the payload, don't silently drop the field. + +**Render the §4 output — the arc map** (question inventory ranked on `etterspørsel × kan-svare × +ikke-besvart`, per-vein market verdict, BÆRENDE/STØTTE reading order). This is deterministic code — +you do not hand-format it: + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ + node --import tsx src/cli.ts arcs # print the arc map + # node --import tsx src/cli.ts arcs --topics "" # narrow to one theme + # node --import tsx src/cli.ts arcs --out "" # write a dated arc map +``` + +The `arcs` verb groups the demand-swept records into veins (relatedIds transitive closure), ranks the +questions, classifies each vein's **honest market verdict**, and renders it. The verdict distinguishes +the two gaps you must never confuse: + +- **supply-gap** (many ask, none answer) — the gold: write here. +- **demand-gap** (few ask, but the sender can answer) — highest value, lowest guaranteed audience. +- **saturated** (already answered) — do not write. +- **unknown / thin** — the honest null; do not fill it in. + +--- + +## What you deliver to the caller + +1. The **arc map** (the `arcs` output) — the ranked question inventory per vein, with the market verdict. +2. A one-line reading: which vein is the strongest write (highest-ranked supply-gap or a high-conviction demand-gap), and which questions are saturated (skip). +3. The blind spots you hit (un-swept channels, approximate counts) — stated plainly, never papered over. + +You persist and render; you never draft the edition — that is `/linkedin:newsletter`. Your output is +the *inntak* to the drafting decision, in the reader's own words. diff --git a/commands/trends.md b/commands/trends.md index 8e2bee1..4fd81fe 100644 --- a/commands/trends.md +++ b/commands/trends.md @@ -36,11 +36,17 @@ All flags are optional, given after the command name: | `--mode kortform\|long-form` | Scoring mode (see the SSOT for what each rewards) | **long-form** — no arguments means a long-form discovery pass | | `--fresh-days N` | Freshness window for the morning brief | CLI default (7) | | `--brief-only` | Skip the discovery poll entirely; render the brief from the existing store | off | +| `--demand` | Run the **demand-sweep** (`demand-spotter`, «innenfra og ut») instead of supply-side discovery: poll where readers ASK, fill the reader fields, render the §4 arc map | off | | `--dry-run` | Poll + score, but persist nothing: no capture, no brief, no status writes, no last-run marker | off | Note the mode inversion deliberately: the **agent's** own default is kortform, this **command's** default is long-form. That is why Step 2 must always pass the mode explicitly. +**`--demand` reroutes the pass** (Step 2D below) to the demand side — a different agent +(`demand-spotter`), a different unit (the arc/åre, not the news-item), a different output (the §4 +arc map, not the pillar brief). The two are complementary: run discovery to find *what happened*, +then `--demand` on a chosen theme to see *what the reader is stuck on*. + ## Step 1: Load context 1. **Pillars:** Read `${DATA}/profile/user-profile.md` and extract the content pillars / @@ -52,6 +58,27 @@ Note the mode inversion deliberately: the **agent's** own default is kortform, t the agent owns Research Routing (its own "Research Routing" section reads the profile's `### Research Tooling` block); duplicating it here would drift. +## Step 2D: Run the demand-sweep (only on `--demand`) + +**If `--demand`:** this replaces Steps 2–4 (the supply-side discovery pass). Delegate to the +demand-spotter agent — invoke it via `Task` with `subagent_type: linkedin-studio:demand-spotter` +(foreground). The prompt MUST state explicitly: + +- The pillars and the resolved source-list path from Step 1 (its **Tier 5** demand sources are what + this pass polls — the inverse of the Tier 1–4 supply sources). +- The theme(s) to sweep (from `--topics` if given, else the user's pillars). +- That the three passes are mandatory: demand-sweep (verbatim questions + signal strength + is-it-answered) → pain-point map (fills `painPoint`) → vocabulary translation (fills `readerQuestion`). +- That persistence is **mandatory unless `--dry-run`**: the agent runs `capture` with the reader + fields (`readerQuestion`/`painPoint`/`saturation`/`demand`/`verdict`/`actionability`), then renders + the §4 arc map with `CLI arcs`. +- That an **honest null is a finding** — a thin/un-swept source is reported, never filled in. + +Then present the arc map the agent returns: per vein, the ranked question inventory (reader's words), +the honest market verdict (supply-gap = write here · demand-gap = high-value/low-audience · saturated += skip), and the BÆRENDE/STØTTE reading order. On `--dry-run`, present the same map but state that +nothing was persisted. **Skip Steps 2–4.** Hand off: a chosen vein feeds `/linkedin:newsletter` +Step 1 (its reader question, pain point, and angle prefill the edition). + ## Step 2: Run the discovery pass **If `--brief-only`:** skip the agent entirely — go to Step 3 and render the brief from the diff --git a/config/trends-sources.template.md b/config/trends-sources.template.md index a6fa997..dbb6c30 100644 --- a/config/trends-sources.template.md +++ b/config/trends-sources.template.md @@ -57,6 +57,19 @@ grouped by tier. Keep `Name — URL — note` so a poll can cite the URL. - [Earnings / report calendar] — [url] — scheduled releases - Seasonal themes: [Q1 …] · [Q2 …] · [Q3 …] · [Q4 …] +## Tier 5 — Demand signals (where the questions live) — the demand-sweep polls these + +*Tier 1–4 are SUPPLY sources — they tell you what was announced. Tier 5 is the inverse: where +your audience already **asks**. The `demand-spotter` agent («innenfra og ut») polls these to turn a +topic into the reader's own question, its pain point, and an honest saturation verdict. Ground truth +first (HN + GitHub APIs are exact); forums are approximate; some channels need an API (see the notes).* + +- [Practitioner forums / communities] — [url] — where people ask (Reddit, HN, product forums, Discord/Slack where reachable). *Reddit blocks direct fetch → counts are approximate; HN via the Algolia API is exact.* +- [GitHub issues on the spec/tool repos] — [url] — undervalued gold: real questions from the people building, with names + comment counts as signal strength (GitHub API = ground truth) +- [Procurement data] — [url] — the hardest demand indicator there is: someone has a budget and wrote the need down (e.g. a national tender portal / TED-equivalent for your market) +- [Regulator / supervisory guidance] — [url] — what your reader is *required* to take a position on (non-optional demand) +- [Comment fields on your channels] — [url] — *NB: YouTube/video comments are JS-rendered → need an API key; the largest blind spot. Specify the fetch method or mark the channel explicitly un-swept — an honest null is a finding.* + --- ## Your niche additions diff --git a/docs/agents-capability-matrix.md b/docs/agents-capability-matrix.md index 1417a98..7758500 100644 --- a/docs/agents-capability-matrix.md +++ b/docs/agents-capability-matrix.md @@ -14,6 +14,7 @@ | network-builder | Sonnet | Teal | Strategic networking and outreach | | content-repurposer | Sonnet | Purple | Format conversion and evergreen refresh | | trend-spotter | (inherits session) | White | Trending topics and opportunity scoring | +| demand-spotter | (inherits session) | Silver | Demand-sweep «innenfra og ut»: reader-side questions, pain-point map, vocabulary translation, §4 arc map | | voice-trainer | Sonnet | Pink | Voice profile building and drift detection | | differentiation-checker | Sonnet | Gray | Originality scoring and commodity detection | | video-scripter | Sonnet | Violet | Video script creation with pacing and visual cues | diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index ce7f2eb..33a8f78 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -90,7 +90,7 @@ warn() { echo -e "${YELLOW}⚠${NC} $1"; WARN=$((WARN + 1)); } # --- Declared registration contract (the "Telling" block) --- # Source of truth: CLAUDE.md headers + STATE.md Telling. Bump these together # with the files when adding/removing an agent, command, reference, or skill. -EXPECT_AGENTS=19 +EXPECT_AGENTS=20 EXPECT_COMMANDS=30 EXPECT_REFS=28 EXPECT_SKILLS=6 diff --git a/scripts/trends/src/arc.ts b/scripts/trends/src/arc.ts new file mode 100644 index 0000000..3c24bd1 --- /dev/null +++ b/scripts/trends/src/arc.ts @@ -0,0 +1,255 @@ +/** + * The arc (åre) layer — MR-F9 «innenfra og ut» output contract (N7.5). + * + * Discovery ranks candidates on SENDER fit ("does this suit my pillars"). This layer inverts the + * view: it reasons over the demand-sweep's READER-side judgment — which questions are actually asked + * (etterspørsel), whether the sender can answer them (kan-svare), and whether they are already + * answered elsewhere (ikke-besvart) — and groups related records into VEINS (arcs), the unit a warm + * topic really is. Each question in a vein is one actionable edition. + * + * Pure + deterministic by contract (like brief.ts): no fs, no clock, no AI, no network. The reader + * judgments (readerQuestion / painPoint / saturation / demand / verdict / actionability) are the + * demand-spotter agent's output, persisted on the record; this module only combines + classifies + + * ranks + renders them. GENERIC BY ARCHITECTURE: no niche calibration here — what counts as demand + * or as a grip is the sweep's judgment, carried by the fields, never hard-coded. + */ + +import type { TrendRecord, TrendVerdict, DemandSignal } from "./types.js"; + +/** + * The honest market verdict for a question (§4 "ærlig markedsdom"). The asymmetry the engine MUST + * express: a gap in the SUPPLY (many ask, none answer = gold) is not a gap in the DEMAND (none ask, + * but the sender can answer = highest value, lowest guaranteed audience). `saturated` = answered + * already (don't write). `unknown` = the sweep did not measure enough to judge (honest null). + */ +export type MarketGap = "supply-gap" | "demand-gap" | "saturated" | "unknown"; + +/** A reader-side view of the demand inputs, redeclared structurally so callers pass a plain shape. */ +interface GapInputs { + demand?: DemandSignal; + verdict?: TrendVerdict; + actionability?: { formulated: boolean }; +} + +/** + * Classify the honest market verdict (§4). Answered ⇒ saturated. Unanswered-and-asked (strong/ + * moderate) ⇒ supply-gap. Unanswered-but-thin, yet the sender can carry it (BÆRENDE verdict or a + * formulated grip) ⇒ demand-gap. Everything else (no demand, or thin with no way to answer) ⇒ + * unknown — never invented into a gap. Pure. + */ +export function classifyMarketGap(r: GapInputs): MarketGap { + const d = r.demand; + if (!d) return "unknown"; + if (d.answered) return "saturated"; + if (d.strength === "strong" || d.strength === "moderate") return "supply-gap"; + // thin, unanswered — a demand-gap only when the sender can actually answer it. + if (r.verdict === "BÆRENDE" || r.actionability?.formulated === true) return "demand-gap"; + return "unknown"; +} + +/** One reader question inside a vein — the record projected onto its demand-side facets, plus the derived gap + rank. */ +export interface ArcQuestion { + id: string; + /** The reader's OWN words (pass 3) — falls back to the record title when no readerQuestion is set. */ + question: string; + /** The field's words (the record title) — kept alongside so the "begge, side om side" contract holds. */ + fieldQuestion: string; + painPoint?: string; + saturation?: string; + verdict?: TrendVerdict; + /** actionability.formulated — whether a reader-actionable grip is formulated. */ + gripFormulated?: boolean; + demand?: DemandSignal; + /** The honest market verdict (§4). */ + gap: MarketGap; + /** Descending demand rank: `etterspørsel × kan-svare × ikke-besvart` (higher = write this first). */ + rank: number; +} + +/** etterspørsel — how strongly asked. Absent (unmeasured) ⇒ 0, so an unmeasured question never floats up. */ +function demandWeight(d: DemandSignal | undefined): number { + if (!d) return 0; + return d.strength === "strong" ? 3 : d.strength === "moderate" ? 2 : 1; +} + +/** ikke-besvart — false (open) outweighs unmeasured outweighs answered (saturated ⇒ 0, sinks). */ +function openWeight(d: DemandSignal | undefined): number { + if (!d) return 1; // unmeasured openness — between answered and known-open + return d.answered ? 0 : 2; +} + +/** kan-avsenderen-svare — the verdict (BÆRENDE > STØTTE > NYHET) plus a bump for a formulated grip. Min 1. */ +function answerWeight(verdict: TrendVerdict | undefined, grip: boolean | undefined): number { + const base = verdict === "BÆRENDE" ? 3 : verdict === "STØTTE" ? 2 : 1; + return base + (grip ? 1 : 0); +} + +/** + * Project a record onto a reader question: reader's words with a title fallback, the demand-side + * facets, the derived gap, and the multiplicative rank. Multiplicative by design (§4's `×`): a zero + * on any axis kills the rank — answered ⇒ saturated ⇒ 0, unmeasured demand ⇒ 0 — so saturated and + * unmeasured questions sink beneath any genuinely-asked, unanswered one. Pure. + */ +export function toArcQuestion(t: TrendRecord): ArcQuestion { + const grip = t.actionability?.formulated; + const rank = demandWeight(t.demand) * openWeight(t.demand) * answerWeight(t.verdict, grip); + return { + id: t.id, + question: t.readerQuestion && t.readerQuestion.trim().length > 0 ? t.readerQuestion : t.title, + fieldQuestion: t.title, + ...(t.painPoint !== undefined ? { painPoint: t.painPoint } : {}), + ...(t.saturation !== undefined ? { saturation: t.saturation } : {}), + ...(t.verdict !== undefined ? { verdict: t.verdict } : {}), + ...(grip !== undefined ? { gripFormulated: grip } : {}), + ...(t.demand !== undefined ? { demand: t.demand } : {}), + gap: classifyMarketGap(t), + rank, + }; +} + +/** rank desc, then id asc — a total order (id is the store's unique dedupe hash), so the output is fixed. */ +function byRankThenId(a: ArcQuestion, b: ArcQuestion): number { + return b.rank - a.rank || a.id.localeCompare(b.id); +} + +/** Rank a set of records as arc questions on `etterspørsel × kan-svare × ikke-besvart`. Pure, total order. */ +export function rankArcQuestions(records: TrendRecord[]): ArcQuestion[] { + return records.map(toArcQuestion).sort(byRankThenId); +} + +/** A vein: related records clustered, their questions ranked, and split into bearing (BÆRENDE) vs support. */ +export interface Arc { + /** Stable arc id — the lexicographically-smallest member id (deterministic, independent of input order). */ + id: string; + /** The vein's questions, ranked (rankArcQuestions order). */ + questions: ArcQuestion[]; + /** The bearing questions (verdict BÆRENDE) — what an edition is built on. */ + bearing: ArcQuestion[]; + /** The supporting questions (STØTTE / NYHET / unjudged) — context around the bearing ones. */ + support: ArcQuestion[]; + /** The vein's honest market verdict — the top-ranked question's gap (the dominant signal). */ + gap: MarketGap; +} + +/** + * Cluster records into veins by relatedIds TRANSITIVE closure (A→B, B→C ⇒ {A,B,C}), undirected: an + * edge exists if either record names the other. A record with no link and none pointing at it is its + * own singleton vein. Union-find over the record set; ids naming absent records are ignored. Pure. + */ +export function groupIntoArcs(records: TrendRecord[]): Arc[] { + const index = new Map(records.map((r) => [r.id, r])); + const parent = new Map(records.map((r) => [r.id, r.id])); + + const find = (x: string): string => { + let root = x; + while (parent.get(root) !== root) root = parent.get(root)!; + // Path-compress for stability across repeated finds. + let cur = x; + while (parent.get(cur) !== root) { + const next = parent.get(cur)!; + parent.set(cur, root); + cur = next; + } + return root; + }; + const union = (a: string, b: string): void => { + const ra = find(a); + const rb = find(b); + if (ra === rb) return; + // Attach the lexicographically-greater root under the smaller, so the component root is its min id. + if (ra < rb) parent.set(rb, ra); + else parent.set(ra, rb); + }; + + for (const r of records) { + for (const rel of r.relatedIds ?? []) { + if (index.has(rel)) union(r.id, rel); + } + } + + const groups = new Map(); + for (const r of records) { + const root = find(r.id); + const bucket = groups.get(root); + if (bucket) bucket.push(r); + else groups.set(root, [r]); + } + + const arcs: Arc[] = []; + for (const [root, members] of groups) { + const questions = rankArcQuestions(members); + arcs.push({ + id: root, + questions, + bearing: questions.filter((q) => q.verdict === "BÆRENDE"), + support: questions.filter((q) => q.verdict !== "BÆRENDE"), + gap: questions[0]?.gap ?? "unknown", + }); + } + + // Veins ordered by their hottest question, then arc id — highest-demand vein first, deterministically. + return arcs.sort((a, b) => (b.questions[0]?.rank ?? 0) - (a.questions[0]?.rank ?? 0) || a.id.localeCompare(b.id)); +} + +/** The human-readable market verdict line per gap (§4) — the honest "write here / don't". */ +function marketVerdictLine(gap: MarketGap): string { + switch (gap) { + case "supply-gap": + return "hull i tilbudet (mange spør, ingen svarer) — skriv her"; + case "demand-gap": + return "hull i etterspørselen (få spør, men avsenderen kan svare) — høyest verdi, lavest garantert publikum"; + case "saturated": + return "mettet (allerede besvart) — ikke skriv"; + case "unknown": + return "tynt / umålt signal — ikke fyll ut"; + } +} + +/** The compact demand token for a question line: strength + answered, when measured. */ +function demandToken(q: ArcQuestion): string { + if (!q.demand) return "umålt etterspørsel"; + return `etterspørsel: ${q.demand.strength} · besvart: ${q.demand.answered ? "ja" : "nei"}`; +} + +/** Render one question's inventory entry — the reader's words lead; the field's words follow (both, side by side). */ +function renderQuestion(q: ArcQuestion, n: number): string[] { + const out = [`${n}. ${q.question} · ${demandToken(q)} · [${q.gap}] · rank ${q.rank}`]; + if (q.fieldQuestion !== q.question) out.push(` - feltets ord: ${q.fieldQuestion}`); + if (q.painPoint) out.push(` - 🩹 smertepunkt: ${q.painPoint}`); + const grip = q.gripFormulated === undefined ? "—" : q.gripFormulated ? "ja" : "nei"; + out.push(` - 🎯 dom: ${q.verdict ?? "—"} · leser-grep: ${grip}`); + if (q.saturation) out.push(` - 🌡️ metning: ${q.saturation}`); + return out; +} + +/** A short, jargon-free vein label — the reader's words of its top question (never the field's title). */ +function arcTitle(arc: Arc): string { + return arc.questions[0]?.question ?? arc.id; +} + +/** + * Render the §4 output contract for a set of veins: per arc a jargon-free heading (the reader's + * words), the honest market verdict, the ranked question inventory, and the BÆRENDE-vs-STØTTE reading + * order. Deterministic — same arcs → byte-identical output (pure, no clock/fs). An empty set renders + * an explicit marker rather than nothing. + */ +export function renderArcs(arcs: Arc[]): string { + const lines: string[] = ["# Årer — etterspørsels-kart (innenfra og ut)", ""]; + if (arcs.length === 0) { + lines.push("_Ingen årer — tynt signal i denne kjøringen._", ""); + return lines.join("\n") + "\n"; + } + arcs.forEach((arc, i) => { + lines.push(`## Åre ${i + 1}: ${arcTitle(arc)}`); + lines.push(`- **Ærlig markedsdom:** ${marketVerdictLine(arc.gap)}`); + lines.push(""); + lines.push("### Spørsmålsinventar (rangert: etterspørsel × kan-svare × ikke-besvart)"); + arc.questions.forEach((q, n) => lines.push(...renderQuestion(q, n + 1))); + lines.push(""); + lines.push("### Arc-forslag (leserekkefølge)"); + lines.push(`- BÆRENDE: ${arc.bearing.length > 0 ? arc.bearing.map((q) => q.question).join(" · ") : "—"}`); + lines.push(`- STØTTE: ${arc.support.length > 0 ? arc.support.map((q) => q.question).join(" · ") : "—"}`); + lines.push(""); + }); + return lines.join("\n") + "\n"; +} diff --git a/scripts/trends/src/brief.ts b/scripts/trends/src/brief.ts index e234983..231f17c 100644 --- a/scripts/trends/src/brief.ts +++ b/scripts/trends/src/brief.ts @@ -280,6 +280,8 @@ function proposalLines(t: TrendRecord): string[] { if (t.readerQuestion) out.push(`- ❓ Leserspørsmål: ${t.readerQuestion}`); if (t.painPoint) out.push(`- 🩹 Smertepunkt: ${t.painPoint}`); if (t.saturation) out.push(`- 🌡️ Metning: ${t.saturation}`); + // N7.5 (MR-F9): the demand-sweep's rankable signal, shown alongside the verbatim saturation above. + if (t.demand) out.push(`- 📊 Etterspørsel: ${t.demand.strength} · besvart: ${t.demand.answered ? "ja" : "nei"}`); if (t.relatedIds && t.relatedIds.length > 0) out.push(`- ↔️ Relatert: ${t.relatedIds.join(", ")}`); return out; } diff --git a/scripts/trends/src/cli.ts b/scripts/trends/src/cli.ts index 6b93baa..ee7f859 100644 --- a/scripts/trends/src/cli.ts +++ b/scripts/trends/src/cli.ts @@ -13,6 +13,7 @@ * echo '' | node --import tsx src/cli.ts capture [--store ] [--json] * node --import tsx src/cli.ts brief [--pillars ] [--fresh-days N] [--first-mover-days N] [--saturation-at N] * [--out ] [--no-mark] [--store ] [--json] + * node --import tsx src/cli.ts arcs [--topics ] [--out ] [--store ] [--json] * node --import tsx src/cli.ts schedule --pillars [--at HH:MM] [--fresh-days N] * [--platform auto|launchd|cron] [--install|--uninstall] [--store ] * @@ -83,6 +84,7 @@ import { } from "./brief.js"; import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js"; import type { ScheduleSpec } from "./schedule.js"; +import { groupIntoArcs, renderArcs } from "./arc.js"; function parseFlags(args: string[]): Record { const out: Record = {}; @@ -123,6 +125,7 @@ function usage(msg: string): never { " score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" + " capture [--store ] [--json] < raw-item-or-batch.json\n" + " brief [--pillars ] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out ] [--no-mark] [--store ] [--json]\n" + + " arcs [--topics ] [--out ] [--store ] [--json]\n" + " schedule --pillars [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store ]", ); process.exit(2); @@ -418,6 +421,44 @@ function main(): void { return; } + if (command === "arcs") { + // The demand-sweep's §4 output (MR-F9, N7.5): group the demand-swept records into veins (arcs) + // and render the reader-side output contract — question inventory ranked on etterspørsel × kan-svare + // × ikke-besvart, per-vein market verdict (supply-gap ≠ demand-gap ≠ saturated), BÆRENDE/STØTTE order. + // Reads only records that carry a demand-side signal (the sweep populated them); an optional --topics + // narrows the pool to a theme. Print to stdout by default; --out writes a dated arc map; --json + // emits the arc structures. No store mutation (a read-only view, like brief without the surfacing write). + const store = loadStore(storePath); + const wanted = splitTopics(flags.topics).map((t) => t.toLowerCase()); + const swept = store.trends.filter((t) => { + const hasSignal = + t.demand !== undefined || + t.readerQuestion !== undefined || + t.painPoint !== undefined || + t.saturation !== undefined; + if (!hasSignal) return false; + if (wanted.length === 0) return true; + const have = new Set(t.topics.map((x) => x.toLowerCase())); + return wanted.some((w) => have.has(w)); + }); + const arcs = groupIntoArcs(swept); + if (asJson) { + console.log(JSON.stringify(arcs, null, 2)); + return; + } + const md = renderArcs(arcs); + if (flags.out && flags.out !== "true") { + const outDir = flags.out; + mkdirSync(outDir, { recursive: true }); + const path = join(outDir, `${today()}-arcs.md`); + writeFileSync(path, md, "utf8"); + console.log(`Wrote arc map: ${path} (${arcs.length} åre(r), ${swept.length} swept record(s))`); + return; + } + process.stdout.write(md); + return; + } + if (command === "schedule") { // RE-R3c — print-first autonomous trigger. Emits (or installs) a launchd plist / cron line firing // the DETERMINISTIC brief daily via run-daily.sh; never runs launchctl or the cron table (C2). diff --git a/scripts/trends/src/item.ts b/scripts/trends/src/item.ts index b62b41e..ab4b38d 100644 --- a/scripts/trends/src/item.ts +++ b/scripts/trends/src/item.ts @@ -20,7 +20,7 @@ import { normalizeField } from "./store.js"; import type { TrendInput } from "./store.js"; import { requiredDimensions, scoreEnvelope, capForActionability } from "./score.js"; import type { ScoreMode, DimensionScores } from "./score.js"; -import type { TrendVerdict, Actionability } from "./types.js"; +import type { TrendVerdict, Actionability, DemandSignal } from "./types.js"; export interface TrendItem { /** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */ @@ -67,6 +67,8 @@ export interface TrendItem { painPoint?: string; /** Market saturation judgment (MR-F9). Blank/absent -> omitted. */ saturation?: string; + /** Demand-sweep signal (MR-F9, N7.5). Present-but-malformed -> validation error (like actionability). */ + demand?: DemandSignal; } export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] }; @@ -131,6 +133,26 @@ function validateScore( /** The closed reader-side utility vocabulary (MR-F7) — mechanism, not niche calibration. */ const VERDICTS = ["BÆRENDE", "STØTTE", "NYHET"] as const; +/** The closed demand-strength vocabulary (MR-F9, N7.5) — the sweep's honest ordinal, not niche calibration. */ +const DEMAND_STRENGTHS = ["strong", "moderate", "thin"] as const; + +/** + * Validate an optional `demand` (MR-F9, N7.5) — never throws. `strength` must be one of the closed + * vocabulary; `answered` is a REQUIRED boolean (the rankable "is it already answered" the arc layer + * reads). Hard-fail when present-but-malformed, like actionability — a bad payload is caught at the + * seam, not persisted silently. + */ +function validateDemand(raw: unknown): { ok: true; value: DemandSignal } | { ok: false; reason: string } { + if (!isPlainObject(raw)) return { ok: false, reason: "demand must be an object" }; + if (typeof raw.strength !== "string" || !(DEMAND_STRENGTHS as readonly string[]).includes(raw.strength)) { + return { ok: false, reason: `demand.strength must be one of ${DEMAND_STRENGTHS.join(", ")} (got ${String(raw.strength)})` }; + } + if (typeof raw.answered !== "boolean") { + return { ok: false, reason: `demand.answered must be a boolean (got ${String(raw.answered)})` }; + } + return { ok: true, value: { strength: raw.strength as DemandSignal["strength"], answered: raw.answered } }; +} + /** The free-text proposal fields, all validated by the `summary` idiom (non-empty string -> kept verbatim, else omitted). */ const FREE_TEXT_FIELDS = ["angle", "targetLevel", "rationale", "readerQuestion", "painPoint", "saturation"] as const; @@ -238,6 +260,13 @@ export function normalizeItem(raw: unknown): NormalizeResult { } } + let demand: DemandSignal | undefined; + if (r.demand !== undefined && r.demand !== null) { + const res = validateDemand(r.demand); + if (!res.ok) errors.push(`invalid demand: ${res.reason}`); + else demand = res.value; + } + if (errors.length > 0) return { ok: false, errors }; // N6 free-text fields: the summary idiom (non-empty string kept verbatim, else the key is omitted). @@ -259,6 +288,7 @@ export function normalizeItem(raw: unknown): NormalizeResult { ...(relatedIds.length > 0 ? { relatedIds } : {}), ...(actionability !== undefined ? { actionability } : {}), ...(verdict !== undefined ? { verdict } : {}), + ...(demand !== undefined ? { demand } : {}), }; return { ok: true, item }; } @@ -298,6 +328,7 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput { ...(item.readerQuestion !== undefined ? { readerQuestion: item.readerQuestion } : {}), ...(item.painPoint !== undefined ? { painPoint: item.painPoint } : {}), ...(item.saturation !== undefined ? { saturation: item.saturation } : {}), + ...(item.demand !== undefined ? { demand: item.demand } : {}), }; } diff --git a/scripts/trends/src/store.ts b/scripts/trends/src/store.ts index 52f5ba5..7b33a39 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, TrendVerdict, Actionability } from "./types.js"; +import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus, TrendVerdict, Actionability, DemandSignal } from "./types.js"; import type { TrendScore } from "./score.js"; export { SCHEMA_VERSION } from "./types.js"; @@ -54,6 +54,8 @@ export interface TrendInput { painPoint?: string; /** Market saturation judgment (MR-F9). */ saturation?: string; + /** Demand-sweep signal — rankable etterspørsel/ikke-besvart (MR-F9, N7.5). */ + demand?: DemandSignal; } export interface AddResult { @@ -179,6 +181,7 @@ export function addTrend(store: TrendStore, input: TrendInput): AddResult { ...(input.readerQuestion !== undefined ? { readerQuestion: input.readerQuestion } : {}), ...(input.painPoint !== undefined ? { painPoint: input.painPoint } : {}), ...(input.saturation !== undefined ? { saturation: input.saturation } : {}), + ...(input.demand !== undefined ? { demand: input.demand } : {}), }; store.trends.push(trend); return { store, added: true, merged: false }; diff --git a/scripts/trends/src/types.ts b/scripts/trends/src/types.ts index 88533b6..0274c98 100644 --- a/scripts/trends/src/types.ts +++ b/scripts/trends/src/types.ts @@ -56,6 +56,25 @@ export interface Actionability { note?: string; } +/** + * The demand-side signal (N7.5 / MR-F9): the «innenfra og ut» demand-sweep's RANKABLE judgment of + * a reader question — how strongly it is actually asked (etterspørsel) and whether a good answer is + * already out there (ikke-besvart). The rankable twin of the verbatim `saturation` text, exactly as + * `actionability.formulated` (bool) is the rankable twin of its verbatim `note` — one for arithmetic, + * one for the human. Avsender-neutral mechanism: what counts as demand is the sweep's judgment, never + * niche calibration hard-coded here. Absent on a record ⇒ UNMEASURED (the honest null — the sweep is + * allowed to say "thin signal" via `strength: "thin"`, or nothing at all, rather than invent demand). + */ +export interface DemandSignal { + /** + * How strongly the question is asked — the sweep's honest ordinal. `"thin"` is a real thin-signal + * FINDING (measured, and low), distinct from the field being absent (never measured). + */ + strength: "strong" | "moderate" | "thin"; + /** Is a good answer already out there? `true` ⇒ saturated (the supply gap is closed — don't write). */ + answered: boolean; +} + export interface TrendRecord { /** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */ id: string; @@ -131,6 +150,11 @@ export interface TrendRecord { painPoint?: string; /** The market saturation judgment — is this already answered by others? (metningsdom), VERBATIM. */ saturation?: string; + /** + * The demand-sweep's rankable demand signal (N7.5). First-sight like the other N6/F9 fields — a + * re-capture never clobbers a triaged demand read. Absent ⇒ unmeasured. See {@link DemandSignal}. + */ + demand?: DemandSignal; } export interface TrendStore { @@ -146,10 +170,11 @@ export interface TrendQueryHit { } /** - * The store schema version. Still 4 after N6: the eight N6 proposal fields + * The store schema version. Still 4 after N6 AND N7.5: 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.) + * plus the N7.5 `demand` signal are all additive-optional — a pre-N6 v4 record is already a valid + * post-N7.5 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/n7_5-demand-sweep.test.ts b/scripts/trends/tests/n7_5-demand-sweep.test.ts new file mode 100644 index 0000000..e61e787 --- /dev/null +++ b/scripts/trends/tests/n7_5-demand-sweep.test.ts @@ -0,0 +1,372 @@ +/** + * N7.5 — MR-F9 «innenfra og ut» demand-sweep: the mechanism that FILLS the N6 reader fields + * (readerQuestion/painPoint/saturation). Discovery finds "what happened"; this layer translates + * it into "the problem the reader is stuck on". Three passes after discovery, before drafting: + * demand-sweep (Pass 1) → pain-point map (Pass 2) → vocabulary translation (Pass 3), with a new + * output unit — the ARC (åre), not the news-item. + * + * This suite pins the deterministic code half: + * (1) the additive-optional `demand` signal (strength + answered) — the rankable twin of the + * verbatim `saturation` text; capture round-trip + hard-fail on malformed, backward-compat; + * (2) `classifyMarketGap` — the §4 "ærlig markedsdom" asymmetry (supply-gap ≠ demand-gap); + * (3) `rankArcQuestions` — the §4 ranking `etterspørsel × kan-svare × ikke-besvart`, total order; + * (4) `groupIntoArcs` — relatedIds transitive closure into veins, BÆRENDE vs STØTTE partition; + * (5) `renderArcs` — the §4 output-contract, reader's words in the title, honest-null, deterministic. + * + * GENERIC BY ARCHITECTURE: the fixtures use a neutral domain — no KTG pillar/source/calibration is + * baked into the mechanism (what counts as demand is the sweep's judgment, carried by the fields). + */ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +import { emptyStore, loadStore, saveStore, addTrend } from "../src/store.js"; +import { normalizeItem, itemToInput } from "../src/item.js"; +import { SCHEMA_VERSION } from "../src/types.js"; +import type { TrendRecord, DemandSignal } from "../src/types.js"; +import { + classifyMarketGap, + toArcQuestion, + rankArcQuestions, + groupIntoArcs, + renderArcs, +} from "../src/arc.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "trends-n75-")); + +/** Build a store record with N7.5-relevant fields; generic domain, no KTG specifics. */ +function rec(over: Partial & Pick): TrendRecord { + return { + source: "tavily", + capturedAt: "2026-07-20", + topics: ["ai"], + ...over, + } as TrendRecord; +} + +// ── (1) demand schema — additive-optional, capture round-trip ────────────────────────────── +describe("N7.5 — demand signal schema (rankable twin of saturation text)", () => { + test("addTrend persists demand + the F9 reader fields, load/save round-trips them", () => { + const dir = tmp(); + try { + const path = join(dir, "trends.json"); + const { store } = addTrend(emptyStore(), { + title: "RAG on your own documents", + url: "https://example.com/rag", + source: "tavily", + capturedAt: "2026-07-20", + topics: ["ai", "search"], + readerQuestion: "Can we point a chatbot at our own files?", + painPoint: "competes against the M365 licence they already pay for", + saturation: "setup guides saturated; the cost/ownership questions are open", + demand: { strength: "strong", answered: false }, + verdict: "BÆRENDE", + actionability: { formulated: true }, + }); + saveStore(path, store); + const t = loadStore(path).trends[0]; + assert.deepEqual(t.demand, { strength: "strong", answered: false }); + assert.equal(t.readerQuestion, "Can we point a chatbot at our own files?"); + assert.equal(t.painPoint, "competes against the M365 licence they already pay for"); + assert.equal(t.saturation, "setup guides saturated; the cost/ownership questions are open"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("schema version stays 4 — demand is additive-optional, no migration", () => { + assert.equal(SCHEMA_VERSION, 4); + }); + + test("normalizeItem accepts a valid demand and carries it through itemToInput", () => { + const res = normalizeItem({ + title: "T", + url: "https://x.test/a", + source: "tavily", + topics: ["ai"], + demand: { strength: "moderate", answered: true }, + }); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.deepEqual(res.item.demand, { strength: "moderate", answered: true }); + const input = itemToInput(res.item, "2026-07-20"); + assert.deepEqual(input.demand, { strength: "moderate", answered: true }); + }); + + test("normalizeItem hard-fails a malformed demand (bad strength vocab)", () => { + const res = normalizeItem({ + title: "T", + url: "https://x.test/a", + source: "tavily", + topics: ["ai"], + demand: { strength: "huge", answered: false }, + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.ok(res.errors.some((e) => /demand/i.test(e)), "should report a demand error"); + }); + + test("normalizeItem hard-fails a demand with a non-boolean answered", () => { + const res = normalizeItem({ + title: "T", + url: "https://x.test/a", + source: "tavily", + topics: ["ai"], + demand: { strength: "thin", answered: "no" }, + }); + assert.equal(res.ok, false); + }); + + test("demand absent → key omitted (a pre-N7.5 record reads unchanged)", () => { + const res = normalizeItem({ title: "T", url: "https://x.test/a", source: "tavily", topics: ["ai"] }); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.equal("demand" in res.item, false); + assert.equal("demand" in itemToInput(res.item, "2026-07-20"), false); + }); +}); + +// ── (2) classifyMarketGap — the §4 "ærlig markedsdom" asymmetry ───────────────────────────── +describe("N7.5 — classifyMarketGap (supply-gap ≠ demand-gap ≠ saturated)", () => { + const strong: DemandSignal = { strength: "strong", answered: false }; + + test("many ask, none answer → supply-gap (the gold)", () => { + assert.equal(classifyMarketGap({ demand: strong }), "supply-gap"); + assert.equal(classifyMarketGap({ demand: { strength: "moderate", answered: false } }), "supply-gap"); + }); + + test("few ask but the sender can answer → demand-gap (highest value, lowest guaranteed audience)", () => { + assert.equal( + classifyMarketGap({ demand: { strength: "thin", answered: false }, verdict: "BÆRENDE" }), + "demand-gap", + ); + assert.equal( + classifyMarketGap({ demand: { strength: "thin", answered: false }, actionability: { formulated: true } }), + "demand-gap", + ); + }); + + test("already answered → saturated (don't write), regardless of demand strength", () => { + assert.equal(classifyMarketGap({ demand: { strength: "strong", answered: true } }), "saturated"); + }); + + test("no demand signal → unknown (honest null, never invented)", () => { + assert.equal(classifyMarketGap({}), "unknown"); + assert.equal(classifyMarketGap({ demand: { strength: "thin", answered: false } }), "unknown"); + }); +}); + +// ── (3) rankArcQuestions — §4 ranking, total order ────────────────────────────────────────── +describe("N7.5 — rankArcQuestions (etterspørsel × kan-svare × ikke-besvart)", () => { + test("supply-gap outranks demand-gap outranks saturated; unmeasured sinks last", () => { + const supply = rec({ + id: "aaa1", + title: "supply", + url: "u1", + demand: { strength: "strong", answered: false }, + verdict: "BÆRENDE", + actionability: { formulated: true }, + }); + const demand = rec({ + id: "bbb2", + title: "demand", + url: "u2", + demand: { strength: "thin", answered: false }, + verdict: "BÆRENDE", + actionability: { formulated: true }, + }); + const saturated = rec({ + id: "ccc3", + title: "saturated", + url: "u3", + demand: { strength: "strong", answered: true }, + }); + const unmeasured = rec({ id: "ddd4", title: "unmeasured", url: "u4" }); + + const ranked = rankArcQuestions([saturated, unmeasured, demand, supply]); + assert.deepEqual( + ranked.map((q) => q.id), + ["aaa1", "bbb2", "ccc3", "ddd4"], + ); + assert.equal(ranked[0].gap, "supply-gap"); + assert.equal(ranked[1].gap, "demand-gap"); + assert.equal(ranked[2].gap, "saturated"); + assert.equal(ranked[3].gap, "unknown"); + }); + + test("total order is deterministic — ties broken by id ascending", () => { + const a = rec({ id: "zzz9", title: "z", url: "uz", demand: { strength: "moderate", answered: false } }); + const b = rec({ id: "aaa1", title: "a", url: "ua", demand: { strength: "moderate", answered: false } }); + const ranked = rankArcQuestions([a, b]); + assert.deepEqual(ranked.map((q) => q.id), ["aaa1", "zzz9"]); + // Same input, second call → identical order (pure). + assert.deepEqual(rankArcQuestions([a, b]), rankArcQuestions([b, a])); + }); + + test("saturated ranks strictly below any unanswered measured demand", () => { + const sat = rec({ id: "s", title: "s", url: "us", demand: { strength: "strong", answered: true } }); + const open = rec({ id: "o", title: "o", url: "uo", demand: { strength: "thin", answered: false } }); + const ranked = rankArcQuestions([sat, open]); + assert.equal(ranked[0].id, "o"); + assert.equal(ranked[1].id, "s"); + }); +}); + +// ── (4) toArcQuestion + groupIntoArcs — reader's words, relatedIds veins ───────────────────── +describe("N7.5 — arc grouping (the vein, not the news-item)", () => { + test("toArcQuestion uses the reader's words (pass 3), keeping the field's words alongside", () => { + const q = toArcQuestion( + rec({ + id: "x1", + title: "OKF-bundle second-brain interop", + url: "u", + readerQuestion: "Will our chat logs become public record?", + painPoint: "arkivplikt", + }), + ); + assert.equal(q.question, "Will our chat logs become public record?"); + assert.equal(q.fieldQuestion, "OKF-bundle second-brain interop"); + assert.equal(q.painPoint, "arkivplikt"); + }); + + test("toArcQuestion falls back to the title when no reader question is set (backward-compat)", () => { + const q = toArcQuestion(rec({ id: "x2", title: "Only a headline", url: "u" })); + assert.equal(q.question, "Only a headline"); + }); + + test("relatedIds cluster transitively into one arc; unrelated records stay separate", () => { + const a = rec({ id: "a1", title: "A", url: "ua", relatedIds: ["b2"] }); + const b = rec({ id: "b2", title: "B", url: "ub", relatedIds: ["c3"] }); // a→b→c transitive + const c = rec({ id: "c3", title: "C", url: "uc" }); + const lone = rec({ id: "z9", title: "Z", url: "uz" }); + + const arcs = groupIntoArcs([a, b, c, lone]); + assert.equal(arcs.length, 2); + const vein = arcs.find((ar) => ar.questions.some((q) => q.id === "a1"))!; + assert.deepEqual(vein.questions.map((q) => q.id).sort(), ["a1", "b2", "c3"]); + const solo = arcs.find((ar) => ar.questions.some((q) => q.id === "z9"))!; + assert.deepEqual(solo.questions.map((q) => q.id), ["z9"]); + }); + + test("an arc partitions its questions into BÆRENDE (bearing) vs the rest (support)", () => { + const bearing = rec({ id: "a1", title: "bear", url: "ua", relatedIds: ["b2"], verdict: "BÆRENDE" }); + const support = rec({ id: "b2", title: "supp", url: "ub", verdict: "STØTTE" }); + const arc = groupIntoArcs([bearing, support])[0]; + assert.deepEqual(arc.bearing.map((q) => q.id), ["a1"]); + assert.deepEqual(arc.support.map((q) => q.id), ["b2"]); + }); + + test("arcs are ordered by their top question's rank (highest-demand vein first)", () => { + const hot = rec({ id: "h1", title: "hot", url: "uh", demand: { strength: "strong", answered: false }, verdict: "BÆRENDE" }); + const cold = rec({ id: "c1", title: "cold", url: "uc", demand: { strength: "thin", answered: true } }); + const arcs = groupIntoArcs([cold, hot]); + assert.equal(arcs[0].questions[0].id, "h1"); + }); +}); + +// ── (5) renderArcs — §4 output-contract, deterministic, honest-null ────────────────────────── +describe("N7.5 — renderArcs (§4 output-contract)", () => { + const supplyVein = () => + groupIntoArcs([ + rec({ + id: "a1", + title: "LLM wiki OKF-bundle", + url: "ua", + relatedIds: ["b2"], + readerQuestion: "Is our knowledge base going to leak?", + painPoint: "informasjonssikkerhet", + saturation: "no independent answer yet", + demand: { strength: "strong", answered: false }, + verdict: "BÆRENDE", + actionability: { formulated: true }, + }), + rec({ + id: "b2", + title: "second brain agent memory", + url: "ub", + readerQuestion: "Who owns the notes when we are five people?", + demand: { strength: "moderate", answered: false }, + verdict: "STØTTE", + }), + ]); + + test("renders the reader's words, not the field's jargon, in the question inventory", () => { + const md = renderArcs(supplyVein()); + assert.ok(md.includes("Is our knowledge base going to leak?"), "reader question present"); + assert.ok(!/^#.*OKF-bundle/m.test(md), "field jargon must not head a section"); + }); + + test("renders the honest market verdict per vein (supply-gap = write here)", () => { + const md = renderArcs(supplyVein()); + assert.ok(/markedsdom/i.test(md), "explicit market verdict"); + assert.ok(/hull i tilbudet|supply-gap/i.test(md), "supply-gap named"); + }); + + test("renders the BÆRENDE vs STØTTE arc proposal (reading order)", () => { + const md = renderArcs(supplyVein()); + assert.ok(/BÆRENDE/.test(md) && /STØTTE/.test(md), "both roles surfaced"); + }); + + test("an unmeasured / thin vein renders an explicit thin-signal marker (no invented demand)", () => { + const thin = groupIntoArcs([rec({ id: "t1", title: "quiet topic", url: "ut" })]); + const md = renderArcs(thin); + assert.ok(/tynt signal|unknown|umålt/i.test(md), "honest null surfaced, not filled in"); + }); + + test("render is deterministic — same arcs → byte-identical output", () => { + assert.equal(renderArcs(supplyVein()), renderArcs(supplyVein())); + }); +}); + +// ── (6) CLI roundtrip — the AC: a swept item flows verbatim capture → arcs §4 map ─────────── +describe("N7.5 — CLI roundtrip (capture demand-swept item → arcs)", () => { + const trendsDir = fileURLToPath(new URL("..", import.meta.url)); + const runCli = (args: string[], input = ""): { status: number | null; stdout: string } => { + const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], { + input, + encoding: "utf8", + cwd: trendsDir, + }); + return { status: res.status, stdout: res.stdout }; + }; + + test("readerQuestion/painPoint/saturation/demand survive capture and drive the arc map", () => { + const dir = tmp(); + try { + const store = join(dir, "trends.json"); + const item = JSON.stringify({ + source: "tavily", + title: "OKF second-brain interop", + url: "https://example.com/okf", + topics: ["ai", "knowledge"], + readerQuestion: "Will our knowledge base become public record?", + painPoint: "arkivplikt + innsyn", + saturation: "setup guides saturated; the ownership question is open", + demand: { strength: "strong", answered: false }, + verdict: "BÆRENDE", + actionability: { formulated: true }, + }); + assert.equal(runCli(["capture", "--store", store, "--json"], item).status, 0); + + const md = runCli(["arcs", "--store", store]); + assert.equal(md.status, 0); + assert.ok( + /## Åre 1: Will our knowledge base become public record\?/.test(md.stdout), + "the vein heading uses the reader's words, not the field's jargon", + ); + assert.ok(/hull i tilbudet|supply-gap/i.test(md.stdout), "the honest market verdict is rendered"); + assert.ok(/feltets ord: OKF second-brain interop/.test(md.stdout), "field words kept alongside"); + + const j = runCli(["arcs", "--store", store, "--json"]); + const parsed = JSON.parse(j.stdout); + assert.equal(parsed[0].questions[0].demand.strength, "strong"); + assert.equal(parsed[0].gap, "supply-gap"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/skills/linkedin-studio/SKILL.md b/skills/linkedin-studio/SKILL.md index 3f7c56d..5fde054 100644 --- a/skills/linkedin-studio/SKILL.md +++ b/skills/linkedin-studio/SKILL.md @@ -154,6 +154,7 @@ These rules apply to ALL content created by any skill or command: | `network-builder` | Sonnet | Cyan | Strategic networking + outreach | | `content-repurposer` | Sonnet | Magenta | Format conversion + evergreen refresh | | `trend-spotter` | (inherits session) | Cyan | Trending topics + opportunity scores | +| `demand-spotter` | (inherits session) | Silver | Demand-sweep «innenfra og ut»: reader's questions + pain-point map + vocabulary translation + arc map | | `voice-trainer` | Sonnet | Magenta | Voice profile building + drift detection | | `differentiation-checker` | Sonnet | Blue | Originality scoring + commodity detection | | `post-feedback-monitor` | Opus | Lime | Post-publish 48h monitoring, real-time interventions |