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:
parent
0b34218a38
commit
9de38c4939
16 changed files with 968 additions and 8 deletions
15
CHANGELOG.md
15
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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
172
agents/demand-spotter.md
Normal 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 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":"<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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
255
scripts/trends/src/arc.ts
Normal 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 (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<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";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
372
scripts/trends/tests/n7_5-demand-sweep.test.ts
Normal file
372
scripts/trends/tests/n7_5-demand-sweep.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue