feat(linkedin-studio): N7.5 — MR-F9 etterspørsels-sveip (Tier-5-kilder + smertepunkt + vokabular-oversettelse + arc-kontrakt) [skip-docs]

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014bE7VbkmR3cqHFEeGfzgwb
This commit is contained in:
Kjell Tore Guttormsen 2026-07-23 22:26:39 +02:00
commit 9de38c4939
16 changed files with 968 additions and 8 deletions

View file

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

View file

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

View file

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

172
agents/demand-spotter.md Normal file
View file

@ -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__<server>__<tool>` 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 14 (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 13 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":"<hn|github|tavily|…>","title":"<the field'\''s words, verbatim>","url":"<source url>",
"topics":["<pillar-tag>"],
"readerQuestion":"<the reader'\''s own words — Pass 3>",
"painPoint":"<cost/risk/duty/tool it hits — Pass 2>",
"saturation":"<metningsdom, verbatim: is it answered, by whom>",
"demand":{"strength":"strong|moderate|thin","answered":true|false},
"verdict":"BÆRENDE|STØTTE|NYHET",
"actionability":{"formulated":true|false,"note":"<the reader grip, or why none>"},
"relatedIds":["<id of another record in the same vein>"]}
]' | 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 "<pillar>" # narrow to one theme
# node --import tsx src/cli.ts arcs --out "<dir>" # 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.

View file

@ -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 24 (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 14 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 24.** 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

View file

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

View file

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

View file

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

255
scripts/trends/src/arc.ts Normal file
View file

@ -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 (AB, BC {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<string, string>(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<string, TrendRecord[]>();
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";
}

View file

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

View file

@ -13,6 +13,7 @@
* echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json]
* node --import tsx src/cli.ts brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N]
* [--out <dir>] [--no-mark] [--store <path>] [--json]
* node --import tsx src/cli.ts arcs [--topics <a,b>] [--out <dir>] [--store <path>] [--json]
* node --import tsx src/cli.ts schedule --pillars <a,b> [--at HH:MM] [--fresh-days N]
* [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]
*
@ -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<string, string> {
const out: Record<string, string> = {};
@ -123,6 +125,7 @@ function usage(msg: string): never {
" score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" +
" capture [--store <path>] [--json] < raw-item-or-batch.json\n" +
" brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out <dir>] [--no-mark] [--store <path>] [--json]\n" +
" arcs [--topics <a,b>] [--out <dir>] [--store <path>] [--json]\n" +
" schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>]",
);
process.exit(2);
@ -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 <dir> 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).

View file

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

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

View file

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

View file

@ -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<TrendRecord> & Pick<TrendRecord, "id" | "title" | "url">): 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 });
}
});
});

View file

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