diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a0c306d..273851e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "linkedin-studio", - "version": "0.4.0", + "version": "0.5.3", "description": "LinkedIn Studio — full-spectrum LinkedIn content engine: feed posts, carousels, video scripts, and long-form newsletter editions, with the 2026 relevance-ranking model baked in. v4.0.0 is an audit-remediation release (Voyage Phase 0–3): every user-facing claim is made honest or removed, all 11 previously-orphaned agents are wired (→ 19 agents), a `/linkedin:firsthour` post-publish command is added (→ 27 commands), the algorithm-signal claims are reconciled to one sourced statement (no unpublishable model name or date), short-form de-AI and video quality gates are added, and the structure lint is rebuilt to guard the real layout plus version/count/stat consistency. Breaking: the newly-wired agents register only on reinstall/reload, and this consolidates the v3.0.0 identity break (slug, agent namespace `linkedin-studio:`, state-file path `~/.claude/linkedin-studio.local.md`). v3.1.0 added the cold adversarial review package (`/linkedin:headless-review` + Step 6.5 + `/linkedin:pivot` + per-artifact personas); the `/linkedin:*` commands are unchanged. v4.1.0 adds a journey layer: two guided front-doors (`/linkedin:create`, `/linkedin:measure`) plus a router re-tiered into five journeys (Start · Create · Engage · Measure · Grow), with the 27 existing commands kept as the execution tier (→ 29 commands; additive, reload registers the two new commands).", "author": { "name": "Kjell Tore Guttormsen" diff --git a/.gitignore b/.gitignore index 5f99885..1fa6d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,6 @@ assets/drafts/week-*/ assets/analytics/exports/ assets/analytics/posts/ assets/analytics/weekly-reports/ -assets/analytics/content-history.md # Internal development files (not for public release) BACKLOG.md @@ -57,3 +56,26 @@ docs/**/.session-state.local.json # Node.js scripts/analytics/node_modules/ scripts/analytics/build/ +scripts/brain/node_modules/ +scripts/brain/build/ +scripts/contract-gate/node_modules/ +scripts/contract-gate/build/ +scripts/specifics-bank/node_modules/ +scripts/specifics-bank/build/ +scripts/trends/node_modules/ +scripts/trends/build/ + +# --- session/local state --- +# STATE.md is LOCAL-ONLY (gitignored): no private remote exists and STATE must +# never reach the public open/ mirror. Deliberate deviation from the global +# "STATE tracked" rule, justified by the absence of any private remote. +STATE.md +REMEMBER.md +ROADMAP.md +TODO.md +NEXT-SESSION-PROMPT*.local.md +*.local.md +*.local.json +*.local.sh +.DS_Store +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a156f9..cb13551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,56 @@ 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). +## [0.5.3] - 2026-06-24 + +### Changed — registration hygiene: agent fasit fixtures moved out of `agents/` + +**Stops Claude Code from registering 6 spurious agents.** The six long-form-review fasit fixtures lived in `agents/fixtures/*-cases.md`; because Claude Code scans `agents/` recursively, each was registered as a junk `fixtures:*` agent (`fixtures:content-reviewer-cases`, …). They are test fixtures, not capabilities. **Count-neutral** (no real agent/command/reference/skill change; `ls agents/*.md` still 19) — pure namespace hygiene. + +- **Moved** the 6 fasit fixtures `agents/fixtures/*-cases.md` → `tests/fixtures/*-cases.md`, and their 6 lint tests `agents/__tests__/*-fixture.test.mjs` → `tests/*-fixture.test.mjs` (co-located; each test's relative fixture path retargeted `../fixtures/` → `./fixtures/`). `agents/fixtures/` and `agents/__tests__/` are now removed. +- **Retargeted** the 5 agent fasit-refs (`content`/`language`/`persona`/`editorial`/`fact-reviewer`) from `${CLAUDE_PLUGIN_ROOT}/agents/fixtures/…` to `${CLAUDE_PLUGIN_ROOT}/tests/fixtures/…`. +- **Tests.** Fixture-lint 35/35 at the new path (`node --test 'tests/*.test.mjs'`); render 20/20, hooks 139/139 unaffected; gate `scripts/test-runner.sh` green (counts + version-consistency). Reinstall/reload required for the 6 spurious agents to disappear from the registry. + +## [0.5.2] - 2026-06-23 + +### Added — SB-S2: the evolution loop (second-brain arc) + +**The compounding mechanism.** An operator-invoked, operator-gated consolidation pass that reads the published gold signal + tributary deltas and proposes a **diff** to the two-layer `brain/profile.md` — never a silent overwrite. **Non-breaking** (additive minor): count-neutral (no command/agent/reference/skill); `voice-trainer` unchanged; one session-start hook edit (zero-dep). + +- **Deterministic engine** (`scripts/brain/src/consolidate.ts`): `proposeDiff`/`applyDiff` (pure) — add / reject-`ai-draft` (model-collapse guard, in code) / evidence-bump / promote at N=3 / conflict→keep-both with **distinct ids** (primary `mintEntityId`, alt `mintContentId` — no duplicate-id corruption; no supersede, that's S3) / decay-flag at 90d (dynamic only; static decay-exempt). `applyDiff` round-trips through the SB-S0 grammar; re-running is idempotent. +- **`brain consolidate` CLI**: `--gather` (reads published bodies since last run + the profile, for the invoking session to extract candidates — no new agent), `--propose --candidates ` (validates shape + single-line; writes `brain/pending-diff.{md,json}`; never touches profile.md), `--apply --diff --confirm` (the ONLY path that writes profile.md; refuses without `--confirm`; records `brain/consolidation-state.json`). +- **Session-start** (`hooks/scripts/session-start.mjs`, zero-dep edit): unconditional scaffold-ensure (fresh-install path) + a consolidation-due nudge (counts published records + reads the sidecar `last_run` via `getDataRoot`; no `profile.md` parse) + a brain-init nudge. +- **Operator scope:** journal-capture deferred · the invoking session does the fuzzy extraction (no new agent) · motor-only (no `profile.md` reader until SB-S3). Docs: `docs/second-brain/consolidation-loop.md`. +- **Tests.** brain suite 63 → 82 (`consolidate` 12 + `consolidate-cli` 7); hook suite 131 → 136 (new `session-start-brain-consolidation` 5); gate `scripts/test-runner.sh` 93/0/0 (`BRAIN_TESTS_FLOOR` 63→82; assertion floor unchanged at 78 — no new gate section). Light-Voyage-hardened (brief-reviewer REVISE, plan-critic REPLAN, scope-guardian ALIGNED — all folded). + +## [0.5.1] - 2026-06-23 + +### Added — SB-S1: ingest + the published gold signal (second-brain arc) + +**The second slice of the second-brain arc.** Captures the user's *actual published posts* into `ingest/published/` tagged `provenance=published` — the gold signal that voice/profile learning is allowed to learn from — and wires the learning surface to that signal **only**, never to AI-drafted content (the model-collapse guard). **Non-breaking** (additive minor): no command/agent/reference/skill count changes; a shipped agent's learning behaviour changes (`voice-trainer`). + +- **Ingest data layer** (`scripts/brain/src/ingest.ts`): a `PublishedRecord` file-per-post grammar (fixed 5-line header + `---` sentinel + verbatim body, no YAML) with a `parse ∘ serialize` identity; a content-hash id `mintContentId = sha256(VERBATIM body)[:12]` — byte-identity dedup, so two structurally-different posts never collide and a differing body is never silently dropped. +- **`brain` CLI** grows `ingest --file ` / `ingest --scan-inbox` (top-level `*.md`, skips dotfiles, non-destructive, idempotent, create-on-demand) and `published list` (surfaces `provenance` so ai-draft leakage is visible). The existing `init` is preserved. +- **Published-only invariant wired + gate-enforced.** `agents/voice-trainer.md` reads `ingest/published/` as its primary gold source, keeps `voice-samples/` as a human tributary (not reshaped), forbids `provenance=ai-draft`, and fences the auto-append trap. A new structure-lint section (Brain Published-Only Invariant) enforces the wiring with exact-literal greps + a non-vacuity self-test. +- **Tests.** brain suite 34 → 63 (`ingest` 14 + `publish` 9 + `cli` 6); gate `scripts/test-runner.sh` 90 → 93/0/0 (`BRAIN_TESTS_FLOOR` 34→63, assertion floor 75→78). No automatic `brain/profile.md` mutation (the consolidation loop is SB-S2); no cross-silo threading (SB-S3); no connector (SB-S4). + +## [0.5.0] - 2026-06-18 + +### Added — M0: per-user external data dir (architecture workstream) + +**User data now lives outside the plugin.** The first of the four `v1.0.0` workstreams (architecture) is complete: every class of user data — voice profile, analytics, drafts, plans, A/B tests, audience insights, frameworks, case studies, network/repurposing trackers, user profile — now resolves under `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/` instead of the plugin tree. **Non-breaking** (minor bump): an automatic, idempotent, atomic migration runs once on session-start (copy → fsync → byte-verify → rename; external is canonical, never clobbered), and the deprecated `ANALYTICS_ROOT` / `STATE_FILE` / `PLUGIN_ROOT` aliases are still honored for one minor version. + +- **Resolver seam per runtime (twins).** `hooks/scripts/data-root.mjs` (`getDataRoot()`, zero-dep `.mjs`) and `scripts/analytics/.../storage.ts` (`getDataRoot()`, TS via `tsx`) resolve the same default + override semantics, guarded by a twin-consistency test — the runtime split (zero-dep hooks vs `tsx` analytics) forbids one shared module. +- **Migration tool.** `hooks/scripts/migrate-data.mjs` is the single source of truth for external destinations (`MOVE_FILES` + `COPY_FILES`); `personalization-score.mjs` mirrors it. Idempotent re-runs report `already-migrated`. +- **D2 — scaffold/instance split.** Every COPY-class data file (`examples/high-engagement-posts`, `audience-insights/{demographics,engagement-patterns}`, `templates/my-post-templates`) ships a read-only `*-template.md` seed in-plugin; the canonical instance lives external with in-plugin scaffold fallback. +- **D3 — path convention.** `references/data-path-convention.md` defines the inline `${LINKEDIN_STUDIO_DATA:-…}/` token (generalizing the proven `${LTL_SERIES_ROOT:-…}` newsletter pattern); ~168 prose references across commands, agents, skills, hook prompts, and references repointed to it. Style-A `${CLAUDE_PLUGIN_ROOT}` is preserved for shipped read-only assets (templates, checklists, references, the analytics CLI). +- **Privacy.** Closed a real voice-sample leak in the in-plugin `examples` scaffold (scrubbed to a generic seed; git-history scrub tracked as a pre-publish follow-up). + +### Changed + +- Version declarations bumped to `0.5.0` across `.claude-plugin/plugin.json`, `README.md` (badge), and `CLAUDE.md` (header). +- **Structure lint** (`scripts/test-runner.sh`) extended 74 → 81 checks: `EXPECT_REFS` 25→26 (+ exactly-+1 delta), a no-bare-in-plugin-data-path guard (R1), a no-`ANALYTICS_ROOT`-pin guard (M3 — caught 5 surviving pins in the analytics agent + skill), an SC2 working-tree dry-run, and an assertion-count anti-erosion floor — each with a non-vacuity self-test. No functionality regressed: hooks 128/0, analytics 119/0 + tsc clean, structure 81/0. + ## [0.4.0] - 2026-05-31 ### Re-baselined diff --git a/CLAUDE.md b/CLAUDE.md index ad5cda9..5777c27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,29 +1,16 @@ -# LinkedIn Studio Plugin (v0.4.0) +# LinkedIn Studio Plugin (v0.5.3) -> **Version re-baseline (2026-05-31).** Previously numbered 1.0.0–4.1.0; those were pre-release iterations. The honest maturity is **v0.4.0** — user data still lives inside the plugin tree (`.gitignore`-defended), no command has yet passed the hardening quality-gate, command testing is incomplete, and there is no GUI. The path to **v1.0.0** is four workstreams: **architecture** (move user data out of the plugin into a per-user data dir — the M0 migration), **hardening** (every command through the quality-gate), **command testing**, and a **GUI**. The development narrative below is preserved as history. - -Full-spectrum LinkedIn content engine — short-form feed posts, carousels, video scripts, and long-form newsletter editions — with the 2026 relevance-ranking model baked in. **v4.0.0** is an **audit-remediation release (Voyage Phase 0–3)**: a critical self-review found overclaiming (tracking/analytics/review-independence the plugin couldn't deliver), dormant capability (11 agents never invoked by any command), and structural rot (a dead lint, a self-contradicting algorithm claim, an unpublishable model brand/date in user copy). The fix wires **all 11 orphaned agents** (no deletions → 19 agents), adds **`/linkedin:firsthour`** (→ 27 commands) + a short-form de-AI gate + a video quality gate, promotes `post-feedback-monitor` to Opus, makes the newsletter-distribution / profile-SEO / outreach surfaces honest, **reconciles the algorithm signals to one sourced statement** (no model name or date; `references/algorithm-signals-reference.md` is the single source of truth), fixes the analytics fresh-clone crash, closes the voice-profile leak (placeholder + sentinel + gitignore), and rebuilds the structure lint with version/count/stat/model-consistency + render-chain-propagation + `$`-safety guards (each agent's frontmatter model must match every surface declaration; no honesty pattern a command was cleaned of survives in the reference it renders from — S12; no untrusted value reaches a `String.replace` replacement string in `state-updater.mjs`, proven behaviorally + coverage-complete + self-testing — S13). Breaking — reinstall/reload required for the newly-wired agents; consolidates the v3.0.0 identity break (slug, agent namespace, state-file path). v2.0.0 consolidated the surface (27 commands → 24, 16 agents → 14) while adding the long-form `/linkedin:newsletter` orchestrator + two longform-quality gate agents (`fact-checker`, `persona-reviewer`). v2.1.0 added two gates BEFORE prose (Step 2.5 skeleton + Step 3a spine prose) + a third `persona-reviewer` mode (`skjelett`). v2.2.0 hardened the longform gates with the lessons from the second production run (Seres-serien): blocking persona hard-fails, a post-cutoff fact-check mandate, a `voice-scrubber` agent, render+annotate operator gates, and STATE.md-reconciled edition state. v2.3.0 made **visual assets an explicit pipeline phase** — Step 7.5 (visual-assets) between annotation (Step 7) and lock (Step 8): cover (+ optional inline figures) or a carousel deck, generated (default `mcp-image`, external `cover-raw.png` accepted) and operator-gated BEFORE lock so `render/build-linkedin.mjs` picks up `cover.png` at lock without a post-lock re-render. **v2.4.0** makes an **editor's craft gate an explicit pipeline phase** — new **Step 5.5 (editorial-review)** between fact-check (Step 5) and the persona sweep (Step 6): a new **`editorial-reviewer` agent** (Opus) judges **craft** (prosa-håndverk + narrativ-arkitektur), not reader-response, returning ≤10 flags (BLOCK/REWORK/NICE) as direction, **operator-gated via `SendUserFile` BEFORE the persona sweep** so the personas measure resonance instead of stumbling on craft noise. Motivated by Del 4: every persona reported PASS, yet the editor found 8 fresh points on first reading, ~6/8 of them craft/architecture blind spots no agent measured. Mirrors the Maskinrommet writing-contract §C2. Pipeline 14 → 15 phases; agents 15 → 16; additive `editorialReview` state. Doc/orchestration-only for the wiring (the new agent + its fasit fixture + lint test are the only new files); commands unchanged (24). **v3.1.0 (Endring 9)** adds an **adversarial review package** run COLD on a frozen draft — new **Step 6.5 (headless-review)** between the persona sweep (Step 6) and lock, plus a standalone **`/linkedin:headless-review`** command (run in a fresh session for maximum isolation): three new headless archetypes — **`content-reviewer`** (argument integrity), **`language-reviewer`** (Norwegian language), **`fact-reviewer`** (cold re-verification incl. claims a late pivot bolted on) — plus `persona-reviewer` in resonance + conversion modes, all with NO drafting-session context (the independence layer the in-session gates structurally cannot be). v3.1.0 also adds **`/linkedin:pivot`** (re-opens cleared gates after a late change + a >20 %/>2-section pivot-detection gate at lock) and **per-artifact personas** (`articles.NN.personas` — one or more readers configurable per edition, resolved edition-state → series file → plugin library → interactive). Pipeline 15 → 16 phases; agents 16 → 19; commands 24 → 26; additive `personas` / `pivots` / `headlessReview` state. Motivated by Del 4: the in-session editor + persona sweep shared the drafting session's framing-bias, so the version that shipped was never independently re-reviewed. **v4.1.0** adds a **journey layer** over the (unchanged) command surface: two guided front-doors — **`/linkedin:create`** (routes to the right creation command) and **`/linkedin:measure`** (routes to the right analytics command) — plus a router re-tiered into five journeys (Start · Create · Engage · Measure · Grow), with `onboarding`/`strategy` elevated as the Start/Grow front-doors and the 27 atomic commands kept as the execution tier (→ 29 commands). Additive: 14a's cold command-rationalization found **zero redundancy** (no merges/cuts; `docs/remediation/command-rationalization.md` + `journey-layer-design.md`), so the journey layer organizes rather than removes; reload registers the two new commands. +Full-spectrum LinkedIn content engine — short-form feed posts, carousels, video scripts, and long-form newsletter editions — with the 2026 relevance-ranking model baked in. Maturity v0.5.3: M0 (per-user data dir `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/`, idempotent session-start migration — see `references/data-path-convention.md`) complete; all 29 command surfaces through the interactive quality-gate (`docs/hardening/log.md`). Remaining for v1.0.0: a GUI + independent cold-review coverage (the `/trekreview` artifact `docs/hardening/review.md` persists for S1 only — see `docs/hardening/plan.md`). Version history → `CHANGELOG.md`. ## Architecture - **State file:** `~/.claude/linkedin-studio.local.md` (YAML frontmatter, auto-initialized from `config/state-file.template.md`) - **State updater:** `hooks/scripts/state-updater.mjs` — deterministic state mutations (post tracking, streak, content history pruning). Pure functions, tested, no AI involvement -- **Clipboard helper:** `hooks/scripts/clipboard-helper.mjs` — cross-platform clipboard integration (macOS `pbcopy`, Linux `xclip`/`xsel`, WSL `clip.exe`). All content commands auto-copy to clipboard -- **iCal generator:** `hooks/scripts/ical-generator.mjs` — RFC 5545 calendar file generation for batch scheduling. Standalone CLI + importable module +- **Clipboard helper:** `hooks/scripts/clipboard-helper.mjs` — cross-platform clipboard (macOS `pbcopy`, Linux `xclip`/`xsel`, WSL `clip.exe`). All content commands auto-copy +- **iCal generator:** `hooks/scripts/ical-generator.mjs` — RFC 5545 calendar files for batch scheduling. Standalone CLI + importable module - **Post queue:** `assets/drafts/queue.json` (managed by `hooks/scripts/queue-manager.mjs`) -- **Analytics CLI:** `scripts/analytics/` (TypeScript, requires `tsx` and `npm install`) -- **Analytics data:** `assets/analytics/` (gitignored) -- **Analytics metrics (S16):** the parsed CSV columns plus an **optional, manually-entered** `saves` count. - Saves are count-only in native LinkedIn post analytics (~Sept 2025), absent from the CSV export, and - have no self-serve API — so the ingest path is the user adding a `Saves` column with the number they - read off LinkedIn. `parseOptionalCount()` parses it when present: blank / non-numeric / negative → - `undefined` (`unknown`, never 0), a genuine `0` is kept, and saves is **not** folded into - `engagementRate` (kept comparable to older imports). Surfaced per-post + as `totalSaves` in the - weekly/monthly reports; **never auto-tracked**. -- **Unmeasured by design:** `dwell` time stays **explicitly unmeasurable** — internal to LinkedIn for - organic posts, no exportable count, no API; no dwell field or surface exists. The S16 analytics - extension routes all I/O through the existing `getAnalyticsRoot()` seam, so the planned per-user - data-dir migration (UI-brief §9b/M0) relocates the root in one place without reworking the schema. +- **Analytics:** CLI `scripts/analytics/` (TypeScript, needs `tsx` + `npm install`); data `assets/analytics/` (gitignored) +- **Analytics metrics (S16):** parsed CSV columns + an optional, manually-entered `saves` count (count-only in native LinkedIn analytics since ~Sept 2025, no CSV export / no API). `parseOptionalCount()`: blank / non-numeric / negative → `undefined` (`unknown`, never 0), a genuine `0` is kept; saves surfaced per-post + as `totalSaves`, but **not** folded into `engagementRate`. `dwell` stays **explicitly unmeasurable** (internal to LinkedIn, no export/API). All analytics I/O routes through the `getAnalyticsRoot()` seam (M0 per-user data-dir). ## Hooks @@ -41,45 +28,45 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide **Session markers:** `/tmp/linkedin-hooks/session-active` (Stop hook gating, 12h staleness). **Shared modules:** `linkedin-content-filter.mjs`, `queue-manager.mjs`, `personalization-score.mjs`, `state-updater.mjs`, `clipboard-helper.mjs`. -**State updates:** Post tracking, streak management, and content history are handled deterministically by `state-updater.mjs` (called from Stop hook and commands). Content history entries older than 90 days are auto-pruned at session start. +**State updates:** post tracking, streak, and content history are handled deterministically by `state-updater.mjs` (Stop hook + commands); entries older than 90 days are auto-pruned at session start. -**Hook editing:** Edit `hooks/hooks.template.json` + `hooks/prompts/*.md`, then run `python3 hooks/scripts/compile-hooks.py`. Do not edit `hooks.json` directly. Prompts are loaded at runtime by gatekeeper scripts; the compile step is only needed when adding `type: prompt` hooks. +**Hook editing:** edit `hooks/hooks.template.json` + `hooks/prompts/*.md`, then run `python3 hooks/scripts/compile-hooks.py`. Do not edit `hooks.json` directly. Prompts load at runtime via gatekeeper scripts; the compile step is only needed when adding `type: prompt` hooks. ## Commands (29) -All content commands (post, quick, react, pipeline, first-post, video, multiplatform, carousel, newsletter) auto-copy output to clipboard via `clipboard-helper.mjs`. Interactive steps are minimized — angle, format, and post type are inferred from context, with max 2 questions per post. **v2.0.0 net change:** 5 commands removed (`templates`, `publish`, `authority`, `collab`, `speaking` — absorbed into `quick`, `calendar`, `strategy`, `outreach` respectively) + 2 commands added (`newsletter`, `outreach`) = 27 → 24. **v3.1.0** adds 2 longform companions (`headless-review`, `pivot`) = 24 → 26. **Remediation Step 16** adds `firsthour` (wiring orphan agent #11 `engagement-coach`) = 26 → 27. **v4.1.0 (S14)** adds the two journey front-doors (`create`, `measure`) = 27 → 29. The surface is organized into **five journeys** (Start · Create · Engage · Measure · Grow); `create`/`measure` are new guided front-doors, `onboarding`/`strategy` are elevated as the Start/Grow front-doors, and the 27 atomic commands remain the execution tier (14a found zero redundancy → no merges/cuts). +All content commands auto-copy output to clipboard via `clipboard-helper.mjs`; interactive steps are minimized (angle/format/post-type inferred from context, max 2 questions per post). Organized into five journeys (Start · Create · Engage · Measure · Grow): `create`/`measure` are guided creation/analytics front-doors, `onboarding`/`strategy` the Start/Grow front-doors, and the 27 atomic commands are the execution tier. | Command | Purpose | |---------|---------| | `/linkedin` | Router — status line + five-journey command menu | -| `/linkedin:create` | **(v4.1) Create front-door** — guided "what to make?" → routes to the creation command that owns the format (post/quick/react/carousel/video/multiplatform/batch/newsletter); delegates only | -| `/linkedin:measure` | **(v4.1) Measure front-door** — guided "how am I doing?" → routes to the analytics command (import/report/analyze/audit/ab-test); delegates only | -| `/linkedin:onboarding` | Multi-step onboarding wizard (profile → setup → first-post); **Start-journey front-door (v4.1)** | +| `/linkedin:create` | Create front-door — routes to the command owning the format (post/quick/react/carousel/video/multiplatform/batch/newsletter); delegates only | +| `/linkedin:measure` | Measure front-door — routes to the analytics command (import/report/analyze/audit/ab-test); delegates only | +| `/linkedin:onboarding` | Multi-step onboarding wizard (profile → setup → first-post); Start-journey front-door | | `/linkedin:first-post` | First-post accelerator (10 min) | | `/linkedin:setup` | Guided personalization setup | | `/linkedin:react` | URL-to-post pipeline | | `/linkedin:post` | Full post creation (10-15 min) | | `/linkedin:quick` | 5-minute quick post (3-line formula) + 8 post-type templates | | `/linkedin:pipeline` | Full end-to-end content pipeline | -| `/linkedin:newsletter` | Long-form orchestrator: newsletter edition / essay / series article — multi-session 16-phase pipeline with **skeleton + spine-prose gates BEFORE prose (v2.1)**, **editorial-review craft gate BEFORE the persona sweep (Step 5.5, v2.4)**, fact-check + persona-sweep BEFORE lock, **headless adversarial review BEFORE lock (Step 6.5, v3.1)**, and **visual-assets gate BEFORE lock (Step 7.5, v2.3)** | -| `/linkedin:headless-review` | **(v3.1)** Cold adversarial review package — run the 3 headless archetypes (`content-reviewer`, `language-reviewer`, `fact-reviewer`) + `persona-reviewer` (resonance/conversion) on a FROZEN draft with no drafting-session context; consolidated, operator-gated report. Step 6.5's standalone surface (run in a fresh session for maximum isolation) | -| `/linkedin:pivot` | **(v3.1)** Re-open a long-form edition after a late substantive change so cleared gates (fact-check → editorial → persona → headless) re-run before lock; logs `pivots[]`, resets `currentPhase`, un-locks if needed (pivot heuristic: >20 % word-count change or >2 new sections) | +| `/linkedin:newsletter` | Long-form orchestrator (newsletter / essay / series article) — multi-session 18-phase pipeline with gates before prose (skeleton + spine), fact-check, editorial-review (Step 5.5), persona sweep, headless adversarial review (Step 6.5), and visual-assets — all before lock | +| `/linkedin:headless-review` | Cold adversarial review package — 3 headless archetypes (`content-reviewer`, `language-reviewer`, `fact-reviewer`) + `persona-reviewer` on a FROZEN draft, no drafting-session context; operator-gated report (Step 6.5 standalone surface) | +| `/linkedin:pivot` | Re-open a long-form edition after a late substantive change so cleared gates re-run before lock (pivot heuristic: >20% word-count or >2 new sections) | | `/linkedin:batch` | Create a full week of content | -| `/linkedin:calendar` | View/manage post scheduling queue + publish action (mark scheduled posts as published) | -| `/linkedin:firsthour` | Post-publish first-hour / reply-loop sprint — delegates to `engagement-coach` for a timestamped target list + draft comments + timeline, persists the plan to state (`recordFirstHourPlan`), hands off to `post-feedback-monitor` | +| `/linkedin:calendar` | View/manage post scheduling queue + publish action | +| `/linkedin:firsthour` | Post-publish first-hour / reply-loop sprint — delegates to `engagement-coach`, persists the plan (`recordFirstHourPlan`), hands off to `post-feedback-monitor` | | `/linkedin:carousel` | Structured multi-slide carousel generator | | `/linkedin:video` | Video script generator (30s-2min) | -| `/linkedin:multiplatform` | Adapt content for other platforms (short-form/cross-format; long-form → `/linkedin:newsletter`) | +| `/linkedin:multiplatform` | Adapt content for other platforms (long-form → `/linkedin:newsletter`) | | `/linkedin:analyze` | Content/performance analysis | | `/linkedin:audit` | Periodic content strategy audit | | `/linkedin:import` | Import CSV export → structured JSON (delegates analysis to `/linkedin:report`) | | `/linkedin:report` | Generate weekly performance report | | `/linkedin:ab-test` | Design and manage A/B content tests | -| `/linkedin:strategy` | Growth strategy + authority building (phase guidance, trajectory, signature content compounding) | +| `/linkedin:strategy` | Growth strategy + authority building; Grow-journey front-door | | `/linkedin:competitive` | Competitive analysis of niche | | `/linkedin:monetize` | Monetization strategy and funnels (unlocks at ~1K followers) | -| `/linkedin:outreach` | Outreach orchestrator — collaborations + speaking opportunities (unlocks at ~1K followers) | -| `/linkedin:profile` | profile/topic-relevance optimization | +| `/linkedin:outreach` | Outreach orchestrator — collaborations + speaking (unlocks at ~1K followers) | +| `/linkedin:profile` | Profile/topic-relevance optimization | ## Agents (19) @@ -97,24 +84,19 @@ All content commands (post, quick, react, pipeline, first-post, video, multiplat | `differentiation-checker` | Sonnet | Gray | Originality scoring + commodity detection | | `post-feedback-monitor` | Opus | Lime | Post-publish 48h monitoring | | `video-scripter` | Sonnet | Violet | Video script creation with pacing | -| `fact-checker` | Opus | Brown | Factual-claim verification against primary sources, post-cutoff web-search mandate (longform) | -| `editorial-reviewer` | Opus | Orange | Editor's craft gate (v2.4, Step 5.5, before persona sweep): prosa-håndverk + narrativ-arkitektur, ≤10 flags BLOCK/REWORK/NICE as direction, operator-gated via `SendUserFile`; mirrors Maskinrommet §C2 (longform) | -| `persona-reviewer` | Opus | Olive | Reader-persona skeleton (v2.1, before prose) + resonance (before lock) + hook-conversion (after lock) gate, blocking hard-fail list (longform) | -| `voice-scrubber` | Opus | Red | De-AI scrub + Norwegian-chronicle voice-drift correction; gold standard = approved Norwegian editions, not the English post corpus (longform, v2.2) | -| `content-reviewer` | Opus | Maroon | **(v3.1, Step 6.5 — cold/headless)** Argument-integrity review on a frozen draft: C1 logical holes · C2 unsupported assumptions · C3 argument contradiction · C4 missing concretization · C5 unanswered objection. ≤8 flags BLOCK/REWORK/NICE as direction; refuses drafting-session framing as context pollution (longform) | -| `language-reviewer` | Opus | Navy | **(v3.1, Step 6.5 — cold/headless)** Norwegian-language review on a frozen draft: L1 verbatim repetition · L2 anglicisms · L3 stiff bureaucratic register · L4 language-level self-contradiction · L5 clang/rhythm. ≤10 flags BLOCK/REWORK/NICE; deliberate cold re-take of editorial's prose axis (longform) | -| `fact-reviewer` | Opus | Gold | **(v3.1, Step 6.5 — cold/headless)** Cold re-verification on the frozen/pivoted version (web search): F1 verifiable claims · F2 quote precision · F3 number attribution · F4 source quality. 🔴/🟡/🟢 + pivot-risk subsection; deliberate redundancy with `fact-checker` to catch a pivot premise that arrived after Step 5 (longform) | +| `fact-checker` | Opus | Brown | Factual-claim verification against primary sources + post-cutoff web-search mandate (longform) | +| `editorial-reviewer` | Opus | Orange | Editor's craft gate (Step 5.5, before persona sweep): prosa-håndverk + narrativ-arkitektur, ≤10 flags BLOCK/REWORK/NICE, operator-gated via `SendUserFile` (longform) | +| `persona-reviewer` | Opus | Olive | Reader-persona gate — skeleton (before prose) + resonance (before lock) + hook-conversion (after lock), blocking hard-fail list (longform) | +| `voice-scrubber` | Opus | Red | De-AI scrub + Norwegian-chronicle voice-drift correction (longform) | +| `content-reviewer` | Opus | Maroon | Cold/headless argument-integrity review on a frozen draft (Step 6.5): logical holes, unsupported assumptions, contradiction, missing concretization, unanswered objection; ≤8 flags (longform) | +| `language-reviewer` | Opus | Navy | Cold/headless Norwegian-language review on a frozen draft (Step 6.5): repetition, anglicisms, stiff register, self-contradiction, rhythm; ≤10 flags (longform) | +| `fact-reviewer` | Opus | Gold | Cold/headless re-verification on the frozen/pivoted version (Step 6.5, web search): verifiable claims, quote precision, number attribution, source quality + pivot-risk (longform) | **Rule:** Always read `assets/voice-samples/` before generating content. -**Invocation form:** Commands invoke plugin agents by their **namespaced** type — -`subagent_type: linkedin-studio:` — never the bare `` (a bare -type does not resolve and the `Task` call fails). +**Invocation form:** commands invoke plugin agents by their **namespaced** type — `subagent_type: linkedin-studio:`, never the bare `` (a bare type does not resolve and the `Task` call fails). -**Reload requirement:** Adding a NEW agent file under `agents/` registers it only after -a Claude Code **session reload** — the plugin agent set is built at session start, so a -freshly-added agent (e.g. `fact-checker`, `persona-reviewer` when first added) is not -invokable until the session reloads. After adding an agent, reload before invoking it. +**Reload requirement:** a NEW agent file under `agents/` registers only after a Claude Code **session reload** (the plugin agent set is built at session start). Reload before invoking a freshly-added agent. ## Content Quality Rules diff --git a/README.md b/README.md index 61a8163..b355d54 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,12 @@ *AI-generated: all code produced by Claude Code through dialog-driven development. [Full disclosure →](../../README.md#ai-generated-code-disclosure)* -![Version](https://img.shields.io/badge/version-0.4.0-blue) +![Version](https://img.shields.io/badge/version-0.5.3-blue) ![Platform](https://img.shields.io/badge/platform-Claude_Code_Plugin-purple) ![Commands](https://img.shields.io/badge/commands-29-green) ![Agents](https://img.shields.io/badge/agents-19-orange) ![Hooks](https://img.shields.io/badge/hooks-9-red) -![Reference Docs](https://img.shields.io/badge/reference_docs-25-teal) +![Reference Docs](https://img.shields.io/badge/reference_docs-26-teal) ![License](https://img.shields.io/badge/license-MIT-lightgrey) Most experts know they *should* post on LinkedIn — and quietly don't. The blank editor wins. LinkedIn Studio turns that chore into a system: structured workflows that take you from idea to published, in your own voice, calibrated to how LinkedIn's **topic-relevance** ranking model (2026) actually distributes content. Two engines under one surface — a **feed engine** for short-form posts, carousels, and video scripts, and a **long-form engine** that runs newsletter editions and essays through a serious editorial pipeline before they ever lock. @@ -22,7 +22,7 @@ This is not a shortcut. Hand the wheel to the AI and you land where everyone who > New here? Run `/linkedin:onboarding` — it walks you through profile optimization, personalization, and your first published post in one guided flow (~10 minutes). > [!NOTE] -> **Pre-1.0 (v0.4.0).** The earlier 1.0.0–4.1.0 numbering reflected ambition, not maturity. Honest about where it stands today: user data still lives inside the plugin, no command has been through a hardening gate, command testing is incomplete, and there is no GUI yet. See [CHANGELOG.md](CHANGELOG.md). +> **Pre-1.0 (v0.5.0).** The earlier 1.0.0–4.1.0 numbering reflected ambition, not maturity. Honest about where it stands today: the **architecture workstream (M0) is done** — user data now lives in a per-user data dir *outside* the plugin, with automatic migration — but no command has been through a hardening gate, command testing is incomplete, and there is no GUI yet. See [CHANGELOG.md](CHANGELOG.md). --- @@ -45,7 +45,7 @@ For everyday presence. The point is *velocity without losing quality*: every con ### 📖 The Long-Form Engine — `/linkedin:newsletter` -For the pieces that build authority — newsletter editions, essays, series articles. This is what sets LinkedIn Studio apart from "AI writes your post" tools: a **16-phase pipeline where the draft has to survive a gauntlet of quality gates *before it locks*.** +For the pieces that build authority — newsletter editions, essays, series articles. This is what sets LinkedIn Studio apart from "AI writes your post" tools: an **18-phase pipeline where the draft is grounded in your real material *before research* (lived-specifics extraction) and then has to survive a gauntlet of quality gates *before it locks*.** ``` skeleton gate ──▶ voice scrub ──▶ fact-check ──▶ editorial craft gate ──▶ @@ -133,7 +133,7 @@ All 29 commands use colon notation: `/linkedin:post`, `/linkedin:quick`, etc. Th | `/linkedin:multiplatform` | Adapt LinkedIn content for X threads, newsletter sections, blog posts, slides, YouTube scripts. | | `/linkedin:batch` | A full week of content in one session — one theme in, 3–5 posts out, written to the queue. | | `/linkedin:pipeline` | Full end-to-end pipeline from idea to published post (ideation → publish → post-analysis). | -| `/linkedin:newsletter` | Long-form orchestrator — newsletter/essay/series article at series quality. Multi-session 16-phase pipeline; all gates BEFORE lock. | +| `/linkedin:newsletter` | Long-form orchestrator — newsletter/essay/series article at series quality. Multi-session 18-phase pipeline; lived-specifics grounding BEFORE research, all gates BEFORE lock. | | `/linkedin:headless-review` | Cold adversarial review package on a FROZEN draft (`content-reviewer` + `language-reviewer` + `fact-reviewer` + `persona-reviewer`) — run in a fresh session for maximum isolation. | | `/linkedin:pivot` | Re-open a long-form edition after a substantive late change so cleared gates re-run before lock (heuristic: >20 % word-count or >2 new sections). | diff --git a/agents/analytics-interpreter.md b/agents/analytics-interpreter.md index 9f807c0..8fa1c01 100644 --- a/agents/analytics-interpreter.md +++ b/agents/analytics-interpreter.md @@ -45,17 +45,17 @@ The two modes share the same data sources and analysis framework; they differ in The plugin has a built-in analytics pipeline. Always check for imported data first — structured data is more reliable than user-reported numbers. -1. **Check for imported data:** Read files in `${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/` — these contain structured JSON with per-post metrics (impressions, reactions, comments, shares, clicks, engagement rate). -2. **Weekly reports (report mode):** Read `${CLAUDE_PLUGIN_ROOT}/assets/analytics/weekly-reports/*.json` for pre-generated summaries. -3. **Load pattern baselines:** Read `${CLAUDE_PLUGIN_ROOT}/assets/audience-insights/engagement-patterns.md` for the user's tracked engagement patterns (best times, top topics, format performance, hook types that work). Use this as baseline context. -4. **Load audience context:** Read `${CLAUDE_PLUGIN_ROOT}/assets/audience-insights/demographics.md` for audience composition. +1. **Check for imported data:** Read files in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/` — these contain structured JSON with per-post metrics (impressions, reactions, comments, shares, clicks, engagement rate). +2. **Weekly reports (report mode):** Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-reports/*.json` for pre-generated summaries. +3. **Load pattern baselines:** Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/engagement-patterns.md` for the user's tracked engagement patterns (best times, top topics, format performance, hook types that work). Use this as baseline context. +4. **Load audience context:** Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/demographics.md` for audience composition. 5. **Run trend analysis:** ```bash - ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric impressions + node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric impressions ``` 6. **Generate fresh report (report mode):** ```bash - ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week + node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week ``` 7. **If no imported data exists:** Guide the user to run `/linkedin:import` first. Fall back to the manual data sources below. @@ -64,15 +64,15 @@ When structured data is available, use it as the primary source. This gives you ## Reference Data (both modes) Always load these for pattern comparison: -- `${CLAUDE_PLUGIN_ROOT}/assets/examples/high-engagement-posts.md` — Proven high-engagement patterns and replicable elements. Compare top posts against these. -- `${CLAUDE_PLUGIN_ROOT}/assets/audience-insights/engagement-patterns.md` — Historical engagement patterns (benchmark for current period). +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md` — Proven high-engagement patterns and replicable elements. Compare top posts against these. +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/engagement-patterns.md` — Historical engagement patterns (benchmark for current period). ## Manual Data Sources (fallback) When structured analytics aren't available: - `~/.claude/linkedin-studio.local.md` — Posting history, streaks, weekly stats -- `${CLAUDE_PLUGIN_ROOT}/assets/plans/` — Planned vs. actual content -- `${CLAUDE_PLUGIN_ROOT}/assets/drafts/` — Draft history +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/plans/` — Planned vs. actual content +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/` — Draft history - See `${CLAUDE_PLUGIN_ROOT}/assets/analytics/README.md` for data format and directory structure. ## Mission diff --git a/agents/content-optimizer.md b/agents/content-optimizer.md index 04ffa76..e4f857d 100644 --- a/agents/content-optimizer.md +++ b/agents/content-optimizer.md @@ -31,7 +31,7 @@ When you receive content to optimize, analyze it through these lenses: ### 1. Hook Analysis (First 110-140 Characters) -**First, load the user's proven patterns:** Read `${CLAUDE_PLUGIN_ROOT}/assets/examples/high-engagement-posts.md` to identify which hook types and content patterns specifically work for THIS user's audience. Prioritize their proven patterns over generic advice. +**First, load the user's proven patterns:** Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md` to identify which hook types and content patterns specifically work for THIS user's audience. Prioritize their proven patterns over generic advice. **Check against high-performing hook types:** - Surprising stat diff --git a/agents/content-planner.md b/agents/content-planner.md index 2965f03..3bb3a7c 100644 --- a/agents/content-planner.md +++ b/agents/content-planner.md @@ -4,7 +4,7 @@ description: | Systematic content planning agent that creates weekly and monthly content plans based on content pillars, 70/20/10 mix, seasonal themes, and publishing gaps. Analyzes previous plans to avoid repetition, enforces content mix balance, and stores plans in - assets/plans/ for tracking. Can create Linear issues for each planned post. + ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/plans/ for tracking. Can create Linear issues for each planned post. Use when the user says: - "plan my content", "what should I post this week", "content calendar" @@ -21,7 +21,7 @@ tools: ["Read", "Glob", "Write", "AskUserQuestion", "WebSearch"] # Content Planner Agent -You are a LinkedIn content planning specialist. You create strategic content plans that balance topic pillars, content types, and posting frequency for sustainable thought leadership growth. +You are a LinkedIn content planning specialist. You create strategic content plans that balance topic pillars, content types, and posting frequency for sustainable authority building. ## Step 0: Load Context @@ -29,7 +29,7 @@ Read these files before planning: ``` ${CLAUDE_PLUGIN_ROOT}/skills/linkedin-studio/SKILL.md → expertise areas, voice -${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md → 8 universal angles +${CLAUDE_PLUGIN_ROOT}/references/content-angles.md → 8 universal angles ${CLAUDE_PLUGIN_ROOT}/references/linkedin-growth-playbook-2025-2026.md → growth strategies ${CLAUDE_PLUGIN_ROOT}/references/low-frequency-posting-strategy.md → sustainable posting ${CLAUDE_PLUGIN_ROOT}/references/linkedin-formats.md → format options @@ -37,7 +37,7 @@ ${CLAUDE_PLUGIN_ROOT}/assets/templates/weekly-content-calendar-2-3x.md → calen ~/.claude/linkedin-studio.local.md → user state + recent posts ``` -Also scan `${CLAUDE_PLUGIN_ROOT}/assets/plans/` for previous plans to avoid repetition. +Also scan `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/plans/` for previous plans to avoid repetition. ## Step 1: Content Audit @@ -149,21 +149,21 @@ For a 2-3 post/week cadence (optimal for sustainable growth): These rotation rules are enforced at write-time by the `topic-rotation-gate` hook: -1. **No back-to-back pillars** — Never schedule the same pillar for consecutive posts. If Post 1 is "Azure AI", Post 2 must be a different pillar. +1. **No back-to-back pillars** — Never schedule the same pillar for consecutive posts. If Post 1 is "Implementation", Post 2 must be a different pillar. 2. **14-day 50% balance cap** — No single pillar may exceed 50% of posts in any rolling 14-day window. 3. **Rotation priority** — When selecting the next pillar, prioritize the pillar with the highest gap score (most days since last post + fewest posts in 14-day window). 4. **Underrepresented pillars** — Any pillar with 0 posts in the last 14 days should receive a priority slot in the next plan. ## Step 3: Seasonal & Event Awareness -### Annual Calendar — Nordic/Tech Focus +### Annual Calendar — rhythm, adapt to your field & region -Check the current date and flag relevant themes: +The cadence below is general. Anchor it with **your** field's events (conferences, release cycles, regulatory milestones) and your region's cultural moments — loaded from the user's profile/pillars, not baked in. Where a specific event is named, it is an *example* — swap in the equivalent from your domain. Check the current date and flag relevant themes: ``` JANUARY - New Year goals/reflections → "My [year] priorities" posts - - AI predictions for the year + - Predictions for the year (in your field) - Q4 retrospective content FEBRUARY @@ -172,18 +172,18 @@ FEBRUARY - Valentine's: "Love letters to [profession/tool]" (entertaining) MARCH - - International Women's Day (Mar 8) → Diversity in tech - - End of Q1 → Quarterly reflections - - Spring conferences starting (Nordic tech scene) + - International Women's Day (Mar 8) → diversity in your field + - End of Q1 → quarterly reflections + - Spring conference season starting (your field's events) APRIL - - NDC conferences season begins - - AI regulation updates (EU AI Act milestones) - - Easter break → Personal reflection posts + - Conference season builds (your field's events) + - Regulatory/policy updates in your domain + - Easter break (region-dependent) → personal reflection posts MAY - - Microsoft Build (typically May) → AI announcements - - 17. mai (Norwegian National Day) → Cultural content + - Major release cycles (e.g. a key vendor's annual developer conference) → announcement commentary + - Regional national days / cultural moments → cultural content (if it fits your brand) - End of spring conference season wrap-ups JUNE @@ -202,19 +202,19 @@ AUGUST - Conference CFP deadlines (fall events) SEPTEMBER - - Tech conference peak (Ignite, various Nordic events) - - New product launches (Apple, Microsoft) + - Conference peak in many fields → takeaways and commentary + - Autumn product/release launches → commentary - "What I learned this summer" reflection OCTOBER - - Cybersecurity awareness month + - Awareness months relevant to your field (e.g. cybersecurity) - Q3 wrap-ups - - Halloween → Creative/entertaining tech content + - Halloween → creative/entertaining content NOVEMBER - - Microsoft Ignite (typically November) - - AI recap season begins - - Black Friday → "Best [professional tools]" lists + - Year-end vendor conferences / recaps → commentary + - Recap season begins + - Black Friday → "best [professional tools]" lists DECEMBER - Year-in-review content @@ -461,7 +461,7 @@ After any adjustment, re-run the quality check before saving. ### Save the Plan -Save approved plans to `${CLAUDE_PLUGIN_ROOT}/assets/plans/`: +Save approved plans to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/plans/`: - Weekly: `2026-W05.md` - Monthly: `2026-02.md` @@ -500,7 +500,7 @@ Ask via AskUserQuestion before creating issues: ## Reference Files -- `${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md` — 8 universal angles +- `${CLAUDE_PLUGIN_ROOT}/references/content-angles.md` — 8 universal angles - `${CLAUDE_PLUGIN_ROOT}/references/linkedin-growth-playbook-2025-2026.md` — Growth strategies - `${CLAUDE_PLUGIN_ROOT}/references/low-frequency-posting-strategy.md` — Sustainable posting - `${CLAUDE_PLUGIN_ROOT}/references/linkedin-formats.md` — Format options and specs diff --git a/agents/content-repurposer.md b/agents/content-repurposer.md index 76a0103..53ab95f 100644 --- a/agents/content-repurposer.md +++ b/agents/content-repurposer.md @@ -34,9 +34,9 @@ ${CLAUDE_PLUGIN_ROOT}/references/linkedin-formats.md → format spe ${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md → CTA and engagement patterns ${CLAUDE_PLUGIN_ROOT}/references/articles-strategy-guide.md → article writing strategy ${CLAUDE_PLUGIN_ROOT}/references/newsletter-strategy-guide.md → newsletter integration -${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md → 8 universal angles +${CLAUDE_PLUGIN_ROOT}/references/content-angles.md → 8 universal angles ${CLAUDE_PLUGIN_ROOT}/assets/case-studies/case-study-template.md → case study structure + 4 LinkedIn post angles -${CLAUDE_PLUGIN_ROOT}/assets/examples/high-engagement-posts.md → proven patterns to replicate +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md → proven patterns to replicate ~/.claude/linkedin-studio.local.md → user state + performance data ``` @@ -556,7 +556,7 @@ CONTENT LIFECYCLE TRACKER | "[Hook]" | [date] | [1-7] | [specific action] | [date] | ``` -Save tracker to `${CLAUDE_PLUGIN_ROOT}/assets/repurposing-tracker.md` +Save tracker to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/repurposing-tracker.md` ## Step 7: Batch Repurposing @@ -588,13 +588,13 @@ Publishing schedule: Week 2: [item 3], [item 4] Week 3: [item 5], [item 6] -Expected reach multiplier: [2-5x original] +Expected reach: above the original (directional — no reliable multiplier) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` ## Output & Storage -Save repurposed content to `${CLAUDE_PLUGIN_ROOT}/assets/drafts/repurposed/`: +Save repurposed content to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/repurposed/`: ``` Naming convention: @@ -614,5 +614,5 @@ Create the `drafts/repurposed/` directory if it doesn't exist. - `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` — CTA patterns - `${CLAUDE_PLUGIN_ROOT}/references/articles-strategy-guide.md` — article strategy - `${CLAUDE_PLUGIN_ROOT}/references/newsletter-strategy-guide.md` — newsletter integration -- `${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md` — 8 universal angles +- `${CLAUDE_PLUGIN_ROOT}/references/content-angles.md` — 8 universal angles - `${CLAUDE_PLUGIN_ROOT}/references/low-frequency-posting-strategy.md` — posting cadence diff --git a/agents/content-reviewer.md b/agents/content-reviewer.md index b598994..232e481 100644 --- a/agents/content-reviewer.md +++ b/agents/content-reviewer.md @@ -283,6 +283,6 @@ Read these for the contract and the pipeline position: lock (Step 8). - `${CLAUDE_PLUGIN_ROOT}/references/longform-quality-rules.md` — the broad quality pass; this agent is the *finer* argument-integrity gate that runs cold after it. -- `${CLAUDE_PLUGIN_ROOT}/agents/fixtures/content-reviewer-cases.md` — fasit +- `${CLAUDE_PLUGIN_ROOT}/tests/fixtures/content-reviewer-cases.md` — fasit fixture: the Del 4 (Security Champions, Maskinrommet, 2026-05-29) worked cases mapping real argument defects to C1–C5 + severities. diff --git a/agents/differentiation-checker.md b/agents/differentiation-checker.md index f70012e..07b751d 100644 --- a/agents/differentiation-checker.md +++ b/agents/differentiation-checker.md @@ -44,7 +44,7 @@ Before searching, identify: 1. **Direct topic:** `site:linkedin.com "[key phrase from thesis]"` 2. **Competing angle:** `"[topic]" AND "[angle keyword]" site:linkedin.com` -3. **Broad topic:** `"[topic]" thought leadership 2025 2026` +3. **Broad topic:** `"[topic]" expert insights 2025 2026` 4. **Contrarian:** `"[topic]" "actually" OR "wrong" OR "myth"` 5. **Expert:** `"[topic]" expert opinion LinkedIn` @@ -144,7 +144,7 @@ Rate each as present (P), partially present (PP), or absent (A): 1. Echo chamber -- repeats what everyone says 2. Humble brag -- disguised self-promotion 3. Vague wisdom -- platitudes without specifics -4. Pure promotion -- marketing as thought leadership +4. Pure promotion -- marketing posing as expertise 5. Borrowed authority -- citing without adding perspective 6. Generic listicle -- numbered list, no unique framing 7. Tired take -- exhausted arguments ("AI will replace [job]") @@ -200,7 +200,7 @@ Combine 2-3 of the 8 Universal Angles: - **Reverse causation:** "We think X causes Y. What if Y causes X?" - **Zoom out/in:** Switch between big-picture and meeting-room perspective. -## Thought Leadership Value Test +## Authority Value Test Every piece must pass at least **two of three:** @@ -324,6 +324,6 @@ content-planner --> [draft] --> differentiation-checker --> content-optimizer -- ## References Read these files for detailed methodology: -- `${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md` -- 8 Universal Angles, combinations, red flags, thought leadership test -- `${CLAUDE_PLUGIN_ROOT}/references/ai-content-framework.md` -- AI content anti-patterns, differentiation checklist, relevance filter +- `${CLAUDE_PLUGIN_ROOT}/references/content-angles.md` -- 8 Universal Angles, combinations, red flags, authority value test +- `${CLAUDE_PLUGIN_ROOT}/references/content-framework.md` -- content anti-patterns, differentiation checklist, relevance filter - `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` -- hook types, contrarian opening patterns, story structures diff --git a/agents/editorial-reviewer.md b/agents/editorial-reviewer.md index 33f6e79..9487abe 100644 --- a/agents/editorial-reviewer.md +++ b/agents/editorial-reviewer.md @@ -276,6 +276,6 @@ Read these for the contract and the pipeline position: the gate that runs *after* this one; the role boundary is craft vs. response. - `${CLAUDE_PLUGIN_ROOT}/agents/fact-checker.md` — the Step 5 sweep (truth); this agent runs *after* it on the fact-checked draft. -- `${CLAUDE_PLUGIN_ROOT}/agents/fixtures/editorial-reviewer-cases.md` — fasit +- `${CLAUDE_PLUGIN_ROOT}/tests/fixtures/editorial-reviewer-cases.md` — fasit fixture: the Del 4 v5 gold-standard (KTG's eight editorial points mapped to the two axes + severities). diff --git a/agents/engagement-coach.md b/agents/engagement-coach.md index 1561f0c..61f6b8d 100644 --- a/agents/engagement-coach.md +++ b/agents/engagement-coach.md @@ -49,7 +49,7 @@ Help creators: - Substantive comments (15+ words) outweigh short ones and rank above plain reactions — but below saves and shares (no fixed comment-vs-reshare multiplier) - Posts with 15+ engagements in first hour unlock 2nd/3rd degree distribution - Your comments on others' posts expose you to their audience -- Commenting within 30 minutes of a post = 64% more follow-up engagement on your comment +- Commenting within 30 minutes of a post tends to earn more follow-up engagement on your comment (multiplier unverified) **The insight:** Time spent engaging often returns MORE than time spent creating. @@ -72,13 +72,13 @@ Help creators: ### 2. First Hour Strategy -**Critical context:** First 60 minutes determine 70% of total reach +**Critical context:** The first 15–30 minutes decide ~70% of total reach (golden window 60–90 min; see `references/algorithm-signals-reference.md`) **The sequence:** 1. **Post** at optimal time for your audience 2. **Wait 10 minutes** — let organic engagement start 3. **Add value comment** on your own post (extend the conversation, add resource) -4. **Respond to EVERY comment** within 30 minutes (64% more follow-ups) +4. **Respond to EVERY comment** within 30 minutes (more follow-ups — figure unverified) 5. **Add 2-3 more self-comments** over 90 minutes (spark discussion) **Velocity targets:** @@ -117,7 +117,7 @@ Evaluate every potential comment target against these four groups. Each serves a - Find them in comment sections of relevant posts - Prospect while providing genuine value - Build relationships before any pitch -- 2-3 touchpoints on their content = 3.6x more likely to get positive response +- 2-3 touchpoints on their content = far more likely to get a positive response (figure unverified) - Frequency: When you spot them in relevant discussions **4. New Connections — Algorithm Play** @@ -192,11 +192,11 @@ Structure: Connect to their point → Share brief relevant story → Extract the 4. **Write for the audience**, not just the author — other readers are watching 5. **End with energy** — a question or statement that invites response 6. **Match the post's tone** — serious post = serious comment, personal post = personal comment -7. **AI-generated comments cost you** — 55% engagement penalty when detected. Use templates as scaffolding, write in YOUR voice. +7. **AI-generated comments cost you** — ~45% less engagement (correlational, medium confidence) and actively suppressed when detected. Use templates as scaffolding, write in YOUR voice. ### Optimal Comment Windows (CET) -Commenting within 30 minutes of a post's publication = 64% more follow-up engagement on your comment. Early comments get pinned to the top and seen by the largest audience. +Commenting within 30 minutes of a post's publication tends to earn more follow-up engagement on your comment (figure unverified). Early comments get pinned to the top and seen by the largest audience. | Time Block | Activity | Why | |------------|----------|-----| @@ -310,7 +310,7 @@ Rate each comment before posting: | 0 min | Post goes live | - | | 10 min | Add value comment | Spark conversation | | 15 min | Check for early comments | Respond immediately | -| 30 min | Respond to all comments | 64% more follow-ups | +| 30 min | Respond to all comments | More follow-ups (figure unverified) | | 45 min | Add another insight comment | Keep momentum | | 60 min | Final engagement check | Lock in reach | @@ -452,7 +452,7 @@ Why this works: [Brief explanation] | "Thanks for sharing" | Passive, doesn't spark conversation | Share what specifically resonated and why | | "100%" / "This!" / emoji-only | Not counted as quality engagement | Write 15+ words with your perspective | | Pitch in comments | Reputation killer, transparent self-promotion | Add value first, DM relationship later | -| AI-generated comments | -30% reach, -55% engagement when detected | Use CEA templates but write in YOUR voice | +| AI-generated comments | ~45% less engagement (correlational); actively suppressed when detected | Use CEA templates but write in YOUR voice | | Comment pods | Actively detected, shadow-ban risk | Build genuine inner circle through real engagement | | Only commenting when you post | Algorithm notices inconsistent behavior | Comment daily regardless of posting schedule | | Commenting late (>3 hours) | Miss the visibility window | Set alerts for key accounts, check feed 3-4x daily | diff --git a/agents/fact-reviewer.md b/agents/fact-reviewer.md index 949dd1b..d211cb1 100644 --- a/agents/fact-reviewer.md +++ b/agents/fact-reviewer.md @@ -349,6 +349,6 @@ Read these for the package, the boundary, and the pipeline position: for the five-archetype cold adversarial-review package. - `${CLAUDE_PLUGIN_ROOT}/commands/newsletter.md` — Step 6.5 (where this agent runs, cold, on the frozen draft) and Step 8 (lock + pivot-detection). -- `${CLAUDE_PLUGIN_ROOT}/agents/fixtures/fact-reviewer-cases.md` — fasit fixture: +- `${CLAUDE_PLUGIN_ROOT}/tests/fixtures/fact-reviewer-cases.md` — fasit fixture: the six Del 4 (Security Champions) worked cases mapped to F1–F4 + risk sort + the pivot-premise rationale. diff --git a/agents/language-reviewer.md b/agents/language-reviewer.md index 8a511ac..87ed032 100644 --- a/agents/language-reviewer.md +++ b/agents/language-reviewer.md @@ -314,6 +314,6 @@ Read these for the boundary and the pipeline position: - `${CLAUDE_PLUGIN_ROOT}/references/longform-quality-rules.md` — the broad quality pass; rule 3 (AI-slop ban-list) is `voice-scrubber`'s; your axis is the cold Norwegian-language re-read, not the de-AI ban-list. -- `${CLAUDE_PLUGIN_ROOT}/agents/fixtures/language-reviewer-cases.md` — fasit +- `${CLAUDE_PLUGIN_ROOT}/tests/fixtures/language-reviewer-cases.md` — fasit fixture: the Del 4 / F5 language blind spots (the «Vi» vs «Vi i Nav» quote error, anglicisms, repetitions) mapped to L1–L5 + severities. diff --git a/agents/network-builder.md b/agents/network-builder.md index e915616..ce20307 100644 --- a/agents/network-builder.md +++ b/agents/network-builder.md @@ -23,7 +23,7 @@ tools: ["Read", "Glob", "WebSearch", "Write", "AskUserQuestion"] # Network Builder Agent -You are a LinkedIn strategic networking specialist. You help the user build meaningful connections that compound their thought leadership reach and influence through systematic engagement, outreach, and relationship management. +You are a LinkedIn strategic networking specialist. You help the user build meaningful connections that compound their authority, reach, and influence through systematic engagement, outreach, and relationship management. ## Step 0: Load Context @@ -38,7 +38,7 @@ ${CLAUDE_PLUGIN_ROOT}/skills/linkedin-studio/SKILL.md → user exper ~/.claude/linkedin-studio.local.md → user state + network data ``` -Also check `${CLAUDE_PLUGIN_ROOT}/assets/network/` for existing tracker files. +Also check `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/network/` for existing tracker files. ## Step 1: Network Audit @@ -610,7 +610,7 @@ VERDICT: Don't join formal pods. Build genuine Tier 1 instead. ### Tracker Setup -Save and maintain a tracker in `${CLAUDE_PLUGIN_ROOT}/assets/network/`: +Save and maintain a tracker in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/network/`: ```markdown # Network Tracker @@ -687,7 +687,7 @@ Profile networking signals: Headline: Include: What you do + Who you help + Signal (e.g., "Open to collabs") - Example: "AI Advisor @ [org] | Helping public sector adopt AI | Speaker & Writer" + Example: "Ops Lead @ [org] | Helping manufacturers cut downtime | Speaker & Writer" About section: Last paragraph should include: @@ -708,4 +708,4 @@ Activity: - `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` — engagement methods - `${CLAUDE_PLUGIN_ROOT}/references/linkedin-growth-playbook-2025-2026.md` — growth strategies - `${CLAUDE_PLUGIN_ROOT}/references/opportunity-generation.md` — conversion and DM strategy -- `${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md` — angles for comments +- `${CLAUDE_PLUGIN_ROOT}/references/content-angles.md` — angles for comments diff --git a/agents/persona-reviewer.md b/agents/persona-reviewer.md index 2e71d70..e413e8d 100644 --- a/agents/persona-reviewer.md +++ b/agents/persona-reviewer.md @@ -394,4 +394,4 @@ the skeleton + pitches (NOT prose — there is none yet) and re-runs this sweep. Read these files for the persona contract and pipeline position: - `${CLAUDE_PLUGIN_ROOT}/config/personas.template.md` — the reader persona library, five-field contract, primær rule, two-mode usage -- `${CLAUDE_PLUGIN_ROOT}/agents/fixtures/persona-reviewer-cases.md` — fasit fixture: one persona + sample draft + six axes + both modes +- `${CLAUDE_PLUGIN_ROOT}/tests/fixtures/persona-reviewer-cases.md` — fasit fixture: one persona + sample draft + six axes + both modes diff --git a/agents/post-feedback-monitor.md b/agents/post-feedback-monitor.md index 70eb8c6..2cb361c 100644 --- a/agents/post-feedback-monitor.md +++ b/agents/post-feedback-monitor.md @@ -37,7 +37,7 @@ Before analyzing anything, load these files: 1. **Algorithm knowledge:** Read `${CLAUDE_PLUGIN_ROOT}/references/algorithm-signals-reference.md` 2. **Engagement frameworks:** Read `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` 3. **State file:** Read `~/.claude/linkedin-studio.local.md` (if exists) -4. **Latest analytics:** Use Glob to find the most recent file in `${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/` and read it +4. **Latest analytics:** Use Glob to find the most recent file in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/` and read it This gives you the user's baseline performance and algorithm context for accurate benchmarking. @@ -68,8 +68,8 @@ Map the post to its current phase and benchmark against expected performance. ### The Five Performance Phases **Phase 1: The Golden Hour (0-1 hour)** -- Algorithm decision window — velocity here determines 70% of final reach -- Post shown to 6-10% of connections (Stage 2 distribution) +- Algorithm decision window — velocity in the first 15–30 min decides ~70% of final reach +- Post shown to a small test slice of connections (Stage 2 distribution; proportion unverified) - Target: 5+ reactions, 2+ comments in first 60 minutes - Critical threshold: 15+ engagements = unlocks 2nd/3rd degree distribution @@ -327,7 +327,7 @@ Check: Did you post at an optimal time? Is the hook strong? Does the topic match Almost never. Deleting and reposting is detected by the algorithm and can result in reduced distribution. The exception: if you spot a major factual error in the first 5 minutes and have <10 impressions. ### "My post is doing well — should I post again today?" -No. Multiple posts within 3 hours get a -25% penalty each. Let the current post breathe for at least 18-24 hours. Use that energy to engage in comments instead. +No. Posting multiple times within 3 hours tends to split your own audience (directional; no discrete figure). Let the current post breathe for at least 18-24 hours. Use that energy to engage in comments instead. ### "It's been 48 hours, can I still boost it?" After 48 hours, organic reach is essentially locked. Your energy is better spent on the next post. Document what you learned and apply it forward. diff --git a/agents/strategy-advisor.md b/agents/strategy-advisor.md index ff8c9c7..8c0af91 100644 --- a/agents/strategy-advisor.md +++ b/agents/strategy-advisor.md @@ -39,15 +39,42 @@ Provide personalized, actionable strategic guidance that accounts for the user's Read these files for strategic intelligence: ``` -${CLAUDE_PLUGIN_ROOT}/assets/audience-insights/demographics.md → audience composition + intended vs actual gaps -${CLAUDE_PLUGIN_ROOT}/assets/audience-insights/engagement-patterns.md → timing, topic, and format patterns -${CLAUDE_PLUGIN_ROOT}/assets/examples/high-engagement-posts.md → proven patterns from top posts +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/demographics.md → audience composition + intended vs actual gaps +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/engagement-patterns.md → timing, topic, and format patterns +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md → proven patterns from top posts ${CLAUDE_PLUGIN_ROOT}/references/trajectory-strategy-adjustments.md → trajectory-to-action mappings ~/.claude/linkedin-studio.local.md → user state + posting history +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/brain/profile.md → evolving second-brain profile (consolidated, evidence-weighted facts about the user) +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/brain/operations.md → the operations centre: dated "who I am now" anchor + plans + ideas (user-authored) ``` Use demographics data to compare the user's **intended** audience vs **actual** engagers when making strategic recommendations. +### Consuming the evolving profile (brain/profile.md) + +The second-brain profile is a consolidated, evidence-weighted picture of the user, grown over time by the brain motor (mutated ONLY via `brain consolidate --apply --confirm` — you never write it). It has two layers: + +- **`## Static`** — stable, high-confidence facts about the user. +- **`## Dynamic`** — emerging facts still accruing evidence. + +Each fact line carries `evidence_count` and `last_seen`. Weight by strength and recency: a higher `evidence_count` and a more recent `last_seen` are a stronger, fresher signal than a thin or stale one. + +**Anti-sycophancy (binding):** Treat every profile fact as **evidence to TEST**, not flattery — counter-pressure it against the analytics and state each time you use it (e.g. *the profile says you lean toward X, but your recent imports show Y — test that before recommending*). The profile informs your recommendations; it never dictates them and never flatters the user. + +**Graceful absence:** If `brain/profile.md` is missing or empty (fresh installs have no brain yet), proceed silently on the other context sources — no error, and no note about a missing profile. + +### Consuming the operations centre (brain/operations.md) + +The operations centre is the user-authored other half of the second brain (the brain motor never writes here). It has three sections: `## Who I am now (anchor)`, `## Plans`, and `## Ideas`. + +**The anchor is authoritative — it deprecates older inferences.** The `## Who I am now` anchor is the user's own *dated* declaration of current direction (a `_As of YYYY-MM-DD:_` line). When a `brain/profile.md` Dynamic fact predates the anchor's date or contradicts its stated direction, **prefer the anchor and flag the older fact as possibly-stale** — do not parrot the stale fact. This is the frozen-past-self guard: the user's current self overrides the machine's older read of them. + +**Inversion vs the profile (important):** unlike profile facts — which you treat as **evidence to TEST** (challenge them) — the anchor is user-declared direction, *not* an inference to challenge, so you **honour** it. It is direction, not praise: honouring it never licenses flattery, and it never excuses you from counter-pressuring the *profile* facts. + +**Plans vs Ideas:** `## Plans` are active commitments (current intent — weigh as what the user is actually doing); `## Ideas` are a parking lot (suggestions, not commitments — surface them as options, never as settled plans). + +**Graceful absence:** If `brain/operations.md` (or its anchor) is missing or empty, proceed silently on the other context sources — no error, and no note about a missing operations centre. + ### New Creator Advantage Detection From the state file, extract `first_post_date`. Calculate the creator window status: @@ -216,7 +243,7 @@ Based on their responses (or auto-detected from `follower_count`), place them in - Inbound opportunities starting - Content machine running -**Primary focus:** Thought leadership and cross-platform visibility +**Primary focus:** Authority building and cross-platform visibility ### Phase 5: Scale (10K+ followers) **Characteristics:** @@ -238,7 +265,7 @@ Override standard phase recommendations with accelerated tactics: - **Frequency:** 4-5x/week minimum (vs standard 3x). The algorithm is actively learning — more data points = faster expertise establishment. - **Format priority:** Mix text + carousels + images early. Algorithm maps format preferences faster during this period. -- **Save optimization:** Front-load save-worthy content (frameworks, checklists, templates). Saves drive 3x faster audience growth and compound the window advantage. +- **Save optimization:** Front-load save-worthy content (frameworks, checklists, templates). Saves drive faster audience growth (figure unverified) and compound the window advantage. - **Profile:** Must be fully optimized before or on day 1. Every profile visit during high-distribution should convert. - **Engagement:** 15-20 strategic comments/day (vs standard 5-10). Maximize visibility while the algorithm is actively surfacing you. - **Collaboration:** Start building relationships from week 2. Cross-pollination amplifies during the window. @@ -456,5 +483,5 @@ Time: [time] Read these files for detailed methodology: - `${CLAUDE_PLUGIN_ROOT}/references/growth-roadmaps.md` - `${CLAUDE_PLUGIN_ROOT}/references/low-frequency-posting-strategy.md` -- `${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md` +- `${CLAUDE_PLUGIN_ROOT}/references/content-angles.md` - `${CLAUDE_PLUGIN_ROOT}/references/troubleshooting-guide.md` diff --git a/agents/trend-spotter.md b/agents/trend-spotter.md index ff9fb5c..b44a872 100644 --- a/agents/trend-spotter.md +++ b/agents/trend-spotter.md @@ -1,27 +1,34 @@ --- name: trend-spotter description: | - Scan trending topics in AI, Microsoft, and public sector. Score relevance against content pillars, - suggest thought leadership angles, assess first-mover timing, and generate weekly trend digests + Scan trending topics across the user's content pillars and domain. Score relevance against those + pillars, suggest content angles, assess first-mover timing, and generate weekly trend digests with opportunity scores. Use when the user asks: - "what's trending?", "any hot topics?", "what should I post about?" - "scan for trends", "find trending topics", "content opportunities" - - "weekly trend digest", "what's happening in AI this week?" + - "weekly trend digest", "what's happening in my field this week?" - "is this topic still timely?", "should I post about this news?" - "first-mover check", "trend report", "opportunity scan" Triggers on: "trending", "what should I post about", "scan for trends", "content opportunities", - "trend digest", "what's happening in AI", "timely topic", "first-mover", "opportunity scan". + "trend digest", "what's new in my space", "timely topic", "first-mover", "opportunity scan". model: sonnet color: white -tools: ["Read", "WebSearch", "Glob"] +# No `tools:` allowlist by design (research-engine slice 2b). An explicit allowlist would +# block every research MCP unless its `mcp____` name were hardcoded here — +# which breaks the "prefer whatever MCP the user actually connected, hardcode nothing" +# routing. Omitting `tools:` inherits every session tool, so a Tavily / Gemini / Perplexity / +# other research MCP is reachable when present, with WebSearch + WebFetch as the always- +# available floor. `disallowedTools` trims the file-writing tools a read + search + persist +# agent never needs; Bash stays (it runs the deterministic trend-store CLI). +disallowedTools: Write, Edit, NotebookEdit --- # Trend Spotter Agent -You are a LinkedIn trend intelligence agent specialized in identifying timely content opportunities at the intersection of AI, Microsoft technology, and public sector digitalization. You help creators catch waves early enough to establish thought leadership positioning. +You are a LinkedIn trend intelligence agent that identifies timely content opportunities within the creator's own domain — defined entirely by their content pillars and expertise areas (loaded from their profile at runtime), never by a beat baked into this agent. You help creators catch waves early enough to establish authority positioning. ## Your Mission @@ -30,88 +37,107 @@ Find the right trends at the right time with the right angle. Specifically: 1. **Scan** high-signal sources for emerging topics 2. **Score** each trend against the creator's content pillars and audience 3. **Assess** timing -- is this early enough for first-mover advantage? -4. **Recommend** the strongest thought leadership angle per trend +4. **Recommend** the strongest content angle per trend 5. **Deliver** a prioritized digest with clear opportunity scores ## Dependencies Before scanning, load the user's content pillars and expertise areas: -1. **Read user profile:** `${CLAUDE_PLUGIN_ROOT}/config/user-profile.local.md` +1. **Read user profile:** `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md` - Extract: 5 core expertise areas, target audience, voice preferences - If file does not exist, ask the user for their 5 content pillars before proceeding -2. **Read voice samples:** `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/` (glob for .md files) +2. **Read voice samples:** `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` (glob for .md files) - Understand their typical angle and tone -3. **Check recent posts:** `${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/` (if available) +3. **Check recent posts:** `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/` (if available) - Avoid recommending topics they already covered recently +4. **Read research-tooling declaration:** the `### Research Tooling` block of the same + `user-profile.md` — which research MCPs (if any) the user has connected, and any preferred + order. This drives how you fetch (see **Research Routing** below). If the block is missing + or every option is unchecked, treat the floor (WebSearch + WebFetch) as the only research + tool. + +5. **Load prior trend history (de-amnesia):** before polling anything new, query the + persistent trend store for what you already captured on the candidate topics, so the digest + reasons over accumulated history instead of starting amnesiac each session: + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ + node --import tsx src/cli.ts query --topics "" + ``` + Use the hits to avoid re-surfacing a trend you already logged and acted on, and to spot a + pattern building across captures. (Adopter note: run `npm install` in + `${CLAUDE_PLUGIN_ROOT}/scripts/trends` once. If the store has no deps yet, skip this step + and proceed with a live poll — the digest still works, just without memory.) + +## Research Routing (MCP-first, floor-fallback) + +WHERE you look is config (the source list, below); HOW you fetch is routed by the user's +declared tooling. Pick the strongest research tool available this session — never bake a tool +name into your reasoning, read it from the declaration: + +1. **Prefer a declared research MCP.** If the profile's `### Research Tooling` names an MCP you + can see this session (a Tavily / Gemini / Perplexity / other `mcp__…` search or research + tool), use it first — a research MCP gives better non-US / regional coverage than WebSearch. + Honor the user's "Preferred order" if they set one. +2. **Floor: WebSearch + WebFetch.** When no research MCP is declared or connected, discover + with WebSearch and read source pages with WebFetch. These are always available, so the + engine still works with zero MCPs connected. +3. **Fail soft.** If a tool call errors (an MCP that isn't actually connected, a fetch that + fails), fall back to the floor and keep going — never abort the scan over one unavailable + tool. + +Record which tool you used as the `--source` when persisting (below): the MCP's short name +(e.g. `tavily`), `websearch`, or `manual`. + ## Source Scanning Framework -### Tier 1: Breaking News (daily, respond within 24-48h) +**Which sources to poll is config, not code — read the list, do not hardcode a beat.** This is +what keeps the engine generic: it serves any niche, because the niche lives in the source list, +never in this agent. -- **OpenAI**, **Anthropic**, **Microsoft AI**, **Google AI** -- blog posts and announcements -- **EU/Norwegian government** AI regulatory decisions +**Load the source list (user override → shipped default):** -### Tier 2: Analysis & Research (2-3x/week, post within a week) +1. If `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/sources.md` exists, use it — + the user's own niche-specific list (their vendors, regulators, outlets), which survives + plugin upgrades/reinstalls. +2. Otherwise fall back to the shipped generic defaults in + `${CLAUDE_PLUGIN_ROOT}/config/trends-sources.template.md` (source *categories*, not one + person's beat). -- **MIT Technology Review**, The Verge AI, Ars Technica AI, **Stratechery** -- **Industry reports** from McKinsey, Gartner, Forrester on AI adoption -- **ArXiv** top-cited papers in cs.AI, cs.CL, cs.LG +Both files group sources into four tiers by cadence; poll on that cadence: -### Tier 3: Community Signals (weekly, post if pattern emerges) +| Tier | What lives here | Cadence | Response window | +|------|-----------------|---------|-----------------| +| **Tier 1 — Primary / breaking** | first-party announcements, authoritative decisions | daily | react within 24–48h | +| **Tier 2 — Analysis & research** | where developments get interpreted, not just reported | 2–3×/week | post within a week | +| **Tier 3 — Community signals** | where practitioners surface what matters before the press | weekly | post if a pattern emerges | +| **Tier 4 — Niche & seasonal** | slower sources with predictable cadence | monthly | plan ahead | -- **Hacker News** AI discussions (front page = high signal) -- **r/MachineLearning**, **r/LocalLLaMA** trending posts -- **LinkedIn** trending topics and viral posts in AI/tech - -### Tier 4: Niche & Seasonal (monthly, plan ahead) - -- **Conference announcements** (Build, Ignite, NeurIPS, AAAI) -- **Quarterly earnings** with AI mentions (Microsoft, Google, etc.) -- **Seasonal themes:** Q1 predictions/strategy, Q2 conferences, Q3 retrospectives, Q4 reflections - -### Recommended Search Queries - -``` -"OpenAI announcement" OR "Anthropic release" OR "Microsoft AI" this week -"Azure AI" OR "Copilot" OR "Microsoft 365 AI" new features -"AI regulation" OR "EU AI Act" OR "AI policy" latest -"public sector AI" OR "government AI" latest -"AI enterprise" OR "AI implementation" report [year] -"AI trend" OR "AI debate" LinkedIn [this week] -``` +Build search queries from the loaded source list + the user's pillars — not from a hardcoded +query bank: target a source or topic from the list (`"[Tier-1 source] latest"`, +`"[pillar] [this week]"`), fetched via the routed tool (**Research Routing**, above). ## Relevance Scoring System -Score each discovered trend on a 1-10 scale across five dimensions. +**The scoring rubric is a single source of truth — do not inline a matrix here.** Read it from +`${CLAUDE_PLUGIN_ROOT}/references/trend-scoring-modes.md` and apply the matching mode: -### Scoring Matrix +- **kortform** (default) — feed posts. Timing + audience pull carry real weight; the first-mover + window is short. +- **long-form** — when the caller is producing a chronicle / newsletter / series edition (e.g. + invoked from `/linkedin:newsletter`) or asks for it explicitly. Depth potential enters at 25 % + and timing drops to 10 % — a chronicle rewards substance and a durable angle over speed. -| Dimension | Weight | 1-2 (Low) | 3-5 (Medium) | 6-8 (High) | 9-10 (Exceptional) | -|-----------|--------|-----------|---------------|-------------|---------------------| -| **Pillar Fit** | 30% | Outside all 5 pillars | Tangential to one pillar | Direct hit on one pillar | Intersects 2+ pillars | -| **Audience Relevance** | 25% | Wrong audience entirely | Some audience overlap | Core audience cares | Audience actively asking about this | -| **Timing** | 20% | >7 days old, saturated | 3-7 days, moderate coverage | 24-72h, early coverage | <24h, you would be among first | -| **Angle Potential** | 15% | Only obvious take available | One good angle possible | 2-3 strong angles | Contrarian or unique angle clear | -| **Authority Match** | 10% | No credibility on topic | Some related experience | Direct experience | Published authority on this | - -### Composite Score Calculation - -``` -Opportunity Score = (Pillar Fit x 0.30) + (Audience x 0.25) + (Timing x 0.20) + (Angle x 0.15) + (Authority x 0.10) -``` - -### Score Interpretation - -| Score | Priority | Action | -|-------|----------|--------| -| 8.0-10 | **Immediate** | Drop everything and draft a post within 24h | -| 6.0-7.9 | **High** | Plan and publish within 48-72h | -| 4.0-5.9 | **Medium** | Add to content calendar for this week | -| 2.0-3.9 | **Low** | Note for future reference, skip for now | -| 0-1.9 | **Skip** | Not relevant to your positioning | +Score each candidate's five dimensions 1–10 per the mode's table — that qualitative judgment is +yours. The deterministic step that follows is NOT: pipe the scored candidates (JSON on stdin) to the +scorer CLI `${CLAUDE_PLUGIN_ROOT}/scripts/trends/src/cli.ts score` (`--mode kortform|long-form +[--threshold N]`), the single owner of the weighted composite, the composite→action bands +(Immediate / High / Medium / Low / Skip), and the keep/drop threshold. It returns the kept candidates +ranked highest-first, each annotated with its composite + band. Do not recompute the composite or +restate the band thresholds here — supply the five judgment scores and let the scorer rank and triage. ## Trend Opportunity Assessment @@ -153,7 +179,7 @@ Before recommending a trend, verify: ## Angle Recommendation Engine -For each trend scoring 4.0+, map to the strongest thought leadership angle. +For each trend scoring 4.0+, map to the strongest content angle. ### The 8 Universal Angles Applied to Trends @@ -176,7 +202,7 @@ For each trend, ask: 2. **Can I connect it to another trend?** If yes, Pattern Recognition for authority 3. **Do I have direct experience?** If yes, Personal Lesson for credibility 4. **Is it complex/jargon-heavy?** If yes, Practical Breakdown for value -5. **Can I predict what happens next?** If yes, Future Implication for thought leadership +5. **Can I predict what happens next?** If yes, Future Implication for authority positioning 6. **Is there a hard truth nobody is saying?** If yes, Uncomfortable Truth for boldness ### Angle Combinations (Most Powerful) @@ -189,7 +215,7 @@ Recommend combining 2 angles when possible: - **Tech releases:** Personal Lesson + Practical Breakdown - **Failures/setbacks:** Human Story + Uncomfortable Truth -### TL Value Test (Gate Before Recommending) +### Authority Value Test (Gate Before Recommending) Every recommended angle must pass at least 3 of 5 tests: @@ -205,8 +231,8 @@ If an angle fails the test, try a different one before including in the digest. | Priority | Trigger Types | Response Window | |----------|---------------|-----------------| -| **High** | Major model releases, capability breakthroughs, regulatory decisions, major acquisitions, security vulnerabilities, Microsoft platform changes | 24-48 hours | -| **Medium** | Research papers, industry reports, tool updates, conference takeaways, strategy shifts, public sector milestones | Within the week | +| **High** | Major product/model releases, capability breakthroughs, regulatory decisions, major acquisitions, security vulnerabilities, platform changes in the user's stack | 24-48 hours | +| **Medium** | Research papers, industry reports, tool updates, conference takeaways, strategy shifts, sector milestones in the user's domain | Within the week | | **Low** | Incremental updates, minor funding rounds, personnel changes, speculation, vendor marketing | Skip or brief mention | **High-priority response formula:** Breaking News + So What? + Now What? @@ -216,7 +242,7 @@ If an angle fails the test, try a different one before including in the digest. Before including any trend in the digest, it must pass at least 2 of 4: 1. **Expertise fit?** Relevant to my core areas (Yes = proceed, No = skip unless huge) -2. **Audience care?** Public sector leaders or enterprise AI implementers would notice +2. **Audience care?** The user's target audience (per their profile) would notice and care 3. **Unique perspective?** I can add experience-based insight, not just commentary 4. **Urgency?** Time-sensitive topic with closing window @@ -224,18 +250,14 @@ Before including any trend in the digest, it must pass at least 2 of 4: ### Step-by-Step Generation -**Step 1: Scan sources (WebSearch)** +**Step 1: Scan sources (routed fetch)** -Run 4-6 targeted searches covering all tiers: - -``` -Search 1: "[AI announcement OR release] [current week/month] [year]" -Search 2: "Microsoft [AI OR Copilot OR Azure] [news OR update] [year]" -Search 3: "[public sector OR government] [AI OR digital] [latest OR news]" -Search 4: "[AI regulation OR policy OR governance] [latest]" -Search 5: "[AI enterprise OR implementation] [trend OR report] [year]" -Search 6: "[AI debate OR controversy OR opinion] LinkedIn [this week]" -``` +Run 4–6 targeted searches covering the tiers in your loaded source list (**Source Scanning +Framework**, above), via the routed tool (**Research Routing** — declared MCP first, WebSearch + +WebFetch floor). Each query targets a source or topic from the list crossed with a user pillar — +e.g. `"[Tier-1 source] latest"`, `"[pillar] [this week]"`, `"[regulator] [recent decision]"`. Do +not use a fixed query bank: the niche lives in the source list and the user's pillars, never in +this agent. **Step 2: Filter and score** @@ -254,9 +276,88 @@ Search 6: "[AI debate OR controversy OR opinion] LinkedIn [this week]" - For each trend scoring 4.0+, recommend primary angle - Suggest angle combination where applicable -- Run TL Value Test on each recommendation +- Run Authority Value Test on each recommendation - Discard angles that fail the test +**Step 4.5: Persist kept trends to the store (de-amnesia)** + +For every trend that cleared the relevance filter (Step 2) — not only the ones that make the +final digest — fold it into the persistent trend store, so the next session reasons over it +instead of re-discovering it. Build ONE raw-item batch (the same trends you just scored) and pipe +it through `capture`: it normalizes each item, dedupes on normalized title+URL, unions topics on +re-capture (so re-capturing an existing trend just enriches the tags), persists the source's +`publishedAt` for later freshness ranking, and — when you carry the score (below) — persists the +relevance assessment so the morning brief ranks on it — one call, not one per trend: + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ + echo '[ + {"source":"","title":"","url":"", + "topics":["",""],"publishedAt":"", + "summary":"", + "score":{"mode":"kortform","dimensions":{"pillar":N,"audience":N,"timing":N,"angle":N,"authority":N}}} + ]' | node --import tsx src/cli.ts capture +``` + +`source` is the tool you actually fetched with (**Research Routing**); `publishedAt` is the +source's own publish date — omit the key when unknown (the store's `capturedAt` is set +automatically and stays distinct from it). + +**Carry the Step-2 scores — do not discard them.** You already scored each candidate's five +dimensions 1–10 in **Relevance Scoring** (Step 2); fold those same numbers into the capture batch +as the item's `"score"`, so the store persists the relevance assessment and the morning brief +ranks on its composite (the store computes the composite + band itself — supply only the judgment). +Use `"mode":"kortform"` by default; use `"mode":"long-form"` with the long-form dimension names +(`pillar`, `depth`, `angle`, `authority`, `currency`) when the caller is producing a chronicle / +newsletter / series edition (e.g. invoked from `/linkedin:newsletter`). The `"dimensions"` keys are +the rubric's, the `"topics"` are the user's pillars — nothing vendor- or sector-specific is baked +in. Omit the `"score"` key when you genuinely did not score an item; an out-of-range or malformed +score is reported in `errors[]` (the valid items still persist) and never crashes the run. + +One `capture` call folds the whole batch and reports +`{added, merged, duplicates, errors}`; content-invalid items land in `errors[]`, never failing the +run. Skip this step silently if the store has no deps installed (an adopter without the trends +store) — the digest still compiles, just without persistence. + +**Re-capture refreshes the score; the operator drives the lifecycle.** Re-capturing a trend already +in the store never duplicates it — its topics union in and its relevance `score` is **refreshed** +(the newer judgment wins, since the timing dimension decays). The operator marks a trend `acted` +(written about) or `skipped` with `act`/`skip --id ` (the id is shown in the brief and via +`list --json`); the morning brief then **excludes** handled trends so the queue surfaces only +unresolved work, and `reset --id` returns one to the queue. + +**Step 4.6: Write the dated morning brief (surfacing)** + +After capturing, render today's dated morning brief over the store so the **next session surfaces +it automatically** (the SessionStart hook reads the latest one). Pass the user's content pillars — +the same ones you scored against in Step 2 — and the brief ranks the store by pillar-overlap, then +recency, into a dated Markdown file: + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/trends" && \ + node --import tsx src/cli.ts brief --pillars ",," +``` + +`--pillars` is the user's pillar list (comma-separated, from their profile/config); the brief is +written to `/trends/morning-brief/YYYY-MM-DD.md` and ranks only on persisted fields +(pillar overlap + `publishedAt`/`capturedAt` freshness, default 7-day window — tune with +`--fresh-days N`). Skip silently if the store has no deps installed — same escape hatch as Step 4.5. + +The brief also applies a **derived temporal overlay** (RE-R3d): within a relevance tier, a fresh, +not-yet-surfaced trend is ranked up as a **first-mover** (`· 🥇 først ute`) and a repeatedly-surfaced +one is ranked down as **saturated** (`· 🔁 mettet`) — computed at render time from the publish/capture +dates + the seen-log, with no new capture step. Tune with `--first-mover-days N` / `--saturation-at N`. + +Each brief also **records the trend ids it showed** (frontmatter `surfaced:`) and renders a +**day-over-day diff** — a `## 🆕 Nytt siden sist` section listing what is new since the most recent +prior brief (plus a ` N nye siden sist` marker on the one-line summary) — no new capture step; the +polling/capture path above is unchanged (RE-R3e). + +The morning brief can also be **scheduled** to regenerate autonomously each morning — deterministic, +from the current store — via `src/cli.ts schedule` (print-first: it emits a launchd/cron entry firing +the `run-daily.sh` headless wrapper). That nightly run re-renders the brief only; your polling above +stays the capture path (autonomous AI polling is a later slice). + **Step 5: Compile digest** - Format using output template below @@ -331,17 +432,17 @@ Search 6: "[AI debate OR controversy OR opinion] LinkedIn [this week]" 1. **First-mover beats best analysis.** A good post published early outperforms a perfect post published late. Prioritize speed for high-scoring trends. -2. **Your angle is the differentiator.** The news is the same for everyone. Your perspective, experience, and framing are what create thought leadership value. +2. **Your angle is the differentiator.** The news is the same for everyone. Your perspective, experience, and framing are what create authority value. 3. **Audience fit over virality.** A trend your specific audience cares about at score 6.0 beats a viral topic at score 4.0. Relevance compounds; virality fades. -4. **Credibility is non-negotiable.** Never recommend posting on a topic where the creator has no authority. The topic-relevance ranking will penalize off-topic content regardless of how trending it is. +4. **Credibility is non-negotiable.** Never recommend posting on a topic where the creator has no authority. Topic relevance is a ranking input — off-topic content gets lower reach regardless of how trending it is. 5. **Saturation awareness saves reputation.** Posting the 15th take on a topic makes you look like a follower, not a leader. Better to skip than to add noise. 6. **Combine angles for power.** Single-angle posts are solid. Two-angle posts are memorable. Recommend combinations wherever the material supports it. -7. **Always answer "So what?"** A trend is just information. The interpretation -- what it means for the audience's work, decisions, or future -- is the thought leadership. +7. **Always answer "So what?"** A trend is just information. The interpretation -- what it means for the audience's work, decisions, or future -- is the expertise. ## Anti-Patterns @@ -350,18 +451,20 @@ Search 6: "[AI debate OR controversy OR opinion] LinkedIn [this week]" | Anti-Pattern | Why It Fails | Instead | |--------------|--------------|---------| | Reporting news without perspective | No differentiation, looks like a news feed | Add "So what?" and "Now what?" to every trend | -| Recommending off-topic trends | topic-relevance penalty, damages authority | Always check pillar fit and authority score | +| Recommending off-topic trends | off-topic content gets lower reach, damages authority | Always check pillar fit and authority score | | Chasing every trend | Dilutes positioning, exhausts creator | Max 2-3 trend posts per week, rest is evergreen | | Ignoring saturation | Late takes look derivative | Check saturation before recommending timing | | Same angle every time | Predictable, audience tunes out | Rotate across 8 angles, track recently used | | Hype without substance | Loses trust, attracts wrong audience | Ground every take in experience or evidence | | Skipping the relevance filter | Wastes creator's time on low-value topics | Always run 4-question filter before scoring | -| Generic "AI is changing everything" takes | Adds zero value, damages credibility | Be specific: what, for whom, by when | +| Generic "[topic] is changing everything" takes | Adds zero value, damages credibility | Be specific: what, for whom, by when | ## References Read these files for detailed methodology: -- `${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md` - 8 universal angles, selection framework, combination patterns -- `${CLAUDE_PLUGIN_ROOT}/references/ai-content-framework.md` - Content pillars, trigger framework, source tiers, seasonal calendar +- `${CLAUDE_PLUGIN_ROOT}/references/content-angles.md` - 8 universal angles, selection framework, combination patterns +- `${CLAUDE_PLUGIN_ROOT}/references/content-framework.md` - Content pillars, trigger framework, source tiers, seasonal calendar (domain-general) - `${CLAUDE_PLUGIN_ROOT}/references/linkedin-growth-playbook-2025-2026.md` - Trend Translator tactic, first-mover advantage - `${CLAUDE_PLUGIN_ROOT}/references/algorithm-signals-reference.md` - Engagement signals and profile/topic-relevance validation +- `${CLAUDE_PLUGIN_ROOT}/references/trend-scoring-modes.md` - **scoring SSOT** — kortform / long-form rubrics + composite→action bands (do not inline a matrix) +- `${CLAUDE_PLUGIN_ROOT}/config/trends-sources.template.md` - shipped generic source-list defaults (user override: data-dir `trends/sources.md`) diff --git a/agents/video-scripter.md b/agents/video-scripter.md index 639e6d4..1f71597 100644 --- a/agents/video-scripter.md +++ b/agents/video-scripter.md @@ -33,9 +33,9 @@ Read these files for video scripting intelligence: ${CLAUDE_PLUGIN_ROOT}/references/video-strategy-guide.md → Script templates, pacing, production guidance ${CLAUDE_PLUGIN_ROOT}/references/linkedin-formats.md → Video specs, algorithm data, technical requirements ${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md → Hook types, CTAs, story structures -${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md → 8 universal angles -${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/ → User's authentic voice (ALWAYS read before scripting) -${CLAUDE_PLUGIN_ROOT}/assets/examples/high-engagement-posts.md → Successful content patterns +${CLAUDE_PLUGIN_ROOT}/references/content-angles.md → 8 universal angles +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/ → User's authentic voice (ALWAYS read before scripting) +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md → Successful content patterns ~/.claude/linkedin-studio.local.md → User state, recent topics, streak ``` @@ -76,7 +76,7 @@ Default recommendation: **60 seconds** — 2026 sweet spot. LinkedIn requires 30 Follow the same pattern as post creation: 1. Identify the core insight or message -2. Read `references/thought-leadership-angles.md` +2. Read `references/content-angles.md` 3. Present 2-3 angle options via AskUserQuestion 4. Check against recent topics in state file to avoid repetition 5. Verify topic alignment with user's 5 core expertise areas @@ -150,7 +150,7 @@ When converting an existing text post to video: After drafting the script: -1. Read `assets/voice-samples/` to match the user's natural speech patterns +1. Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` to match the user's natural speech patterns 2. Check for: - **Sentence length** — match their natural rhythm - **Vocabulary level** — match their word choices @@ -207,15 +207,15 @@ Iterate until satisfied. ## Step 8: Save and Update State -Save the final script to `${CLAUDE_PLUGIN_ROOT}/assets/drafts/`: +Save the final script to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/`: ``` Naming convention: video-[YYYY-MM-DD]-[slug]-[type]-[length].md Examples: - video-2026-01-30-ai-implementation-talking-head-90s.md - video-2026-01-30-copilot-demo-screen-recording-60s.md + video-2026-01-30-onboarding-walkthrough-talking-head-90s.md + video-2026-01-30-dashboard-demo-screen-recording-60s.md ``` Update state in `~/.claude/linkedin-studio.local.md`: @@ -236,5 +236,5 @@ Update state in `~/.claude/linkedin-studio.local.md`: - `${CLAUDE_PLUGIN_ROOT}/references/video-strategy-guide.md` — Script templates, pacing, production - `${CLAUDE_PLUGIN_ROOT}/references/linkedin-formats.md` — Video specs, algorithm, technical requirements - `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` — Hook types, CTAs -- `${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md` — 8 universal angles +- `${CLAUDE_PLUGIN_ROOT}/references/content-angles.md` — 8 universal angles - `${CLAUDE_PLUGIN_ROOT}/references/first-comment-strategy.md` — First comment timing and tactics diff --git a/agents/voice-scrubber.md b/agents/voice-scrubber.md index c0bbca0..faf869a 100644 --- a/agents/voice-scrubber.md +++ b/agents/voice-scrubber.md @@ -52,7 +52,7 @@ This is the single most important rule of this agent. - The gold standard for Norwegian chronicle voice is the **approved Norwegian editions** (e.g. the series' approved Del 1 + Del 2). The caller passes the path(s); read them as the corpus before scrubbing. -- **Do NOT calibrate against `assets/voice-samples/authentic-voice-samples.md`.** +- **Do NOT calibrate against `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md`.** That corpus is for **English short-form posts** and encodes rules that are WRONG for Norwegian chronicle — e.g. it forbids the em-dash, which the author *does* use in long-form Norwegian. Using it as the gold standard would actively @@ -111,10 +111,10 @@ overwrite identity-level voice. After scrubbing, append what you learned to a drift log so the agent gets sharper each edition: -- Write to `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/chronicle-voice-drift-log.md` +- Write to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/chronicle-voice-drift-log.md` (create if absent). One dated entry per run: which tells recurred, which voice traits the draft drifted on, and any newly-confirmed gold-standard pattern. -- Do **not** rewrite the general voice profile (`config/user-profile.local.md`) — +- Do **not** rewrite the general voice profile (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md`) — that is `voice-trainer`'s job. This log is the chronicle-specific memory; over editions it becomes the calibration record for this agent. - Never auto-update identity-level traits (register, em-dash policy, banned diff --git a/agents/voice-trainer.md b/agents/voice-trainer.md index cb5f7c3..998ed71 100644 --- a/agents/voice-trainer.md +++ b/agents/voice-trainer.md @@ -21,7 +21,7 @@ tools: ["Read", "Glob", "Write"] # Voice Trainer Agent -You are a linguistic analyst specializing in personal writing voice for LinkedIn thought leadership. You study writing samples with forensic precision to extract the patterns that make someone's writing uniquely theirs. +You are a linguistic analyst specializing in personal writing voice for LinkedIn authority building. You study writing samples with forensic precision to extract the patterns that make someone's writing uniquely theirs. ## Your Mission @@ -133,20 +133,23 @@ Architecture: [prose/sectioned/framework] ### Analysis Process -1. **Gather** — Read all files in `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/`, existing profile from `config/user-profile.local.md`, and template from `config/user-profile.template.md` +1. **Gather (published-only gold signal first)** — Read the user's actual published posts from `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ingest/published/` (`provenance=published` — the highest-trust source). Then read the human-curated `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` corpus (kept as a tributary, not reshaped), the existing profile from `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md`, and the template from `config/user-profile.template.md`. **Never learn voice patterns from any content marked `provenance=ai-draft`** — a content engine that learns its own voice from its own drafts collapses toward its priors (this generalises voice-scrubber's "gold standard = approved editions, never the draft corpus"). **Fence:** any future auto-capture into a learning corpus (the voice-samples template's Stop-hook hint) MUST tag provenance and admit `provenance=published` only — never blind-append generated drafts. 2. **Analyze** — Apply all six dimensions to each sample. Note dates for temporal analysis. Flag inconsistent samples as outliers or evolution. 3. **Synthesize** — Patterns in 70%+ of samples = core traits. 40-70% = situational traits (note context). <40% = experimental traits. Track temporal trends. 4. **Build** — Compile into Voice Profile Document format. Include confidence levels (high/medium/low) and concrete examples for every trait. -5. **Update** — Write voice profile section to `config/user-profile.local.md`. Create from template if needed. Preserve non-voice sections. +5. **Update** — Write voice profile section to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md`. Create from template if needed. Preserve non-voice sections. ### Sample Quality Priorities +0. **`ingest/published/` records (`provenance=published`) — the gold signal; rank above all else.** 1. Published posts with high engagement (audience-validated authenticity) 2. Recent samples (last 6 months reflect current voice) 3. Author-confirmed samples ("this sounds like me") 4. Longer samples (more data points) 5. Varied contexts (different content types reveal range) +**Exclude unconditionally: anything marked `provenance=ai-draft`** (the model-collapse guard — never train voice on the engine's own drafts). + Flag if: fewer than 5 samples (low confidence), single time period (temporal bias), or contradictory patterns (possible ghostwriting). ## Voice Drift Detection @@ -323,8 +326,8 @@ Fixes: [specific corrections with baseline examples] ## References Read these files for context and methodology: -- `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/` — Source samples for analysis +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` — Source samples for analysis - `${CLAUDE_PLUGIN_ROOT}/config/user-profile.template.md` — Profile structure template -- `${CLAUDE_PLUGIN_ROOT}/config/user-profile.local.md` — Current voice profile (if exists) -- `${CLAUDE_PLUGIN_ROOT}/references/ai-content-framework.md` — AI content anti-patterns and quality checklist +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md` — Current voice profile (if exists) +- `${CLAUDE_PLUGIN_ROOT}/references/content-framework.md` — content anti-patterns and quality checklist - `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` — Hook psychology and tone guidelines diff --git a/assets/audience-insights/demographics-template.md b/assets/audience-insights/demographics-template.md new file mode 100644 index 0000000..e1d1ea3 --- /dev/null +++ b/assets/audience-insights/demographics-template.md @@ -0,0 +1,211 @@ +# Audience Demographics + +Track WHO is actually engaging with your content. LinkedIn Analytics provides this data for free - use it to understand your real audience vs. your intended audience. + +## How to Access This Data + +1. Go to LinkedIn Analytics: https://www.linkedin.com/analytics/ +2. Click on any post +3. Navigate to "Demographics" tab +4. Review data monthly and update this file + +--- + +## Current Demographics (Last Updated: [Date]) + +### Industries (Top 10) + +Based on LinkedIn Analytics → Post Analytics → Demographics + +| Rank | Industry | % of Engagement | Trend | +|------|----------|----------------|--------| +| 1 | [Industry name] | [X]% | [↑/→/↓] | +| 2 | [Industry name] | [X]% | [↑/→/↓] | +| 3 | [Industry name] | [X]% | [↑/→/↓] | +| 4 | [Industry name] | [X]% | [↑/→/↓] | +| 5 | [Industry name] | [X]% | [↑/→/↓] | +| 6 | [Industry name] | [X]% | [↑/→/↓] | +| 7 | [Industry name] | [X]% | [↑/→/↓] | +| 8 | [Industry name] | [X]% | [↑/→/↓] | +| 9 | [Industry name] | [X]% | [↑/→/↓] | +| 10 | [Industry name] | [X]% | [↑/→/↓] | + +**Key insights:** +- [Observation 1 - e.g., "60% from government sector, higher than expected"] +- [Observation 2 - e.g., "Tech companies underrepresented vs. my assumptions"] +- [Implication - e.g., "Should increase public sector case studies"] + +--- + +### Job Functions (Top 10) + +| Rank | Function | % of Engagement | Trend | +|------|----------|----------------|--------| +| 1 | [Function] | [X]% | [↑/→/↓] | +| 2 | [Function] | [X]% | [↑/→/↓] | +| 3 | [Function] | [X]% | [↑/→/↓] | +| 4 | [Function] | [X]% | [↑/→/↓] | +| 5 | [Function] | [X]% | [↑/→/↓] | +| 6 | [Function] | [X]% | [↑/→/↓] | +| 7 | [Function] | [X]% | [↑/→/↓] | +| 8 | [Function] | [X]% | [↑/→/↓] | +| 9 | [Function] | [X]% | [↑/→/↓] | +| 10 | [Function] | [X]% | [↑/→/↓] | + +**Key insights:** +- [Who is actually engaging] +- [Implication for content framing] + +--- + +### Seniority Levels + +| Level | % of Engagement | Change vs. Last Month | +|-------|----------------|----------------------| +| Entry level | [X]% | [+/-X%] | +| Individual contributor | [X]% | [+/-X%] | +| Manager | [X]% | [+/-X%] | +| Director | [X]% | [+/-X%] | +| VP | [X]% | [+/-X%] | +| C-level | [X]% | [+/-X%] | +| Owner/Partner | [X]% | [+/-X%] | + +**Key insights:** +- **Dominant level:** [Which level engages most] +- **Decision-maker presence:** [% at Director+ level] +- **Content implication:** [How technical/strategic should content be?] + +--- + +### Geographic Distribution (Top 10 Countries) + +| Rank | Country | % of Engagement | Trend | +|------|---------|----------------|--------| +| 1 | [Country] | [X]% | [↑/→/↓] | +| 2 | [Country] | [X]% | [↑/→/↓] | +| 3 | [Country] | [X]% | [↑/→/↓] | +| 4 | [Country] | [X]% | [↑/→/↓] | +| 5 | [Country] | [X]% | [↑/→/↓] | +| 6 | [Country] | [X]% | [↑/→/↓] | +| 7 | [Country] | [X]% | [↑/→/↓] | +| 8 | [Country] | [X]% | [↑/→/↓] | +| 9 | [Country] | [X]% | [↑/→/↓] | +| 10 | [Country] | [X]% | [↑/→/↓] | + +**Key insights:** +- **Primary market:** [Where most engagement comes from] +- **Time zone implications:** [Optimal posting times] +- **Regional context:** [Does content need localization?] + +--- + +### Company Size (Of Engagers) + +| Size | % of Engagement | Trend | +|------|----------------|--------| +| 1-10 employees | [X]% | [↑/→/↓] | +| 11-50 | [X]% | [↑/→/↓] | +| 51-200 | [X]% | [↑/→/↓] | +| 201-500 | [X]% | [↑/→/↓] | +| 501-1000 | [X]% | [↑/→/↓] | +| 1001-5000 | [X]% | [↑/→/↓] | +| 5001-10000 | [X]% | [↑/→/↓] | +| 10000+ | [X]% | [↑/→/↓] | + +**Key insights:** +- **Dominant segment:** [Enterprise/Mid-market/SMB] +- **Content implication:** [Scale of examples, budget assumptions] +- **Opportunity:** [Underserved segment to target] + +--- + +## Intended vs. Actual Audience + +### Who I Thought My Audience Was +- **Industries:** [Your original assumptions] +- **Roles:** [Your original assumptions] +- **Seniority:** [Your original assumptions] +- **Geography:** [Your original assumptions] + +### Who My Audience Actually Is +- **Industries:** [Reality from data above] +- **Roles:** [Reality from data above] +- **Seniority:** [Reality from data above] +- **Geography:** [Reality from data above] + +### Strategic Implications + +**Content adjustments needed:** +1. [Adjustment 1 - e.g., "Increase public sector examples, decrease startup references"] +2. [Adjustment 2 - e.g., "Frame for Director-level, not just technical ICs"] +3. [Adjustment 3 - e.g., "Add European regulatory context"] + +**Opportunities identified:** +1. [Opportunity 1 - e.g., "Large enterprise segment underserved by competitors"] +2. [Opportunity 2 - e.g., "Growing Nordic audience interested in topic X"] + +--- + +## Follower vs. Engager Analysis + +**Important distinction:** +- Your followers = who follows you +- Your engagers = who actually interacts with content + +Often these are different groups. LinkedIn prioritizes showing your content to engagers, not just followers. + +### Follower Demographics +[If you have LinkedIn Premium, note follower demographics here] +- [Key differences from engager demographics] + +### Insight +[What the difference between followers and engagers tells you] + +--- + +## Competitive Audience Analysis + +How does your audience compare to key competitors/peers? + +| Peer | Their Primary Industry | Their Seniority Level | Difference from Mine | +|------|----------------------|---------------------|---------------------| +| [Name] | [Industry] | [Level] | [What's different] | +| [Name] | [Industry] | [Level] | [What's different] | +| [Name] | [Industry] | [Level] | [What's different] | + +**Content gap opportunity:** +[Where your unique audience positioning creates content opportunities] + +--- + +## Month-over-Month Trends + +### [Current Month] vs. [Previous Month] + +**Industry shifts:** +- [What changed and why] + +**Seniority shifts:** +- [What changed and why] + +**Geographic shifts:** +- [What changed and why] + +**Analysis:** +[What these trends indicate about content resonance and audience evolution] + +--- + +## Update Schedule + +- **Monthly:** Update all demographics from LinkedIn Analytics +- **Quarterly:** Deep analysis of trends and strategic implications +- **Yearly:** Major review of intended vs. actual audience fit + +--- + +## Update Log + +- **[Date]:** Initial demographics captured +- **[Date]:** Observed [significant change] in [demographic category] +- **[Date]:** Shifted content strategy based on [insight] diff --git a/assets/audience-insights/engagement-patterns-template.md b/assets/audience-insights/engagement-patterns-template.md new file mode 100644 index 0000000..fd261d8 --- /dev/null +++ b/assets/audience-insights/engagement-patterns-template.md @@ -0,0 +1,267 @@ +# My Audience Engagement Patterns + +Track YOUR audience's specific behaviors and preferences here. This data is more valuable than generic "best practices" because it's based on YOUR actual results. + +## Update Frequency + +**Weekly (5 minutes):** Update posting times and add best-performing topic from the week +**Monthly (15 minutes):** Deep dive into patterns, update demographics, analyze format performance + +--- + +## Best Posting Times (Based on MY Data) + +**Important:** These should be YOUR times based on YOUR analytics, not generic advice. Track this in LinkedIn Analytics under "Post impressions by time of day." + +### Primary Posting Windows +1. **[Day] at [Time]:** Avg. impressions: [X] | Avg. engagement: [Y] + - Why this works: [e.g., "My audience (public sector leaders) checks LinkedIn during lunch break"] + +2. **[Day] at [Time]:** Avg. impressions: [X] | Avg. engagement: [Y] + - Why this works: [Your analysis] + +3. **[Day] at [Time]:** Avg. impressions: [X] | Avg. engagement: [Y] + - Why this works: [Your analysis] + +### Worst Posting Times (To Avoid) +- [Day/Time]: [Why it underperforms for YOUR audience] +- [Day/Time]: [Why it underperforms for YOUR audience] + +**Update Log:** +- [Date]: [Change observed - e.g., "Tuesday 2pm now outperforms Friday 8am"] + +--- + +## Top-Performing Topics (Last 90 Days) + +Track which topics YOUR audience actually engages with, not what you think they should care about. + +1. **[Topic]:** Avg. engagement: [X] | Posts: [Y] + - Best-performing post example: [Brief description] + - Why it resonates: [Your analysis] + +2. **[Topic]:** Avg. engagement: [X] | Posts: [Y] + - Best-performing post example: [Brief description] + - Why it resonates: [Your analysis] + +3. **[Topic]:** Avg. engagement: [X] | Posts: [Y] + - Best-performing post example: [Brief description] + - Why it resonates: [Your analysis] + +### Topics That Surprisingly Underperformed +- **[Topic]:** [Why you thought it would work] → [Why it didn't] +- **[Topic]:** [Analysis] + +**Implication for content strategy:** +[What you'll do differently based on this data] + +--- + +## Format Performance (MY Audience) + +Based on YOUR analytics, not generic benchmarks. Track in LinkedIn Analytics and your own spreadsheet. + +### Format Rankings (By Engagement) +1. **[Format - e.g., "Story-based posts"]:** + - Avg. impressions: [X] + - Avg. engagement rate: [Y%] + - Best time to post: [When] + - Character sweet spot: [Range] + +2. **[Format - e.g., "Framework posts"]:** + - Avg. impressions: [X] + - Avg. engagement rate: [Y%] + - Best time to post: [When] + - Character sweet spot: [Range] + +3. **[Format - e.g., "Data/research posts"]:** + - [Same metrics] + +4. **[Format - e.g., "Case study posts"]:** + - [Same metrics] + +### Visual Content Performance +- **Posts with images:** Avg. engagement: [X] vs text-only: [Y] +- **Posts with documents:** Avg. engagement: [X] +- **Posts with carousels:** Avg. engagement: [X] +- **Video posts:** Avg. engagement: [X] + +**Your insights:** +[What format performs best for YOUR audience and why] + +--- + +## Hook Types That Work for ME + +Not all hook styles work for all audiences. Track which hooks YOUR audience responds to. + +### Top-Performing Hook Styles +1. **[Hook type - e.g., "Counterintuitive stat"]** + - Example: [Actual hook you used] + - Avg. engagement: [X] + - Why it works for your audience: [Analysis] + +2. **[Hook type - e.g., "Bold contrarian statement"]** + - Example: [Actual hook] + - Avg. engagement: [X] + - Why it works: [Analysis] + +3. **[Hook type - e.g., "Personal story opening"]** + - Example: [Actual hook] + - Avg. engagement: [X] + - Why it works: [Analysis] + +### Hook Styles That Don't Work for YOUR Audience +- **[Hook type]:** [Why it underperforms with your specific audience] +- **[Hook type]:** [Why it underperforms] + +--- + +## CTA Performance Analysis + +Which calls-to-action actually drive engagement from YOUR audience? + +### High-Performing CTAs +1. **[CTA type - e.g., "Specific implementation question"]** + - Example: "Which stage is your organization in?" + - Avg. comments generated: [X] + +2. **[CTA type]** + - Example: [Actual CTA] + - Avg. comments generated: [X] + +### Low-Performing CTAs (To Avoid) +- **[CTA type]:** [Why YOUR audience doesn't respond to this] + +--- + +## Audience Demographics (Who Actually Engages) + +Based on LinkedIn Analytics → Analytics → Demographics of people who interacted with your posts + +### Industries (Top 5) +1. [Industry]: [% of engagement] +2. [Industry]: [% of engagement] +3. [Industry]: [% of engagement] +4. [Industry]: [% of engagement] +5. [Industry]: [% of engagement] + +**Insight:** [What this means for content focus] + +### Job Functions (Top 5) +1. [Function]: [% of engagement] +2. [Function]: [% of engagement] +3. [Function]: [% of engagement] +4. [Function]: [% of engagement] +5. [Function]: [% of engagement] + +**Insight:** [How this should shape your content] + +### Seniority Levels +- C-level: [%] +- VP/Director: [%] +- Manager: [%] +- Individual contributor: [%] +- Entry level: [%] + +**Insight:** [Technical depth and framing implications] + +### Geographic Distribution (Top 5 Countries) +1. [Country]: [%] +2. [Country]: [%] +3. [Country]: [%] +4. [Country]: [%] +5. [Country]: [%] + +**Insight:** [Time zone and regional context considerations] + +### Company Size (Of Engagers) +- 1-10 employees: [%] +- 11-50: [%] +- 51-200: [%] +- 201-500: [%] +- 501-1000: [%] +- 1001-5000: [%] +- 5001-10000: [%] +- 10000+: [%] + +**Insight:** [Scale and organizational context implications] + +--- + +## Content Length Performance (YOUR Data) + +Track the optimal length for YOUR audience, not generic advice. + +- **800-1000 characters:** Avg. engagement: [X] +- **1000-1200 characters:** Avg. engagement: [X] +- **1200-1500 characters:** Avg. engagement: [X] +- **1500-1900 characters:** Avg. engagement: [X] +- **1900+ characters:** Avg. engagement: [X] + +**Your sweet spot:** [Range that consistently performs best] +**Why:** [Your analysis of why this works for your audience] + +--- + +## Engagement Velocity Patterns + +How quickly does YOUR content gain traction? + +### First Hour Performance +- **Average engagement in first 60 minutes:** [X] likes, [Y] comments +- **Threshold for algorithm boost:** [Based on your data, when does reach accelerate?] +- **Your current hit rate:** [% of posts that hit the threshold] + +### 24-Hour Patterns +- **Most engagement happens in:** [Time window - e.g., "First 3 hours"] +- **Secondary surge times:** [If applicable] +- **Typical engagement curve:** [Description of how your posts perform over 24 hours] + +--- + +## Strategic Insights (The "So What") + +Based on all the data above, what should you do differently? + +### Content Strategy Adjustments +1. **More of this:** [What data says you should double down on] +2. **Less of this:** [What data says isn't working] +3. **Test this:** [New hypotheses based on patterns] + +### Audience Alignment +- **Who you thought your audience was:** [Original assumption] +- **Who actually engages:** [Reality based on data] +- **Strategic implication:** [How content should shift] + +### Competitive Edge Opportunities +Based on YOUR unique audience makeup: +- **Gap 1:** [Underserved need you could fill] +- **Gap 2:** [Content angle competitors miss] +- **Gap 3:** [Format opportunity] + +--- + +## Monthly Comparison + +Track month-over-month to see if patterns are stable or shifting. + +### [Current Month] +- Avg. impressions per post: [X] +- Avg. engagement per post: [Y] +- Follower growth: [+X] +- Best-performing topic: [Topic] +- Best-performing format: [Format] + +### [Previous Month] +- [Same metrics for comparison] + +**Key changes:** [What's different and why] + +--- + +## Update Log + +- **[Date]:** [Significant finding - e.g., "Discovered Thursday posts now outperform Tuesday"] +- **[Date]:** [Pattern shift - e.g., "Framework posts have overtaken story posts in engagement"] +- **[Date]:** [Audience insight - e.g., "Realize 60% of engagers are from enterprise, not SMB"] diff --git a/assets/case-studies/case-study-template.md b/assets/case-studies/case-study-template.md index d9a94ba..69e63af 100644 --- a/assets/case-studies/case-study-template.md +++ b/assets/case-studies/case-study-template.md @@ -170,7 +170,7 @@ Real examples from your work provide credibility and specificity that generic sc - What we learned - CTA: Ask what others would have done -**Best for:** Thought leadership, showing expertise +**Best for:** Authority content, showing expertise --- diff --git a/assets/checklists/quality-scorecard.md b/assets/checklists/quality-scorecard.md index 6990334..b39967e 100644 --- a/assets/checklists/quality-scorecard.md +++ b/assets/checklists/quality-scorecard.md @@ -29,7 +29,7 @@ Rate each criterion 0-3: | Criterion | Score | Notes | |-----------|-------|-------| -| Works in 140 characters | /3 | Mobile threshold | +| Works in 110-140 characters | /3 | Mobile band (floor + ceiling) | | Creates curiosity gap | /3 | Click-through driver | | Promises clear value | /3 | Worth reading? | | **Subtotal** | /9 x 2 = **/18** | | @@ -111,7 +111,7 @@ Before posting, verify your profile supports the post's topic (topic/interest re For quick posts, use this simplified check: 1. On-topic for my expertise? (Y/N) -2. Hook works in 140 chars? (Y/N) +2. Hook works in 110-140 chars? (Y/N) 3. Clear value delivered? (Y/N) 4. Ends with engagement prompt? (Y/N) 5. No external links in body? (Y/N) @@ -129,7 +129,8 @@ Before finalizing any post, verify: - [ ] Character count within optimal range - [ ] Short paragraphs with white space - [ ] Tone is authentic, not corporate +- [ ] No corporate buzzwords (leverage, synergy, paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach) - [ ] Provides genuine value to readers - [ ] CTA is specific and natural - [ ] Passes the "mobile test" (readable on phone) -- [ ] Passes thought leadership test: Does it help someone make a better decision or think differently? +- [ ] Passes Authority Value Test: Does it help someone make a better decision or think differently? diff --git a/assets/examples/high-engagement-posts-template.md b/assets/examples/high-engagement-posts-template.md new file mode 100644 index 0000000..0da1b71 --- /dev/null +++ b/assets/examples/high-engagement-posts-template.md @@ -0,0 +1,60 @@ +# High-Engagement Posts Collection + +Store your top-performing posts here for pattern analysis. Add 5-10 of your best posts to identify what consistently works for YOUR audience. + +> **Placeholder seed.** Your real, per-user collection lives in your external data +> dir (`~/.claude/linkedin-studio/examples/high-engagement-posts.md`). Replace the +> example structure below with your own posts. + +## How to Use This File + +After each successful post (high engagement relative to your baseline): + +1. Add a new `## Post N` section (where N is a number) per saved post +2. Note engagement metrics and timing +3. Analyze WHY it worked (hook, angle, timing, CTA) +4. Document the replicable pattern + +Claude studies these to learn your successful patterns and apply them to new content. + +## Entry Format + +Each saved post is one `## Post N` section with these fields: + +- **Posted:** date, time, timezone +- **Engagement:** likes / comments / shares +- **Reach:** impressions and engagement rate +- **The Post:** the full post text +- **Why It Worked:** hook, angle, timing, CTA +- **Pattern to Replicate:** the elements you want to reuse + +(Add your first `## Post N` section above this line once you have a high performer.) + +## Patterns Across All High-Performing Posts + +**Common Elements:** + +- [ ] [Element you notice across your best posts] +- [ ] [Element 2] + +**Audience Preferences (What YOUR Audience Responds To):** + +- Format: [Your best-performing format] +- Length: [Your best-performing length] +- Tone: [Your best-performing tone] +- CTAs: [What drives replies for you] + +**Topics That Resonate:** + +1. [Topic] +2. [Topic] + +**Best Posting Times (Based on YOUR Data):** + +- Primary: [Time] +- Secondary: [Time] +- Avoid: [Time] + +## Update Log + +- [Date]: [What you added or learned] diff --git a/assets/examples/high-engagement-posts.md b/assets/examples/high-engagement-posts.md index 112d422..0da1b71 100644 --- a/assets/examples/high-engagement-posts.md +++ b/assets/examples/high-engagement-posts.md @@ -2,157 +2,59 @@ Store your top-performing posts here for pattern analysis. Add 5-10 of your best posts to identify what consistently works for YOUR audience. +> **Placeholder seed.** Your real, per-user collection lives in your external data +> dir (`~/.claude/linkedin-studio/examples/high-engagement-posts.md`). Replace the +> example structure below with your own posts. + ## How to Use This File After each successful post (high engagement relative to your baseline): -1. Copy the full post text below + +1. Add a new `## Post N` section (where N is a number) per saved post 2. Note engagement metrics and timing 3. Analyze WHY it worked (hook, angle, timing, CTA) 4. Document the replicable pattern -Claude will study these to understand your successful patterns and apply them to new content. +Claude studies these to learn your successful patterns and apply them to new content. ---- +## Entry Format -## Post 1: Ralph Wiggum / Vibe Coding (BASELINE) +Each saved post is one `## Post N` section with these fields: -**Posted:** 2026-01-23, 23:13 CET (suboptimal timing) -**Engagement:** Likes: 19 | Comments: 6 | Shares: 0 -**Reach:** 502 impressions -**Engagement Rate:** 4.98% -**Your Follower Count:** ~1,000 +- **Posted:** date, time, timezone +- **Engagement:** likes / comments / shares +- **Reach:** impressions and engagement rate +- **The Post:** the full post text +- **Why It Worked:** hook, angle, timing, CTA +- **Pattern to Replicate:** the elements you want to reuse -**The Post:** -``` -𝗘𝗻 𝗱𝗮𝗴. 𝟭𝟬 𝟬𝟬𝟬 𝗹𝗶𝗻𝗷𝗲𝗿. 𝗨𝘁𝗲𝗻 å 𝘃æ𝗿𝗲 𝘂𝘁𝘃𝗶𝗸𝗹𝗲𝗿. - -Jeg er ikke utvikler. Jeg er KI-rådgiver. Jeg kan ikke skrive kode fra bunnen av. - -Men jeg kan kommunisere med Claude Code. Og det viser seg at det er nok. - -𝗛𝘃𝗼𝗿𝗱𝗮𝗻 𝗱𝗲𝘁 𝘀𝘁𝗮𝗿𝘁𝗲𝘁 - -Denne uken var jeg på Claude Code Meetup i Oslo. 250+ deltakere. Arrangert av Aleksander Stensby og Mesh Oslo. - -Aleksander nevnte "Ralph Wiggum-teknikken" som er en metode for å la AI bygge applikasjoner helt på egen hånd. - -På spørsmål om hvem som faktisk hadde fullført en hel slik prosess, rakk én person opp hånden. Av 250. - -Den kvelden bestemte jeg meg: I morgen tester jeg dette. - -𝗞𝗼𝗻𝘀𝗲𝗽𝘁𝗲𝘁 - -Du blir intervjuet og ender opp med en liste med oppgaver. Starter en prosess. Går og lager kaffe, eller sover. - -Når du kommer tilbake er applikasjonen bygget. - -𝗠𝗶𝗻 𝗱𝗮𝗴 - -Klokken 08:00 fant jeg et enkelt Ralph Wiggum script på 100 linjer. Klokken 23:00 hadde jeg 10 000 linjer og et komplett rammeverk. - -Ikke ved å skrive kode selv — men ved å forklare hva jeg ville ha: - -"Claude, stopp etter fem feil på rad." -"Claude, send meg Slack-melding når du er ferdig." -"Claude, lag en AI som vurderer om ting ser bra ut visuelt." - -Claude foreslo løsninger. Jeg sa ja. Ferdig. - -𝗙ø𝗹𝗲𝗹𝘀𝗲𝗻 - -Starte prosessen med 30 oppgaver. Gjør noe annet. Komme tilbake og se oppgavene tikke av. Én etter én. - -Å våkne til en Slack-melding: "🎉 Ferdig. Alle 30 oppgaver fullført." - -Å åpne mappen og se en fungerende app. Som jeg ikke skrev. Men som jeg 𝘥𝘦𝘧𝘪𝘯𝘦𝘳𝘵𝘦. - -𝗥𝗲𝘀𝘂𝗹𝘁𝗮𝘁 - -Tre prototyper i dag; booking-app, dashbord, skjemaverktøy. Hver tok én time. Null linjer kode. Bare beskrivelser. - -𝗗𝗲𝗻 æ𝗿𝗹𝗶𝗴𝗲 𝗱𝗲𝗹𝗲𝗻 - -Alt dette tok én dag. Og jeg skraper bare i overflaten. - -Det ryktes at Anthropic bygde Claude Cowork, et helt produkt, med fire personer på ti dager. Vi er i starten av noe stort. - -De som eksperimenterer nå kommer til å ha et forsprang. Det er ikke lenger AI som er begrensningen, det er deg og meg. - -𝗦å 𝗷𝗮. 𝗥𝗮𝗹𝗽𝗵 𝗪𝗶𝗴𝗴𝘂𝗺. - -Oppkalt etter Simpsons-karakteren som sier: "I'm learnding!" - -Det føles passende :-) - -— - -Jeg jobber med KI i offentlig sektor. Mer om dette og andre eksperimenter i kommende innlegg. - -𝗧𝗶𝗽𝘀: Claude Code Meetup i Oslo arrangeres jevnlig, sjekk [lenke] - -#AI #ClaudeCode #VibeCoding #OffentligSektor #Innovasjon -``` - -**Why It Worked (Despite Mistakes):** -- **Hook:** Strong - "En dag. 10 000 linjer. Uten å være utvikler." Creates immediate curiosity gap with specific numbers and contrast -- **Angle:** Personal Lesson + Discovery narrative - "I tried this, here's what happened" -- **Timing:** FAILED - Posted 23:13, missed Golden Hour entirely -- **CTA:** MISSING - No engagement prompt at end -- **Key insight:** Concrete numbers (10,000 lines, 250 people, 1 person raised hand) create credibility - -**Mistakes Made:** -1. Posted at 23:13 (should be 08:00) -2. Link in post body (should be in first comment) -3. 5 hashtags (should be 3-4) -4. No CTA (should ask question or invite discussion) -5. Em dash used (should avoid) -6. Post was in Norwegian (strategy says English) - -**Pattern to Replicate:** -- Hook with specific numbers + contrast works well -- "I'm not X, but I did Y" framing creates relatability -- Concrete timeline (08:00 to 23:00) adds credibility -- "Følelsen" section (emotional payoff) resonates -- Bold-formatted section headers improve readability - -**Audience Response Themes:** -- Interest in the technical process -- Questions about Ralph Wiggum technique -- Recognition from Claude Code community - -**What to Test Next:** -- Same quality content, but posted at 08:00 -- With proper CTA -- Without link in body -- In English - ---- +(Add your first `## Post N` section above this line once you have a high performer.) ## Patterns Across All High-Performing Posts **Common Elements:** -- [x] Specific numbers in hook (10,000 lines, 250 people) -- [x] Personal story structure (I did X, here's what happened) -- [x] Concrete timeline and details -- [ ] Strong CTA (not yet tested) -- [ ] Optimal timing (not yet tested) + +- [ ] [Element you notice across your best posts] +- [ ] [Element 2] **Audience Preferences (What YOUR Audience Responds To):** -- Format: Story-based posts with concrete details -- Length: ~2,100 characters (slightly over optimal 1,800) -- Tone: Professional but personal, showing vulnerability ("I'm not a developer") -- CTAs: Unknown - need to test + +- Format: [Your best-performing format] +- Length: [Your best-performing length] +- Tone: [Your best-performing tone] +- CTAs: [What drives replies for you] **Topics That Resonate:** -1. AI-assisted coding / Vibe coding -2. [More data needed] -3. [More data needed] + +1. [Topic] +2. [Topic] **Best Posting Times (Based on YOUR Data):** -- Primary: Unknown - need to test 08:00 CET -- Secondary: Unknown - need to test -- **Avoid:** After 21:00 (confirmed by Ralph Wiggum failure) + +- Primary: [Time] +- Secondary: [Time] +- Avoid: [Time] ## Update Log -- 2026-01-24: Added Ralph Wiggum post as baseline reference. Note: Post had good engagement rate (4.98%) despite multiple mistakes, suggesting content quality is strong. Focus on fixing timing, CTA, and link placement for next posts. +- [Date]: [What you added or learned] diff --git a/assets/templates/my-post-templates-template.md b/assets/templates/my-post-templates-template.md new file mode 100644 index 0000000..a0b03a4 --- /dev/null +++ b/assets/templates/my-post-templates-template.md @@ -0,0 +1,249 @@ +# My Custom Post Templates + +Save your proven post structures here. When you find a format that works consistently, document it so Claude can replicate the pattern. + +--- + +## Template 1: [Name - e.g., "My Framework Introduction Template"] + +**When to use:** [e.g., "When introducing a new framework or model I've developed"] + +**Structure:** + +``` +[HOOK - Counterintuitive stat or bold statement] +(1-2 lines, <110 characters) + +[CONTEXT - The problem this framework solves] +(2-3 lines explaining why people struggle) + +[FRAMEWORK INTRODUCTION] +"I developed [Framework Name] to solve this." +(Brief one-line description) + +[COMPONENT BREAKDOWN] +Stage 1: [Name] +→ [Key characteristic in one line] + +Stage 2: [Name] +→ [Key characteristic in one line] + +Stage 3: [Name] +→ [Key characteristic in one line] + +[IMPLICATION] +"Most organizations are stuck at Stage 1. +Here's what moving to Stage 2 unlocks..." +(2-3 lines on practical value) + +[CTA] +"Which stage is your organization in?" +``` + +**Why this works for me:** +- [Reason 1 - e.g., "My audience loves actionable frameworks"] +- [Reason 2 - e.g., "The diagnostic question always generates 15+ comments"] + +**Example posts using this template:** +- [Link to post 1] +- [Link to post 2] + +**Average engagement:** [Metrics] + +--- + +## Template 2: [Name - e.g., "My Before/After Transformation Story"] + +**When to use:** [e.g., "When sharing case study or project results"] + +**Structure:** + +``` +[HOOK - The transformation in numbers] +"6 months ago: [painful metric] +Today: [improved metric]" + +[THE BEFORE] +[Organization] was struggling with [specific problem]. +(Paint picture of pain - 3-4 lines) + +[THE TURNING POINT] +We decided to [key decision]. +Most teams choose [alternative]. Here's why we didn't... + +[THE APPROACH] +"Three things mattered: +• [Element 1] +• [Element 2] +• [Element 3]" + +[THE AFTER] +Results: +→ [Metric 1]: [Before] → [After] +→ [Metric 2]: [Before] → [After] +→ [Metric 3]: [Before] → [After] + +[KEY LESSON] +"The real breakthrough wasn't [expected thing]. +It was [non-obvious insight]." + +[CTA] +"What's been YOUR biggest lesson in [topic]?" +``` + +**Why this works for me:** +- [Reason 1] +- [Reason 2] + +**Average engagement:** [Metrics] + +--- + +## Template 3: [Name - e.g., "My Contrarian Take"] + +**When to use:** [e.g., "When challenging conventional wisdom in my field"] + +**Structure:** + +``` +[HOOK - Bold contrarian statement] +"Everyone says [conventional wisdom]. +I think that's wrong." + +[THE CONVENTIONAL APPROACH] +Most [target audience] believe [common belief]. +(Explain the mainstream view fairly - 2-3 lines) + +[WHY IT FAILS] +But here's the problem... +(2-3 specific reasons with examples) + +[THE ALTERNATIVE] +Instead, try this: +→ [Alternative approach 1] +→ [Alternative approach 2] +→ [Alternative approach 3] + +[EVIDENCE] +"I've seen this play out across [X] projects: +[Specific result/pattern you've observed]" + +[NUANCE] +"To be clear: [conventional wisdom] works if [specific condition]. +But for [your context], [your approach] is better because..." + +[CTA] +"What's your experience? Am I missing something?" +``` + +**Why this works for me:** +- [Reason 1] +- [Reason 2] + +**Average engagement:** [Metrics] + +--- + +## Template 4: [Name - e.g., "My Failure Lesson Post"] + +**When to use:** [e.g., "When sharing what didn't work to build trust"] + +**Structure:** + +``` +[HOOK - Admission of failure] +"[Approach] should have worked. +It failed spectacularly." + +[SETUP] +We were trying to [goal]. +The plan: [what you intended to do] +On paper, perfect. + +[THE FAILURE] +"Here's what actually happened..." +(Specific description of what went wrong - 3-4 lines) + +[WHY IT FAILED] +Looking back, three mistakes: +1. [Mistake 1] - We assumed [wrong assumption] +2. [Mistake 2] - We underestimated [factor] +3. [Mistake 3] - We ignored [warning sign] + +[THE PIVOT] +"So we tried [different approach] instead. +That worked because..." + +[THE LEARNING] +"Key lesson: +[Non-obvious insight that only came from the failure]" + +[CTA] +"Have you failed at [topic] too? What did you learn?" +``` + +**Why this works for me:** +- [Reason 1] +- [Reason 2] + +**Average engagement:** [Metrics] + +--- + +## Template 5: [Name - Your custom template] + +**When to use:** [Context] + +**Structure:** +[Your proven structure] + +**Why this works for me:** +[Analysis] + +**Average engagement:** [Metrics] + +--- + +## Template Performance Comparison + +| Template | Avg. Likes | Avg. Comments | Avg. Reach | Best Use Case | +|----------|-----------|---------------|------------|---------------| +| Framework Intro | [X] | [Y] | [Z] | [When] | +| Before/After | [X] | [Y] | [Z] | [When] | +| Contrarian | [X] | [Y] | [Z] | [When] | +| Failure Lesson | [X] | [Y] | [Z] | [When] | + +**Insights:** +[What these patterns tell you about your audience preferences] + +--- + +## Template Selection Guide + +**Use Framework template when:** +- Introducing new model/system +- Teaching actionable process +- Want high saves (reference value) + +**Use Before/After template when:** +- Have strong results to share +- Building credibility +- Want case study authority + +**Use Contrarian template when:** +- Challenging assumptions +- Positioning unique POV +- Want engagement/debate + +**Use Failure template when:** +- Building trust/authenticity +- Sharing hard-won lessons +- Want vulnerable connection + +--- + +## Update Log + +- [Date]: Created template 1 based on [successful posts] +- [Date]: Refined template 2 after [pattern observation] +- [Date]: Added template 3 for [new content type] diff --git a/commands/ab-test.md b/commands/ab-test.md index fa81184..cc589c9 100644 --- a/commands/ab-test.md +++ b/commands/ab-test.md @@ -34,11 +34,11 @@ ${CLAUDE_PLUGIN_ROOT}/references/algorithm-signals-reference.md Check for existing state and analytics data: ```bash -ls -1 ${CLAUDE_PLUGIN_ROOT}/assets/analytics/ab-tests/ 2>/dev/null | head -20 +ls -1 ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/ab-tests/ 2>/dev/null | head -20 ``` ```bash -ls -1 ${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/ 2>/dev/null | grep -E '\.json$' | head -10 +ls -1 ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/ 2>/dev/null | grep -E '\.json$' | head -10 ``` If `~/.claude/linkedin-studio.local.md` exists, read it for user context (posting frequency, follower level, topics). @@ -125,7 +125,7 @@ Generate a posting schedule that alternates A/B on comparable days: - Use the user's typical posting days (from state file or ask) - Alternate variants across the same day slots each week - Default: 3 posts per variant across 2 weeks (6 total) -- Suggest posting times based on algorithm-signals-reference.md (Tue-Thu, 8-9 AM optimal) +- Suggest posting times based on algorithm-signals-reference.md (Tue-Thu mornings) ### 2a.6: Set Success Criteria @@ -184,13 +184,13 @@ Present the complete test plan: Create the ab-tests directory if it does not exist: ```bash -mkdir -p ${CLAUDE_PLUGIN_ROOT}/assets/analytics/ab-tests +mkdir -p ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/ab-tests ``` Save the test plan as a markdown file: ``` -${CLAUDE_PLUGIN_ROOT}/assets/analytics/ab-tests/[test-name].md +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/ab-tests/[test-name].md ``` Use the test name slug (e.g., `hook-question-vs-statement.md`). @@ -206,7 +206,7 @@ Confirm to the user: "Test plan saved. When you publish your first post, come ba Scan for active tests: ```bash -ls -1 ${CLAUDE_PLUGIN_ROOT}/assets/analytics/ab-tests/ 2>/dev/null | grep -E '\.md$' +ls -1 ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/ab-tests/ 2>/dev/null | grep -E '\.md$' ``` If no tests exist, tell the user: "No active tests found. Use option 1 to design a new test first." @@ -218,7 +218,7 @@ If tests exist, present them and ask which test to log for using AskUserQuestion Read the selected test file: ```bash -cat ${CLAUDE_PLUGIN_ROOT}/assets/analytics/ab-tests/[test-name].md +cat ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/ab-tests/[test-name].md ``` ### 2b.3: Collect Post Metrics @@ -267,7 +267,7 @@ If minimum sample size (3 per variant) is reached, suggest: "You have enough dat List tests with sufficient data (3+ posts per variant): ```bash -ls -1 ${CLAUDE_PLUGIN_ROOT}/assets/analytics/ab-tests/ 2>/dev/null | grep -E '\.md$' +ls -1 ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/ab-tests/ 2>/dev/null | grep -E '\.md$' ``` Read each file and check if both variants have 3+ posts logged. Present only tests ready for analysis. If no tests have sufficient data, tell the user how many more posts are needed. @@ -281,10 +281,10 @@ Read the test file. For each variant: ### 2c.3: Cross-Reference Analytics Data -If analytics CLI data is available in `assets/analytics/posts/`, cross-reference the test period data with weekly reports for additional context (baseline comparison, trend alignment). +If analytics CLI data is available in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/`, cross-reference the test period data with weekly reports for additional context (baseline comparison, trend alignment). ```bash -ls -1 ${CLAUDE_PLUGIN_ROOT}/assets/analytics/weekly-reports/ 2>/dev/null | grep -E '\.json$' | head -10 +ls -1 ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-reports/ 2>/dev/null | grep -E '\.json$' | head -10 ``` ### 2c.4: Present Analysis @@ -349,7 +349,7 @@ Update the test file status from ACTIVE to COMPLETED. Add the conclusion and rec ### 2d.1: Scan All Tests ```bash -ls -1 ${CLAUDE_PLUGIN_ROOT}/assets/analytics/ab-tests/ 2>/dev/null | grep -E '\.md$' +ls -1 ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/ab-tests/ 2>/dev/null | grep -E '\.md$' ``` If no tests exist: "No test history yet. Design your first test with option 1." @@ -396,8 +396,8 @@ Read each test file and extract: test name, variable tested, status, verdict, ke Check what data is available: -1. **Test history:** Read `assets/analytics/ab-tests/` for completed tests -2. **Analytics data:** Check `assets/analytics/posts/` for performance data +1. **Test history:** Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/ab-tests/` for completed tests +2. **Analytics data:** Check `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/` for performance data 3. **User context:** Read state file for posting patterns and goals ### 2e.2: Generate Suggestions @@ -472,7 +472,7 @@ After any action, offer relevant next steps: ## Error Handling ### No Tests Directory -If `assets/analytics/ab-tests/` does not exist and the user selects options 2-4: +If `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/ab-tests/` does not exist and the user selects options 2-4: - Inform the user: "No tests found. The test directory will be created when you design your first test." - Redirect to option 1 (Design) or option 5 (Suggestions). diff --git a/commands/analyze.md b/commands/analyze.md index 933eac4..3390291 100644 --- a/commands/analyze.md +++ b/commands/analyze.md @@ -38,7 +38,7 @@ Use AskUserQuestion to understand the situation: ## Step 2: Gather Data -If imported analytics data exists (`assets/analytics/`), delegate audience-pattern discovery to the `analytics-interpreter` agent (interpret mode) — invoke it via `Task` with `subagent_type: linkedin-studio:analytics-interpreter` (foreground, from this command layer) — to ground the diagnosis in what the data actually shows before relying on self-report. +If imported analytics data exists (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/`), delegate audience-pattern discovery to the `analytics-interpreter` agent (interpret mode) — invoke it via `Task` with `subagent_type: linkedin-studio:analytics-interpreter` (foreground, from this command layer) — to ground the diagnosis in what the data actually shows before relying on self-report. Based on their answer, ask relevant follow-up questions: @@ -98,7 +98,7 @@ Based on `references/troubleshooting-guide.md`, diagnose the pattern: - Posted at wrong time for YOUR audience - No pre-posting engagement (cold start) - Topic drift confusing algorithm -- External link penalizing reach +- External links correlate with lower reach - Inconsistent posting breaking topical authority ### Pattern: High Views, Low Engagement @@ -146,7 +146,6 @@ Run through this checklist: - [ ] Are topics all over the place recently? - [ ] Did you receive generic AI-like comments? - [ ] Did you post way more/less frequently than usual? -- [ ] Did you use 5+ hashtags in posts? - [ ] Did you tag unrelated people for reach? ## Step 5: Reach Drop Severity Assessment @@ -228,8 +227,8 @@ Set realistic expectations: | Suppression Level | Initial Improvement | Baseline Recovery | Full Restoration | |-------------------|---------------------|-------------------|------------------| | Moderate (link / off-topic) | 7-10 days | 14-21 days | 3-4 weeks | -| Moderate (50-70% drop) | 2-3 weeks | 4-6 weeks | 2-3 months | -| Severe (75%+ drop) | 4-6 weeks | 3-6 months | May not be possible | +| Moderate (partial reach loss) | 2-3 weeks | 4-6 weeks | 2-3 months | +| Severe (sharp reach loss) | 4-6 weeks | 3-6 months | May not be possible | ## Step 8: Prevention Checklist diff --git a/commands/audit.md b/commands/audit.md index 9d86680..0872340 100644 --- a/commands/audit.md +++ b/commands/audit.md @@ -9,7 +9,6 @@ allowed-tools: - Read - Glob - Grep - - WebFetch - AskUserQuestion --- @@ -21,12 +20,12 @@ You are a LinkedIn content strategy auditor. Conduct a thorough review of the us Load all available data: - Read `~/.claude/linkedin-studio.local.md` for posting history -- Read `${CLAUDE_PLUGIN_ROOT}/assets/plans/` for planned content +- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/plans/` for planned content - Read `${CLAUDE_PLUGIN_ROOT}/skills/linkedin-studio/SKILL.md` for strategy reference -- Check for any analytics data in `${CLAUDE_PLUGIN_ROOT}/assets/analytics/` -- Read `assets/audience-insights/demographics.md` for audience composition — compare intended vs actual audience -- Read `assets/audience-insights/engagement-patterns.md` for tracked patterns (timing, topics, formats, hooks) -- Read `assets/examples/high-engagement-posts.md` for proven success patterns to benchmark against +- Check for any analytics data in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/` +- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/demographics.md` for audience composition — compare intended vs actual audience +- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/engagement-patterns.md` for tracked patterns (timing, topics, formats, hooks) +- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md` for proven success patterns to benchmark against Ask the user to provide: - Screenshot of LinkedIn analytics (last 90 days) or key metrics @@ -58,7 +57,7 @@ For each top-performing post, analyze: Same analysis for bottom performers: - What went wrong? - Common factors in low-performing posts? -- Were any off-topic (topic-relevance penalty)? +- Were any off-topic (topic relevance is a ranking input — off-topic gets lower reach)? - External links in body? - Poor timing? @@ -90,7 +89,7 @@ Carousels: ████░░░░░░░░░░░░ 15% Video: █░░░░░░░░░░░░░░░ 5% Polls: ░░░░░░░░░░░░░░░░ 0% -Recommendation: Increase carousel content (highest save rate) +Recommendation: Increase carousel content (top organic format) ``` ## Step 5: Engagement Trends diff --git a/commands/batch.md b/commands/batch.md index ed491a6..4b4c4fc 100644 --- a/commands/batch.md +++ b/commands/batch.md @@ -25,8 +25,8 @@ You are a LinkedIn batch content creator. Help the user create an entire week's Load state and personalization: - Read `~/.claude/linkedin-studio.local.md` for recent topics and weekly goals - Read `${CLAUDE_PLUGIN_ROOT}/skills/linkedin-studio/SKILL.md` for profile and preferences -- Check `${CLAUDE_PLUGIN_ROOT}/assets/plans/` for existing weekly plan -- Read `assets/templates/my-post-templates.md` for proven templates — vary templates across the batch for format diversity +- Check `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/plans/` for existing weekly plan +- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/templates/my-post-templates.md` for proven templates — vary templates across the batch for format diversity If a plan exists for this week, use it as the foundation. If not, create one first. @@ -40,7 +40,7 @@ Use AskUserQuestion: 3. **Use existing plan** — Follow the weekly plan already created 4. **Mix it up** — Diverse topics across pillars -If they choose a theme, help them identify 3-5 unique angles from `references/thought-leadership-angles.md`. For timely angles, delegate to the `trend-spotter` agent — invoke it via `Task` with `subagent_type: linkedin-studio:trend-spotter` (foreground, from this command layer) — to surface trending topics and score their relevance against the user's pillars. +If they choose a theme, help them identify 3-5 unique angles from `references/content-angles.md`. For timely angles, delegate to the `trend-spotter` agent — invoke it via `Task` with `subagent_type: linkedin-studio:trend-spotter` (foreground, from this command layer) — to surface trending topics and score their relevance against the user's pillars. ## Step 2: Plan the Batch (with Scheduling) @@ -92,15 +92,15 @@ Follow the standard structure: - CTA: 50-100 characters ### 3b. Quick Quality Check -- Character count in range +- Character count in the band for this post's format (standard 1,200–1,800 · quick 150–500 · carousel/video caption per that format) — not just "in range" - Hook works standalone - No external links in body - No corporate buzzwords - Voice matches profile ### 3c. Save Draft -Write each post to `${CLAUDE_PLUGIN_ROOT}/assets/drafts/`: -- Create directory if needed: `assets/drafts/week-[WXX]/` +Write each post to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/`: +- Create directory if needed: `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/week-[WXX]/` - Filename: `[day]-[topic-slug].md` - Include metadata header: @@ -110,7 +110,7 @@ planned_date: YYYY-MM-DD scheduled_date: YYYY-MM-DD scheduled_time: "HH:MM" pillar: [expertise area] -angle: [thought leadership angle] +angle: [content angle] format: [text/carousel/video] status: scheduled --- @@ -122,7 +122,7 @@ status: scheduled After saving each draft, add it to the queue: ```bash -node --input-type=module -e "import { queueAdd } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/queue-manager.mjs'; console.log(queueAdd('[YYYY-WXX-day-topic-slug]', 'assets/drafts/week-[WXX]/[day]-[topic-slug].md', '[YYYY-MM-DD]', '[HH:MM]', '[pillar]', '[format]', '[hook preview first 50 chars]', [character_count]));" +node --input-type=module -e "import { queueAdd } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/queue-manager.mjs'; console.log(queueAdd('[YYYY-WXX-day-topic-slug]', '${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/week-[WXX]/[day]-[topic-slug].md', '[YYYY-MM-DD]', '[HH:MM]', '[pillar]', '[format]', '[hook preview first 50 chars]', [character_count]));" ``` This ensures the post appears in `/linkedin:calendar` (both for viewing and for the publish action) and in session-start reminders. @@ -138,7 +138,7 @@ Batch Summary: [X] posts created 2. [Day] — "[Hook preview...]" (X chars) — [format] 3. [Day] — "[Hook preview...]" (X chars) — [format] -Saved to: assets/drafts/week-[WXX]/ +Saved to: ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/week-[WXX]/ Content mix: X educational / Y inspirational / Z entertaining Pillars covered: [list] @@ -180,7 +180,7 @@ import { generateIcalFromQueue, writeIcalFile } from '${CLAUDE_PLUGIN_ROOT}/hook const upcoming = queueUpcoming(14); if (upcoming.length === 0) { console.log('No upcoming posts to schedule.'); process.exit(0); } const events = generateIcalFromQueue(upcoming); -const icsPath = '${CLAUDE_PLUGIN_ROOT}/assets/drafts/week-[WXX]/schedule.ics'; +const icsPath = '${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/week-[WXX]/schedule.ics'; writeIcalFile(icsPath, events); console.log('Calendar file: ' + icsPath + ' (' + events.length + ' events)'); " @@ -191,7 +191,7 @@ Replace `[WXX]` with the actual ISO week number used for the batch directory. Show the user: ``` -Calendar file generated: assets/drafts/week-[WXX]/schedule.ics +Calendar file generated: ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/week-[WXX]/schedule.ics Import this file into your calendar app: - macOS: Double-click the .ics file → Calendar.app imports it @@ -203,10 +203,10 @@ Each scheduled post has a 15-minute reminder before posting time. ## Reference Files -- `${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md` +- `${CLAUDE_PLUGIN_ROOT}/references/content-angles.md` - `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` - `${CLAUDE_PLUGIN_ROOT}/references/low-frequency-posting-strategy.md` - `${CLAUDE_PLUGIN_ROOT}/references/scheduling-strategy.md` - `${CLAUDE_PLUGIN_ROOT}/assets/templates/weekly-content-calendar-2-3x.md` - `${CLAUDE_PLUGIN_ROOT}/assets/checklists/quality-scorecard.md` -- `${CLAUDE_PLUGIN_ROOT}/assets/drafts/queue.json` +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json` diff --git a/commands/calendar.md b/commands/calendar.md index 0baa665..661021a 100644 --- a/commands/calendar.md +++ b/commands/calendar.md @@ -39,8 +39,15 @@ console.log('=== OVERDUE ==='); console.log(queueFormatSummary(queueOverdue())); console.log('=== COUNTS ==='); console.log(JSON.stringify(queueCount(), null, 2)); +console.log('=== ENTRY RECORDS (internal — id / draft_path / character_count etc. for the publish & reschedule actions; do NOT show the user) ==='); +const _seen = new Set(); +for (const e of [...queueToday(), ...queueOverdue(), ...queueUpcoming(14)]) { + if (_seen.has(e.id)) continue; _seen.add(e.id); + console.log(JSON.stringify({ id: e.id, draft_path: e.draft_path, scheduled_date: e.scheduled_date, scheduled_time: e.scheduled_time, hook_preview: e.hook_preview, pillar: e.pillar, format: e.format, character_count: e.character_count })); +} " ``` +The `queueFormatSummary` blocks are the human-readable overview; the **ENTRY RECORDS** block is the agent's lookup table for the `id`, `draft_path`, and `character_count` that the action steps need (these fields are not in the readable summary). Also read state for context: - `~/.claude/linkedin-studio.local.md` for weekly goal and current progress @@ -110,7 +117,7 @@ No posts scheduled for today. - Run /linkedin:quick for an unplanned quick post ``` -**3b. Pick a post.** Use AskUserQuestion to ask which post was published (show the list above). +**3b. Pick a post.** Use AskUserQuestion to ask which post was published (show the list above). Map the chosen post to its `id` (and `draft_path`/`character_count` if needed downstream) using the **ENTRY RECORDS** block emitted in Step 1 — that block is the source of the `[post-id]` used below. **3c. Update queue status:** ```bash @@ -165,9 +172,15 @@ First Hour: If they choose to reschedule: 1. Ask which post (by number or hook preview) -2. Ask for new date and time -3. Update queue.json via queue_update_status + queue_add with new date -4. Show updated calendar +2. Ask for the new date and time +3. Re-add the entry with the **same id** and new date/time — `queueAdd` replaces any + existing entry with that id, so the post moves in place (no duplicate). Carry the + unchanged fields (id, draft_path, pillar, format, hook preview, char count) from the + **ENTRY RECORDS** block emitted in Step 1: +```bash +node --input-type=module -e "import { queueAdd } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/queue-manager.mjs'; console.log(queueAdd('[post-id]', '[draft_path]', '[new-YYYY-MM-DD]', '[new-HH:MM]', '[pillar]', '[format]', '[hook preview]', [charCount]));" +``` +4. Show the updated calendar ### Action: Cancel @@ -199,4 +212,4 @@ After showing the calendar (or after a publish action loops back), provide brief - `${CLAUDE_PLUGIN_ROOT}/references/scheduling-strategy.md` - `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` -- `${CLAUDE_PLUGIN_ROOT}/assets/drafts/queue.json` +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json` diff --git a/commands/carousel.md b/commands/carousel.md index 1d1376a..acc0095 100644 --- a/commands/carousel.md +++ b/commands/carousel.md @@ -24,7 +24,7 @@ You are a LinkedIn carousel content specialist. Create high-engagement carousel ## Step 0: Load Context - Read `~/.claude/linkedin-studio.local.md` for posting state and expertise areas -- Read `assets/voice-samples/authentic-voice-samples.md` for voice profile +- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` for voice profile - Check recent posts to avoid topic repetition ## Step 1: Choose Template @@ -120,7 +120,7 @@ If any item fails, fix before presenting. ### De-AI / Differentiation Gate -The caption is the feed text, and it rides the same low-substance down-rank LinkedIn confirmed. Confirm the caption and cover slide carry the signals LinkedIn named — **personal substance, original thinking, concrete specifics, genuine voice** — and use no mechanical-response engagement bait ("Comment YES", "Like for Part 2"); a genuine question is fine. (The voice-guardian hook scores the caption on save.) +The caption is the feed text, and it rides the same low-substance down-rank LinkedIn confirmed. Confirm the caption and cover slide carry the signals LinkedIn named — **personal substance, original thinking, concrete specifics, genuine voice** — and use no mechanical-response engagement bait ("Comment YES", "Like for Part 2"); a genuine question is fine. **Strip corporate buzzwords** from the caption (Content Quality Rule #4: leverage, synergy, paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach) — caption generation can slip them in, and they trip the same low-substance signal. (The voice-guardian hook scores the caption on save.) If the deck's premise is a list the audience has seen many times — commodity content — delegate an originality pass to the `differentiation-checker` agent: invoke it via `Task` with `subagent_type: linkedin-studio:differentiation-checker` (foreground, from this command layer), then sharpen the angle before generating slides. @@ -130,7 +130,7 @@ Generate a visual for each slide using mcp-image (Nano Banana Pro). If mcp-image 1. **Create output directory:** ```bash - mkdir -p assets/drafts/carousel-$(date +%Y%m%d)-SLUG + mkdir -p ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/carousel-$(date +%Y%m%d)-SLUG ``` Replace SLUG with a short kebab-case version of the carousel topic (e.g., `ai-governance`). @@ -144,11 +144,11 @@ Generate a visual for each slide using mcp-image (Nano Banana Pro). If mcp-image 3. **For each slide (1 through N),** call `mcp__mcp-image__generate_image` with: - **prompt:** `"Professional LinkedIn carousel slide. [TEMPLATE STYLE from above]. Background: [consistent color scheme across all slides]. Bold header text: '[SLIDE HEADER]' in large white sans-serif font near the top. Body text below: '[SLIDE BODY lines]' in smaller matching font. Slide [N] of [TOTAL]. Portrait orientation, clean minimal professional design."` - **aspect_ratio:** `"3:4"` (closest available to LinkedIn's 4:5) - - **output_path:** `assets/drafts/carousel-[date]-[slug]/slide-[N].png` + - **output_path:** `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/carousel-[date]-[slug]/slide-[N].png` 4. **After all slides are generated,** verify the output directory contains the expected number of images: ```bash - ls -la assets/drafts/carousel-$(date +%Y%m%d)-SLUG/ + ls -la ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/carousel-$(date +%Y%m%d)-SLUG/ ``` **On failure:** If any mcp-image call fails, log the error and continue with remaining slides. If ALL calls fail, fall back to the text-only design guide in Step 6. @@ -162,7 +162,7 @@ Show all slides in order with their text content, then the caption. ``` SLIDE IMAGES ━━━━━━━━━━━━ -Generated [N] slide images in assets/drafts/carousel-[date]-[slug]/ +Generated [N] slide images in ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/carousel-[date]-[slug]/ To publish: 1. Download the slide images from the folder above @@ -208,9 +208,11 @@ CAPTION Then auto-copy the full deck to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Substitute `` with the assembled deck above — all slides' copy + the caption. Then confirm: "Full deck — [N] slides + caption — copied to clipboard." +Substitute `` between the heredoc markers with the assembled deck above — all slides' copy + the caption (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Full deck — [N] slides + caption — copied to clipboard." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the deck above manually — do not claim it was copied. Offer refinement options as text (no interactive prompt): "Want to refine? Options: adjust slide text / change visual style / regenerate specific slide / different hook / ready for publishing." diff --git a/commands/competitive.md b/commands/competitive.md index 1d61a8f..f12dc92 100644 --- a/commands/competitive.md +++ b/commands/competitive.md @@ -8,7 +8,6 @@ description: | allowed-tools: - Read - Glob - - WebFetch - WebSearch - AskUserQuestion --- @@ -159,6 +158,6 @@ Emphasize: The goal is inspiration and differentiation, NOT copying. Always find ## Reference Files -- `${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md` +- `${CLAUDE_PLUGIN_ROOT}/references/content-angles.md` - `${CLAUDE_PLUGIN_ROOT}/references/linkedin-growth-playbook-2025-2026.md` - `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` diff --git a/commands/create.md b/commands/create.md index 99549da..b47ebb0 100644 --- a/commands/create.md +++ b/commands/create.md @@ -11,7 +11,6 @@ description: | "new content", "help me create", "i want to post something", "linkedin create". allowed-tools: - Read - - Glob - AskUserQuestion --- diff --git a/commands/first-post.md b/commands/first-post.md index 5a21f04..d629bda 100644 --- a/commands/first-post.md +++ b/commands/first-post.md @@ -24,7 +24,7 @@ The first post doesn't need to be perfect. It needs to EXIST. Every day without ## Step 0: Load Context Read `~/.claude/linkedin-studio.local.md` for current state. -Read `assets/voice-samples/authentic-voice-samples.md` for voice profile (if it exists). +Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` for voice profile (if it exists). Check: If `first_post_date` is already set, this user has posted before. Suggest `/linkedin:post` or `/linkedin:quick` instead, and explain this command is for true first-timers. @@ -44,7 +44,7 @@ Total: ~10 minutes. Let's go. ## Step 2: Quick Voice Setup -Check if `assets/voice-samples/authentic-voice-samples.md` has substantive content (more than just the template headers). +Check if `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` has substantive content (more than just the template headers). **If voice profile exists:** Say "I already have your voice profile. Let's use it." Skip to Step 3. @@ -138,10 +138,12 @@ Show the post with: Auto-copy the post text to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Then present: "Post copied to clipboard. Go to linkedin.com, click 'Start a post', paste it, and hit Post." +Substitute `` with the exact post text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, present: "Post copied to clipboard. Go to linkedin.com, click 'Start a post', paste it, and hit Post." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. ## Step 7: State Update diff --git a/commands/firsthour.md b/commands/firsthour.md index 086f2ab..d8fba78 100644 --- a/commands/firsthour.md +++ b/commands/firsthour.md @@ -1,7 +1,7 @@ --- name: linkedin:firsthour description: | - Run the critical first hour after you publish — the window that decides ~70% of a post's + Run the critical first hour after you publish — its first 15–30 minutes decide ~70% of a post's reach. Builds a timestamped first-hour plan: a warm-up + reply-loop target list, draft self-comments and CEA replies in your voice, and a minute-by-minute timeline — then persists it to state so you can work it live. Hands off to the 48-hour monitor afterwards. @@ -19,13 +19,14 @@ allowed-tools: # First Hour / Reply Loop — Post-Publish Engagement Sprint You are a LinkedIn engagement operator. A post just went live (or is about to). The first -60 minutes set ~70% of its total reach, so this command turns that window into a concrete, -worked plan: who to engage, what to say, and exactly when — persisted to state. +15–30 minutes decide ~70% of its reach and the first hour is the golden window you work it, +so this command turns that window into a concrete, worked plan: who to engage, what to say, +and exactly when — persisted to state. ## Step 0: Load Context - Read `~/.claude/linkedin-studio.local.md` for posting state (streak, weekly progress, recent posts, follower phase). -- Read `assets/voice-samples/authentic-voice-samples.md` so every draft comment is in the user's voice. +- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` so every draft comment is in the user's voice. - Note the user's growth phase (follower count) — it sets daily comment volume and target split. ## Step 1: Identify the Post @@ -68,10 +69,12 @@ Show, in this order: Auto-copy the self-comments + draft replies to clipboard silently (so they're one paste away): ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Then confirm: "Copied your draft comments to clipboard." +Substitute `` with the exact comments block between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Copied your draft comments to clipboard." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. ## Step 4: Persist the Plan to State @@ -113,6 +116,6 @@ delayed spike) with the post-feedback monitor — invoke it via `Task` with ## Reference Files -- `assets/voice-samples/authentic-voice-samples.md` — voice matching for the draft comments -- `references/engagement-frameworks.md` — hook types, CEA, engagement hierarchy +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` — voice matching for the draft comments +- `references/engagement-frameworks.md` — hook types, engagement hierarchy - `references/algorithm-signals-reference.md` — first-hour weighting, signal order, timing data diff --git a/commands/import.md b/commands/import.md index 7ab00c8..3a892e2 100644 --- a/commands/import.md +++ b/commands/import.md @@ -34,7 +34,7 @@ For data format details and directory structure, see `assets/analytics/README.md First, check if any CSV files exist in the exports directory: ```bash -ls -lh ${CLAUDE_PLUGIN_ROOT}/assets/analytics/exports/*.csv 2>/dev/null || echo "No CSV files found" +ls -lh ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/exports/*.csv 2>/dev/null || echo "No CSV files found" ``` **If files found:** Skip to Step 3. @@ -59,7 +59,7 @@ Options: On file selection, copy the file to the exports directory: ```bash -cp "" ${CLAUDE_PLUGIN_ROOT}/assets/analytics/exports/ +cp "" ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/exports/ ``` Then continue to Step 4. @@ -88,10 +88,10 @@ After the script completes, continue to Step 4. 1. Go to [linkedin.com/analytics/creator/content/](https://linkedin.com/analytics/creator/content/) 2. Click the **"Export"** button (top right) 3. LinkedIn will download a CSV file -4. Move it to: `${CLAUDE_PLUGIN_ROOT}/assets/analytics/exports/` +4. Move it to: `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/exports/` ```bash -mv ~/Downloads/linkedin_analytics_export*.csv ${CLAUDE_PLUGIN_ROOT}/assets/analytics/exports/ +mv ~/Downloads/linkedin_analytics_export*.csv ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/exports/ ``` Once done, run `/linkedin:import` again. @@ -123,101 +123,66 @@ cd "${CLAUDE_PLUGIN_ROOT}/scripts/analytics" && npm install --silent Once the user selects, run the import CLI: ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import ``` If importing multiple files, run the command for each file sequentially. ## Step 5: Capture and Present Results -The CLI will output: -- Number of posts imported -- Date range covered (earliest to latest post) -- Any duplicate posts detected -- Anomalies or alerts detected +The CLI prints (see `cli.ts` `handleImport`): +- `Posts imported:` — count of valid rows (rows with an empty title or an unparseable date are skipped, each with a `Warning:` line) +- `Date range:` — earliest to latest post in the batch +- `Batch ID:` and `Saved to: posts/` — the batch file written +- `Saves entered:` — only when the CSV carried a `Saves` column (manual entry) +- An anomaly block — either `Immediate alerts detected:` with 🔴/⚠️/ℹ️ spike/drop lines, or `No anomalies detected in imported data.` -**Parse the output** and present a summary: +**Surface the CLI's output to the user** — for example: ``` -Import completed successfully! +Import successful! +───────────────────────────────────── +Posts imported: 42 +Date range: 2025-12-01 to 2026-01-29 +Saved to: posts/2025-12-01-batch-a1b2c3d4.json +Saves entered: 1,204 across 18 post(s) (manual) -Summary: -- Posts imported: 42 -- Date range: 2025-12-01 to 2026-01-29 -- Duplicates skipped: 3 -- Anomalies detected: 2 posts with unusually high engagement - -Alerts: -- Post "AI agents are eating..." (2026-01-15): 340% above baseline impressions -- Post "The future of no-code..." (2026-01-22): Viral threshold reached (10k+ impressions) - -Data saved to: -- ${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/YYYY-WXX.json +Immediate alerts detected: +───────────────────────────────────── +ℹ️ [INFO] Post "AI agents are eating..." has unusually high impressions: 21,400 (2.4 std deviations above mean) ``` -### Step 5b: Import Analysis & Anomaly Detection +The saved file is named `posts/YYYY-MM-DD-.json` (the batch's earliest post date + a short batch id), not by ISO week. -After successful import, automatically analyze the imported data for anomalies and patterns. +### Step 5b: Surface the Anomalies the Importer Detected -**Anomaly Detection:** -Compare the imported week's data against existing baselines (if available from previous imports): +The import CLI runs **intra-batch** anomaly detection during Step 4 (`detectAlerts` +in `cli.ts`): for the just-imported batch it flags any post whose impressions +deviate sharply — by standard deviation — from *that batch's own mean*, printing +either `Immediate alerts detected:` (🔴/⚠️/ℹ️ spike/drop lines) or +`No anomalies detected in imported data.` -1. **Engagement anomalies:** - - Any post with >3x average impressions -> flag as "breakout post" - - Any post with <0.5x average engagement rate -> flag as "underperformer" - - Any post with comment:reaction ratio >1:3 -> flag as "conversation starter" - -2. **Pattern recognition:** - - Most successful day of week (by average impressions) - - Most successful format (if detectable from post content) - - Posting frequency vs. previous weeks - -**Read baselines for comparison:** -```bash -cat ${CLAUDE_PLUGIN_ROOT}/assets/analytics/baselines.json 2>/dev/null -``` - -**If baselines exist**, compare each imported post's metrics against baseline means. If no baselines exist yet, note that this is the first import and baselines will be established. +Surface those lines as-is, and state the scope honestly: a flagged post stands out +**among the posts in this export**, not against a stored historical baseline — the +importer keeps no baseline file. Cross-week comparison is Step 6's job. **Present as:** ``` -### Import Analysis — YYYY-WXX +### Import Summary — -X posts imported (Y new, Z updated) +X posts imported (Y skipped: empty title or unparseable date) -#### Standout Posts -Breakout: "[hook text...]" — X impressions (3.2x your average) -Conversation Starter: "[hook text...]" — X comments (ratio 1:2.5) +#### Standout in this batch +ℹ️ "[hook text...]" — 21,400 impressions (2.4 std dev above this batch's mean) +⚠️ "[hook text...]" — 180 impressions (2.1 std dev below this batch's mean) -#### Patterns Detected -- Best day: Tuesday (avg 2,100 impressions vs. 1,400 other days) -- Best time: Posts before 8 AM outperformed by 35% -- Format winner: Listicles averaged 40% more engagement - -#### Baseline Update -Your rolling 4-week averages have been updated: -- Impressions: X -> Y (change Z%) -- Engagement rate: X% -> Y% (change Z%) +(or: "No standout deviations within this batch.") ``` -**If this is the first import (no baselines):** -``` -### Import Analysis — YYYY-WXX - -X posts imported (first import — baselines will be established) - -#### Initial Observations -Top post: "[hook text...]" — X impressions -Most discussed: "[hook text...]" — X comments - -#### Baselines Established -Your initial baselines are now set: -- Avg impressions per post: X -- Avg engagement rate: X% -- Avg comments per post: X - -Import 2-3 more weeks of data for meaningful trend analysis. -``` +Cross-week analysis — best day of week, format performance, week-over-week trend, +rolling averages — is **not** computed here; it is produced by `/linkedin:report` +(Step 6), which reads the full post history via the `trends`/`heatmap` CLI. Defer +that analysis to Step 6 rather than restating it. ## Step 6: Analytics-to-Strategy Feedback Loop @@ -240,8 +205,8 @@ Run /linkedin:report (period: 4w) 1. Read `expertise_areas` from `~/.claude/linkedin-studio.local.md` 2. Call `trends` for impressions and engagement_rate over the last 4 weeks: ```bash - ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period 4w --metric impressions - ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period 4w --metric engagement_rate + "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period 4w --metric impressions + "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period 4w --metric engagement_rate ``` 3. Produce the Content Pillar Performance, Format Performance, and Day-of-Week Performance tables, plus exactly 3 actionable recommendations @@ -266,12 +231,12 @@ Write the updated state file Present next steps using AskUserQuestion based on the analysis results: -**If data shows declining engagement** (current < baseline by >15%): +**If the report's trend is down** (impressions or engagement trending DOWN): - "Run /linkedin:report for full weekly breakdown" - "Run content audit to review strategy" - "Analyze your top post to understand what worked" -**If data shows strong performance** (current > baseline by >15%): +**If the report's trend is up** (impressions or engagement trending UP): - "Run /linkedin:report for the full numbers" - "Create more content in your top format" - "Draft your next post while insights are fresh" @@ -290,10 +255,10 @@ Present using AskUserQuestion with the top 3 most relevant suggestions. ## Step 8: Demographics Sync Suggestion -After completing the import workflow, check if `assets/audience-insights/demographics.md` still has placeholder data: +After completing the import workflow, check if `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/demographics.md` still has placeholder data: ```bash -grep -c '\[Industry name\]\|\[Function\]\|\[Country\]\|\[X\]%' ${CLAUDE_PLUGIN_ROOT}/assets/audience-insights/demographics.md 2>/dev/null +grep -c '\[Industry name\]\|\[Function\]\|\[Country\]\|\[X\]%' ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/demographics.md 2>/dev/null ``` If placeholder count is > 10 (still mostly unfilled), suggest: @@ -305,7 +270,7 @@ If placeholder count is > 10 (still mostly unfilled), suggest: If the import fails: 1. **Check the CSV format** - LinkedIn sometimes changes export format -2. **Verify the file path** - Ensure the file is in `assets/analytics/exports/` +2. **Verify the file path** - Ensure the file is in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/exports/` 3. **Check file permissions** - The CLI needs read access 4. **Show the error message** and suggest solutions @@ -318,15 +283,13 @@ If the import fails: ## Reference Files The import system creates: -- `assets/analytics/posts/YYYY-WXX.json` - Weekly post data -- `assets/analytics/metadata.json` - Import tracking and baseline metrics -- `assets/analytics/baselines.json` - Statistical baselines for anomaly detection +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/YYYY-MM-DD-.json` - one JSON batch per import (earliest post date + short batch id) + +Weekly and monthly report files (under `weekly-reports/` and `monthly-reports/`) are created separately by `/linkedin:report`, not by import. ## State Tracking -After import, the system automatically: -- Updates baseline metrics (mean, median, std dev for each metric) -- Detects and flags anomalies (posts >2 sigma from baseline) -- Organizes posts by ISO week for trend analysis -- Preserves historical data (never overwrites existing weeks) -- Updates `last_import_date` and `last_import_week` in state file +After import: +- A new batch file `posts/YYYY-MM-DD-.json` is written, one per import — existing batch files are never overwritten; `loadAllPosts` deduplicates by post id at read time (latest import wins) +- Intra-batch spike/drop alerts are computed and surfaced (std deviation from the batch's own mean — no persisted baseline) +- `last_import_date` and `last_import_week` are updated in the state file (`~/.claude/linkedin-studio.local.md`, see Step 6b) diff --git a/commands/linkedin.md b/commands/linkedin.md index 8e07379..cce8f30 100644 --- a/commands/linkedin.md +++ b/commands/linkedin.md @@ -1,7 +1,7 @@ --- name: linkedin description: | - Main router for LinkedIn thought leadership commands. Lists all available subcommands + Main router for LinkedIn Studio commands. Lists all available subcommands and helps the user choose the right workflow. Use when the user mentions "linkedin", "linkedin help", "what linkedin commands", or needs guidance on which LinkedIn command to use. Triggers on: "linkedin", "/linkedin", "linkedin help", "show linkedin commands". @@ -13,7 +13,7 @@ allowed-tools: # LinkedIn Studio Command Router -You are a LinkedIn thought leadership assistant. The user has invoked the main `/linkedin` command. Your job is to help them navigate to the right subcommand. +You are the LinkedIn Studio assistant. The user has invoked the main `/linkedin` command. Your job is to help them navigate to the right subcommand. ## Session Status diff --git a/commands/measure.md b/commands/measure.md index 47b9d32..995417e 100644 --- a/commands/measure.md +++ b/commands/measure.md @@ -8,7 +8,6 @@ description: | Triggers on: "measure", "how am I doing", "my performance", "show my analytics", "performance overview", "how are my posts doing", "linkedin measure". allowed-tools: - - Read - Glob - AskUserQuestion --- @@ -21,7 +20,7 @@ intent in one question and route. **You do not run the analysis here.** ## Step 0: Quick context (optional) -If `assets/analytics/` holds imported data, you may note "last import: [date]" in one +You may `Glob` `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/`; if it holds imported data, note "last import: [date]" in one line so the user knows whether a fresh import is needed first. Do not block on it. ## Step 1: Identify what they need diff --git a/commands/monetize.md b/commands/monetize.md index 50bc014..08fbab1 100644 --- a/commands/monetize.md +++ b/commands/monetize.md @@ -10,14 +10,12 @@ allowed-tools: - Read - Glob - Grep - - WebFetch - AskUserQuestion - - Write --- # LinkedIn Monetization Strategy -You are a LinkedIn monetization strategist. Help the user turn their thought leadership into revenue streams — from first lead magnet to scalable offer suite. +You are a LinkedIn monetization strategist. Help the user turn their LinkedIn authority into revenue streams — from first lead magnet to scalable offer suite. ## Step 0: Load Context @@ -340,10 +338,9 @@ DM Conversion Framework: - Paid: "Want to grab 15 min to see if [offer] is a fit? [booking link]" - Not ready: "No rush — follow along and reach out when timing is right" -Response time matters: -- Same day: roughly half convert to the next step -- Next day: 20-30% conversion -- 3+ days: <10% conversion +Response time matters — speed compounds conversion: +- Same-day responses convert far better than days-later ones; the longer the gap, the colder the lead. +- For per-channel benchmarks (speaking / consulting / podcast) and the response window each warrants, see `references/opportunity-generation.md`. ``` ## Step 5: CTA Optimization diff --git a/commands/multiplatform.md b/commands/multiplatform.md index fa4db82..639b2be 100644 --- a/commands/multiplatform.md +++ b/commands/multiplatform.md @@ -21,9 +21,13 @@ You are a multi-platform content strategist. Help the user adapt their LinkedIn ## Step 0: Load Source Content Ask the user to provide their LinkedIn content or read from drafts: -- Read `${CLAUDE_PLUGIN_ROOT}/assets/drafts/` for recent content +- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/` for recent content - Read `~/.claude/linkedin-studio.local.md` for recent posts +If none of these yields source content (empty/absent drafts, no recent posts, and nothing +pasted), **stop and ask the user to paste the LinkedIn post to adapt** — never fabricate +source content to adapt. + ## Step 1: Select Target Platform > **Long-form lives elsewhere.** Newsletters, blog posts, and essays are produced by @@ -38,6 +42,11 @@ Use AskUserQuestion: ## Adaptation Templates +> **Voice hygiene (all platforms):** adaptation rewrites the source — keep the adapted copy free +> of the banned corporate buzzwords (content rule #4: leverage, synergy, paradigm shift, thought +> leader, disruptive, value proposition, ecosystem, holistic approach) on every platform; don't +> let the rewrite introduce them. + ### LinkedIn → Twitter/X Thread ``` @@ -106,12 +115,14 @@ YouTube tips: ## Step 2: Adapt and Save After creating the adaptation: -- Save to `${CLAUDE_PLUGIN_ROOT}/assets/drafts/multiplatform/[platform]-[slug].md` +- Save to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/multiplatform/[platform]-[slug].md` - Auto-copy the adapted content to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -- Present the content and confirm: "Copied to clipboard." +- Substitute `` with the exact adapted text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Present the content, and only if the helper prints `COPIED`, confirm: "Copied to clipboard." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. - Note platform-specific publishing tips ## Reference Files diff --git a/commands/newsletter.md b/commands/newsletter.md index 40b68e2..f4f837c 100644 --- a/commands/newsletter.md +++ b/commands/newsletter.md @@ -22,9 +22,11 @@ allowed-tools: # LinkedIn Newsletter — Long-Form Content Engine > **⏱ Before you start — this is a campaign, not a post.** Producing one edition is -> a **multi-session, multi-gate process (16 phases)**: research → skeleton gate → -> spine gate → full draft → fact-check → editorial craft gate → persona sweep → -> cold headless review → visual assets → lock → hook-conversion gate → schedule. +> a **multi-session, multi-gate process (18 phases)**: lived-specifics extraction → +> research → skeleton gate → +> spine gate → full draft → contract-gate → fact-check → editorial craft gate → +> persona sweep → cold headless review → visual assets → lock → hook-conversion +> gate → schedule. > Budget **~4–8+ hours of focused work spread across several sessions**, not a > single sitting. State is maintained between sessions (Step 0 resumes exactly > where you left off), so you can stop and resume at any phase boundary. If you @@ -81,7 +83,7 @@ delegate the fan-out to a nested background agent. > only layer that can reliably spawn parallel sub-agents. So this command issues > the parallel `Task` calls itself and synthesizes their returns inline. -## Pipeline overview (16 phases) +## Pipeline overview (18 phases) The phase order is fixed. Two gates run **BEFORE prose** (skeleton + spine prose), an **editorial craft gate** runs before the persona sweep, the persona @@ -95,11 +97,13 @@ split; v3.1 / Endring 9 on adversarial independence + framing-bias). |------|-------|------|-------| | 0 | **Load context** | edition-state + `/STATE.md`, voice profile, persona library, series brief | `Read` | | 1 | **Brief + calibration** | angle, voice, audience personas (mark primær), key points, tone, leader-takeaway. ≤3 questions | `AskUserQuestion` | -| 2 | **Research** | parallel scoped mandates → verified notes; triangulation | **`Task` fan-out (foreground)** | +| 1.5 | **Lived-specifics extraction — BEFORE research** | elicitation interview (3–5 sharp questions, **vagueness refused**) → `add` lived material to the specifics-bank + populate `articles.NN.livedSpecifics` slot-map (each load-bearing key-point bound specific/abstrakt/ekstern/unresolved) → render `NN-kilder.md`. The grounded-sourcing layer (Fix #2, kilde-så-draft; never fabricated — invariant 3). | `AskUserQuestion` + `scripts/specifics-bank` (`node --import tsx src/cli.ts`) | +| 2 | **Research — scoped by the binding** | parallel scoped mandates → verified notes; triangulation. Fills the `ekstern`/`unresolved` slots + verifies your own numbers (regel 6/7) | **`Task` fan-out (foreground)** | | 2.5 | **Skeleton + section pitch — BEFORE prose** | five-line skeleton (premiss/problem/anbefaling/gevinst/vei videre) + per-section one-line pitch. Operator-gate JA/NEI/REVIDER. Persona-skjelett-sweep before any prose is written. | `AskUserQuestion` + **`persona-reviewer`** (skjelett mode) | | 3a | **Spine prose — BEFORE full expansion** | one paragraph per section carrying that section's pitch, nothing more. ~20–30 % of final length. Operator-gate on whether the axis is right now that there is prose on it. | inline drafting + `content-repurposer` | | 3b | **Full prose expansion** | expand each section with argument, examples, anchors from research; may span sessions | `content-repurposer` + `Task` | | 4 | **Consistency + quality** | threads, premise→conclusion arc, leader-takeaway, AI-slop removal, de-AI/voice scrub, formatting dose | inline + `references/longform-quality-rules.md` + **`voice-scrubber`** | +| 4.5 | **Contract-gate — BEFORE the AI sweeps** | deterministic §B/§C1 rule-gate on the draft: ratify `rules.ts` ↔ §E-manifest (KTG invariant) → gate the draft (BLOCK = fix by tightening + re-run; WARN = direction). Mechanical violations die here, before the costly AI gates. | `scripts/contract-gate` (`node --import tsx src/cli.ts`) | | 5 | **Fact-check sweep** | risk-sorted (🔴/🟡/🟢), guilty-until-disproven, verification log | **`fact-checker` (parallel)** | | 5.5 | **Editorial review — BEFORE persona sweep** | editor's craft gate: prose-craft (em-dash density, verbatim repetition, postulated numbers, contradictions, versal-tic) + narrative-architecture (concrete instantiation, theory-anchored hypotheses, series-title symmetry, equal action per addressee, un-overloaded conclusion). ≤10 flags, BLOCK/REWORK/NICE. Operator-gated via `SendUserFile`. | **`editorial-reviewer`** + `SendUserFile` | | 6 | **Persona sweep — BEFORE lock** | reader jury, primær wins, convergence to clean YES | **`persona-reviewer`** (resonance mode) | @@ -110,11 +114,13 @@ split; v3.1 / Endring 9 on adversarial independence + framing-bias). | 9 | **Hook / conversion gate** | persona gate on the distribution text post-lock: "would YOU click?" | **`persona-reviewer`** (conversion mode) | | 10 | **Scheduling** | register the edition in the plugin queue/state for native scheduling | `hooks/scripts/queue-manager.mjs` | -> **Build status:** all 16 phases (Steps 0–2.5, 3a, 3b, 4, 5, 5.5, 6, 6.5, 7, -> 7.5, 8–10) are implemented below. This command takes an edition end-to-end: -> load → calibration → verified research → **skeleton + section pitch (operator + +> **Build status:** all 18 phases (Steps 0, 1, 1.5, 2, 2.5, 3a, 3b, 4, 4.5, 5, +> 5.5, 6, 6.5, 7, 7.5, 8–10) are implemented below. This command takes an edition +> end-to-end: +> load → calibration → **lived-specifics extraction (elicitation + bank binding, +> BEFORE research)** → verified research (scoped by the binding) → **skeleton + section pitch (operator + > persona gate BEFORE prose)** → **spine prose (operator gate BEFORE full -> expansion)** → full prose draft → consistency/quality → fact-check sweep → +> expansion)** → full prose draft → consistency/quality → **contract-gate (deterministic §B/§C1)** → fact-check sweep → > **editorial review (craft gate, operator-gated BEFORE the persona sweep)** → > pre-lock persona sweep → **headless adversarial review (cold review package, > operator-gated BEFORE lock)** → optional annotation → **visual assets @@ -170,8 +176,8 @@ the edition left off before doing anything. Step 2. Do not confuse `/STATE.md` (this edition's production state) with the plugin's own `STATE.md` / `docs/BUILD-HANDOVER.local.md` (which govern building the plugin itself). -4. **Read the voice profile** — `assets/voice-samples/authentic-voice-samples.md` - and anything else under `assets/voice-samples/`. Long-form must match the +4. **Read the voice profile** — `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` + and anything else under `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`. Long-form must match the author's voice; this is the reference for every drafting and review phase. 5. **Resolve the active personas (per-artifact).** Personas are configured **per edition**, not from one fixed global file. Resolve the set for @@ -204,7 +210,8 @@ Look up `edition-state.json` → `articles.` (and the top-level |---------------------------------|-----------| | *(no state file)* | **NEW edition** → Step 1 (init state at end of Step 2) | | `load-context` | Step 1 — Brief + calibration | -| `brief-calibration` | Step 2 — Research | +| `brief-calibration` | Step 1.5 — Lived-specifics extraction *(Fix #2 — elicitation + bank binding BEFORE research)* | +| `lived-specifics` | Step 2 — Research *(scoped by the binding)* | | `research` | Step 2.5 — Skeleton + section pitch *(v2.1 — skeleton gate BEFORE prose)* | | `skeleton-pitch` | Step 3a — Spine prose *(v2.1 — one paragraph per section, BEFORE full expansion)* | | `spine-prose` | Step 3b — Full prose expansion | @@ -279,7 +286,8 @@ Settle these dimensions (most should come from context, not questions): (accept it), but a *primær* NO is never accepted (revise until a clean YES). **Persist** the resolved set to `edition-state.json` → `articles.NN.personas` (each entry: name, tier, the five fields, source) at the - Step 2 checkpoint — it is then stable across sessions and is the single source + **Step 1.5 checkpoint** (the first machine-state write; Step 2 then adds the + research notes) — it is then stable across sessions and is the single source every later sweep reads. See `config/personas.template.md` → "Per-artifact personas". - **Key points** — the 2–4 load-bearing claims the edition must make. @@ -303,6 +311,128 @@ Edition brief - Leader-takeaway: ``` +## Step 1.5: Lived-specifics extraction — BEFORE research + +> **The grounded-sourcing layer (Fix #2, kilde-så-draft).** Sourcing, not styling, +> is where authenticity is won. A draft that starts from your real number / named +> case / what-actually-broke is forwardable; a draft that invents plausible filler +> is hollow, and every downstream voice gate then only polishes hollow content. So +> BEFORE research and BEFORE prose, each load-bearing claim is bound to real +> material — or you *consciously* decide it does not need a concrete instance. This +> step is that binding; **vagueness is refused** (drømme-spec G4). The plugin never +> invents lived experience (invariant 3). + +> **Order assertion (enforced).** Step 1.5 runs AFTER the brief (Step 1) and BEFORE +> research (Step 2). It populates the slot-map and renders the `NN-kilder.md` +> ledger; the BLOCKing «vaghet avvises» gate (`validate-binding`) is enforced at +> the **Step 2.5 skeleton-gate**, once the spine is visible. Research (Step 2) is +> then scoped BY this binding — it fills the `ekstern`/`unresolved` gaps and +> verifies your own numbers, instead of re-sourcing what is already grounded. `[GATE]` + +> **Adopter note.** This step uses the KTG-internal specifics-bank +> (`${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank` — run `npm install` there once). +> If its deps are absent (a plain adopter ships none), this Step is a **no-op**: +> skip to Step 2 and supply specifics inline during drafting. It never blocks an +> adopter. + +**Procedure:** + +1. **Surface inventory you already have.** Derive the edition's topic tags from the + brief (Step 1 angle + key points), and query the bank for lived material that + already matches — so the interview asks only for what is genuinely missing. The + CLI runs from its own dir (where `tsx` lives): + + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank" && \ + node --import tsx src/cli.ts query --tags "" + ``` + + Show the hits to the operator; each is a candidate binding for a load-bearing + key-point. + +2. **Elicitation interview — 3–5 sharp questions, vagueness refused.** For each + load-bearing key-point (Step 1) NOT already covered by inventory, ask one tight + question that demands a *concrete instance*, not a generality. Pick the shape the + claim needs (one per question): + - a **number** you measured ("how many / how much / how long — your figure"), + - a **named case** ("which project / tool / team — name it"), + - a **what-broke** ("what actually failed, concretely"), + - a **contrarian** point ("where do you disagree with the consensus, and why"), + - a **mind-change** ("what did you used to believe and stop — what flipped it"). + + **Refuse vagueness.** If the answer is a generality ("it's generally faster", + "people struggle with this"), push back once for the concrete instance. If the + operator genuinely has none, that key-point becomes an `abstrakt` slot (with a + stated rationale) or an `ekstern` slot (research will source it) — **never a + fabricated specific** (invariant 3). The plugin elicits your raw material; it + does not invent it. + +3. **Fold elicited material into the bank.** For each concrete answer, `add` it so + it is reusable across editions (content-hash dedupe + tag-union are automatic): + + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank" && \ + node --import tsx src/cli.ts add \ + --type \ + --content "" --tags "" \ + [--source ""] [--verification ] + ``` + + A figure the operator states from memory is `--verification unverified` — it + stays guilty-until-checked, and Step 2 verifies it (regel 6/7). + +4. **Populate the slot-map.** Write `articles.NN.livedSpecifics` in + `edition-state.json` — one `kind: "key-point"` slot per load-bearing claim, each + bound to exactly one of: + - `{ type: "specific", specificId: "" }` — grounded in lived material; + - `{ type: "abstrakt", rationale: "" }`; + - `{ type: "ekstern", source?: "" }` — to be + sourced, not lived; + - `{ type: "unresolved" }` — a temporary placeholder only; this is the vagueness + the Step 2.5 gate BLOCKs, so resolve it before then. + + Set `status: "pending"` (Step 2.5 flips it to `"bound"` when the gate passes). + The shape is documented in `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` + (`_doc.livedSpecifics`). + +5. **Render the `NN-kilder.md` ledger.** Deterministically render the read-only + sources artifact from the slot-map + bank (NN = the same zero-padded edition + number as `NN-utkast.md`). Pass absolute paths: + + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank" && \ + node --import tsx src/cli.ts render-kilder \ + --edition "/linkedin/edition-state.json" \ + --out "/linkedin/NN-kilder.md" + ``` + + It is regenerated like POST.html, never hand-edited. Surface it to the operator + so the binding is visible (coverage by type + the numbers-to-verify line). + +6. **Persist + checkpoint state.** Step 1.5 is the **first phase that writes machine + state**, so: + - If `edition-state.json` does not exist yet (fresh edition), initialize it from + the template schema (`${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json`). + - Persist the resolved **brief** (Step 1) and **personas** (the set + primær) + into `articles.NN` — this is the first durable write of both (Step 2 then adds + the research notes). + - Write `articles.NN.livedSpecifics` (the slot-map above, `status: "pending"`). + - Set `currentPhase: "lived-specifics"` in `edition-state.json`. + - Write a "lived-specifics bound → next: research (scoped by the binding)" line + to `/STATE.md` (overwrite). + +``` +Lived-specifics extraction (BEFORE research) — complete. +- Inventory hits reused: New material elicited + added to bank: +- Slot-map: slots — levd · abstrakt · ekstern · uavklart +- Numbers to verify (regel 6/7): (carried to Step 2) +- Ledger rendered: /linkedin/NN-kilder.md +- Vagueness refused: (never fabricated — invariant 3) +Next: Step 2 — Research (scoped by the binding: fill ekstern/unresolved, verify numbers). +``` + +--- + ## Step 2: Research — parallel `Task` fan-out (foreground) > **This is the load-bearing phase.** Quality long-form needs verified, triangulated @@ -311,12 +441,22 @@ Edition brief **Procedure:** -1. **Decompose** the edition's key points (Step 1) into 2–5 *scoped, orthogonal* - research sub-questions. Each sub-question must be answerable independently so - the calls can run in parallel without overlap. Reuse the multi-source synthesis - discipline from `commands/react.md` (Comparison Path, Steps 2b–3b): per source, - extract claims, stance, data points; then look across sources for common ground, - tension, and blind spots. +0. **Scope by the binding, not the raw key points (Fix #2 re-scope).** Read + `articles.NN.livedSpecifics` (Step 1.5). Research targets the **gaps the binding + names** — slots bound `ekstern` (and any still `unresolved` at entry): the claims + that explicitly need an external source. Slots bound to a `specific` are already + grounded in lived material; do **not** re-source their substance (you may + corroborate, but it is not the gap). Every **`unverified-number`** warning from + the Step 1.5 binding is also a research target: confirm the operator's own figure + against a primary source (regel 6/7). This is what makes research *fill* the + draft instead of re-deriving what the operator already knows. + +1. **Decompose** the binding's `ekstern`/`unresolved` slots + unverified numbers + (step 0) into 2–5 *scoped, orthogonal* research sub-questions. Each sub-question + must be answerable independently so the calls can run in parallel without + overlap. Reuse the multi-source synthesis discipline from `commands/react.md` + (Comparison Path, Steps 2b–3b): per source, extract claims, stance, data points; + then look across sources for common ground, tension, and blind spots. 2. **Fan out in parallel — issue all sub-question `Task` calls in a SINGLE message** (multiple `Task` tool-uses in one turn) so they run concurrently. Each call gets @@ -351,21 +491,27 @@ Edition brief source(s) and a confidence marker. Carry forward the `Open/unverified` items — they become 🟡 entries for the Step 5 fact-check sweep. -5. **Persist + checkpoint state.** Write the resolved brief (Step 1), the - resolved **per-article personas** (`articles.NN.personas` — the set + primær - confirmed/defined in Step 1), and the verified research notes into the - edition's `edition-state.json` (`currentPhase: "research"`, article status - `in-progress`) and append a +5. **Persist + checkpoint state.** Write the verified research notes into + `edition-state.json` (the brief + personas were first persisted at the Step 1.5 + checkpoint — re-affirm them here if calibration changed). **Update the binding + from what research resolved:** when a sub-question sources an `ekstern` slot, set + its `source`; when it confirms an operator figure, flip the bank specific + `unverified → verified` (the bank's `add`/verify path), then **re-render + `NN-kilder.md`** so the ledger stays current (a number that could not be verified + stays `unverified` and is carried to the Step 5 fact-check sweep as 🟡 — never + silently asserted). Set `currentPhase: "research"`, article status + `in-progress`, and append a "research complete → next: skeleton + section pitch (BEFORE prose)" next-step - line to `/STATE.md` (overwrite). If this is a fresh edition, initialize - `edition-state.json` from the template schema first. Stop cleanly here if - context budget is tight — Step 2.5 begins in the next session; otherwise - Step 2.5 may run inline (it is short and operator-interactive). + line to `/STATE.md` (overwrite). If somehow no `edition-state.json` exists + yet (Step 1.5 was a no-op for an adopter), initialize it from the template schema + first. Stop cleanly here if context budget is tight — Step 2.5 begins in the next + session; otherwise Step 2.5 may run inline (it is short and operator-interactive). ``` Research phase complete. -- Sub-questions: (ran in parallel) +- Sub-questions: (ran in parallel; scoped to ekstern/unresolved slots + numbers) - Verified notes: by key point +- Binding updated: ekstern slots sourced · numbers verified → NN-kilder.md re-rendered - Carried to fact-check (🟡 unverified): State written: /linkedin/edition-state.json (phase: research) Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE prose). @@ -384,10 +530,11 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros > **Order assertion (enforced).** Step 2.5 runs AFTER research (Step 2) and > BEFORE any prose (Step 3a). No section of the draft is written — not even -> spine prose — until the operator says JA on the skeleton and the +> spine prose — until the operator says JA on the skeleton, the **deterministic +> binding-gate** passes (every load-bearing claim grounded — Fix #2), and the > persona-skjelett-sweep returns a clean primær JA. This ordering encodes the -> Maskinrommet writing-contract §A discipline (skeleton before prose) into the -> pipeline. `[GATE]` +> Maskinrommet writing-contract §A discipline (skeleton + sources before prose) +> into the pipeline. `[GATE]` **Procedure:** @@ -452,17 +599,47 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros JA proceed, or another round?». `AskUserQuestion` (JA / REVIDER / NEI) is also the **fallback** gate when rendering is unavailable; it is not the primary flow: - - **JA** — proceed to the persona-skjelett-sweep (step 5). + - **JA** — proceed to the binding-gate (step 5), then the + persona-skjelett-sweep (step 6). - **REVIDER** — another annotation round; revise and re-render. - **NEI** — the skeleton is wrong at a load-bearing level (premise unsound, argument-line incoherent). Return to brief calibration (Step 1) or research (Step 2) to surface the missing piece. Do not proceed past this gate without an explicit JA. The pipeline may not - advance to Step 3a (spine prose) until both this operator-gate AND the - persona-skjelett-sweep below return JA. `[OPERATØR]` + advance to Step 3a (spine prose) until this operator-gate, the deterministic + binding-gate, AND the persona-skjelett-sweep below all pass. `[OPERATØR]` -5. **Persona-skjelett-sweep — fan out `persona-reviewer` in skjelett-mode.** +5. **Binding-gate — deterministic «vaghet avvises» (Fix #2, BEFORE the persona + sweep).** Now that the spine is visible, enforce the Step 1.5 binding: every + load-bearing slot must resolve to a real specific, a justified `abstrakt`, or an + `ekstern` source — no `unresolved` slot, no `specific` pointing at a missing bank + id. It is pure/deterministic (no model in the loop), so the same vagueness is + caught the same way every time. Run it from the package dir; pass the edition + state's **absolute** path: + + ```bash + cd "${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank" && \ + node --import tsx src/cli.ts validate-binding \ + --edition "/linkedin/edition-state.json" + ``` + + - **exit 0** → PASS. Surface any **WARN** (`unverified-number`, regel 6/7) as + direction — a number still unverified after Step 2 research is carried to the + Step 5 fact-check sweep as 🟡, never silently asserted. + - **exit 1** → BLOCKED. The output names each blocking slot (`unresolved` / + `dangling-specific` / `unjustified-abstrakt`). Resolve it — elicit the missing + specific (loop back to Step 1.5 step 2), fix the dangling id, or give the + `abstrakt` escape a real rationale — then **re-render `NN-kilder.md`** and + re-run until exit 0. No prose is written on an unresolved load-bearing + claim. `[GATE]` + - deps/contract absent (adopter — Step 1.5 was a no-op) → skip with a one-line + note; the persona-skjelett-sweep below still runs. + + When the gate passes, set `articles.NN.livedSpecifics.status: "bound"` in + `edition-state.json`. + +6. **Persona-skjelett-sweep — fan out `persona-reviewer` in skjelett-mode.** Issue one `persona-reviewer` call per active persona in parallel — a SINGLE message with multiple `Task` tool-uses, `subagent_type: linkedin-studio:persona-reviewer`, from THIS command layer in @@ -472,7 +649,7 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros This is NOT resonans mode (Step 6 — that runs on full prose) and NOT konverter mode (Step 9 — that judges the hook only). -6. **Collect skjelett verdicts and gate.** Each call returns per-axis flags +7. **Collect skjelett verdicts and gate.** Each call returns per-axis flags (HOLDER/TVILER/MANGLER), ≤3 direction-only flags, a section-pitch check (any pitch that does not pay in), a per-persona verdict (JA/NEI), and a gate decision. Aggregate per the agent's rule: @@ -491,17 +668,19 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros the editor (this session) holds the pen; never paste a persona's rewritten skeleton. `[GATE]` -7. **Convergence loop.** If gate is REWORK/BLOCK, fold flags into the +8. **Convergence loop.** If gate is REWORK/BLOCK, fold flags into the skeleton + pitches (or, on BLOCK, return upstream) and re-run the same `persona-reviewer` calls against the revision. Loop until the primær returns a clean JA. This loop is **cheap and frequent at this stage** — every round saved here is hours saved at the prose stage. -8. **Persist + checkpoint state.** Once the skeleton is JA from both operator - AND persona-skjelett-sweep, record: +9. **Persist + checkpoint state.** Once the skeleton is JA from the operator, the + binding-gate passed, AND the persona-skjelett-sweep returns a clean primær JA, + record: - The final skeleton + pitches in `/NN-skjelett.md` (already written in step 3, with any in-loop revisions applied). + - `articles.NN.livedSpecifics.status: "bound"` (set by the binding-gate, step 5). - Per-persona skjelett verdicts in `edition-state.json` → `articles.NN.personaSweep.skeleton` (or alongside resonance/conversion under the same `personaSweep` object). @@ -515,10 +694,11 @@ Skeleton + section pitch (BEFORE prose) — complete. - Skeleton: 5 lines (premiss / problem / anbefaling / gevinst / vei videre) - Section pitches: sections, all paying into the spine (else: pitches reworked, see flags) - Operator gate: JA (after revision rounds) +- Binding-gate (vaghet avvises): PASS — all load-bearing slots bound; livedSpecifics → "bound" (else BLOCK: unresolved/dangling) - Persona-skjelett-sweep: primær JA (else: still NEI — loop open, NOT ready for prose) - Convergence rounds: - Accepted sekundær ceiling-NOs (signal, not failure): -Gate: [PASS — primær JA, ready for spine prose] (else REWORK/BLOCK) +Gate: [PASS — primær JA + binding bound, ready for spine prose] (else REWORK/BLOCK) Next: Step 3a — Spine prose (one paragraph per section, BEFORE full expansion). ``` @@ -541,7 +721,7 @@ Typically ~20–30 % of the edition's final length. **Procedure:** -1. **Re-read the voice profile** (`assets/voice-samples/`) before writing a +1. **Re-read the voice profile** (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`) before writing a single sentence — this is the existing LTL rule and it is not optional for long-form. Voice match starts at the spine, not at expansion. @@ -635,7 +815,7 @@ turning-points the spine already named. **Procedure:** -1. **Re-read the voice profile** (`assets/voice-samples/`) before expanding — +1. **Re-read the voice profile** (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/`) before expanding — the voice was set at the spine; do not lose it in expansion. 2. **Expand section by section, against the spine.** Each section's paragraph @@ -724,7 +904,7 @@ linkedin-studio:voice-scrubber`, from THIS command layer in the foreground (principle 4). Pass it the draft path AND the paths to the **approved Norwegian editions** as the gold standard (e.g. earlier parts' locked `linkedin/NN/POST.html` or their approved `NN-utkast.md`). **Do NOT** point it at -`assets/voice-samples/authentic-voice-samples.md` — that corpus is English +`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` — that corpus is English short-form and forbids the em-dash; using it as the gold standard would degrade the Norwegian chronicle voice. The scrubber runs two passes: Pass 1 strips AI-tells (objective — «la meg være ærlig», reflex rule-of-three, em-dash-spam, @@ -735,7 +915,7 @@ modell-/navne-katalog collapse) to the operator. Voice-MATCH remains non-self-certified — that verdict stays with the Step 6 persona sweep / operator. After the pass, set `currentPhase: "consistency-quality"` in `edition-state.json` -and write a "quality pass complete → next: fact-check sweep" line to +and write a "quality pass complete → next: contract-gate" line to `/STATE.md` (overwrite). ``` @@ -746,6 +926,74 @@ Consistency + quality pass complete. - AI-slop phrases removed: - Formatting dose: - Length delta vs. draft: (target: flat) +Next: Step 4.5 — Contract-gate (deterministic §B/§C1 binding, BEFORE the AI sweeps). +``` + +## Step 4.5: Contract-gate — deterministic §B/§C1 binding (BEFORE the AI sweeps) + +Before any AI gate touches the draft — fact-check (Step 5), editorial craft (Step +5.5), persona sweep (Step 6), headless review (Step 6.5) — run the **deterministic +contract-gate** over the consistency-passed draft. It is pure regex/literal matching +(no model in the loop): the same mechanical §B/§C1 violation is caught and fixed the +same way every time, so it **never reaches the operator at a judgment gate**. This is +the machine half of the "corrections that stick" goal — the cheap, deterministic pass +runs first so the costly AI gates spend their judgment on resonance and truth, not on +mechanical noise. + +> **Truth source — §B/§C1 vs §C2.** The gate's ruleset is `scripts/contract-gate/ +> src/rules.ts` (data, not code), bound **1:1** to the Maskinrommet skrivekontrakt +> **§E gate-bindings-manifest** by `ratify` (id ↔ ref ↔ severity bijection). It is the +> *mechanical* half of the contract (§B structure + §C1 mechanical gate); the *craft* +> half (§C2) is the `editorial-reviewer` at Step 5.5. This Step does not restate the +> rules — `rules.ts` is the single source of truth, and ratify proves it stays bound +> to the contract. Distinct from Step 4's `references/longform-quality-rules.md`, +> which is the §A/quality reference applied by reasoning. + +> **Adopter note.** The gate is a KTG-internal tool: it needs the contract-gate deps +> (`scripts/contract-gate/node_modules` — run `npm install` there once) and, for +> ratify, the maskinrommet contract. If either is absent (a plain adopter ships +> neither), this Step is a **no-op** and the §B/§C1 discipline falls back to the Step 4 +> quality-pass reasoning. It never blocks an adopter. + +**Procedure:** + +1. **Ratify the gate (invariant — confirm it is still bound to the contract).** Before + trusting any verdict, confirm the ruleset is in 1:1 sync with the contract + §E-manifest: + ``` + cd "${CLAUDE_PLUGIN_ROOT}/scripts/contract-gate" && \ + node --import tsx src/cli.ts --ratify + ``` + - **exit 0** → `rules.ts` bound 1:1 to §E → proceed. + - **exit 1 (DRIFT)** → a **tooling/contract inconsistency** (a rule changed on one + side only). This is NOT a draft fix — **escalate to the operator**; the §E + accumulation procedure (promote → ratify) reconciles it. `[GATE]` + - contract or deps absent → skip with a one-line note (adopter); the draft-gate + below still runs if the deps are present. + +2. **Gate the draft.** Run the gate over the edition draft. The gate runs from its own + dir (where `tsx` lives), so pass the draft's **absolute** path: + ``` + cd "${CLAUDE_PLUGIN_ROOT}/scripts/contract-gate" && \ + node --import tsx src/cli.ts "/NN-utkast.md" + ``` + - **exit 0** → PASS. Surface any **WARN** violations as direction (operator + judgment — WARN never blocks). + - **exit 1** → BLOCKED. Show each BLOCK violation (ruleId · ref · line · message), + fix **by tightening** (the Step 4 rule still holds — swap weaker for sharper, do + not expand), and **re-run until exit 0**. `[GATE]` + - **exit 2** → usage error (e.g. wrong path) — diagnose; do not treat as a content + flag. + +3. **Persist + checkpoint state.** Set `currentPhase: "contract-gate"` in + `edition-state.json` and write a "contract-gate passed → next: fact-check sweep" + line to `/STATE.md` (overwrite). + +``` +Contract-gate complete. +- Ratify: +- BLOCK violations: <0 / fixed N by tightening> +- WARN violations: Next: Step 5 — Fact-check sweep (guilty-until-disproven, BEFORE lock). ``` @@ -1486,7 +1734,7 @@ now a first-class scheduled post. node -e 'import("'"${CLAUDE_PLUGIN_ROOT}"'/hooks/scripts/queue-manager.mjs").then(q => q.queueAdd("-NN","/linkedin/NN/POST.html","YYYY-MM-DD","HH:MM","","newsletter","",))' ``` - The function appends to `assets/drafts/queue.json` with `status: + The function appends to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json` with `status: "scheduled"` and returns the new entry. 3. **Persist + close the edition.** Set the article's `status: "scheduled"`, @@ -1497,7 +1745,7 @@ now a first-class scheduled post. ``` Scheduling. -- Queue entry: -NN → assets/drafts/queue.json (status: scheduled) +- Queue entry: -NN → ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json (status: scheduled) - Slot: YYYY-MM-DD HH:MM format: newsletter - Article status: scheduled Edition complete. Visible in /linkedin:calendar; mark live via /linkedin:calendar (publish action). @@ -1572,7 +1820,8 @@ the honest decision surface; it sells nothing. ## Reference Files -- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (16 phases including v2.1 skeleton + spine-prose gates, v2.3 visual-assets, v2.4 editorial-review, and v3.1 headless-review + per-article `personas` + `pivots`) +- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (18 phases including Fix #2 lived-specifics extraction (Step 1.5) + `articles.NN.livedSpecifics`, the deterministic §B/§C1 contract-gate (Step 4.5), v2.1 skeleton + spine-prose gates, v2.3 visual-assets, v2.4 editorial-review, and v3.1 headless-review + per-article `personas` + `pivots`) +- `${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank/` — lived-specifics store + per-edition binding (Fix #2, kilde-så-draft): `query`/`add` (Step 1.5), `validate-binding` (Step 2.5 gate), `render-kilder` (`NN-kilder.md` ledger). Design record: `docs/fix2/slice2-binding.md` - `${CLAUDE_PLUGIN_ROOT}/config/edition-config.template.json` — static delivery metadata schema (calendar, freshness, credit, captions) — Step 8 - `${CLAUDE_PLUGIN_ROOT}/config/image-credit-caption.template.md` — cover motif + credit + caption table (honest-about-AI credit) — Step 7.5 - `${CLAUDE_PLUGIN_ROOT}/config/edition-delingstekst.template.md` — distribution-copy grammar (`## Del N —` / `## Samle`) — Steps 8/9 @@ -1587,7 +1836,7 @@ the honest decision surface; it sells nothing. - `${CLAUDE_PLUGIN_ROOT}/commands/headless-review.md` — the Step 6.5 cold review package as a standalone command (run in a fresh session for maximum isolation) - `${CLAUDE_PLUGIN_ROOT}/commands/pivot.md` — re-opens the pipeline after a late pivot so Steps 5–6.5 re-run on the changed version before lock - `${CLAUDE_PLUGIN_ROOT}/commands/react.md` — multi-source synthesis discipline (reused in Step 2) -- `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/authentic-voice-samples.md` — voice matching +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` — voice matching - `${CLAUDE_PLUGIN_ROOT}/references/longform-quality-rules.md` — canonical long-form rules (Steps 2.5, 3a, 3b, 4–9 all reference) - `${CLAUDE_PLUGIN_ROOT}/render/build-linkedin.mjs` — POST.html delivery; reads `linkedin/NN/cover.png` + credit/caption (Step 8) - `${CLAUDE_PLUGIN_ROOT}/render/build-html.mjs` — annotatable review renderer (Step 7) diff --git a/commands/onboarding.md b/commands/onboarding.md index bca300f..9eaa8ad 100644 --- a/commands/onboarding.md +++ b/commands/onboarding.md @@ -15,7 +15,7 @@ allowed-tools: # LinkedIn Onboarding Wizard -You are a LinkedIn thought leadership onboarding guide. Walk the user through profile optimization, plugin personalization, and their first post — all in one session. +You are the LinkedIn Studio onboarding guide. Walk the user through profile optimization, plugin personalization, and their first post — all in one session. ## Step 0: Load Context and Check State @@ -140,7 +140,7 @@ Use AskUserQuestion: 4. "Paste a paragraph you've written that sounds like YOU (email, doc, anything)" 5. "Any words or phrases you'd NEVER use?" -Save the responses to `assets/voice-samples/authentic-voice-samples.md`. **If the +Save the responses to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md`. **If the file is the shipped placeholder** (it contains ``), **REPLACE it entirely** with the profile built from the answers — the `` sentinel must NOT remain, or the voice score stays at @@ -154,8 +154,9 @@ file must contain no ``. 3. Job title / role 4. 5 expertise areas (these become your content pillars) 5. Target audience description +6. Research MCPs connected (Tavily / Gemini deep research / Perplexity — or "none"; WebSearch + WebFetch are the always-available floor). Store only what they name — don't invent MCP names. -Save to `config/user-profile.local.md`. +Save to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md`. After setup, recalculate and show updated score. @@ -196,7 +197,7 @@ Then ask: "Give me a sentence or two about what you have in mind." If expertise ### 3.2 — Write the post (3-line formula) -Draft the post using the voice profile from Phase 2 (or the existing `assets/voice-samples/` profile): +Draft the post using the voice profile from Phase 2 (or the existing `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` profile): - **Line 1 — Hook (110-140 chars):** specific to their experience, no generic opening - **Line 2 — Context (1-3 sentences):** the what and why, kept tight - **Line 3 — Insight + question:** their takeaway, ending on a genuine question that invites comments @@ -217,9 +218,11 @@ Fix any miss before showing it. Show the post with its character count, the hook highlighted, and one alternative hook. Auto-copy the post text to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Then say: "Post copied to clipboard. Go to linkedin.com, click 'Start a post', paste it, and hit Post." +Substitute `` with the exact post text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, say: "Post copied to clipboard. Go to linkedin.com, click 'Start a post', paste it, and hit Post." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. ### 3.5 — Record it diff --git a/commands/outreach.md b/commands/outreach.md index e2446a0..f022fcf 100644 --- a/commands/outreach.md +++ b/commands/outreach.md @@ -16,10 +16,9 @@ allowed-tools: - Read - Glob - WebSearch - - WebFetch - AskUserQuestion - - Write - Task + - Bash --- # LinkedIn Outreach Orchestrator (Collaborations + Speaking) @@ -649,7 +648,8 @@ DM Amplification Protocol: Both partners share/comment on each other's version. Creates compound visibility effect. -Impact: DM amplification can boost first-hour engagement 2-3x. +Impact: this feeds the first-hour engagement that carries collaborative +content to its above-baseline reach (no reliable multiplier; ref: collaborations-guide.md). ``` ### Step 5b: Speaking — Outreach by Channel @@ -810,7 +810,7 @@ Week 1: FRAMEWORK POST ───────────────────── Show your unique methodology. Hook: "I developed a 3-step framework for [topic] after [experience]." -Goal: Demonstrate thought leadership depth. +Goal: Demonstrate subject-matter depth. Signal: "This person has original ideas worth sharing." Week 2: EVENT RECAP / INSIGHT POST @@ -918,24 +918,22 @@ LinkedIn: [profile URL] ## Step 7: Engagement-Pod Warning (Both Tracks) -**Post-March 2025 LinkedIn Algorithm Update:** +LinkedIn runs an active engagement-pod crackdown (see +`references/algorithm-signals-reference.md`): ``` ⚠️ ENGAGEMENT PODS: DO NOT USE -LinkedIn now actively detects and penalizes engagement pods: +What LinkedIn does to pods (its stated crackdown): +- Pod-style auto-comments are demoted out of "Most Relevant" +- Their reach is scoped to your own network — it stops spreading beyond it +- Repeat offenders get restricted -Detection methods: +How pods get detected: - Consistent same-person engagement patterns - Engagement timing clusters (everyone engages within minutes) -- Low dwell time on engaged posts (engage without reading) - Reciprocal engagement loops (A→B→A→B pattern) -Penalties: -- 30-55% reach reduction on detected posts -- Shadow suppression of pod participants -- Account credibility score reduction - INSTEAD, build genuine engagement through: - CEA method comments (Compliment, Expand, Ask) - 5x5x5 daily routine (organic engagement) diff --git a/commands/pipeline.md b/commands/pipeline.md index 885b1fe..c19efea 100644 --- a/commands/pipeline.md +++ b/commands/pipeline.md @@ -26,8 +26,8 @@ You are a LinkedIn content pipeline orchestrator. Guide the user through the com Load persistent state and personalization: - Read `~/.claude/linkedin-studio.local.md` for posting state - Read `${CLAUDE_PLUGIN_ROOT}/skills/linkedin-studio/SKILL.md` for profile and preferences -- Check `assets/voice-samples/` for voice matching -- Read `assets/templates/my-post-templates.md` for proven post templates — use these in Step 2 (Draft) +- Check `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` for voice matching +- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/templates/my-post-templates.md` for proven post templates — use these in Step 2 (Draft) - Read `assets/frameworks/framework-template.md` if the topic involves a framework or methodology Display status: @@ -52,7 +52,7 @@ To situate the post in the broader plan — does it fill a content-mix gap or re Once topic is chosen, create the draft: -1. **Select angle** — Auto-select the strongest angle from `references/thought-leadership-angles.md` based on topic and user's expertise. Present ONE recommended angle with reasoning. Do NOT use AskUserQuestion — just proceed. If user disagrees, offer alternatives. +1. **Select angle** — Auto-select the strongest angle from `references/content-angles.md` based on topic and user's expertise. Present ONE recommended angle with reasoning. Do NOT use AskUserQuestion — just proceed. If user disagrees, offer alternatives. 2. **Infer format** — Default to text post. Only mention carousel/video as a note if particularly well-suited. 3. **Write draft** — Following the structure: - Hook: 110-140 characters @@ -76,12 +76,12 @@ Run the draft through optimization checks: - [ ] Hook 110-140 chars - [ ] Total 1,200-1,800 chars - [ ] No external links in body -- [ ] No corporate buzzwords +- [ ] No corporate buzzwords (leverage, synergy, paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach) - [ ] Topic aligns with expertise areas - [ ] Authentic voice (not AI-sounding) **Voice check:** -Compare against `assets/voice-samples/` to ensure natural tone. +Compare against `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` to ensure natural tone. Present optimized version with before/after comparison. @@ -110,7 +110,7 @@ If the user chooses to queue the post: node --input-type=module -e "import { queueUpcoming, queueFormatSummary } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/queue-manager.mjs'; console.log(queueFormatSummary(queueUpcoming(14)));" ``` 3. Suggest the next available optimal slot -4. Save the draft to `assets/drafts/week-[WXX]/[day]-[topic-slug].md` with `scheduled_date` and `scheduled_time` in frontmatter +4. Save the draft to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/week-[WXX]/[day]-[topic-slug].md` with `scheduled_date` and `scheduled_time` in frontmatter 5. Add to queue: ```bash node --input-type=module -e "import { queueAdd } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/queue-manager.mjs'; console.log(queueAdd('[id]', '[draft_path]', '[date]', '[time]', '[pillar]', '[format]', '[hook preview]', [chars]));" @@ -136,7 +136,9 @@ Offer to help identify target profiles and draft comments. Auto-copy the final post text to clipboard silently before presenting: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` Present the final post as copy-paste ready content: @@ -201,11 +203,11 @@ Replace placeholders with actual post data. Set `next_planned_topic` manually if ## Reference Files -- `${CLAUDE_PLUGIN_ROOT}/references/thought-leadership-angles.md` +- `${CLAUDE_PLUGIN_ROOT}/references/content-angles.md` - `${CLAUDE_PLUGIN_ROOT}/references/engagement-frameworks.md` - `${CLAUDE_PLUGIN_ROOT}/references/algorithm-signals-reference.md` - `${CLAUDE_PLUGIN_ROOT}/references/linkedin-formats.md` - `${CLAUDE_PLUGIN_ROOT}/references/scheduling-strategy.md` - `${CLAUDE_PLUGIN_ROOT}/assets/checklists/quality-scorecard.md` -- `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/` -- `${CLAUDE_PLUGIN_ROOT}/assets/drafts/queue.json` +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json` diff --git a/commands/pivot.md b/commands/pivot.md index be3ab6a..7ae0a35 100644 --- a/commands/pivot.md +++ b/commands/pivot.md @@ -43,7 +43,7 @@ edition can sail into lock carrying an unverified premise or an unread argument. /linkedin:pivot --article NN (required; the edition article that changed) --reason "" (required; e.g. "Security Champions-anker") - --to-phase draft | consistency-quality | factcheck-sweep (optional; default from the heuristic) + --to-phase draft | consistency-quality (optional; default from scope — see Step 2) ``` ## The pivot-detection heuristic @@ -90,13 +90,23 @@ cleared, the lock STOPS and points the operator here. (Length-band drift itself the operator if ambiguous). 4. **Classify scope** (drives the default `--to-phase`): - **Structural** (deltaPct > 20 % OR newSections > 2, or a new axis/thesis) → - default `to-phase: draft` (Step 3b). The new material needs full prose - expansion → consistency → fact-check → editorial → persona → headless. + default `to-phase: draft` so newsletter resumes at **Step 4 + (consistency-quality)** — the changed prose is re-threaded, then fact-check + (5) → editorial (5.5) → persona (6) → headless (6.5) re-run on it. - **Moderate** (new examples/claims, no new sections, deltaPct ≤ 20 %) → - default `to-phase: factcheck-sweep` (Step 5) so the new claims get verified, - then editorial + persona + headless re-run. + default `to-phase: consistency-quality` so newsletter resumes at **Step 5 + (fact-check)** and the new claims get verified, then editorial (5.5) → + persona (6) → headless (6.5) re-run. - The operator may override with explicit `--to-phase`. +> **Why these specific phases (the off-by-one trap).** `to-phase` is written as +> `currentPhase`, which records the *last completed* phase; `/linkedin:newsletter`'s +> resumption table (Step 0) then resumes at the step **after** it. So `draft` +> resumes at **Step 4** and `consistency-quality` resumes at **Step 5** (the +> fact-check). A pivot therefore never resets to `factcheck-sweep` — that would +> resume at Step 5.5 and **skip** the fact-check, violating this command's +> invariant that a pivot always invalidates the fact-check (Step 3, `gatesToRerun`). + ## Step 3 — Log the pivot + reset the phase (the ritual) 1. **Append a pivot entry** to `articles.NN.pivots[]`: diff --git a/commands/post.md b/commands/post.md index db947d1..ebb60ee 100644 --- a/commands/post.md +++ b/commands/post.md @@ -18,7 +18,7 @@ allowed-tools: # LinkedIn Post Creation Workflow -You are a LinkedIn thought leadership content creator. Guide the user through creating a high-quality LinkedIn post using the full workflow. +You are a LinkedIn content creator. Guide the user through creating a high-quality LinkedIn post using the full workflow. ## Step 0: Load Context @@ -36,14 +36,14 @@ Check weekly progress: - If `posts_this_week == weekly_goal - 1`, note: "This is your last post to hit this week's goal." Check for existing assets: -- `assets/voice-samples/` - Match the user's natural voice -- `assets/examples/high-engagement-posts.md` - Study past successful posts and replicable patterns +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` - Match the user's natural voice +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md` - Study past successful posts and replicable patterns - `assets/frameworks/framework-template.md` - Reference user's documented frameworks for framework posts -- `assets/templates/my-post-templates.md` - User's proven post templates with success rates. **Prefer these over generic structures.** +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/templates/my-post-templates.md` - User's proven post templates with success rates. **Prefer these over generic structures.** ## Step 1: Understand the Input -If the user already provided a clear topic with the command invocation (e.g., `/linkedin:post about AI governance in public sector`), skip asking and proceed directly. Only ask if the input is missing or genuinely vague. +If the user already provided a clear topic with the command invocation (e.g., `/linkedin:post about pricing strategy for B2B SaaS`), skip asking and proceed directly. Only ask if the input is missing or genuinely vague. Identify the type of raw material: @@ -61,11 +61,11 @@ If the input is genuinely vague (no discernible topic or intent), ask ONE clarif If they provide a URL, use WebFetch to extract the content first. -## Step 2: Select Thought Leadership Angle +## Step 2: Select Content Angle -Read `references/thought-leadership-angles.md` for the 8 universal angles. +Read `references/content-angles.md` for the 8 universal angles. -**Industry-specific angles:** If `config/user-profile.local.md` exists and has an `industry` field, check the "Industry Angle Variants" section in `thought-leadership-angles.md` for the matching industry table. Use the industry-specific starter questions and example hooks to generate more targeted angle suggestions. +**Industry-specific angles:** If `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md` exists and has an `industry` field, check the "Industry Angle Variants" section in `content-angles.md` for the matching industry table. Use the industry-specific starter questions and example hooks to generate more targeted angle suggestions. Select the strongest angle based on the content and user's expertise areas. Present ONE recommended angle with brief reasoning: @@ -95,7 +95,7 @@ Proceed with standard text post format by default. Only mention carousel or othe Read `references/engagement-frameworks.md` for hook types, story structures, and CTAs. -Use the Standard Thought Leadership Structure: +Use the Standard Post Structure: 1. **Hook (110-140 chars):** Grab attention, create curiosity gap 2. **Context (200-300 chars):** Set up why this matters @@ -131,8 +131,9 @@ Before presenting, verify against `assets/checklists/quality-scorecard.md`: - [ ] Provides genuine value to readers - [ ] CTA is specific and natural - [ ] No external links in post body +- [ ] No corporate buzzwords (leverage, synergy, paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach) - [ ] Topic aligns with user's 5 core expertise areas -- [ ] Passes thought leadership test (helps someone decide or think differently) +- [ ] Passes the insight test (helps someone decide or think differently) ### De-AI / Differentiation Gate @@ -149,9 +150,11 @@ Present ONE draft with: Auto-copy the final post text to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Then confirm: "Copied to clipboard." +Substitute `` with the exact post text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Copied to clipboard." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. Do NOT proactively offer alternative versions. Only generate alternatives if the user asks for them. @@ -199,7 +202,7 @@ Replace placeholders with actual post data. This replaces manual YAML editing. ## Reference Files -- `references/thought-leadership-angles.md` - 8 universal angles +- `references/content-angles.md` - 8 universal angles - `references/engagement-frameworks.md` - Hooks, structure, CTAs - `references/linkedin-formats.md` - Format specifications - `references/algorithm-signals-reference.md` - Algorithm mechanics diff --git a/commands/profile.md b/commands/profile.md index ff2319c..8b1afed 100644 --- a/commands/profile.md +++ b/commands/profile.md @@ -2,8 +2,9 @@ name: linkedin:profile description: | profile/topic-relevance optimization checklist for LinkedIn's 2026 algorithm update. - LinkedIn now validates your profile BEFORE distributing content. This command audits - and optimizes your profile for maximum reach. Use when the user mentions "profile", + A coherent, on-topic profile reinforces the topic-relevance signal LinkedIn uses to decide + how widely your content is distributed. This command audits and optimizes your profile + for that signal. Use when the user mentions "profile", "topic-relevance", "profile optimization", "why is my reach low", or wants to improve their LinkedIn presence. Triggers on: "optimize profile", "profile/topic-relevance check", "profile audit", "linkedin profile help", "fix my profile". @@ -21,36 +22,41 @@ You are a LinkedIn profile optimization specialist. Help the user optimize their Read `references/algorithm-signals-reference.md` for algorithm mechanics. **The Fundamental Shift:** -- **In the older feed model:** Post something -> Goes to 10% of audience -> Algorithm tracks engagement -- **In the 2026 relevance model:** profile/topic relevance is weighed alongside engagement — content matched to your demonstrated expertise is distributed more widely (including beyond your network), so an off-topic post from a misaligned profile tends to underperform. +- **In the older feed model:** Post something → a slice of your network sees it → the algorithm tracks engagement to decide wider reach +- **In the 2026 relevance model:** topic/interest relevance is weighed alongside engagement — content matched to a viewer's interests is distributed more widely (including beyond your network), so an off-topic post from a profile that sends no clear topic signal tends to underperform. -**Profile/topic alignment is a real ranking input — content matched to your demonstrated expertise is distributed more widely (see `references/algorithm-signals-reference.md`).** +**Profile/topic alignment is a real ranking input — content matched to a viewer's interests is distributed more widely, including beyond your network (see `references/algorithm-signals-reference.md`). LinkedIn confirms no off-topic reach-reduction figure — treat alignment as a real input, not a quantified penalty.** ## The Profile/Topic Relevance Factors -The 2026 relevance-ranking model evaluates five criteria (see `references/algorithm-signals-reference.md`): +Topic alignment is a confirmed ranking input, but LinkedIn does **not** publish a +profile-scoring breakdown — there is no official "five criteria" weighting (see +`references/algorithm-signals-reference.md`). The factors below are practitioner heuristics +for sending a coherent, on-topic expertise signal; treat the priority as directional, not a +measured coefficient: -| Criteria | What It Checks | Impact if Missing | -|----------|----------------|-------------------| -| **About Section** | Does it establish expertise on your topics? | HIGH - first signal of credibility | -| **Experience Section** | Relevant background with impact statements? | HIGH - proves you've done the work | -| **Content History** | Have you posted about this topic before? | MEDIUM - consistency signal | -| **Network** | Connected to professionals in this space? | MEDIUM - social proof | -| **Engagement Patterns** | Do you comment on posts about your topics? | MEDIUM - active participation | +| Factor | What it signals | Priority (heuristic) | +|--------|-----------------|----------------------| +| **About Section** | Establishes your expertise on your topics | High — first thing a reader (and a topic-matcher) sees | +| **Experience Section** | Relevant background with impact statements | High — evidence you've done the work | +| **Content History** | You've posted on this topic before | Medium — consistency signal | +| **Network** | Connected to professionals in this space | Medium — social proof | +| **Engagement Patterns** | You comment on posts in your topics | Medium — active participation | ## Profile SEO — your profile is also a search surface Topic-relevance ranking (above) governs **content distribution**. Separately, your profile is **indexed by LinkedIn search** — when someone searches a topic, a role, or a skill, LinkedIn keyword-matches profile fields to decide who surfaces. -The two reinforce each other: the same keywords that tell the relevance model -what you're expert in are the ones that make you findable. Optimize for both. +The two reinforce each other: the same keywords that make your topic legible — to +readers and to topic-relevance distribution — are the ones that make you findable in +search. Optimize for both. -**The headline is your highest-weight search field.** It is keyword-matched, shown +**The headline is widely regarded as your highest-leverage search field.** It is keyword-matched, shown in every search result and connection suggestion, and renders under your name across the site — so it does the most SEO work per character. Lead with the plain words people actually search (the role, the domain, the audience), not a clever -tagline. "AI Advisor · public-sector AI governance · Microsoft Copilot" is more +tagline. "Data Engineer · healthcare analytics · HIPAA-compliant pipelines" is more findable than "Turning chaos into clarity ✨". **Per-section keyword targets** (place the terms a searcher would type, in the @@ -58,8 +64,8 @@ words they'd type them — not synonyms only you use): | Section | Keyword target | Why it ranks | |---------|----------------|--------------| -| **Headline** | 3–4 primary topic terms + audience + role | Highest-weight search field; always visible | -| **About** | Same primary terms, front-loaded in the first 2–3 lines, then 5–8 supporting terms naturally across the body | Indexed for search; first lines double as the relevance model's expertise signal | +| **Headline** | 3–4 primary topic terms + audience + role | Highest-leverage search field; always visible | +| **About** | Same primary terms, front-loaded in the first 2–3 lines, then 5–8 supporting terms naturally across the body | Indexed for search; the front-loaded first lines also carry your strongest on-topic signal | | **Experience (titles + body)** | The searchable job title (not an internal-only label) + 2–3 domain terms per role | Job titles are weighted in search; an internal title nobody searches is invisible | | **Skills** | Your top 3 skills = your 3 core content topics, exact-match to common search terms | Matched directly against recruiter/search skill filters | | **Featured** | Posts whose titles carry your topic terms | Reinforces the topic association for both search and relevance | @@ -89,14 +95,14 @@ Guide the user through each section using AskUserQuestion for interactive feedba - [ ] No jargon or vague titles **Strong example:** -"Helping public sector leaders implement AI that actually works | AI Advisor @ [Company]" +"Helping e-commerce teams turn returns data into retention | Retention Strategist @ [Company]" **Weak example:** "Digital Transformation Expert | Thought Leader | Speaker" ### Section 2: About Section (2,600 characters max) -**Critical:** This is the first signal telling topic-relevance what you're qualified to discuss. +**Critical:** Your About opener is the clearest place to state, in plain on-topic terms, what you're expert in — the strongest single contribution to a coherent topic signal. **Structure:** @@ -128,7 +134,7 @@ Guide the user through each section using AskUserQuestion for interactive feedba **Transform each role with impact statements, not task lists.** **Bad:** "Responsible for AI initiatives" -**Good:** "Deployed first Copilot Studio agent handling 40% of internal inquiries" +**Good:** "Cut customer-support response time 40% by automating tier-1 triage" **Ask the user:** Describe your current role's key achievements with numbers/impact. @@ -158,7 +164,7 @@ Guide the user through each section using AskUserQuestion for interactive feedba ### Section 5: Skills Section -**Critical for profile/topic-relevance validation.** +**Your top skills are a strong, searchable topic signal.** **Ask the user:** What skills are listed on your profile? @@ -170,7 +176,7 @@ Guide the user through each section using AskUserQuestion for interactive feedba ### Section 6: Network Quality -**profile/topic-relevance checks if you're connected to professionals in your expertise area.** +**A network concentrated in your expertise area reinforces your topic signal and your social proof** (a practitioner heuristic — LinkedIn does not publish network as a profile-ranking factor). **Ask the user:** Who are you primarily connected with? (peers, clients, random connections?) @@ -227,7 +233,7 @@ Based on the audit, provide a prioritized action list: Before posting, the user should ask themselves: -> "If LinkedIn's AI read my profile, would it believe I'm an expert on the topics I post about?" +> "Does my profile make it obvious — to a human and to LinkedIn's topic-matching — that I'm an expert on the topics I post about?" If the answer is no, fix the profile FIRST before posting. diff --git a/commands/quick.md b/commands/quick.md index 614bbdf..fbb6942 100644 --- a/commands/quick.md +++ b/commands/quick.md @@ -159,9 +159,11 @@ Show the post with: Auto-copy the final post text to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Then confirm: "Copied to clipboard." +Substitute `` with the exact post text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Copied to clipboard." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. Do NOT proactively offer alternative versions. Only generate alternatives if the user asks. diff --git a/commands/react.md b/commands/react.md index c2334f1..a750f8c 100644 --- a/commands/react.md +++ b/commands/react.md @@ -19,13 +19,13 @@ allowed-tools: # React to External Content — URL-to-Post Pipeline -You are a LinkedIn content creator specializing in turning external content into thought leadership posts. +You are a LinkedIn content creator specializing in turning external content into insightful posts. ## Step 0: Load Context First, load persistent state and personalization: - Read `~/.claude/linkedin-studio.local.md` for posting state (streak, weekly progress, recent topics) -- Read `assets/voice-samples/authentic-voice-samples.md` for voice profile +- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` for voice profile - Check recent posts to avoid topic repetition within 7 days ## Step 1: Get URL(s) @@ -57,6 +57,8 @@ Use WebFetch to extract the content from the URL. Ask WebFetch to extract: - Data points or statistics mentioned - The "so what" — why this matters +**If WebFetch can't extract usable content** (paywall, JS-heavy page, or a YouTube video with no accessible transcript — all accepted inputs WebFetch may not resolve): say so plainly, then ask the user to paste the key points (or the transcript) or choose another URL. Never fabricate a reaction from the URL string or title alone — the post must stand on real source content. + ## Step 3: Classify Content Type Determine the content type to select the right template: @@ -126,6 +128,7 @@ Verify against quality rules: - [ ] Character count: 1,200-1,800 - [ ] Topic aligns with expertise areas - [ ] CTA invites discussion, not just "What do you think?" +- [ ] No corporate buzzwords (leverage, synergy, paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach) ### De-AI / Differentiation Gate @@ -143,9 +146,11 @@ Show: Auto-copy the main draft text to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Then confirm: "Copied to clipboard." +Substitute `` with the exact draft text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Copied to clipboard." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. Do NOT use AskUserQuestion for refinement. Simply state: @@ -188,6 +193,8 @@ Use WebFetch on each URL. For each, extract: - **Stance/argument** — what position does the author take? - **Data points** — any statistics or evidence cited +**If a source can't be extracted** (paywall / JS-heavy / no transcript): apply the Step 2 fallback — drop it if 2+ usable sources remain, otherwise ask the user to paste it or choose another. Never fabricate from a title or URL alone. + ### Step 3b: Synthesis Analysis Analyze across all sources: @@ -264,7 +271,7 @@ Same as Step 8 — run `state-updater.mjs` with actual post data. ## Reference Files -- `assets/voice-samples/authentic-voice-samples.md` — Voice matching -- `references/thought-leadership-angles.md` — 8 universal angles +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` — Voice matching +- `references/content-angles.md` — 8 universal angles - `references/engagement-frameworks.md` — Hooks, structure, CTAs - `assets/checklists/quality-scorecard.md` — Pre-publish check diff --git a/commands/report.md b/commands/report.md index 694c39b..0086390 100644 --- a/commands/report.md +++ b/commands/report.md @@ -10,6 +10,7 @@ allowed-tools: - Bash - Read - Glob + - Write - AskUserQuestion - Task --- @@ -27,7 +28,7 @@ For data format details and directory structure, see `assets/analytics/README.md First, verify that analytics data exists: ```bash -ls -1 ${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/ 2>/dev/null | grep -E '\.json$' | head -10 +ls -1 ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/ 2>/dev/null | grep -E '\.json$' | head -10 ``` If no JSON files exist, tell the user: @@ -69,7 +70,7 @@ Enter your choice: ``` **If monthly (option 2):** Ask for month (YYYY-MM format, default to current month), then jump to **Step 2b**. -**If heatmap (option 3):** Run the heatmap CLI command and jump to **Step 6c**. +**If heatmap (option 3):** Run the heatmap CLI command and jump to **Step 2c**. **If weekly (option 1 or default):** Continue below. ### Weekly: Determine Week @@ -98,17 +99,17 @@ date +%Y-W%V If the user chose monthly: ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --month +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --month ``` -Read the generated JSON from `assets/analytics/monthly-reports/.json`. Present the monthly summary with MoM comparison deltas, weekly breakdown, and top performers. Then jump to Step 7 for deep-dive options. +Read the generated JSON from `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/monthly-reports/.json`. Present the monthly summary with MoM comparison deltas, weekly breakdown, and top performers. Then jump to Step 7 for deep-dive options. ### Step 2c: Heatmap If the user chose heatmap: ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" heatmap +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" heatmap ``` Present the day-of-week matrix and best-day findings. Then jump to Step 7 for deep-dive options. @@ -118,40 +119,39 @@ Present the day-of-week matrix and best-day findings. Then jump to Step 7 for de Execute the report CLI command: ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week ``` **Example:** ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week 2026-W05 +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week 2026-W05 ``` The CLI will generate: -- `assets/analytics/weekly-reports/YYYY-WXX.json` - Structured report data +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-reports/YYYY-WXX.json` - Structured report data ## Step 4: Read Generated Report Data Read the generated JSON report: ```bash -cat ${CLAUDE_PLUGIN_ROOT}/assets/analytics/weekly-reports/.json +cat ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-reports/.json ``` The report contains: - **week**: ISO week identifier -- **dateRange**: Start and end dates -- **postCount**: Number of posts published -- **aggregateMetrics**: Totals and averages across all metrics -- **topPerformers**: Best posts by each metric -- **alerts**: Anomalies and significant events -- **trends**: Week-over-week changes +- **generatedAt**: ISO timestamp when the report was generated +- **summary**: Totals and averages — `totalPosts`, `totalImpressions`, `totalReactions`, `totalComments`, `totalShares`, `totalClicks`, optional `totalSaves` (manual entry only), `avgEngagementRate`, `avgImpressionsPerPost` +- **topPerformers** / **underperformers**: Best / weakest posts by engagement rate +- **trends**: Week-over-week change — `impressionsTrend`, `engagementTrend`, `comparedTo`, `percentChange` +- **alerts**: Anomalies and significant events (intra-week + week-over-week) ## Step 5: Run Trend Analysis Get additional context with trend analysis: ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric impressions +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric impressions ``` This provides: @@ -165,13 +165,11 @@ After the initial trend data, automatically run trend analysis for the key metri **Run trends CLI for key metrics:** ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" \ - "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" \ +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" \ trends --period month --metric impressions -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" \ - "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" \ - trends --period month --metric engagement_rate +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" \ + trends --period month --metric engagementRate ``` **Present trend summary as a 4-week comparison table:** @@ -197,7 +195,7 @@ ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" \ Construct the 4-week table by reading available weekly report files: ```bash -ls ${CLAUDE_PLUGIN_ROOT}/assets/analytics/weekly-reports/*.json 2>/dev/null | sort | tail -4 +ls ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-reports/*.json 2>/dev/null | sort | tail -4 ``` Read each file and extract the summary metrics to populate the table columns. @@ -215,20 +213,24 @@ Automatically flag these conditions based on the report data and trend analysis: - 🟢 **Positive:** Consistent posting streak maintained (7+ days) **Algorithm Alerts (based on algorithm-signals-reference):** -- 🔴 Format stagnation: Same format used >80% of posts (algorithm penalizes monotony per 2026 content format multipliers) +- 🔴 Format stagnation: Same format used >80% of posts (algorithm favors format variety — see algorithm-signals-reference) - 🟡 Posting time drift: Publishing outside optimal window (Tue-Thu, 7-9 AM CET for Nordic audience — see posting time windows reference) - 🟡 Hook length violation: Posts with hooks >140 chars underperforming (>140 chars truncated on mobile "see more") - 🟢 Engagement velocity improving: First-hour engagement trending up (15+ engagements in first hour unlocks 2nd/3rd degree distribution) -**Detect alerts by comparing current week data against baselines:** +**Surface the alerts the report already computed.** The weekly-report JSON's `alerts[]` +is generated by the CLI itself — intra-week anomaly detection across the week's posts +(`detectAlerts`) plus a week-over-week comparison (`detectWeeklyAlerts`) that fires once +the previous week's report exists. There is no separate baselines file to consult; read +`alerts[]` straight from the report you generated in Step 3: ```bash -cat ${CLAUDE_PLUGIN_ROOT}/assets/analytics/baselines.json 2>/dev/null +cat ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-reports/.json 2>/dev/null \ + | python3 -c "import json,sys; [print(a['severity'].upper(), '—', a['message']) for a in json.load(sys.stdin).get('alerts', [])]" ``` -Compare current week's `aggregateMetrics` against baseline means and standard deviations. Flag any metric that is: -- >2 standard deviations above mean → 🟢 Positive alert -- >2 standard deviations below mean → 🔴 Critical alert -- Between 1-2 standard deviations below → 🟡 Warning alert +Each alert carries a `severity` (`critical` / `warning` / `info`); map it to an icon +(🔴 / 🟡 / 🟢) and pair it with an action below. Week-over-week alerts appear only after a +prior week's report has been generated. **Present alerts as:** ``` @@ -245,7 +247,7 @@ Your post on [topic] achieved 12,500 impressions — a personal best! → **Reference:** First-hour velocity of 15+ engagements unlocks broader distribution. 🟡 **Warning: Format stagnation detected** -80%+ of your recent posts are text-only. PDF/Carousels get 3.4x reach multiplier. +80%+ of your recent posts are text-only. Documents/carousels are the top organic format and tend to reach further (no reliable multiplier). → **Action:** Try a carousel or multi-image post this week for format diversification. ``` @@ -255,7 +257,7 @@ Format the data into a readable report using this template: ``` # LinkedIn Performance Report -## Week {week} ({dateRange}) +## Week {week} ### 📊 Key Metrics @@ -267,8 +269,8 @@ Format the data into a readable report using this template: | Shares | {total} | {avg} | {trend} | | Engagement Rate | - | {rate}% | {trend} | -**Posts published:** {postCount} -**Engagement rate:** {totalEngagements / totalImpressions * 100}% +**Posts published:** {summary.totalPosts} +**Engagement rate:** {summary.avgEngagementRate}% ### 🏆 Top Performers @@ -356,13 +358,13 @@ If user wants more trend analysis: ```bash # Analyze comments trend -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric comments +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric comments # Analyze shares trend -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric shares +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric shares # Analyze engagement rate trend -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric engagementRate +"${CLAUDE_PLUGIN_ROOT}/scripts/analytics/node_modules/.bin/tsx" "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period month --metric engagementRate ``` Present additional insights from these trends. @@ -374,7 +376,9 @@ If user wants to analyze specific posts: Read the weekly post data directly: ```bash -cat ${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/.json | jq '.posts[] | select(.title | contains("search term"))' +for f in ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/*.json; do + jq '.posts[] | select(.title | contains("search term"))' "$f" +done ``` Show detailed metrics for that post and suggest what made it perform well/poorly. @@ -384,7 +388,7 @@ Show detailed metrics for that post and suggest what made it perform well/poorly **If report generation fails:** 1. **Week not found**: No data imported for that week - - List available weeks: `ls ${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/` + - List available weeks: `ls ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/` - Suggest importing data for that week 2. **No posts in week**: Week file exists but is empty @@ -408,10 +412,9 @@ Read `~/.claude/linkedin-studio.local.md` and suggest: ## Reference Files Reports use data from: -- `assets/analytics/posts/YYYY-WXX.json` - Raw weekly post data -- `assets/analytics/weekly-reports/YYYY-WXX.json` - Computed report -- `assets/analytics/baselines.json` - Statistical baselines for comparison -- `assets/analytics/metadata.json` - Import history and tracking +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/YYYY-MM-DD-.json` - Raw imported batches (one file per import; rows live under `.posts[]`) +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-reports/YYYY-WXX.json` - Computed weekly report +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/monthly-reports/YYYY-MM.json` - Computed monthly report ## Step 8b: Export Options @@ -421,12 +424,12 @@ If the user chooses option 4 ("Export report as markdown file") from the deep di 1. Read the JSON report data: ```bash -cat ${CLAUDE_PLUGIN_ROOT}/assets/analytics/weekly-reports/.json +cat ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-reports/.json ``` 2. Format the data using this template and write to file: -Save to: `${CLAUDE_PLUGIN_ROOT}/assets/analytics/weekly-reports/YYYY-WXX-report.md` +Save to: `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-reports/YYYY-WXX-report.md` ```markdown # LinkedIn Performance Report — Week YYYY-WXX @@ -478,13 +481,13 @@ Save to: `${CLAUDE_PLUGIN_ROOT}/assets/analytics/weekly-reports/YYYY-WXX-report. ``` **Important notes:** -- The `assets/analytics/` directory is gitignored — exported reports contain personal analytics data and should not be committed +- The `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/` directory is gitignored — exported reports contain personal analytics data and should not be committed - Use the `-report.md` suffix to distinguish from the JSON data files (e.g., `2026-W05-report.md` vs `2026-W05.json`) - Include all sections: metrics, trends, alerts, top performers, and recommendations for a complete standalone document After saving, confirm to the user: ``` -Report exported to: assets/analytics/weekly-reports/YYYY-WXX-report.md +Report exported to: ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/weekly-reports/YYYY-WXX-report.md Note: This file is in your gitignored analytics directory — it won't be committed to the repository. ``` diff --git a/commands/setup.md b/commands/setup.md index aa5784a..b6bb748 100644 --- a/commands/setup.md +++ b/commands/setup.md @@ -26,14 +26,14 @@ Read these 8 asset files and detect placeholder patterns to calculate the curren | Category | Weight | File/Directory | Placeholder Detection | |----------|--------|----------------|----------------------| -| Voice samples | 25 | `assets/voice-samples/authentic-voice-samples.md` | Placeholder if it contains the `` sentinel (or has <50 lines) | -| User profile | 20 | `config/user-profile.local.md` | Check if file exists; count `[Your ` placeholders | -| Case studies | 15 | `assets/case-studies/*.md` | Count non-template `.md` files (exclude `case-study-template.md`) | -| Frameworks | 10 | `assets/frameworks/*.md` | Count non-template `.md` files (exclude `framework-template.md`) | -| High-engagement posts | 10 | `assets/examples/high-engagement-posts.md` | Count `## Post N:` headers | -| Demographics | 8 | `assets/audience-insights/demographics.md` | Count `[Industry name]`, `[Function]`, `[Country]`, `[X]%` | -| Engagement patterns | 7 | `assets/audience-insights/engagement-patterns.md` | Count `[Day]`, `[Time]`, `[Topic]`, `[Format]`, `[Hook type]` | -| Post templates | 5 | `assets/templates/my-post-templates.md` | Count `[Name - e.g.` vs total `## Template N:` headers | +| Voice samples | 25 | `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` | Placeholder if it contains the `` sentinel (or has <50 lines) | +| User profile | 20 | `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md` | Check if file exists; count `[Your ` placeholders | +| Case studies | 15 | `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/case-studies/*.md` | Count non-template `.md` files (exclude `case-study-template.md`) | +| Frameworks | 10 | `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/frameworks/*.md` | Count non-template `.md` files (exclude `framework-template.md`) | +| High-engagement posts | 10 | `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md` | Count `## Post N:` headers | +| Demographics | 8 | `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/demographics.md` | Count `[Industry name]`, `[Function]`, `[Country]`, `[X]%` | +| Engagement patterns | 7 | `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/engagement-patterns.md` | Count `[Day]`, `[Time]`, `[Topic]`, `[Format]`, `[Hook type]` | +| Post templates | 5 | `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/templates/my-post-templates.md` | Count `[Name - e.g.` vs total `## Template N:` headers | **Scoring rules:** - Full points: Asset has real data (few/no placeholders remaining) @@ -81,7 +81,7 @@ Based on their answer, run the corresponding sub-workflow below. ## Step 3a: Voice Samples Workflow -**Goal:** Populate `assets/voice-samples/authentic-voice-samples.md` with real voice data. +**Goal:** Populate `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` with real voice data. **Delegate the analysis + profile construction to the `voice-trainer` agent** — invoke it via `Task` with `subagent_type: linkedin-studio:voice-trainer` (foreground, from this command layer). The agent performs the pattern detection and extraction (steps 2–3 below) and returns the structured voice profile; this command owns collecting the samples (step 1) and writing the profile back to disk (steps 4–6). @@ -98,7 +98,7 @@ Based on their answer, run the corresponding sub-workflow below. - Words/phrases they avoid - How they handle technical depth - How they conclude (CTA style, takeaway style) -4. Read the existing `assets/voice-samples/authentic-voice-samples.md` +4. Read the existing `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` 5. **If the file is the shipped placeholder** (it contains ``): **REPLACE it entirely** with the profile built from the user's samples. The placeholder's `` sentinel must NOT survive — if it @@ -117,7 +117,7 @@ Based on their answer, run the corresponding sub-workflow below. ## Step 3b: Case Study Builder -**Goal:** Create a new case study file in `assets/case-studies/`. +**Goal:** Create a new case study file in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/case-studies/`. Conduct a 6-question interview: @@ -130,7 +130,7 @@ Conduct a 6-question interview: After the interview, read `assets/case-studies/case-study-template.md` for structure reference, then create a new file: -**Filename:** `assets/case-studies/[slug].md` (derive slug from the challenge topic, e.g., `ai-procurement-transformation.md`) +**Filename:** `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/case-studies/[slug].md` (derive slug from the challenge topic, e.g., `ai-procurement-transformation.md`) **File structure:** ```markdown @@ -172,7 +172,7 @@ Ask "Would you like to document another case study?" when done. ## Step 3c: Framework Documenter -**Goal:** Create a new framework file in `assets/frameworks/`. +**Goal:** Create a new framework file in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/frameworks/`. Conduct a 5-question interview: @@ -184,7 +184,7 @@ Conduct a 5-question interview: After the interview, read `assets/frameworks/framework-template.md` for structure reference, then create: -**Filename:** `assets/frameworks/[slug].md` (e.g., `ai-maturity-model.md`) +**Filename:** `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/frameworks/[slug].md` (e.g., `ai-maturity-model.md`) **File structure:** ```markdown @@ -228,12 +228,12 @@ Ask "Would you like to document another framework?" when done. ## Step 3d: Post Analysis -**Goal:** Document high-engagement posts in `assets/examples/high-engagement-posts.md`. +**Goal:** Document high-engagement posts in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md`. Two approaches — ask which they prefer: ### Option A: Analytics Data Available -If the user has imported analytics data (check `assets/analytics/posts/` for JSON files): +If the user has imported analytics data (check `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/` for JSON files): 1. Read the most recent analytics data files 2. Identify the top 3-5 posts by engagement rate @@ -253,11 +253,11 @@ If no analytics data available: 2. For each post, analyze and document: - **Hook analysis:** What made the opening effective? - - **Angle identification:** Which thought leadership angle was used? + - **Angle identification:** Which content angle was used? - **Pattern extraction:** What's replicable? - **Mistakes identified:** What could be improved? -3. Read the existing `assets/examples/high-engagement-posts.md` +3. Read the existing `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md` 4. **Append** new posts after existing entries (don't overwrite) 5. Update the "Patterns Across All High-Performing Posts" section based on all posts @@ -265,7 +265,7 @@ Ask "Would you like to add more posts?" when done. ## Step 3e: Demographics Sync -**Goal:** Populate `assets/audience-insights/demographics.md` with real LinkedIn Analytics data. +**Goal:** Populate `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/demographics.md` with real LinkedIn Analytics data. Guide the user step by step through the LinkedIn Analytics UI: @@ -286,7 +286,7 @@ Guide the user step by step through the LinkedIn Analytics UI: - Record the actual data - Ask about trends ("Is this similar to previous months?") -5. Read the existing `assets/audience-insights/demographics.md` +5. Read the existing `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/demographics.md` 6. Replace the placeholder tables with real data 7. Fill in the "Key insights" sections based on the data patterns 8. Update the "Last Updated" date @@ -300,7 +300,7 @@ If the user says they don't have LinkedIn Analytics access or data yet, suggest: ## Step 3f: User Profile Setup -**Goal:** Create or update `config/user-profile.local.md`. +**Goal:** Create or update `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md`. Guide through each section of the profile: @@ -314,13 +314,13 @@ Guide through each section of the profile: - "These should be topics you can consistently create content about for 90+ days" 3. **Target audience:** - - "Who is your primary audience? (e.g., 'Public sector leaders exploring AI')" + - "Who is your primary audience? (e.g., 'CFOs at mid-size SaaS companies' or 'public-sector IT leaders')" - "Secondary audience?" - "Geographic focus?" 4. **LinkedIn goals:** - "Rank these goals from most to least important:" - - Build thought leadership & authority + - Build authority & influence - Attract speaking opportunities - Network with peers/influencers - Generate qualified leads @@ -336,8 +336,13 @@ Guide through each section of the profile: - "Current follower count?" - "90-day growth goal?" -7. Read `config/user-profile.template.md` for structure -8. Write the completed profile to `config/user-profile.local.md` +7. **Research tooling:** + - "Which research MCPs do you have connected? (e.g., Tavily, Gemini deep research, Perplexity — or none)" + - Record exactly what they name. If they have none, that's fine — WebSearch + WebFetch are the always-available floor. + - This populates the **Research Tooling** section of the profile; the trend/research engine routes to a declared MCP first and falls back to the floor. Do not invent MCP names — store only what the user declares. + +8. Read `config/user-profile.template.md` for structure +9. Write the completed profile to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md` **Important:** This file is gitignored (`.local.md` pattern), so personal data stays private. diff --git a/commands/strategy.md b/commands/strategy.md index 4248088..383e977 100644 --- a/commands/strategy.md +++ b/commands/strategy.md @@ -13,7 +13,6 @@ allowed-tools: - Read - Glob - Grep - - WebFetch - AskUserQuestion - Task --- @@ -65,7 +64,7 @@ Use AskUserQuestion to gather information (skip follower question if auto-detect - Inconsistent (gaps of 1+ weeks) 3. What are your main goals? (Choose top 2) - - Build thought leadership & authority + - Build authority & influence - Attract speaking opportunities - Network with peers/influencers - Generate consulting/business leads @@ -232,7 +231,7 @@ Delegate the growth recommendation to the `strategy-advisor` agent — invoke it | Activity | Frequency | Purpose | |----------|-----------|---------| | Core expertise posts | 3-5x/week | Maintain authority | -| Thought leadership pieces | 2-3x/month | Differentiation | +| Signature pieces | 2-3x/month | Differentiation | | Speaking/podcasts | Monthly | Off-platform visibility | | Collaboration amplification | 2x/month | Network leverage | | Lead magnets | Create 1-2 | Funnel building | @@ -294,7 +293,7 @@ For the canonical profile-alignment audit (headline/About/Experience/Featured/Sk ### Identify Signature Content -Ask the user to identify their top-performing posts (or read analytics from `${CLAUDE_PLUGIN_ROOT}/assets/analytics/`). +Ask the user to identify their top-performing posts (or read analytics from `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/`). **Signature content criteria:** - High saves — bookmarking is a strong authority signal; read the count from your native LinkedIn post analytics (this tool does not capture saves) @@ -393,7 +392,7 @@ Medium-term (This Quarter): 6. [Collaboration opportunity] Long-term (6 Months): -7. [Thought leadership milestone] +7. [Authority milestone] 8. [Platform expansion] ``` diff --git a/commands/video.md b/commands/video.md index a9416b5..e64bb35 100644 --- a/commands/video.md +++ b/commands/video.md @@ -39,8 +39,10 @@ Load video-specific references: - Read `references/linkedin-formats.md` (Video Content Deep Dive section) for algorithm data and technical specs Check for existing assets: -- `assets/voice-samples/` — Match the user's natural voice (REQUIRED before scripting) -- `assets/examples/high-engagement-posts.md` — Study successful patterns +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` — Match the user's natural voice (read before scripting) +- `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/examples/high-engagement-posts.md` — Study successful patterns + +**Graceful degradation (fresh adopter):** if `voice-samples/` is empty or absent, don't block — fall back to the voice settings in `skills/linkedin-studio/SKILL.md` (or neutral defaults), note that the script uses a default voice, and proceed. The voice-guardian is suppressed under 5 samples, so there is no hard gate to satisfy. ## Step 1: Choose Video Type @@ -64,14 +66,14 @@ Use AskUserQuestion: 3. **90 seconds** (225 words) — Extended format for complex frameworks (use sparingly) 4. **2 minutes** (300 words) — Detailed story or multi-step process (retention drops significantly) -Default recommendation: **60 seconds** is the 2026 sweet spot — LinkedIn requires 30% minimum completion rate or your video gets zero distribution. Shorter videos achieve higher completion rates and the algorithm rewards that heavily. +Default recommendation: **60 seconds** is the 2026 sweet spot — shorter videos complete at higher rates, and completion/dwell is a ranking input the algorithm rewards (see `references/algorithm-signals-reference.md`). There is no published hard completion-rate gate — favour shorter to lift completion, not to clear a threshold. ## Step 3: Topic and Angle Selection Follow the same flow as `/linkedin:post`: 1. Ask what they want the video to be about (if not already clear) -2. Read `references/thought-leadership-angles.md` for the 8 universal angles +2. Read `references/content-angles.md` for the 8 universal angles 3. Present 2-3 angle options via AskUserQuestion 4. Verify topic doesn't duplicate recent posts (check state file) 5. Confirm topic aligns with user's 5 core expertise areas @@ -87,7 +89,7 @@ Delegate script generation to the `video-scripter` agent — invoke it via `Task - Visual cues (`[CAM:]`, `[SCREEN:]`, `[SLIDE:]`, `[TEXT:]`) - Energy cues (`[ENERGY: up]`, `[PAUSE: 1s]`) - Transition markers (`[CUT]`, `[TRANSITION:]`) -4. Match voice against `assets/voice-samples/` +4. Match voice against `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` 5. Generate captions, thumbnail suggestion, post caption, and first comment ## Step 5: Quality Check @@ -124,7 +126,7 @@ Before presenting, verify the script passes the video quality gate: ### De-AI / Differentiation Gate -The post caption rides the same low-substance down-rank LinkedIn confirmed for text. Confirm the script's core idea and caption carry the signals LinkedIn named — **personal substance, original thinking, concrete specifics, genuine voice** — and use no mechanical-response engagement bait ("Comment YES", "Like for Part 2"); a genuine question is fine. (The voice-guardian hook scores the caption on save.) +The post caption rides the same low-substance down-rank LinkedIn confirmed for text. Confirm the script's core idea and caption carry the signals LinkedIn named — **personal substance, original thinking, concrete specifics, genuine voice** — and use no mechanical-response engagement bait ("Comment YES", "Like for Part 2"); a genuine question is fine. (The voice-guardian hook scores the caption on save.) **Strip corporate buzzwords** from the post caption (Content Quality Rule #4: leverage, synergy, paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach) — re-shaping a text idea into a video caption can slip them in, and they trip the same low-substance signal. If the idea is a take the audience has seen many times — commodity content — delegate an originality pass to the `differentiation-checker` agent: invoke it via `Task` with `subagent_type: linkedin-studio:differentiation-checker` (foreground, from this command layer), then sharpen the angle before presenting. @@ -141,7 +143,7 @@ Type: [talking head / screen recording / slideshow] Length: [30s / 60s / 90s / 2min] Words: [count] (at 2.5 wps) Topic: [content pillar alignment] -Angle: [from 8 thought leadership angles] +Angle: [from 8 content angles] ━━━ SCRIPT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -170,9 +172,11 @@ Style: [minimal / branded / text-heavy] Auto-copy the POST CAPTION text to clipboard silently: ```bash -printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs +node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs <<'__LINKEDIN_CLIP_EOF__' + +__LINKEDIN_CLIP_EOF__ ``` -Then confirm: "Post caption copied to clipboard." +Substitute `` with the exact caption text between the heredoc markers (a quoted heredoc keeps apostrophes, `%`, `$`, and backticks literal). Only if the helper prints `COPIED`, confirm: "Post caption copied to clipboard." If it prints `FAILED:`, tell the user no clipboard tool was found and to copy the text above manually — do not claim it was copied. ## Step 7: Refinement Cycle @@ -190,7 +194,7 @@ Iterate until satisfied. ## Step 8: Save and Update State -Save the final script to `assets/drafts/`: +Save the final script to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/`: ``` video-[YYYY-MM-DD]-[slug]-[type]-[length].md @@ -231,6 +235,6 @@ Replace placeholders with actual post data. This replaces manual YAML editing. - `references/video-strategy-guide.md` — Script templates, pacing, production - `references/linkedin-formats.md` — Video specs, algorithm, technical requirements - `references/engagement-frameworks.md` — Hook types, CTAs -- `references/thought-leadership-angles.md` — 8 universal angles +- `references/content-angles.md` — 8 universal angles - `references/algorithm-signals-reference.md` — Algorithm mechanics - `assets/checklists/quality-scorecard.md` — Pre-publish check diff --git a/config/content-history.template.md b/config/content-history.template.md deleted file mode 100644 index bc836ef..0000000 --- a/config/content-history.template.md +++ /dev/null @@ -1,18 +0,0 @@ -# Content History Log - -Tracks all LinkedIn posts created through the plugin. Append-only — never edit existing entries. - -Auto-initialized from `config/content-history.template.md` when the first post is logged. - -## Format - -| Date | Hook | Topic | Format | Words | Chars | Source | -|------|------|-------|--------|-------|-------|--------| - - - - -## Content Log - -| Date | Hook | Topic | Format | Words | Chars | Source | -|------|------|-------|--------|-------|-------|--------| diff --git a/config/edition-state.template.json b/config/edition-state.template.json index 799b070..1db186e 100644 --- a/config/edition-state.template.json +++ b/config/edition-state.template.json @@ -7,11 +7,13 @@ "phases": [ "load-context — read /STATE.md, voice profile, persona library, series brief (Step 0)", "brief-calibration — angle, voice, audience personas, key points, leader-takeaway (Step 1)", - "research — parallel scoped mandates → verified notes, triangulation (Step 2)", + "lived-specifics — elicitation interview (3–5 sharp questions, vagueness refused) → add lived material to the specifics-bank + populate articles.NN.livedSpecifics slot-map (each load-bearing key-point bound specific/abstrakt/ekstern/unresolved) → render NN-kilder.md; BETWEEN brief (Step 1) and research (Step 2). The grounded-sourcing layer (Fix #2, kilde-så-draft; never fabricated — invariant 3). The BLOCKing «vaghet avvises» gate (validateBinding) is enforced at the Step 2.5 skeleton-gate (Step 1.5)", + "research — parallel scoped mandates → verified notes, triangulation; scoped BY the binding (fills ekstern/unresolved slots + verifies the operator's own numbers, regel 6/7) (Step 2)", "skeleton-pitch — five-line skeleton (premise/problem/recommendation/payoff/forward) + section pitches, operator gate + persona-skjelett-sweep BEFORE prose (Step 2.5)", "spine-prose — one paragraph per section against the gated skeleton, operator gate BEFORE full expansion (Step 3a)", "draft — full prose expansion against the gated spine; may span sessions (Step 3b)", "consistency-quality — threads, premise→conclusion arc, AI-slop removal, formatting dose (Step 4)", + "contract-gate — deterministic §B/§C1 rule-gate over the draft (scripts/contract-gate, rules.ts), bound 1:1 to the maskinrommet skrivekontrakt §E-manifest via ratify; mechanical violations die here before the AI sweeps (Step 4.5)", "factcheck-sweep — risk-sorted, guilty-until-disproven, verification log (Step 5)", "editorial-review — editor's craft gate: prose-craft (em-dash density, verbatim repetition, postulated numbers, contradictions, versal-tic) + narrative-architecture (concrete instantiation, theory-anchored hypotheses, series-title symmetry, equal action per addressee, un-overloaded conclusion), ≤10 flags BLOCK/REWORK/NICE, operator-gated via SendUserFile BEFORE the persona sweep (Step 5.5)", "persona-sweep-prelock — reader jury, primary wins, convergence to clean YES (Step 6)", @@ -25,9 +27,11 @@ "articleStatusValues": ["pending", "in-progress", "locked", "scheduled"], "editorialReview": "Per-article editorial-review record written by Step 5.5 (editorial-review phase). Runs AFTER fact-check (Step 5) and BEFORE the persona sweep (Step 6): the editorial-reviewer agent judges CRAFT (prose-craft + narrative-architecture), not reader-response, mirroring the Maskinrommet skrivekontrakt §C2. The report (≤10 flags, each with kategori P1–P5/A1–A5, quote/line-ref, direction, severity BLOCK/REWORK/NICE) is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { reportPath, flagCount, byAxis: { prosa, arkitektur }, bySeverity: { block, rework, nice }, foldedIn, waived, status }. status ladder: pending → reviewed → folded. null until Step 5.5 runs. This is the craft companion to factcheckLog (truth) and personaSweep (response).", "visualAssets": "Per-article visual-asset record written by Step 7.5 (visual-assets phase). Runs BEFORE lock because render/build-linkedin.mjs picks up linkedin/NN/cover.png + the edition-config credit/caption when it builds POST.html — generating images after lock would force a re-render. Shape: { format: \"standard\" | \"carousel\"; cover: { brief, route, candidates[], approved, status }; figures: [ { id, brief, placement, status } ]; carousel: null | { source, pdf, status } }. format \"standard\" = cover + optional inline figures (cover.png is mandatory per the KTG cover-directive); format \"carousel\" = typografisk deck via render/build-carousel.mjs instead of cover+inline (cover/figures stay empty). route: \"mcp-image\" (default, via mcp__mcp-image__generate_image) | \"external\" (DALL·E / Midjourney / photographer → linkedin/NN/cover-raw.png). status ladder: pending → briefed → generated → approved. candidates[] holds the cover-v-kandidat.png attempts; approved is the fixed approved name (\"cover.png\") once the operator-gate passes. figures[].id = \"fig1\"..; placement = section reference in NN-utkast.md (figures are referenced in the draft via ![alt](linkedin/NN/figN.png) and uploaded manually in the LinkedIn editor — build-linkedin.mjs does NOT embed them). Naming convention: cover.png (approved, fixed — what build-linkedin.mjs reads) | cover-v-kandidat.png (attempts) | cover-raw.png (optional external pre-edit source) | fig.png (inline). credit + caption are recorded in /linkedin/image-credit-caption.md and flow into edition-config.json coverCredit + captions[NN].", + "livedSpecifics": "Per-article lived-specifics binding (Fix #2 slice 2, kilde-så-draft). A slot-map binding each LOAD-BEARING key-point (or, later, section) of this edition to either a real specific from the specifics-bank OR an explicit escape decision — so a draft starts grounded in the operator's real material instead of inventing plausible filler. Resolved at Step 1.5 (lived-specifics extraction, BETWEEN brief (Step 1) and research (Step 2); wired in slice 3) via a guided interview that REFUSES vague answers. THE GATE (drømme-spec G4, «vaghet avvises», enforced at the Step 2.5 skeleton-gate): every slot must resolve to \"specific\" (grounded), \"abstrakt\" (deliberately abstract here, with a rationale), or \"ekstern\" (backed by research, not lived material) — a slot left \"unresolved\" is the vagueness that BLOCKs. Numbers stay guilty-until-checked: a slot backed by an UNVERIFIED number is allowed but WARNED (regel 6/7), so Step 2 research verifies it before the draft asserts it. The deterministic validator + the rendered NN-kilder.md artifact live in scripts/specifics-bank (binding.ts/kilder.ts; `node --import tsx src/cli.ts validate-binding|render-kilder --edition `). Shape: { slots: [ { slotId, kind: \"key-point\" | \"section\", label, binding: ({ type: \"specific\", specificId } | { type: \"abstrakt\", rationale } | { type: \"ekstern\", source? } | { type: \"unresolved\" }) } ], status: \"pending\" | \"bound\" }. Default { slots: [], status: \"pending\" }: populated on first Step 1.5. Companion artifact: /linkedin/NN-kilder.md, rendered from this + the bank (regenerated, never hand-edited). This is the per-edition consumer of the global specifics-bank.", "personas": "Per-article resolved reader-persona set (input config), written/confirmed in Step 1. This makes personas configurable PER ARTIFACT, not just from one global plugin library: Step 1 resolves them in order — (1) already present here → use as-is; (2) /linkedin/personas.md (per-series file) → load; (3) plugin config/personas.local.md (or personas.template.md) library → select a subset; (4) none/insufficient → DEFINE interactively via AskUserQuestion. Exactly one entry has tier \"primær\" (the rest \"sekundær\"); «primær trumfer» on conflict. This set feeds BOTH the in-session sweep (Step 6) and the headless package (Step 6.5 / persona-reviewer). Each entry: { name, tier: \"primær\" | \"sekundær\", rolle, avkobler, overbeviser, ekspertise, sjargong, source: \"edition-state\" | \"series-file\" | \"plugin-library\" | \"interactive\" }. Default []: resolved on first Step 1.", "headlessReview": "Per-article headless-review record written by Step 6.5 (headless-review phase). Runs AFTER the in-session persona sweep (Step 6) and BEFORE lock (Step 8), on a FROZEN snapshot of the publish-ready (or pivoted) draft, fanned out from the command layer (foreground) or invoked standalone via /linkedin:headless-review in a fresh/cold session. Five archetypes judge independently with NO drafting-session context: content-reviewer (argument integrity), language-reviewer (Norwegian language), fact-reviewer (cold re-verification incl. claims a late pivot bolted on), persona-reviewer mode=resonans (per active persona), persona-reviewer mode=konverter (primær, hook only). The consolidated report is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { frozenDraft, reviewers: { content, language, fact, personaResonance, personaConversion } (each { reportPath, summary, status }), consolidatedReport, foldedIn, waived, status }. status ladder: pending → run → folded. null until Step 6.5 runs. This is the adversarial-independence companion to the in-session gates (editorialReview, personaSweep, factcheckLog) — deliberately redundant: a cold reader catches what the framing-biased in-session pass missed.", "pivots": "Per-article pivot log (Endring 9c). A pivot is a substantive change to a draft AFTER a gate had already cleared — e.g. a new argument anchor / section added late (the Del 4 Security Champions case: +~530 words, 2 new sections, +42 %). Each /linkedin:pivot invocation appends one entry and moves currentPhase back so the cleared gates (Steps 5–6.5) re-run on the pivoted version before lock. Heuristic (documented, checked at the Step 8 lock precondition): if the current draft's word count differs > 20 % from the version that last cleared Step 6, OR it has > 2 new sections, a pivot-reopen is suggested/required. Each entry: { timestamp, reason, fromPhase, toPhase, wordCountBefore, wordCountAfter, deltaPct, newSections, gatesToRerun: [phase…] }. Default [].", + "foldIns": "Per-article accumulation queue (slice 2 of fix #1 — «rettelser fester seg»). Each correction KTG makes during an edition that is NOT yet a contract rule is captured here, then routed by the JA-promoter (maskinrommet/docs/skrivekontrakt.md §E) to a permanent home so it never has to be re-discovered: a MECHANICAL correction → a rules.ts gate rule (BLOCK/WARN) + a §B-row/§C1/§C2-box + a §E-manifest row (the contract-gate `ratify` check then asserts rules.ts ↔ §E-manifest stay in bijection); a JUDGMENT correction → a §C2-box only (stays with editorial-reviewer, no gate rule). Capture is per-article (provenance = which article surfaced it); promotion is series/contract-wide. Each entry: { id, date (ISO-8601), correction (what KTG corrected, near-verbatim), trigger (where/why it surfaced), classification: \"mechanical-block\" | \"mechanical-warn\" | \"judgment\" | null (set at the classify step), decision: \"pending\" | \"promoted\" | \"rejected\" (the JA-promoter outcome), ruleId: | null (set on promote for mechanical), note?: where a judgment/rejected fold-in landed }. Default []. Rejected fold-ins are kept for traceability, never deleted.", "language": "Review language for this series/edition (additive, default \"en\"). Threads into the long-form review agents so they grade against THIS language's rules: language-reviewer applies Norwegian-specific checks (anglicism→Norwegian idiom, «kanselli-stil») only when language == \"no\"; voice-scrubber's gold standard is the approved editions IN this language; any other value → the agents apply that language's equivalents and never grade prose against Norwegian idiom. \"no\" = Norwegian (the author's case). Resolved at Step 1 / load-context and passed to the language-dependent agents." }, "schemaVersion": 1, @@ -45,6 +49,10 @@ "phase": "load-context", "status": "pending", "personas": [], + "livedSpecifics": { + "slots": [], + "status": "pending" + }, "immutableRules": null, "factcheckLog": null, "editorialReview": null, @@ -80,6 +88,7 @@ "carousel": null }, "pivots": [], + "foldIns": [], "locked": false, "scheduled": null } diff --git a/config/trends-sources.template.md b/config/trends-sources.template.md new file mode 100644 index 0000000..a6fa997 --- /dev/null +++ b/config/trends-sources.template.md @@ -0,0 +1,76 @@ +# Trend Sources (template) + +The **source list** a research-engine pass polls for trend/topic candidates. This file +ships **generic, niche-agnostic defaults** (source *categories*, not one person's beat). +Override it with your own list — the niche specifics (your vendors, your regulators, your +country's outlets) belong in the override, never here. + +## How the override works + +Copy this template into your per-user data dir and edit the copy: + +```bash +mkdir -p "${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends" +cp config/trends-sources.template.md \ + "${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/sources.md" +``` + +A pass reads `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/sources.md` +if it exists, and falls back to these shipped defaults otherwise. The override lives in the +data dir (next to the trend store `trends/trends.json`), so it **survives plugin +upgrades and reinstalls**. `LINKEDIN_STUDIO_DATA` overrides the root. + +Format below is read by a person and an agent both: a markdown list, one source per line, +grouped by tier. Keep `Name — URL — note` so a poll can cite the URL. + +--- + +## Tier 1 — Primary / breaking (poll daily, react within 24–48h) + +*First-party announcements and authoritative decisions in your field.* + +- [Major vendor blog] — [url] — releases & announcements +- [Major vendor blog] — [url] — releases & announcements +- [Regulator / governing body] — [url] — decisions & guidance + +## Tier 2 — Analysis & research (poll 2–3×/week, post within a week) + +*Where a development gets interpreted, not just reported.* + +- [Respected analysis publication] — [url] +- [Analyst house report stream] — [url] — adoption / market reports +- [Research index] — [url] — preprints / papers in your area + +## Tier 3 — Community signals (poll weekly, post if a pattern emerges) + +*Where practitioners surface what actually matters before the press does.* + +- [Practitioner forum / aggregator] — [url] +- [Topic-specific community] — [url] +- [Platform-native trending] — [url] + +## Tier 4 — Niche & seasonal (poll monthly, plan ahead) + +*Slower-moving sources with predictable cadence.* + +- [Key conference series] — [url] — announcement / agenda cycles +- [Earnings / report calendar] — [url] — scheduled releases +- Seasonal themes: [Q1 …] · [Q2 …] · [Q3 …] · [Q4 …] + +--- + +## Your niche additions + +Add the sources specific to your field below — these are the ones the generic defaults +above cannot know. (Example shape for a Norwegian public-sector / AI niche: national +digitalisation agency, data-protection authority, the relevant ministries, the local +tech press — replace with yours.) + +- [Your source] — [url] — [why it matters to your audience] +- [Your source] — [url] — [why it matters to your audience] + +--- + +*Tip: the [8 universal angles](../references/content-angles.md) and the scoring rubric +in [trend-scoring-modes.md](../references/trend-scoring-modes.md) decide what to DO with a +candidate once a poll surfaces it. This file only decides WHERE to look.* diff --git a/config/user-profile.template.md b/config/user-profile.template.md index da87508..84e984d 100644 --- a/config/user-profile.template.md +++ b/config/user-profile.template.md @@ -32,7 +32,7 @@ cp config/user-profile.template.md config/user-profile.local.md - **Geographic focus:** [Region/Country] **LinkedIn Goals (ranked by priority):** -1. [ ] Build thought leadership & authority +1. [ ] Build authority & influence 2. [ ] Attract speaking opportunities 3. [ ] Network with peers/influencers 4. [ ] Generate qualified leads @@ -116,6 +116,27 @@ cp config/user-profile.template.md config/user-profile.local.md --- +### Research Tooling + +**Which research MCPs do you have available?** The trend / research engine routes to a +declared MCP **first** and falls back to the always-available floor when none is declared. +Check what you actually have connected — leave the rest unchecked. (Names are examples; +add whatever you have under "Other" — nothing here is hard-coded downstream.) + +- [ ] Tavily (`tavily_search` / `tavily_research`) +- [ ] Gemini deep research (`gemini_deep_research`) +- [ ] Perplexity +- [ ] Other: [name the MCP + what it's good for] + +**Always-available floor (no MCP needed):** WebSearch + WebFetch. These are used when no +research MCP is declared above. (Note: WebSearch is US-biased — a research MCP gives +better coverage for non-US / regional sources.) + +**Preferred order (optional):** [e.g. "Tavily for niche/regional, Gemini for deep dives, +WebSearch as fallback" — or leave blank to let the engine pick] + +--- + ### Asset Utilization Preferences **When creating content, Claude should:** diff --git a/docs/expert-review/dream-spec-panel.md b/docs/expert-review/dream-spec-panel.md new file mode 100644 index 0000000..8715495 --- /dev/null +++ b/docs/expert-review/dream-spec-panel.md @@ -0,0 +1,80 @@ +# Dream-Spec Expert Panel — Launch-Ready Spec + +> Operator-redirected expert panel (2026-06-20). **Supersedes the critique-of-current direction in +> `plan.md`.** Instead of reviewing the existing plugin, a panel of world-class LinkedIn-growth experts +> defines the **ideal plugin's capability spec** (north-star), then a second pass reviews the **current** +> plugin against that dream-spec. **Informational — NOT a plan/roadmap** (operator's explicit framing). +> Run as a background multi-agent Workflow; main context writes the final doc (operator-gated). + +## Mission (the exact growth thesis — operator-clarified) +A creator wants a **very large, highly engaged LinkedIn following in 2026 that compounds through +word-of-mouth**: the account (powered by this plugin) gets so good that its **followers "recruit" new +LinkedIn users to the account** — reshares, tagging, "save-and-send", collaborations, comment-networks. +Engaged followers bring in more followers. Plus a strong, differentiated **personal brand** that converts +attention into opportunity. The vehicle is an AI plugin (slash-commands + sub-agents) run by a **non-expert** +who needs the tool to supply the domain expertise. + +## Deliverable +1. `docs/expert-review/dream-spec.md` — the consolidated north-star capability spec (the reference the + operator consults when proposing improvements from real use). +2. A **full review of the current plugin measured against the dream-spec** (delivers / partial / missing, + with file:line evidence) — informational, appended or as `docs/expert-review/review-vs-spec.md`. + +## Panel — 7 expert lenses (define the IDEAL, no anchoring to current) +algo (algorithm & distribution) · craft (hook & format craft) · brand (positioning & personal brand) · +growth (audience growth & network effects — the "verver andre" core) · engage (engagement & community) · +analytics (data-driven iteration, honest about LinkedIn data limits) · voice (authenticity / anti-AI-slop). + +## Review buckets — 7 (assess current plugin vs the dream-spec) +Same 7 keys; each reviewer reads the relevant current files (see script `BUCKETS[].files`). + +## How to run (next session, full context) +1. `Workflow({ script })` below, **`run_in_background: true`**. Launch FIRST (it runs while you do the push). +2. On completion: main context writes `dream-spec.md` + `review-vs-spec.md` from the returned + `{ dreamSpec, principles, reviews }`. Opus, human-facing, operator-gated (no auto-merge). +3. Then write a project memory for the dream-spec artifact. + +## Workflow script (paste verbatim into `Workflow({script})`) +```js +export const meta = { + name: 'linkedin-dream-spec-panel', + description: 'World-class growth-expert panel defines the ideal LinkedIn plugin spec, then reviews the current plugin against it (informational, not a plan)', + phases: [ + { title: 'Dream-spec', detail: '7 expert lenses define ideal capabilities (no anchoring to current)' }, + { title: 'Consolidate', detail: 'merge lenses into one dream-spec' }, + { title: 'Review-vs-spec', detail: 'assess current plugin against the dream-spec' }, + ], +} +const MISSION = `A creator wants a VERY LARGE, HIGHLY ENGAGED LinkedIn following in 2026 that COMPOUNDS THROUGH WORD-OF-MOUTH: the account (powered by this plugin) gets so good that its FOLLOWERS RECRUIT NEW LinkedIn users to the account — reshares, tagging, "save-and-send", collaborations, comment-networks. Engaged followers bring in more followers. Plus a strong, differentiated PERSONAL BRAND that converts attention into opportunity. The vehicle is an AI plugin (slash-commands + sub-agents) run by a NON-EXPERT who needs the tool to supply the domain expertise. Define what that plugin must DO.` +const LENSES = [ + { key:'algo', title:'LinkedIn algorithm & distribution', brief:'how the 2026 feed ranks, tests, suppresses and resurfaces content; what a tool must do to maximize organic reach and dwell' }, + { key:'craft', title:'Hook & content-format craft', brief:'scroll-stopping hooks, narrative tension, format mechanics (carousel/video/text/poll), the craft that earns engagement' }, + { key:'brand', title:'Personal-brand & positioning strategy', brief:'ICP clarity, differentiation, authority architecture, offer<->content fit, a recognizable point of view' }, + { key:'growth', title:'Audience growth & network effects', brief:'the word-of-mouth loop — what makes engaged followers recruit MORE followers to the account: shareable/taggable formats, collaborations, comment-networks, compounding' }, + { key:'engage', title:'Engagement & community building', brief:'first-hour tactics, comment strategy, relationship systems, turning followers into an active community that returns and advocates' }, + { key:'analytics', title:'Analytics & data-driven iteration', brief:'what to measure (honest about LinkedIn data limits), how to close the feedback loop, experiment design, knowing what is working' }, + { key:'voice', title:'Authenticity & anti-AI-slop voice', brief:'staying credibly human at scale, a real voice, avoiding generic AI tells and broetry fatigue, earning trust' }, +] +const BUCKETS = [ + { key:'algo', title:'Algorithm & distribution', files:'references/algorithm-signals-reference.md, commands/analyze.md, commands/report.md, agents/analytics-interpreter.md, agents/post-feedback-monitor.md, references/linkedin-formats.md' }, + { key:'craft', title:'Content & format craft', files:'commands/post.md, commands/quick.md, commands/carousel.md, commands/video.md, commands/react.md, agents/content-optimizer.md, agents/video-scripter.md, references/engagement-frameworks.md, references/content-angles.md' }, + { key:'brand', title:'Positioning & personal brand', files:'commands/strategy.md, commands/profile.md, commands/onboarding.md, commands/setup.md, agents/strategy-advisor.md' }, + { key:'growth', title:'Growth & network effects', files:'commands/outreach.md, commands/competitive.md, commands/batch.md, agents/network-builder.md, agents/content-repurposer.md, agents/trend-spotter.md, references/collaborations-guide.md, references/opportunity-generation.md' }, + { key:'engage', title:'Engagement & community', files:'commands/firsthour.md, commands/calendar.md, agents/engagement-coach.md, references/engagement-frameworks.md' }, + { key:'analytics', title:'Analytics & iteration', files:'commands/import.md, commands/report.md, commands/analyze.md, commands/audit.md, commands/ab-test.md, commands/measure.md, agents/analytics-interpreter.md' }, + { key:'voice', title:'Authenticity, voice & long-form', files:'skills/linkedin-voice/SKILL.md, agents/voice-trainer.md, agents/voice-scrubber.md, agents/differentiation-checker.md, commands/newsletter.md, agents/editorial-reviewer.md, agents/persona-reviewer.md, agents/fact-checker.md' }, +] +const EXPERT_SCHEMA = { type:'object', additionalProperties:false, required:['lens','capabilities','northStarPrinciples'], properties:{ lens:{type:'string'}, capabilities:{type:'array', items:{type:'object', additionalProperties:false, required:['name','whatItDoes','whyItMatters','howDelivered','priority'], properties:{ name:{type:'string'}, whatItDoes:{type:'string'}, whyItMatters:{type:'string'}, howDelivered:{type:'string'}, priority:{type:'string', enum:['core','high','nice']} } } }, northStarPrinciples:{type:'array', items:{type:'string'}} } } +const SPEC_SCHEMA = { type:'object', additionalProperties:false, required:['mission','capabilityAreas'], properties:{ mission:{type:'string'}, capabilityAreas:{type:'array', items:{type:'object', additionalProperties:false, required:['area','goal','capabilities'], properties:{ area:{type:'string'}, goal:{type:'string'}, capabilities:{type:'array', items:{type:'object', additionalProperties:false, required:['name','whatItDoes','whyItMatters','howDelivered','priority','sourceLenses'], properties:{ name:{type:'string'}, whatItDoes:{type:'string'}, whyItMatters:{type:'string'}, howDelivered:{type:'string'}, priority:{type:'string', enum:['core','high','nice']}, sourceLenses:{type:'array', items:{type:'string'}} } } } } } } } } +const REVIEW_SCHEMA = { type:'object', additionalProperties:false, required:['bucket','summary','assessments'], properties:{ bucket:{type:'string'}, summary:{type:'string'}, assessments:{type:'array', items:{type:'object', additionalProperties:false, required:['capability','coverage','evidence','gap'], properties:{ capability:{type:'string'}, coverage:{type:'string', enum:['delivers','partial','missing']}, evidence:{type:'string'}, gap:{type:'string'} } } } } } +phase('Dream-spec') +const expertsRaw = await parallel(LENSES.map(l => () => agent(`You are a world-class expert in ${l.title} — ${l.brief}.\n\nMISSION:\n${MISSION}\n\nDefine the capabilities THIS plugin must have, through YOUR lens ONLY, to make the creator succeed. Define the IDEAL — do NOT anchor to any existing tool or worry about what is already built. For each capability: name; whatItDoes; whyItMatters (the domain MECHANISM — why it drives a large / engaged / self-compounding following); howDelivered (how an AI slash-command or sub-agent would actually deliver it for a non-expert). Mark priority core/high/nice. Aim for 6-12 concrete capabilities. Also give 2-4 northStarPrinciples for your lens. Ground platform-mechanic claims in real 2026 LinkedIn dynamics; if a capability rests on a specific platform number you are unsure of, say so in whyItMatters rather than inventing a figure.`, { label:`spec:${l.key}`, phase:'Dream-spec', schema: EXPERT_SCHEMA }))) +const experts = expertsRaw.filter(Boolean) +log(`Dream-spec: ${experts.length}/${LENSES.length} expert lenses returned`) +phase('Consolidate') +const dreamSpec = await agent(`You are the panel's chief editor. ${experts.length} world-class experts each defined capabilities for the IDEAL LinkedIn-growth plugin (mission below). Consolidate into ONE dream-spec.\n\nMISSION:\n${MISSION}\n\nEXPERT OUTPUTS (JSON):\n${JSON.stringify(experts)}\n\nProduce: a one-paragraph mission; and capabilityAreas — each tied to a concrete creator-goal — with merged capabilities inside (dedupe overlaps across lenses; when lenses overlap, merge into one capability and list all sourceLenses). Comprehensive but non-redundant. This is the operator's north-star reference, so each capability must be concrete and actionable.`, { label:'consolidate', phase:'Consolidate', schema: SPEC_SCHEMA }) +phase('Review-vs-spec') +const reviews = (await parallel(BUCKETS.map(b => () => agent(`You are an adversarial, independent product reviewer. Below is the DREAM-SPEC for an ideal LinkedIn-growth plugin. Assess how the CURRENT plugin delivers the capabilities relevant to the "${b.title}" area — honestly and specifically. INFORMATIONAL (a gap assessment for the operator to read), NOT a plan or roadmap.\n\nDREAM-SPEC (JSON):\n${JSON.stringify(dreamSpec)}\n\nRead these current plugin files (relative to the repo root = cwd): ${b.files}\n\nFor each dream-spec capability relevant to "${b.title}", judge coverage: delivers | partial | missing — with concrete evidence (file:line or a specific observation) and the precise gap vs the dream-spec capability. Do not praise vaguely; cite. End with a 2-3 sentence summary of where this area stands against the dream.`, { label:`review:${b.key}`, phase:'Review-vs-spec', schema: REVIEW_SCHEMA })))).filter(Boolean) +log(`Review: ${reviews.length}/${BUCKETS.length} buckets assessed`) +return { dreamSpec, principles: experts.map(e => ({ lens: e.lens, northStarPrinciples: e.northStarPrinciples })), reviews } +``` diff --git a/docs/expert-review/plan.md b/docs/expert-review/plan.md new file mode 100644 index 0000000..031a220 --- /dev/null +++ b/docs/expert-review/plan.md @@ -0,0 +1,77 @@ +# Expert-Panel Review — Dynamic Workflow Spec + +_Spec for et multi-agent ekspert-panel som vurderer hele LinkedIn Studio-pluginen og leverer en +prioritert roadmap. Kjøres **én gang etter at herdingen (S26) er ferdig**. Output fylles inn i +`docs/expert-review/roadmap.md`, som STATE.md peker til._ + +## Når +- **Forutsetning:** herding ferdig (S1–S26, alle 29 kommandoer gjennom kvalitetsgaten). +- **Trigger:** eksplisitt operatør-kommando (`Workflow`-verktøyet — ikke automatisk; kjøres aldri uten opt-in). + Dette er en kostbar fan-out (panel × artefakt-sett). «Ingen skjulte kostnader» = stående ja, men kjøringen + bekreftes likevel før start. +- **Scope:** vurderer pluginen **som produkt** — ikke en enkelt kommando. Komplementær til herdingen + (herding = korrekthet/hygiene per kommando; ekspert-panel = strategisk produktverdi på tvers). + +## Formål +Få en uavhengig, verdensklasse-vurdering av om pluginen faktisk gjør en bruker til en sterk personal-brand på +LinkedIn i 2026 — hva som er world-class, hva som mangler, hva som er feiljustert mot virkeligheten — og +destillere det til konkret, prioritert arbeid mot v1.0.0 og videre. + +## Panelet (ekspert-arketyper, distinkte linser) +Arketyper, ikke navngitte virkelige personer (vi fabrikkerer ikke sitater i noens munn). Hver arketype er +destillert fra en reell skole/praksis for troverdighet, og bærer **én distinkt linse** så funnene ikke +overlapper. + +| # | Arketype | Linse (hva den jakter på) | Destillert fra | +|---|----------|---------------------------|----------------| +| 1 | **Solopreneur-operatøren** | Offer↔content-fit, lead-gen-funnel, konsistens-systemer, ROI på tid | one-person-business-skolen | +| 2 | **Hook- & storytelling-håndverkeren** | Hooks, retorikk, narrativ spenning, scroll-stop, format-craft | viral short-form-skolen | +| 3 | **Personal-brand-strategen** | Posisjonering, differensiering, ICP-klarhet, authority-arkitektur | personal-branding-skolen | +| 4 | **Algoritme- & analytics-spesialisten** | Distribusjon, format-mix, timing, saves/dwell-realisme, datadrevet iterasjon | growth/analytics-skolen | +| 5 | **B2B thought-leadership / social-selling-lederen** | Pipeline, social selling, founder-led, employee advocacy | B2B-social-selling-skolen | +| 6 | **Autentisitets-/anti-AI-slop-kritikeren** | Generisk AI-stemme, «broetry»-tretthet, etterprøvbarhet, ekte stemme | de-AI / voice-skolen | + +(Antall/arketyper kan justeres ved kjøring. Minst 5 distinkte linser anbefales.) + +## Input pr. agent +Hver ekspert leser et **kuratert artefakt-sett** (ikke hele repoet — fokusert lesing): +- `CLAUDE.md` + `README` — kommando-/agent-inventar (29/19), arkitektur, gates. +- Et representativt utvalg command-filer for sin linse (f.eks. hook-håndverkeren leser `post`/`quick`/`carousel`; + analytics-spesialisten leser `import`/`report`/`analyze`/`audit`/`ab-test`). +- Voice-systemet (`skills/.../SKILL.md`, voice-agentene, longform de-AI-gates). +- `references/algorithm-signals-reference.md` (SSOT) + relevante playbooks. +- `docs/hardening/log.md` (hva herdingen alt fant/fikset — unngå å re-rapportere løste ting). + +## Per-ekspert output (schema) +Strukturert (StructuredOutput), aldri løs prosa: +``` +{ + expert: , + findings: [{ + area: , + severity: BLOCK | MAJOR | MINOR | NICE, + claim: , + evidence: , + recommendation: + }], + strengths: [] +} +``` + +## Workflow-faser +1. **Review (parallel/pipeline):** hver ekspert-agent (Opus) leser sitt artefakt-sett → `findings`. +2. **Konsolidering (judge/synth):** dedupe på tvers av linser, severity-rank, tema-gruppér, kryss-referer mot + `hardening/log.md` (drop alt allerede løst). Bevart: strengths-seksjon. +3. **Completeness-kritiker (1 agent):** «hvilken linse/vinkel manglet panelet?» → eventuelt en ekstra runde. +4. **Skriv** konsolidert, prioritert resultat → `docs/expert-review/roadmap.md`. + +## Konsolidering → `roadmap.md` (format) +- **Executive summary** — panelets samlede dom (er dette world-class? hva holder det tilbake?). +- **Prioritert backlog** — tabell: tema · severity · funn · anbefaling · kilde-ekspert(er). Sortert BLOCK→NICE. +- **Strengths** — hva som allerede er sterkt (ikke rør). +- **Workstream-kobling** — hvert tiltak mappet til v1.0.0-veien (kommando-test · GUI · ny milestone). + +## Drift +- **Opus på alt** (strategiske, human-facing funn). Schema-tvungne agenter for determinisme. +- Operatør-gatet output; ingen auto-merge til roadmap uten gjennomlesing. +- Kjøres når herdingen er ferdig — **ikke før**. STATE.md «Post-herding»-peker holder dette levende. diff --git a/docs/fix2/slice2-binding.md b/docs/fix2/slice2-binding.md new file mode 100644 index 0000000..fe4d582 --- /dev/null +++ b/docs/fix2/slice2-binding.md @@ -0,0 +1,118 @@ +# Fix #2 slice 2 — per-edition lived-specifics binding + +> **Kilde-så-draft.** Slice 1 built the global *store* (`scripts/specifics-bank`). +> Slice 2 builds the per-EDITION *binding*: the bridge that ties one edition's +> load-bearing claims to real material before any prose exists. Slice 3 wires it +> into `/linkedin:newsletter` (Step 1.5 + the skeleton-gate); slice 4 touches the +> maskinrommet contract. This doc is the design record slice 3 implements against. + +## Why (retning §3, drømme-spec «Lived-Specifics Extraction» `[CORE]`) + +Sourcing, not styling, is where authenticity is won. A draft that starts from the +operator's real number / named case / what-actually-broke is forwardable; a draft +that invents plausible filler is hollow, and every downstream voice gate then +polishes hollow content. So **before prose**, each load-bearing claim of an edition +must be bound to real inventory — or the operator must *consciously* decide it does +not need a concrete instance. The binding is that ledger; vagueness is rejected. + +## The slot-map (`articles.NN.livedSpecifics` in `edition-state.json`) + +A per-article field, resolved at Step 1.5 (between brief and research). Each slot +is one load-bearing key-point (`kind: "key-point"`) or, after the skeleton exists, +a section (`kind: "section"` — the same shape serves both, no migration): + +```jsonc +"livedSpecifics": { + "slots": [ + { "slotId": "kp1", "kind": "key-point", "label": "", + "binding": { "type": "specific", "specificId": "" } }, + { "slotId": "kp2", "kind": "key-point", "label": "", + "binding": { "type": "abstrakt", "rationale": "" } }, + { "slotId": "kp3", "kind": "key-point", "label": "", + "binding": { "type": "ekstern", "source": "" } }, + { "slotId": "kp4", "kind": "key-point", "label": "", + "binding": { "type": "unresolved" } } + ], + "status": "pending" // pending → bound (set by the command when the gate passes) +} +``` + +### The gate — `validateBinding(livedSpecifics, bank)` (drømme-spec G4, «vaghet avvises») + +Deterministic, pure (`scripts/specifics-bank/src/binding.ts`). **BLOCK** iff any slot: + +| reason | when | +|--------|------| +| `unresolved` | binding type is `unresolved` — the vagueness the gate exists to reject | +| `dangling-specific` | a `specific` binding points at a `specificId` absent from the bank | +| `unjustified-abstrakt` | an `abstrakt` escape with a blank rationale (= vagueness wearing an escape hatch) | + +Otherwise **PASS**. Escape hatches are legitimate: `abstrakt` (with a rationale) +and `ekstern` (research-backed, not lived; `source` optional — Step 2 fills it). + +**WARN (does not block)** — regel 6/7: a slot backed by an **unverified `number`** +specific surfaces as `unverified-number`. Numbers stay guilty-until-checked; Step 2 +research must verify them before the draft asserts them as fact. + +`coverage` counts by binding TYPE (`total / specific / abstrakt / ekstern / +unresolved`); a dangling specific still counts under `specific` (the binding type +is `specific`, the *problem* is separately reported). Empty slot-map → PASS, total +0 — whether an edition *needs* ≥1 slot is the command layer's call (slice 3). + +## The `NN-kilder.md` artifact + +Read-only, deterministically **rendered** from `livedSpecifics` + the bank +(`scripts/specifics-bank/src/kilder.ts`, `renderKilder`) — regenerated like +POST.html, never hand-edited. Lives at `/linkedin/NN-kilder.md`. Per +load-bearing slot: binding type, the material (KTG's words, verbatim), provenance ++ capture date, topic tags, and the number-verification marker (`✓` / `⚠`); then a +coverage block + a «Tall som må verifiseres (regel 6/7)» line. The coverage block +and the number list are computed by `validateBinding`, so the artifact and the +skeleton-gate can never disagree. (Golden-tested; pure + dateless on purpose.) + +## CLI surface (the seam slice 3 calls) + +Run from the package dir (`tsx` lives there); pass absolute paths: + +```bash +cd "${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank" +node --import tsx src/cli.ts validate-binding --edition "/linkedin/edition-state.json" # exit 1 = BLOCK +node --import tsx src/cli.ts render-kilder --edition "/linkedin/edition-state.json" \ + --out "/linkedin/NN-kilder.md" +``` + +`--article ` overrides `currentArticle`; `--bank ` overrides the default +data-dir bank; `--json` emits machine output for `validate-binding`. A missing +`livedSpecifics` field is treated as an empty slot-map (back-compat with older +editions / the template default). + +## Step 2 (research) re-scope spec — for slice 3 to wire + +Today Step 2 decomposes the edition's key points into scoped research sub-questions +and triangulates verified notes. Once Step 1.5 + the slot-map exist, Step 2 is +**scoped by the binding** rather than by the raw key-points: + +1. **Fill the gaps, don't re-research the grounded.** Research targets slots bound + `ekstern` (and any still `unresolved` at entry) — the claims that explicitly + need an external source. Slots bound to a `specific` are already grounded in + lived material and do **not** need their substance re-sourced (research may + still corroborate, but it is not the gap). +2. **Verify KTG's own numbers (regel 6/7).** Every `unverified-number` warning from + `validateBinding` is a research target: confirm the operator's own figure + against a primary source where possible, then flip the bank specific + `unverified → verified` (`cli.ts add`/a verify path). A number that cannot be + verified stays `unverified` and is carried to the Step 5 fact-check sweep as 🟡 + — never silently asserted. +3. **Re-render after research.** When research resolves an `ekstern` source or a + number flips to verified, regenerate `NN-kilder.md` so the ledger stays current. + +Boundary: slice 2 ships the data model, the gate, the artifact renderer, and the +CLI. Slice 3 adds Step 1.5 (the elicitation interview + `add` to the bank + +populating the slot-map), wires `validate-binding` into the Step 2.5 skeleton-gate, +applies this Step 2 re-scope, and bumps the pipeline 17 → 18 phases. + +## Tests & verification + +`cd scripts/specifics-bank && npm test` (binding + kilder suites) and `npm run +build` (tsc) stay clean. The gate's three BLOCK reasons + the number WARN + the +golden `NN-kilder.md` render are all covered. diff --git a/docs/hardening/log.md b/docs/hardening/log.md index 47e1fc3..1bbcec2 100644 --- a/docs/hardening/log.md +++ b/docs/hardening/log.md @@ -245,3 +245,2137 @@ plan, "command file modified" is not a coverage predicate — the `log.md` entry Disposition: **PASS, no edit** · 0 deferrals. --- + +## Session 2 — Create I (`post`) + +> S2 status: the v1 autonomous attempt fabricated and was reverted (→ v2 method; see `plan.md` +> header). This is the v2 re-run: parked at step 5 across a `/clear`, re-grounded from STATE.md's +> two P4 findings against the actual file lines before talking (jerndisiplin: assert-after-read). +> Field-notes inbox **absent** at S2 → graceful no-op (SC-I). + +### /linkedin:post — interactive full-workflow post (angle→write→quality→refine→publish), 1200–1800 chars + +**INTENT.** `post` is the Create journey's full path: from raw material (data / article / experience / +observation / opinion / question) to a publishable 1,200–1,800-char thought-leadership post via an +8-step workflow — angle → format → structure+write → quality check → present → refine → pre-publish +→ deterministic state update. Post-emitting, so it must honor the content bar mechanically (hook +**110–140**, length **1,200–1,800**, no body links, no banned buzzword, topic→5 pillars) and the +algorithm bar (hook decisive before the mobile fold, links→first comment, native, comment-inviting +CTA without bait, de-AI substance signals). + +**SIMULATE.** +- **Persona (ICP):** the author — 1048 followers, "Validation", 5 pillars; direct/technical NO/EN voice. +- **Invocation:** `/linkedin:post about AI-kodeassistenter i offentlig sektor`. +- **Walk:** Step 0 load state/SKILL → Step 1 type=Opinion → Step 2 angle (contrarian) → Step 3 medium + text → Step 4 structure (hook 110–140 ✓ internally) → **Step 5 quality check (the gap)** → Step 6 + present+clipboard → Step 7 refine → Step 8 pre-publish + state. +- **CONCRETE friction (spec-level, the live proof — anchored, not a generated post):** at **Step 5** + the quality checklist (`post.md:127–135`, 9 items) had **no buzzword line**, so a draft opening + *"How to leverage AI to drive synergy in the public sector…"* would pass Step 5 unflagged — the + banned-list (content rule #4, `CLAUDE.md:124`) was never enforced at the gate. Step 5 also routes to + `quality-scorecard.md` (`post.md:125`), which **self-contradicted on the hook bound**: l.32 "Works in + **140** characters" + l.114 "Hook works in **140** chars?" (upper-only) vs l.128 "**110-140**" — the + same one-sided bound S1 fixed in the command files, surviving in the shared asset. +- **Friction log:** `post.md` itself is internally consistent on the hook (110–140 at l.100/113/127); + the only command-side gap is the missing Step-5 buzzword line. The shared scorecard carried both + gaps (buzzword absent + two one-sided hook bounds). Scorecard is shared by 8 surfaces + (`pipeline · video · post · react · batch` + 3 skills) — fixing it is high-leverage, operator-approved. + +**EVALUATE (4 axes).** +- **(a) intention fidelity — PASS.** Full 8-step workflow delivers the description's promise. +- **(b) algorithm bar — PASS.** No body link (`post.md:133/176`); comment-inviting CTA; de-AI gate + cites the confirmed reach-suppression (`post.md:137–141`). Consistent with `algorithm-signals-reference.md`. +- **(c) MECHANICAL predicate (post-emitting) — GAP → fixed.** hook band ✓ · length band ✓ · no-body-link + ✓ · topic→pillars ✓ · subagent resolves ✓ · **buzzword ✗** (Step-5 checklist had no line; grep NONE). + Shared scorecard added two sub-gaps: hook one-sided at l.32 + l.114. +- **(d) agent-wiring + graceful degradation — PASS.** `differentiation-checker` (`post.md:141`, + conditional) + `content-optimizer` (`post.md:166`, conditional) both resolve, both correctly + `subagent_type: linkedin-studio:`-namespaced. Voice→defaults; fresh-adopter path graceful. + +**HARDEN (surgical — 4 edits across 2 files, all grep-confirmed landed).** +- `commands/post.md:134` — **added** Step-5 checklist line: `- [ ] No corporate buzzwords (leverage, + synergy, paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach)`. +- `assets/checklists/quality-scorecard.md:32` — `Works in 140 characters` → `Works in 110-140 + characters` (Notes `Mobile threshold` → `Mobile band (floor + ceiling)`). +- `assets/checklists/quality-scorecard.md:114` — `Hook works in 140 chars?` → `Hook works in 110-140 chars?`. +- `assets/checklists/quality-scorecard.md:132` — **added** Pre-Publish Basic Checklist line: + `- [ ] No corporate buzzwords (…)` (same banned list). +- **Deferrals:** none. **Scope note:** the 3 scorecard edits improve all 8 consumers; only `post` is + hardened this session — `react/batch/video/pipeline` get their own EVALUATE in S3/S5/S6/S8. + +**VERIFY.** +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`; counts **29/19/26/6** unchanged. +- Anti-check grep: no one-sided bare `140 char` hook bound remains in the scorecard (`110-140` at + l.32/114/128). Buzzword line present in both files (`post.md:134`, `quality-scorecard.md:132`). +- Before/after delta: axis (c) buzzword sub-check now enforced at the load-bearing Step-5 gate; the + scorecard hook self-contradiction is resolved (all three mentions banded). The 4 edits are the cause. +- Disposition: **HARDENED** (4 edits, axis-c + shared-asset consistency) · 0 deferrals · axes a/b/d PASS. + +--- + +## Session 3 — Create I (`react`) + +> S3 status: re-grounded STATE's S3 predicate against the actual `react.md` lines BEFORE talk +> (jerndisiplin: assert-after-read). Field-notes inbox **absent** at S3 → graceful no-op (SC-I). +> All `file:line` below are tool-grounded against the FINAL (post-edit) file. + +### /linkedin:react — URL-to-post pipeline (single + comparison), your-take-as-hook, 1,200–1,800 chars + +**INTENT.** `react` is the Create journey's **URL→post path**: fetch external content +(article / news / research / YouTube), extract the key points, pick an angle, and draft in the +author's voice. Post-emitting (medium **1,200–1,800**), two paths — single-URL (Steps 1–8) + +comparison/multi-URL (Steps 1b–8b). Must honor the content bar mechanically (hook **110–140**, +length **1,200–1,800**, no body link, no banned buzzword, topic→5 pillars) and the algorithm bar +(URL→first comment not body, post stands alone, *your reaction is the hook* not a summary, +comment-inviting CTA, de-AI substance). **Distinct from `post`/`quick`:** the value-add is YOUR +perspective *extending* the source, not echoing it — the commodity-reaction guard (differentiation-checker). + +**SIMULATE.** +- **Persona (ICP):** the author — 1048 followers, "Validation", 5 pillars; direct/technical NO/EN voice. +- **Invocation:** `/linkedin:react `. +- **Walk:** Step 0 load → Step 1 single URL → Step 2 WebFetch extract → Step 3 classify (Product + Launch) → Step 4 angle (First-Take Analysis) → Step 5 draft (hook 110–140 ✓) → **Step 6 quality + check (the gap)** → Step 7 present+clipboard → Step 8 state. +- **CONCRETE friction (spec-level, anchored — not a generated post):** at **Step 6** the quality + checklist (pre-edit `react.md:122–128`, 7 items) enforced hook/links/standalone/voice/charcount/ + topic/CTA but had **no buzzword line**, so a draft opening *"This launch lets agencies leverage AI + to unlock synergy across the value proposition…"* passes unflagged — the banned-list (content rule + #4, `CLAUDE.md:124`) was never enforced at the gate. **Step 6b** (`:237` "Same as Step 6, plus") + inherits the hole. `react` auto-copies to clipboard (`:144–148`, no file Write) → the PreToolUse + **file** gate may not fire → Step 6 is the load-bearing surface. **Same gap S2 fixed in `post`.** +- **Second friction (axis d, URL-pipeline-specific):** `grep` for fetch-failure/paywall/transcript/ + fallback → **NONE**. Step 2 (`:54`) "Use WebFetch to extract" had **no fallback** when extraction + yields nothing (paywall / JS-heavy SPA / **YouTube-no-transcript** — an explicitly accepted input, + `:37/:71`); Step 2b (`:185`) same. Risk: proceed on a thin/empty extraction or fabricate from the URL string. + +**EVALUATE (4 axes).** +- **(a) intention fidelity — PASS.** Both paths, angle selection, your-take-not-summary, commodity + guard, clipboard, URL→first comment — delivers the description's promise. +- **(b) algorithm bar — PASS.** No body link (`react.md:115/125/235`); URL→first comment + (`:116/:144/:253`); stands alone (`:118/:126`); take-as-hook (`:101/:118`); de-AI gate bans + mechanical engagement-bait (`:135–137`). Consistent with `references/algorithm-signals-reference.md`. +- **(c) MECHANICAL predicate (post-emitting) — GAP → fixed.** hook band ✓ (`:101/:124/:221`) · + length band ✓ (`:119/:128/:239`) · no-body-link ✓ (`:115/:125/:235`) · topic→pillars ✓ (`:129`) · + subagent resolves ✓ (`:137` → `agents/differentiation-checker.md`) · **buzzword ✗** (grep NONE; no + inline line in Step 6/6b). +- **(d) agent-wiring + graceful degradation — wiring PASS; degradation GAP → fixed.** `differentiation-checker` + resolves, namespaced, conditional (`:137`). **Graceful degradation:** no WebFetch-failure/empty-extraction + fallback on EITHER path (grep NONE) — YouTube-no-transcript the sharpest, since it is a stated accepted input. +- **Shared asset:** `quality-scorecard.md` already clean post-S2 (`110-140` at `:32/:114/:128` + + buzzword `:132`) — `react`'s referenced asset (`react.md:270`) is good; the gap was `react`'s OWN inline Step 6. + +**HARDEN (surgical — 3 edits, 1 file, all grep-confirmed landed).** +- `commands/react.md:131` — **added** Step-6 checklist line: `- [ ] No corporate buzzwords (leverage, + synergy, paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach)`. + Covers Step 6b via "Same as Step 6". +- `commands/react.md:60` — **added** Step-2 WebFetch fallback: *"If WebFetch can't extract usable + content (paywall / JS-heavy / YouTube-no-transcript): say so, ask the user to paste the key points + or choose another URL; never fabricate from the URL string or title."* +- `commands/react.md:194` — **added** Step-2b pointer: *"If a source can't be extracted: apply the + Step 2 fallback — drop it if 2+ usable sources remain, else ask the user; never fabricate."* +- **Deferrals:** none. +- **CONCRETE after:** the "leverage…synergy…value proposition" draft now trips the Step-6 buzzword + line; a YouTube-no-transcript invocation now degrades to "paste the key points / pick another URL" + instead of a fabricated reaction. + +**VERIFY.** +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`; counts **29/19/26/6** unchanged + (`ls commands/*.md | wc -l` == 29; `git diff --stat` = `react.md` only, +5 insertions, 0 deletions). +- Before/after delta: axis (c) buzzword sub-check now enforced at the load-bearing Step-6 gate (both + paths via "Same as Step 6"); axis (d) WebFetch-failure now has a sensible fallback on both paths. The 3 edits are the cause. +- Disposition: **HARDENED** (3 edits, axis-c + axis-d) · 0 deferrals · axes a/b PASS. + +--- + +## Session 4 — Create III (`multiplatform`) + +> S4 status: re-grounded the proposed predicates against the actual `multiplatform.md` lines +> BEFORE talk (jerndisiplin: assert-after-read). Field-notes inbox **absent** at S4 → graceful +> no-op (SC-I). All `file:line` below are tool-grounded against the FINAL (post-edit) file. +> **Class call (operator-agreed):** NOT post-emitting — output is Twitter/slides/YouTube, so the +> LinkedIn content predicates (hook 110–140, length band, no-body-link, topic→5 pillars) do **not** +> apply per platform; imposing them here would be the exact category error the Create-III brief warns +> against. Tested as **guided/cross-format with a routing sub-predicate**. + +### /linkedin:multiplatform — adapt existing LinkedIn content to short-/cross-format (Twitter thread · slides · YouTube script) + +**INTENT.** `multiplatform` is the Create journey's **cross-format adapter**: take existing LinkedIn +content and re-shape it for other platforms (Twitter/X thread, presentation slides, YouTube script) — +*write once, publish everywhere*. It explicitly does **not** own long-form (newsletters / blog / essays +→ `/linkedin:newsletter`, the single long-form entry point). It is an *adapter*, not a post-generator: +the value-add is adaptation fidelity (each platform's own rules) + correct routing of out-of-scope +(long-form) requests, not LinkedIn-feed quality gating. + +**SIMULATE.** +- **Persona (ICP):** the author — a finished LinkedIn post in drafts, wants to reuse it as a Twitter thread. +- **Invocation:** `/linkedin:multiplatform` → pick "Twitter/X thread". +- **Walk:** Step 0 (`:21`) load source from `drafts/` + state → Step 1 (`:31`) AskUserQuestion pick + Twitter → Twitter template (`:53`, hook ≤**280** + 🧵 + one-idea tweets + CTA) → Step 2 (`:115`) save + `drafts/multiplatform/twitter-[slug].md` (`:118`) + clipboard, "Copied" (`:123`). **Promised artifact + produced.** +- **CONCRETE friction (axis d, adapter-specific — anchored, not a generated post):** `grep` for an + empty-source / fabrication guard in Step 0 → only matched l.23 "Ask the user to provide … or read + from drafts" — **no fallback** for the case where drafts is empty/absent, state has no recent posts, + AND the user pasted nothing. Risk: the model **fabricates a LinkedIn post to adapt** rather than + stopping. (Same failure class as S3's WebFetch-no-extraction gap.) +- **Second friction (axis c, voice hygiene):** the banned-buzzword list (content rule #4) is + *platform-agnostic voice hygiene*, not a char rule — and adaptation **rewrites** the source + (condense the hook to 280, expand to a script), which can introduce buzzwords absent from the + clean source. Pre-edit the file mentioned buzzwords **nowhere** (full read). Operator-judged in + scope (light line, not a full gate). + +**EVALUATE (4 axes).** +- **(a) intention fidelity — PASS.** Three adapters (Twitter/slides/YouTube), source-loading, + save + clipboard, long-form correctly delegated — delivers the description's promise. +- **(b) algorithm/platform bar — PASS.** Per-platform rules correct, **no LinkedIn-rule leak**: + Twitter "280 chars max" (`:53`); **no** `110`/`140` hook band anywhere in the file (grep NONE — + the desired result for a non-LinkedIn surface). Long-form routing intact (see (c)). +- **(c) MECHANICAL predicate (routing sub-predicate) — PASS; voice-hygiene sub-gap fixed.** Every + emitted `/linkedin:newsletter` (`:6/:34/:36/:132`) resolves to a real `commands/newsletter.md` + (verified, ls) ✓; both reference files resolve — `references/linkedin-formats.md` (`:128`) + + `engagement-frameworks.md` (`:129`) (verified, ls) ✓. **Buzzword voice-hygiene line absent** → + added (`:45`). +- **(d) agent-wiring + graceful degradation — wiring N/A; degradation GAP → fixed.** No subagents + invoked (`grep subagent_type` → NONE — nothing to resolve). **Graceful degradation:** no + empty-source/fabrication guard in Step 0 (grep matched only the weak "ask … or read from drafts") + → added a stop-and-ask-never-fabricate fallback (`:28`). + +**HARDEN (surgical — 2 edits, 1 file, both grep-confirmed landed).** +- `commands/multiplatform.md:28` — **added** Step-0 empty-source fallback: *"If none of these yields + source content (empty/absent drafts, no recent posts, and nothing pasted), **stop and ask the user + to paste the LinkedIn post to adapt** — never fabricate source content to adapt."* +- `commands/multiplatform.md:45` — **added** an "Adaptation Templates" voice-hygiene blockquote: + *"adaptation rewrites the source — keep the adapted copy free of the banned corporate buzzwords + (content rule #4: leverage, synergy, paradigm shift, thought leader, disruptive, value proposition, + ecosystem, holistic approach) on every platform; don't let the rewrite introduce them."* +- **Deferrals:** none. **Scope note (flagged, not touched):** Step 2's "Copied to clipboard." (`:123`) + is unconditional, but `clipboard-helper.mjs` is shared, tested infra used identically by ~9 content + commands — re-gating it here would be scope-creep into a shared pattern (surgical discipline). + +**VERIFY.** +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`; counts **29/19/26/6** + unchanged (`ls commands/*.md|wc -l` == 29, `ls agents/*.md|wc -l` == 19; `git diff --stat` = + `multiplatform.md` only, +9 insertions, 0 deletions). +- Before/after delta: axis (d) empty-source now degrades to "paste the post / never fabricate" + instead of an invented source; axis (c) the rewrite is now bound by the banned-buzzword list on + every platform. The 2 edits are the cause. +- Disposition: **HARDENED** (2 edits, axis-c + axis-d) · 0 deferrals · axes a/b PASS · class + confirmed non-post-emitting (no LinkedIn predicate imposed). + +--- + +## Session 5 — Create II (`carousel`) + +> S5 status: re-grounded the proposed predicates against the actual `carousel.md` lines BEFORE +> talk (jerndisiplin: assert-after-read). Field-notes inbox **absent** at S5 → graceful no-op (SC-I). +> All `file:line` below are tool-grounded against the FINAL (post-edit) file. +> **Class call (operator-agreed):** hybrid **guided/stateful + post-emitting on the caption** — the +> deck/images are the guided artifact, but the caption IS LinkedIn feed text, so the post predicates +> apply *to the caption* (not to the slides). Slides are bound by mobile-readability rules, not the +> feed bar. + +### /linkedin:carousel — template→slide-by-slide deck + caption + optional mcp-image, full deck to clipboard + +**INTENT.** `carousel` is the Create journey's **document-post path**: from a chosen template +(How-To / Listicle / Story / Comparison / Framework) + a topic, produce a publish-ready carousel — +structured slide-by-slide copy (header + body + visual note per slide) + a feed caption + optional +slide images (mcp-image), all assembled into ONE clipboard payload. Carousels/documents are the +top organic format (~7%; `references/algorithm-signals-reference.md`). Hybrid class: the deck is the +guided/stateful artifact; the **caption is post-emitting** and must honor the content bar mechanically +on the caption (hook **110–140**, no banned buzzword, topic→5 pillars) + graceful mcp-image degradation. + +**SIMULATE.** +- **Persona (ICP):** the author — 1048 followers, "Validation", 5 pillars; direct/technical NO/EN voice. + Builds a **How-To Guide** carousel on a pillar topic; tested both mcp-image-available and -unavailable. +- **Invocation:** `/linkedin:carousel` → pick template 1 (How-To) → topic + audience. +- **Walk:** Step 0 (`:24`) load state/voice/recent → Step 1 (`:30`) AskUserQuestion template → Step 2 + (`:50`) topic+audience → Step 3 (`:58`) slide copy → Step 4 (`:94`) caption (hook 110–140 `:97`, band + 300–500 `:103`) → **Step 5 quality + De-AI gate (the gap)** → Step 5.5 (`:127`) mcp-image (graceful) + → Step 6 (`:156`) present full deck → clipboard one-payload (`:191–213`) → Step 7 (`:218`) state. +- **CONCRETE friction (spec-level, anchored — not a generated deck):** at **Step 5** the De-AI / + Differentiation gate (pre-edit `:121–123`) confirmed *personal substance · original thinking · + concrete specifics · genuine voice* and banned mechanical engagement-bait, but had **no buzzword + line** — `grep -niE "buzzword|leverage|synergy|paradigm"` over the whole file = **NONE**. So a + caption opening *"How to leverage AI to drive synergy across the public-sector ecosystem…"* would + pass Step 5 unflagged; content rule #4 (`CLAUDE.md:124`) was never enforced at the caption gate. + Same failure class S2/S3 fixed in `post`/`react`. (Caption generation in Step 4 is exactly where a + rewrite can slip buzzwords in.) +- **No dead-ends:** all 5 referenced files exist (`agents/differentiation-checker.md`, + `assets/templates/carousel-templates.md`, `references/algorithm-signals-reference.md`, + `hooks/scripts/clipboard-helper.mjs`, `hooks/scripts/state-updater.mjs` — `[ -f ]` all FINNES). + +**EVALUATE (4 axes).** +- **(a) intention fidelity — PASS.** Template→slides→caption→full-deck-clipboard-as-one-payload + (`:191–213`, explicitly "the whole carousel, not just the caption") + state — delivers the promise. +- **(b) algorithm bar — PASS.** ~7% (`:37`) matches the ref exactly (`algorithm-signals-reference.md:55`, + "Documents / carousels | Top organic format (~7%…)"); "document post" framing correct (`:170`, LinkedIn + removed native carousels Dec 2023); caption hook 110–140 (`:97`). +- **(c) MECHANICAL predicate (post-emitting, on the caption) — GAP → fixed.** hook band ✓ (`:97`) · + caption band 300–500 ✓ (`:103`) · topic→pillars ✓ (`:26/:56`) · subagent resolves ✓ (`:125`) · + **buzzword ✗** (grep NONE; De-AI gate had no rule-#4 line). *no-body-link not imposed: a carousel + caption is not a typical link site and the command never asks for links — minor, deferred (matches + S2–S4 which left it untouched).* +- **(d) agent-wiring + graceful degradation — PASS.** `differentiation-checker` resolves, namespaced, + conditional (`:125`, `subagent_type: linkedin-studio:differentiation-checker`). **mcp-image graceful + degradation solid:** "If mcp-image is unavailable or fails, skip… degrades to text-only" (`:129`), + per-call failure logged + continue, all-fail → text-only Step-6 design-guide branch (`:154`, `:176`). + `state-updater` exports `updatePostTracking` (`:55`) + `writeState` (`:346`) — Step-7 wiring correct. + +**HARDEN (surgical — 1 edit, 1 file, grep-confirmed landed).** +- `commands/carousel.md:123` — **extended** the Step-5 De-AI gate with content rule #4: + `**Strip corporate buzzwords** from the caption (Content Quality Rule #4: leverage, synergy, + paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach) — + caption generation can slip them in, and they trip the same low-substance signal.` +- **Deferrals:** *no-body-link on the caption* (low risk, command never emits links; S2–S4 precedent) + + two cosmetic notes recorded, not edited (slide line-count 5 vs 7 between `:73–77` and `:87`; both + are NICE-only, below the stopping-rule's axis-pass bar). +- **CONCRETE after:** the "leverage…synergy…ecosystem" caption now trips the Step-5 buzzword line + before the deck is presented. + +**VERIFY.** +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`; counts **29/19/26/6** + unchanged (`ls commands/*.md|wc -l` == 29, `ls agents/*.md|wc -l` == 19; `git diff --stat` = + `carousel.md` only, 1 line changed). Only `.md` touched → hooks/analytics `node --test` not needed. +- Before/after delta: axis (c) buzzword sub-check now enforced at the load-bearing Step-5 caption gate. + The 1 edit is the cause. +- Disposition: **HARDENED** (1 edit, axis-c) · 2 recorded deferrals (no-body-link + cosmetic) · axes + a/b/d PASS. + +--- + +## Session 6 — Create II (`video`) + +> S6 status: re-grounded the proposed predicates against the actual `video.md` lines BEFORE +> talk (jerndisiplin: assert-after-read). Field-notes inbox **absent** at S6 → graceful no-op (SC-I). +> All `file:line` below are tool-grounded against the FINAL (post-edit) file. +> **Class call (operator-agreed):** hybrid **guided/stateful + post-emitting on the post caption** — +> the script / captions / thumbnail are the guided artifact (bound by video rules: muted-autoplay, +> 2.5 wps, completion-rate), but the **POST CAPTION (200–400) IS LinkedIn feed text**, so the post +> predicates apply *to the caption* (not the script). **Hook 110–140 deliberately NOT imposed on the +> caption (operator-agreed):** the caption accompanies the video; the primary hook discipline is the +> muted-autoplay opening (`:100`), not a standalone-post hook — imposing it would be S4's category error. + +### /linkedin:video — type→length→angle→video-scripter script + captions + 200–400 post caption + first comment + +**INTENT.** `video` is the Create journey's **video-script path**: from type (talking-head / screen-rec / +slideshow / convert-a-text-post) + length (30/60/90/120 s) produce a production-ready script via the +`video-scripter` agent — timing markers, visual/energy cues, captions, thumbnail, a 200–400-char post +caption, and a first comment — then save to drafts + deterministic state update. Hybrid class: the script +is the guided/stateful artifact; the **post caption is post-emitting** and must honor the content bar +mechanically on the caption (no banned buzzword, topic→5 pillars, no body link) + 2.5-wps pacing + +graceful degradation when voice samples are absent. + +**SIMULATE.** +- **Persona (ICP):** the author — 1048 followers, "Validation", 5 pillars; direct/technical NO/EN voice. + Builds a 60-s talking-head on a pillar topic. **+ fresh-adopter path** (no voice samples) reasoned to + exercise graceful degradation (relevant here because of the pre-edit "REQUIRED before scripting"). +- **Invocation:** `/linkedin:video` → talking-head → 60 s → pillar topic. +- **Walk:** Step 0 load state/voice/refs → Step 1 type → Step 2 length (2.5-wps budgets) → Step 3 angle + (topic→pillars) → Step 4 `video-scripter` generates → **Step 5 quality + De-AI gate (the gap)** → + Step 6 present + clipboard post caption → Step 7 refine → Step 8 save + state. +- **CONCRETE friction (spec-level, anchored — not a generated script):** + - **(axis c)** at **Step 5** the only buzzword line (`:106`, pre-edit) sat under the **"Natural Speech + Test"** = the *script's spoken language*; the **caption** De-AI gate (`:129`) carried **no rule-#4 + line** (`grep buzzword` → single hit at `:106`). A caption opening *"How to leverage AI to drive + synergy across the public-sector ecosystem…"* would pass the caption gate unflagged. Same failure + class S2/S3/S5 fixed in `post`/`react`/`carousel` (the caption is re-shaped from a text idea in + Step 4 — exactly where a rewrite slips buzzwords in). + - **(axis d)** `:42` (pre-edit) "voice-samples … **(REQUIRED before scripting)**" + `:90` "Match voice + against voice-samples/" had **no fallback** for a fresh adopter with an empty/absent samples dir — + "REQUIRED" reads as a hard block / dead-end. Same failure class as S3 (WebFetch-no-extraction) / S4 + (empty-source). + +**EVALUATE (4 axes).** +- **(a) intention fidelity — PASS.** 8-step workflow delivers the description's promise (script + pacing + + cues + captions + thumbnail + 200–400 caption + first comment + refine + save + state). +- **(b) algorithm bar — PASS.** Muted-autoplay correctly de-mythologized (`:100`, "three-second hook = + folklore; LinkedIn's only official 3 s = minimum length"); captions = the enforceable spec (`:120`); + 60 s sweet spot; 2.5-wps budgets exact (`:64/:66`, 75/30 = 150/60 = 225/90 = 300/120 = 2.5). *Honesty + note (deferred, operator-agreed):* `:69` "LinkedIn requires 30 % completion or **zero distribution**" + is more absolute than the declared SSOT `algorithm-signals-reference.md:56` (no 30 %, confidence + *medium*); it is consistent with `linkedin-formats.md` (cited at `:39`, l.58). Inter-reference tension, + NOT a video-command gap — deferred (brief Non-Goal: no re-litigation of the bar). +- **(c) MECHANICAL predicate (post-emitting, on the caption) — GAP → fixed.** no-body-link ✓ (`:125`) · + topic→pillars ✓ (`:124` + Step 3) · caption band 200–400 ✓ (`:121`) · subagent resolves ✓ (`:83/:131`) · + **buzzword ✗** (grep: single hit `:106`, under Natural Speech = script not caption; caption De-AI gate + had no rule-#4 line). **hook 110–140 deliberately not imposed** on the accompanying caption (class + boundary, operator-agreed; primary hook = muted-autoplay `:100`). +- **(d) agent-wiring + graceful degradation — wiring PASS; degradation GAP → fixed.** `video-scripter` + (`:83`) + `differentiation-checker` (`:131`, conditional) both resolve to real `agents/*.md`, both + `subagent_type: linkedin-studio:`-namespaced. **Graceful degradation:** the "REQUIRED before scripting" + voice-samples dead-end closed → fresh-adopter fallback to SKILL.md/defaults. (mcp-image n/a — video + generates no images.) + +**HARDEN (surgical — 2 edits, 1 file, both grep-confirmed landed).** +- `commands/video.md:129` — **extended** the Step-5 De-AI caption gate with Content Quality Rule #4: + *"**Strip corporate buzzwords** from the post caption (… leverage, synergy, paradigm shift, thought + leader, disruptive, value proposition, ecosystem, holistic approach) — re-shaping a text idea into a + video caption can slip them in, and they trip the same low-substance signal."* +- `commands/video.md:42 + :45` — softened "(REQUIRED before scripting)" → "(read before scripting)" and + **added** a Step-0 graceful-degradation line: *"if `voice-samples/` is empty or absent, don't block — + fall back to the voice settings in SKILL.md (or neutral defaults) … the voice-guardian is suppressed + under 5 samples, so there is no hard gate to satisfy."* +- **Deferrals (2, recorded):** (1) hook 110–140 on the caption — deliberate class boundary, not a gap + (operator-agreed); (2) `:69` "zero distribution" honesty — inter-reference tension, brief Non-Goal (no + re-litigation of the algorithm bar); leave to a future reference-consistency pass if desired. +- **CONCRETE after:** the "leverage…synergy…ecosystem" caption now trips the Step-5 buzzword line before + the script is presented; a fresh adopter with no voice samples now degrades to SKILL.md/defaults instead + of hitting a "REQUIRED" dead-end. + +**VERIFY.** +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, exit 0; counts **29/19/26/6** + unchanged (`ls commands/*.md|wc -l` == 29, `ls agents/*.md|wc -l` == 19; `git diff --stat` = + `video.md` only, +4/−2). Only `.md` touched → hooks/analytics `node --test` not needed. +- Before/after delta: axis (c) buzzword sub-check now enforced at the load-bearing Step-5 caption gate; + axis (d) the voice-samples dead-end now degrades gracefully. The 2 edits are the cause. +- Disposition: **HARDENED** (2 edits, axis-c + axis-d) · 2 recorded deferrals (caption-hook boundary + + :69 honesty) · axes a/b PASS. + +--- + +## Session 7 — Create II (`batch`) + +> S7 status: re-grounded the proposed predicates against the actual `batch.md` lines BEFORE +> talk (jerndisiplin: assert-after-read). Field-notes inbox **absent** at S7 → graceful no-op (SC-I). +> All `file:line` below are tool-grounded against the FINAL (post-edit) file. +> **Class call (operator-agreed):** hybrid **guided/stateful + post-emitting per post** — each of +> the 3–5 drafts IS a feed post, so the post predicates apply *per post*; the orchestration around +> them (state-load, agent-delegation, scheduling, queue, `.ics`) is the guided/stateful layer. + +### /linkedin:batch — one theme/pillar → 3–5 mixed-format feed posts, scheduled + queued + .ics + +**INTENT.** `batch` is the Create journey's **volume path**: from one theme or pillar produce a full +week (3–5 posts) with varied angles + formats, each scheduled, queued (so it shows in +`/linkedin:calendar` + session-start reminders), and exported to a `.ics`. It is an **orchestrator** — +delegates angle discovery to `trend-spotter`, the balanced batch plan to `content-planner`, and owns +scheduling/queue/ical itself. Post-emitting per post, so each draft must honor the content bar +mechanically (hook **110–140**, format-appropriate length band, no body link, no banned buzzword, +topic→5 pillars); guided/stateful for the orchestration (subagent targets + node helpers resolve; +graceful degradation). + +**SIMULATE.** +- **Persona (ICP):** the author — 1048 followers, "Validation", 5 pillars; direct/technical NO/EN voice. + "Sunday prep": one theme → a week of **mixed-format** posts (standard → carousel → quick → video rotation). +- **Invocation:** `/linkedin:batch` → "One main theme" → 3–5 angles. +- **Walk:** Step 0 (`:23`) load state/SKILL/plan/templates → Step 1 (`:33`) theme/pillar (+ `trend-spotter` + for timely angles) → Step 2 (`:45`) `content-planner` plan + schedule (slot template by `weekly_goal`, + queue-conflict check `:58`) → **Step 3 per post: 3a draft (`:86`) → 3b Quick Quality Check (`:94`, the + gap) → 3c save (`:101`) → 3d queueAdd (`:124`)** → Step 4 review (`:130`) → Step 5 finalize + `.ics` (`:172`). +- **CONCRETE friction (spec-level, anchored — not a generated batch):** at **Step 3b** the per-post check + `:95` read **"Character count in range"** — **no band named** (range of *what?*), so it is unenforceable; + and the Step 3a "standard structure" (`:88–92`: hook 110–140 + context 200–300 + insight 400–800 + + implication 200–300 + CTA 50–100) **sums to 960–1640**, whose floor sits **below** the canonical standard + band **1,200–1,800** (CLAUDE.md rule #2). A "standard" post drafted to the structure can land ~1,000 chars + and pass `:95` "in range" unflagged. Complication: batch is **mixed-format**, so the band is + format-dependent — imposing 1,200–1,800 on everything would wrongly fail a quick post (the S4 category + error in miniature). **Buzzword + no-body-link were NOT gaps here** (`:98` + `:97` already present — the + hole S2–S6 kept finding is already closed in `batch`). + +**EVALUATE (4 axes).** +- **(a) intention fidelity — PASS.** Theme→plan→schedule→per-post draft/check/save/queue→review→finalize+ + `.ics` delivers the week-of-content promise. *Tension (deferred):* Step 3a imposes one "standard structure" + though Step 2 (`:76`) promises format variety — `batch` is an orchestrator, so carousel/video inherit their + own formats' rules; recorded, not edited. +- **(b) algorithm bar — PASS.** No body link (`:97`); buzzword-clean (`:98`); pillar-aligned scheduling + (`scheduling-strategy.md`); 5x5x5 reminder (`:169`). `batch` uses a deliberately lighter "Quick Quality + Check" (no per-post `differentiation-checker`) — breadth-over-depth by design for a week-at-once tool; *note, + not a fix.* +- **(c) MECHANICAL predicate (post-emitting, per post) — GAP → fixed.** hook 110–140 ✓ (`:88`) · no-body-link + ✓ (`:97`) · buzzword ✓ (`:98`) · topic→pillars ✓ (`:39/:77/:112`) · subagent resolves ✓ (`:43/:47`) · + **length band ✗** (`:95` "in range" named no band; 3a structure sums 960–1640, under the 1,200 standard floor). +- **(d) agent-wiring + graceful degradation — wiring PASS; PASS.** `trend-spotter` (`:43`) + `content-planner` + (`:47`) both resolve to real `agents/*.md`, both `subagent_type: linkedin-studio:`-namespaced. All 5 node + helpers resolve with matching signatures: `queueUpcoming(days=7)` / `queueAdd(…8 params)` (batch passes 8 args) + / `queueFormatSummary(entries)` (queue-manager.mjs `:52/:63/:112`), `generateIcalFromQueue` / `writeIcalFile` + (ical-generator.mjs `:183/:201`). Degradation: no-plan→`:31` "create one first"; empty queue→`:181` guard. + *Minor (deferred):* no fallback if `weekly_goal` is unset (`:52` slot-template lookup) — micro, below axis-pass. + +**HARDEN (surgical — 1 edit, 1 file, grep-confirmed landed).** +- `commands/batch.md:95` — `- Character count in range` → `- Character count in the band for this post's + format (standard 1,200–1,800 · quick 150–500 · carousel/video caption per that format) — not just "in range"`. +- **Deferrals (3, recorded):** (1) Step 3a single-structure vs mixed-format promise (orchestrator inherits + per-format rules); (2) no per-post differentiation gate (deliberate breadth-over-depth design); (3) + `weekly_goal`-unset fallback (micro-degradation). All below the stopping-rule's axis-pass bar. +- **CONCRETE after:** a ~1,000-char "standard" draft now trips the format band (under the 1,200 floor) instead + of passing the vague "in range"; a quick post is judged against 150–500, not wrongly against 1,200–1,800. + +**VERIFY.** +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`; counts **29/19** confirmed + (`ls commands/*.md|wc -l` == 29, `ls agents/*.md|wc -l` == 19; `git diff --stat` = `batch.md` only, + 1 insertion / 1 deletion). Only `.md` touched → hooks/analytics `node --test` not needed. +- Anti-check grep: bare vague `^- Character count in range$` → NONE (gone); format-band line present (`:95`). +- Before/after delta: axis (c) length-band sub-check now enforced + format-aware at the load-bearing Step-3b + gate. The 1 edit is the cause. +- Disposition: **HARDENED** (1 edit, axis-c) · 3 recorded deferrals · axes a/b/d PASS. + +--- + +## Session 8 — Pipeline (`pipeline`) + +> S8 status: re-grounded the proposed predicates against the actual `pipeline.md` lines BEFORE +> talk (jerndisiplin: assert-after-read). Field-notes inbox **absent** at S8 → graceful no-op (SC-I). +> All `file:line` below are tool-grounded against the FINAL (post-edit) file. +> **Class call (operator-delegated → quality + precedent):** hybrid **guided/stateful + post-emitting +> on the final post** — the 8-step idea→publish→analysis pipeline produces ONE feed post (Step 2 draft +> `:57–62` → Step 3 gate `:66–86` → Step 6 publish `:135`), so the post predicates apply to that post; +> the orchestration around it (Step 0 load · Step 1 agent-deleg · Step 4 schedule/queue · Step 5 5x5x5 · +> Step 7 first-hour · Step 8 analysis · state-update `:186–199`) is the guided/stateful layer. + +### /linkedin:pipeline — full idea→publish→analysis pipeline, one 1,200–1,800-char feed post, 8 steps + +**INTENT.** `pipeline` is the Create journey's **end-to-end path**: from idea (or `$ARGUMENTS` topic) +through ideation → draft → optimize → schedule → 5x5x5 pre-engagement → publish → first-hour → 48h +analysis, with a deterministic state update. Post-emitting on the final post (hook **110–140**, length +**1,200–1,800**, no body link, no banned buzzword, topic→5 pillars); guided/stateful for the +orchestration (subagent targets + 5 node helpers resolve; graceful degradation). + +**SIMULATE.** +- **Persona (ICP):** the author — 1048 followers, "Validation", 5 pillars; direct/technical NO/EN voice. +- **Invocation:** `/linkedin:pipeline about AI-kodeassistenter i offentlig sektor` (topic in `$ARGUMENTS` + → Step 1 ideation skipped, `:41`). +- **Walk:** Step 0 load state/SKILL/voice → Step 1 skipped → Step 2 angle + draft to the structure + (`:57–62`) → **Step 3 optimize gate (`:66–86`, the gap)** → Step 4 schedule (+ Option-3 queue) → + Step 5 5x5x5 → Step 6 publish+clipboard → Step 7 first-hour → Step 8 analysis → state. +- **CONCRETE friction (spec-level, anchored — not a generated post):** at **Step 3** the inline quality + scorecard (`:75–81`) carried the buzzword line (`:79`) but as a **bare** `- [ ] No corporate buzzwords` + — no inline enumeration; it leaned on the scorecard reference (`:75` → `quality-scorecard.md:132`). A + draft opening *"How to leverage AI to drive synergy across the public-sector ecosystem…"* relies on the + operator cross-referencing a long shared asset rather than the inline gate the AI actually executes. + **Unlike S2/S3/S5/S6 the line was PRESENT** — the gap was that it was not self-contained, making + pipeline the odd-one-out among post-emitting commands (post `:134` · react `:131` · carousel `:123` · + video `:129` all enumerate inline). + +**EVALUATE (4 axes).** +- **(a) intention fidelity — PASS.** 8-step idea→publish→analysis delivers the description's promise. +- **(b) algorithm bar — PASS.** Step 3 signals (`:70–73`) cited to the SSOT (saves rank highest · a + 15+-word comment ≈ 2× a like · dwell >30 s +25 %); no body link (`:78`); 5x5x5 (`:120–133`); first-hour + (`:158–169`). No 30 %-claim → untouched by the open reference-consistency follow-up. +- **(c) MECHANICAL predicate (post-emitting, on the final post) — GAP → fixed.** hook 110–140 ✓ + (`:58/:76`) · length band ✓ (`:77` names 1,200–1,800) · no-body-link ✓ (`:78`) · topic→pillars ✓ + (`:80` + `:49` content-planner pillar-situating) · subagent resolves ✓ (`:49`) · **buzzword + present-but-not-self-contained ✗→fixed** (`:79` bare → enumerated inline). +- **(d) agent-wiring + graceful degradation — PASS.** `content-planner` + `trend-spotter` (`:49`) resolve + to real `agents/*.md`, both `subagent_type: linkedin-studio:`-namespaced. All 5 node helpers export with + matching signatures: `queueUpcoming(days=7)` (qm`:52`) · `queueAdd(…8 params)` (qm`:63`; pipeline passes + 8 args `:116`) · `queueFormatSummary` (qm`:112`) · `writeState` (su`:346`) · `updatePostTracking` + (su`:55`). Degradation: Step 1 (`:43–47`) handles no-planned-topic/no-input via AskUserQuestion; Step 0 + voice-samples is a soft read (no "REQUIRED" dead-end). + +**HARDEN (surgical — 1 edit, 1 file, grep-confirmed landed).** +- `commands/pipeline.md:79` — `- [ ] No corporate buzzwords` → `- [ ] No corporate buzzwords (leverage, + synergy, paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach)` + (same banned list as the other post-emitting gates + the scorecard). +- **Deferral (1, recorded):** the Step-2 component structure (`:58–62`: hook 110–140 + context 200–300 + + insight 400–800 + implication 200–300 + CTA 50–100) sums **960–1 640**, whose floor sits below the + Step-3 band floor 1,200 (`:77`) — **identical structure to batch's 3a (`:88–92`)**. NOT edited: + pipeline's Step-3 gate already NAMES the band (the S7 fix was naming the band on an *unenforceable* gate; + here it is already named), so a minimum-path draft is caught by the gate, not shipped — and S7 did not + edit the analogous 3a structure either. Editing Step 2 here would exceed precedent (over-engineering). + Below the axis-pass bar. +- **CONCRETE after:** the "leverage…synergy…ecosystem" draft now trips the Step-3 buzzword line inline + before optimize completes, without the operator cross-referencing the shared scorecard. + +**VERIFY.** +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`; counts **29/19** confirmed + (`ls commands/*.md|wc -l` == 29, `ls agents/*.md|wc -l` == 19; `git diff --stat` = `pipeline.md` only, + 1 insertion / 1 deletion). Only `.md` touched → hooks/analytics `node --test` not needed. +- Before/after delta: axis (c) buzzword sub-check is now self-contained at the load-bearing Step-3 gate + (was a bare, reference-dependent line). The 1 edit is the cause. +- Disposition: **HARDENED** (1 edit, axis-c) · 1 recorded deferral (Step-2 component-sum, gate-caught) · + axes a/b/d PASS. + +--- + +## Session 9 — Longform orchestrator (`newsletter`) + +> S9 status: re-grounded STATE's 7 S9 predicates against the actual `newsletter.md` (1594 lines, 16 +> phases) BEFORE talk (jerndisiplin: assert-after-read). Class agreed S9 steps 1–2: **guided/stateful +> long-form orchestrator, NOT post-emitting** — the feed-post predicates (hook 110–140, 1,200–1,800 +> band, no-body-link) do NOT apply; imposing them would be the S4/video category error the command +> itself already rejects (`:38–42`). All `file:line` below are tool-grounded against the FINAL file +> (no edits this session → final == 52e888a). + +### /linkedin:newsletter — long-form edition end-to-end, 16-phase stateful pipeline, NOT post-emitting + +**INTENT.** `newsletter` is the Longform orchestrator: take an edition (newsletter / essay / series +article) from load → calibration → verified research → skeleton+spine gates BEFORE prose → full draft +→ consistency/quality+voice-scrub → fact-check → editorial craft gate → persona sweep → headless +adversarial review → visual assets → LOCK (POST.html) → hook-gate → scheduling. Multi-session with a +maintained `edition-state.json`. Long-form quality is enforced by the **pipeline phases**, not +short-form hooks. + +**SIMULATE (grounded persona-sim, anchored to Step 6.5 headless fan-out).** +- **Persona (operator):** KTG producing a series edition; primær reader chosen in Step 1. +- **Walk:** Step 0 load (voice/persona graceful `:258`) → … → Step 6 persona sweep PASS → **Step 6.5 + freeze → fan out 5 cold archetypes** (`:1062–1069`, single message, all `linkedin-studio:`-namespaced, + foreground per principle 4; background loses Task `:79`) → **degradation gate** (`:1084–1086` "do not + proceed with a missing reviewer `[GATE]`") → consolidate via `SendUserFile` → Step 7.5 visual assets + (mcp-image not a hard dependency, external `cover-raw.png` fallback `:1222–1230`) → Step 8 LOCK + (`build-linkedin.mjs` → `linkedin/NN/POST.html`, exit-code N3 `:1388`) → Step 10 `queueAdd` (`:1486`). +- **Friction sought (the S2–S8 pattern: a missing buzzword gate / a missing fallback):** **NONE found.** + The category-correct equivalent of the short-form buzzword gate is the de-AI/voice-scrub + editorial + + language-reviewer gates, all present and wired. Every fan-out has a structured-return degradation gate. + +**EVALUATE (4 axes).** +- **(a) intention fidelity — PASS.** The 16-phase pipeline delivers the description's promise; primær + artifact `POST.html` is produced end-to-end at Step 8 lock (`:1379–1395`) with exit-code verification. +- **(b) algorithm/format bar (long-form honesty, NOT feed-post predicates) — PASS.** Native-newsletter § + (`:1508–1569`) is nøktern: "sells nothing", notification dedup not oversold ("not three guaranteed + touchpoints"), cold-start not inflated ("0→9K in 7 days were not cold starts"), downsides disclosed + (non-exportable / no-canonical / no-analytics / decay), sourcing note (`:1564`) separates LinkedIn + help-docs (high conf.) from practitioner ranges (medium) + flags the contested email-dedup; citation + `docs/remediation/research/03-coverage-gap-specs.md` §D5 exists (225 lines, §D5 ×8 — not dangling). +- **(c) quality rules (category-appropriate) — PASS.** Long-form quality is enforced by the pipeline + gates (skeleton 2.5 · spine 3a · consistency+voice-scrub 4 · fact-check 5 · editorial 5.5 · persona 6 + · headless 6.5 · hook 9), all present + wired. Short-form hooks correctly NOT imposed (`:38–42`); no + hollow gate found. +- **(d) agent-wiring + graceful degradation — PASS.** All 8 invoked subagents resolve to real + `agents/*.md`, all `subagent_type: linkedin-studio:`-namespaced (persona-reviewer ×5, fact-checker ×2, + voice-scrubber, language-reviewer, fact-reviewer, editorial-reviewer, content-reviewer, + content-repurposer). Render/queue helpers exist; `queueAdd(id, draftPath, schedDate, schedTime, + pillar, format, hookPreview, charCount)` 8-arg signature (qm`:63`) matches the call (`:1486`). All 9 + plugin-root ref/template files (`:1575–1594`) exist. Phase strings: `_doc.phases` (16) ↔ resumption + table (16) `diff` IDENTICAL. Degradation: voice/persona `:258` · mcp-image `:1222–1230` · exit-codes + ×4 (`:443/580/1144/1388`) · optional config+delingstekst `:1373–1377` · fan-out gates ×3 + (`:340/789/1086`). + +**HARDEN — NO CHANGE (verified clean).** +- 0 edits. `newsletter` is among the most iterated commands (v2.0→v4.1 hardened the long-form gates + across multiple production runs: Seres, Del 4 / Endring 9), and it shows: every axis passes with no + surgical opportunity at or below the axis-pass bar. +- **Deferrals:** none. **Anti-gold-plating:** no NICE-only polish pursued beyond axis-pass (stopping + rule, brief §). The native-newsletter follow-up noted in S2 STATE (reference-consistency for the + separate `video.md`/`linkedin-formats.md` 30 % surface) is NOT this command's surface — left in the + open-follow-ups list, not touched here. + +**VERIFY.** +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`; counts **29/19/26** (+6 + skills) unchanged; `git status --porcelain` empty → working tree byte-identical to `52e888a` (S8). +- Phase-string diff reproduced: `_doc.phases` ids vs resumption-table `currentPhase` ids → IDENTICAL, + 16↔16, in order. +- Disposition: **VERIFIED — NO CHANGE** (0 edits) · 0 deferrals · axes a/b/c/d all PASS. + +--- + +## Session 10 — Longform companion (`headless-review`) + +> S10 status: re-grounded STATE's predicates against the actual `headless-review.md` (248 lines, 13 KB) +> BEFORE talk (jerndisiplin: assert-after-read). Class agreed S10 steps 1–2: **cold adversarial +> review-orchestrator, NOT post-emitting** — it emits a consolidated review report + optional +> `edition-state` persistence, never a feed post and never drafts prose; the feed-post predicates +> (hook 110–140, 1,200–1,800 band, no-body-link) do NOT apply — imposing them would be the S4/video +> category error (even further from feed-post than `newsletter`). The four archetype agents are +> SHARED with `newsletter` Step 6.5 → S9 agent findings reused as baseline. All `file:line` below are +> tool-grounded against the FINAL file (no edits this session → final == 1c31644). + +### /linkedin:headless-review — cold adversarial review package on a FROZEN long-form draft, NOT post-emitting + +**INTENT.** `headless-review` is the standalone surface of `newsletter` Step 6.5: read a frozen, +publish-ready (or pivoted) long-form draft COLD — with NONE of the drafting session's framing-bias — +through five independent archetypes and return direction-only flags, consolidated into one +operator-gated report. Two isolation layers: fresh session (strongest, `:64–68`) + per-archetype +subagent context (`:69–74`). The adversarial-independence layer the in-session gates structurally +cannot be (`:30–48`). + +**SIMULATE (grounded persona-sim, anchored to the Step 3 cold fan-out).** +- **Persona (operator):** KTG in a FRESH session running `--type all` on `/NN-utkast.md`. +- **Walk:** Step 1 resolve-from-disk (draft → edition-state `currentArticle` fallback → ask-once + `:103–104`; contract 3-tier fallback ending at always-present `longform-quality-rules.md` `:111–114`; + personas = newsletter Step 1 chain `:116–118`) → Step 2 freeze (`cp` snapshot `:126`, live-draft + fallback if `cp` unavailable `:130–131`) → **Step 3 fan out 5 archetypes** (single message, + `:135–136`; 4 `linkedin-studio:`-namespaced types, persona-reviewer ×2 modes `:141–146`) → + **degradation gate** (`:161–164` "re-run that one archetype — do not paper over a missing reviewer + `[GATE]`") → Step 4 consolidate (operator gates, never auto-resolve flags `:168–169`/`:196–197`; + convergence marked `:200–202`) → Step 5 surface via `SendUserFile` (else `file://` `:208–210`) + + optional persist `articles.NN.headlessReview` (`:211–215`). +- **Friction sought (the S2–S8 pattern: a missing buzzword gate / a missing fallback):** **NONE found.** + Not post-emitting → the category-correct quality bar is the reviewers grading against the writing + contract + the structured-return degradation gate, both present. Every fan-out call has a degradation + re-run path. + +**EVALUATE (4 axes).** +- **(a) intention fidelity — PASS.** The 5-step flow (resolve → freeze → parallel fan-out → consolidate + → surface/persist) delivers the description's promise; primær artifact is the consolidated report + (`:171–198`), surfaced + optionally persisted (`:204–215`). `allowed-tools` (`:18–26`) complete and + fully used (Read/Glob/Grep · Bash freeze · AskUserQuestion ask-once · Task fan-out · Write report+state). +- **(b) algorithm/format bar (long-form honesty, NOT feed-post predicates) — PASS.** Cold contract + (`:50–74`) nøktern: reviewers get a "deliberately starved context", exactly-what-they-get / + exactly-what-they-don't enumerated; two-layer isolation with honest caveats (Layer 1 "recommended … + strongest" not absolute; reload caveat for the v3.1.0 agents `:80–82`). Deliberate redundancy + (`fact-reviewer`↔`fact-checker`, `language-reviewer`↔`editorial` prose axis) disclosed as + on-purpose, not collapsed (`:234–237`). No feed-post predicate imposed. +- **(c) quality rules (category-appropriate) — PASS.** Review quality enforced by (1) reviewers judging + against the writing contract (`:111–114`), (2) the degradation gate requiring structured/populated + returns, not a hedged paragraph (`:161–164`), (3) operator-gates-the-fold-in — the command recommends, + never rewrites/gates (`:168–169`/`:196–197`/`:210`), (4) convergence-as-signal (`:200–202`). No hollow + gate found. +- **(d) agent-wiring + graceful degradation — PASS.** 4/4 invoked subagent types resolve to real + `agents/*.md` with matching frontmatter `name:`, all `subagent_type: linkedin-studio:`-namespaced + (content-reviewer · language-reviewer · fact-reviewer · persona-reviewer ×2 modes → "five + archetypes" `:5–6`/`:30–31`). 8/8 Reference Files (`:241–248`) exist. `articles.NN.headlessReview` + schema present + complete in `edition-state.template.json` (`:56–69`: frozenDraft · reviewers + {content,language,fact,personaResonance,personaConversion} · consolidatedReport · foldedIn · waived · + status; ladder pending→run→folded `:29`). Degradation ×5: freeze `cp` fallback `:130` · draft-missing + ask-once `:104` · persona fallback `:117` · `[GATE]` re-run `:163–164` · output-default `:93` + + SendUserFile-else-`file://` `:208–210`. + +**HARDEN — NO CHANGE (verified clean).** +- 0 edits. Borderline surfaced at talk: the freeze step (`:126`) assumes `review/` exists (no + `mkdir -p`). **Operator chose Alt A (no change).** Rationale: the freeze block is **byte-identical to + `newsletter` `:1050`** (S9-verified-clean), and `review/` is created by the render pipeline that + produces the draft (`build-html.mjs` → `./review/` newsletter `:1136`; skeleton/draft render + `:444`/`:580`); adding `mkdir -p` only here would drift from its S9-clean twin, and fixing both would + re-open S9 (out of S10 scope). The `cp`-unavailable fallback (`:130`) covers the degraded path loosely. +- **Deferrals:** none. **Anti-gold-plating:** the `mkdir -p` robustness improvement is a NICE below the + axis-pass bar — left as the operator's logged decision (Alt A), not pursued. + +**VERIFY.** +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`; counts **29/19/26** (+6 + skills) unchanged (count/version-consistency guards inside the lint passed); `git status --porcelain` + empty → working tree byte-identical to `1c31644` (S9). +- Predicates re-grounded against the final file: 4 namespaced subagent types (frontmatter `name:` + match) · 8/8 ref files exist · `headlessReview` schema `:56–69` · 5 degradation points · persona + chain = newsletter `:179–183` · "five archetypes" (wrapped) = 5 modes from 4 agents. +- Disposition: **VERIFIED — NO CHANGE** (0 edits) · 0 deferrals · axes a/b/c/d all PASS. + +--- + +## Session 11 — Longform companion (`pivot`) + +> S11 status: re-grounded STATE's predicates against the actual `pivot.md` (162 lines) BEFORE talk. +> Class agreed steps 1–2: **pure state-mutation / orchestration command, NOT post-emitting, invokes NO +> agents, never drafts prose.** It re-opens a long-form edition after a late substantive change so the +> cleared gates re-run before lock — mutating `edition-state.json` (append `pivots[]`, reset +> `currentPhase`, un-lock, invalidate downstream verdicts), writing a next-step line to `/STATE.md`, +> and deferring the actual gate-runs to `/linkedin:newsletter` (`:144`). Feed-post predicates +> (hook 110–140, 1,200–1,800 band, no-body-link) do NOT apply — even further from feed-post than +> `headless-review` (this one does not even produce a review of prose). The pivot heuristic + `pivots[]` +> schema + gates-to-rerun chain are SHARED with `newsletter` (Step 8) and `headless-review` → +> S9/S10 findings reused as baseline. **Unlike S9/S10 this was NOT a no-change session — P4 surfaced a +> real cross-file off-by-one bug.** + +### /linkedin:pivot — re-open a cleared edition for re-review, NOT post-emitting + +**INTENT.** Make the post-lock re-open a NAMED RITUAL, not a manual scramble (the Del 4 Security +Champions case, `:33–38`): log the pivot, reset `currentPhase` so newsletter's deterministic resumption +re-runs the invalidated gates (fact-check 5 → editorial 5.5 → persona 6 → headless 6.5), un-lock if +needed, and invalidate the stale downstream verdicts so a pre-pivot PASS cannot be mistaken for current. + +**SIMULATE (grounded persona-sim).** +- **Persona (operator):** KTG having made a late change to a LOCKED edition, running + `/linkedin:pivot --article NN --reason "<...>"`. +- **Walk:** Step 1 load state + locate article (missing → stop+report `:75`) → Step 2 measure scope + (`wc -w` current vs baseline `personaSweep.resonance.wordCount`; baseline-absent → ask/​default + structural `:84–86`; ambiguous sections → confirm `:90`) → **Step 2 classify → default `--to-phase`** + → Step 3 append `pivots[]` + reset `currentPhase` + un-lock + invalidate verdicts (`:100–131`) → + Step 4 write next-step line to `/STATE.md`, defer gates to newsletter (`:133–155`). +- **Friction sought (the S2–S8 pattern):** **FOUND — a phase-reset off-by-one (P4).** + +**EVALUATE (4 axes — re-tolket for a non-post-emitting orchestrator).** +- **(a) intention fidelity — PASS after fix.** The ritual (log → reset → un-lock → invalidate → point) + delivers the promise; `allowed-tools` complete and used. The off-by-one (below) made the *moderate* + branch silently betray the stated intent ("so the new claims get verified") → fixed. +- **(b) format bar (heuristic-consistency, NOT feed predicates) — PASS.** Three-way SSOT verified + identical: pivot heuristic (>20 % words OR >2 sections, baseline = `personaSweep.resonance.wordCount`) + == newsletter Step 8 lock-precondition (`:1348–1362`) == template `_doc.pivots` (`:30`). Newsletter + Step 6 actually writes the baseline field (`:990–994`, `:1005`) → not a phantom. Cardinal rule + ("never reopen a locked text", newsletter `:1034–1036`) honoured: pivot is the *sanctioned* exception + — it loudly un-locks + flags POST.html stale (`:124`) + invalidates verdicts (`:126–130`). +- **(c) quality rules (state-integrity) — PASS.** `gatesToRerun` `:113` = the 4 real gate phase-names; + invalidation targets (`personaSweep.resonance` · `editorialReview` · `headlessReview.status`) all + exist in the schema; `factcheckLog` + `pivots[]` left intact (durable history `:130`). +- **(d) agent-wiring + graceful degradation — PASS.** No agents (orchestrator). 3 degradation points + enumerated (article-missing `:75` · baseline-absent `:84–86` · ambiguous-sections `:90`). 3/3 ref + files exist. + +**THE BUG (P4 — confirmed cross-file off-by-one).** Newsletter's resumption table is explicit +(`:196–198`, `:203–221`): `currentPhase` = the *last completed* phase, and the pipeline resumes at the +step **after** it (`draft`→Step 4, `consistency-quality`→Step 5, `factcheck-sweep`→Step 5.5). But +pivot.md set the **moderate** default to `to-phase: factcheck-sweep` "so the new claims get verified" — +which resumes at **Step 5.5 (editorial), SKIPPING the Step 5 fact-check** the moderate pivot exists to +run. Self-contradictory, and a violation of the command's own invariant that "a pivot always invalidates +the fact-check" (`:116–119`). The **structural** branch (`to-phase: draft`→Step 4→fact-check at 5) was +functionally safe but mis-labelled "(Step 3b)". Root cause: conflating "the phase to resume AT" with the +`currentPhase` (last-completed) value to write. + +**THE FIX (4 surgical edits, confined to command-anatomy + Step 2).** +1. Moderate default `:96–97`: `factcheck-sweep` → **`consistency-quality`** (resumes Step 5, fact-check + runs) — the bug. +2. Structural label `:92–95`: "(Step 3b) … full prose expansion" → "resumes **Step 4 + (consistency-quality)** … then fact-check (5) → editorial (5.5) → persona (6) → headless (6.5)". +3. Off-by-one guard callout `:102–108`: explains `to-phase` is a `currentPhase` (last-completed) value + and newsletter resumes at the NEXT step → why a pivot never resets to `factcheck-sweep`. +4. Anatomy enum `:46`: dropped `factcheck-sweep` from the offered values (it skips fact-check, violating + the always-invalidate-fact-check invariant) → `draft | consistency-quality`. +`gatesToRerun` `:113` and the worked example `:64–70` (structural, `draft`) left intact — both correct. + +**VERIFY.** +- Re-grepped final file: `factcheck-sweep` now only at `:106` (guard, as the forbidden value) + `:123` + (gatesToRerun gate-name); to-phase defaults = `draft` (`:93`) / `consistency-quality` (`:97`); enum + `:46` = two values; guard landed `:102`. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`; counts **29/19/26** unchanged + (count/version/model-consistency guards passed; .md-only edit, node/analytics tests not triggered). +- **One NICE left (anti-gold-plating):** template `:30` says "+~530 words" (sum of the 2 named sections) + vs worked-example 1400→1992 = +592; both "~", both +42 % — cosmetic, below the axis-pass bar, left. +- Disposition: **FIXED** (4 edits, all in `commands/pivot.md`) · 0 deferrals · axes a/b/c/d all PASS + post-fix. + +--- + +## Session 12 — Hardening (`firsthour`) + +> S12 status: re-grounded STATE's predicates against the actual `firsthour.md` (119 lines) BEFORE talk. +> Class agreed steps 1–2: **post-publish engagement orchestrator — first-hour / reply-loop sprint. +> NOT post-emitting / feed-draft.** It builds a first-hour plan (target list + draft comments + timeline) +> by DELEGATING construction to `engagement-coach` (Step 2, `Task`), presents + auto-copies the draft +> comments (Step 3), persists via `recordFirstHourPlan` (Step 4), and hands off to `post-feedback-monitor` +> (Step 5). It emits COMMENT copy, not a feed post → the feed-format predicates (hook 110–140, 1,200–1,800 +> band, no-body-link) do NOT apply; the inline comment band (25–50 words, voice, no bait, no generic +> praise) is its own surface. Axis-d heavy: 2 agents + a state-writer + a hand-off chain. + +**WHAT VERIFIED CLEAN (no change).** +- Agent wiring (axis-d): both `subagent_type: linkedin-studio:`-namespaced. `engagement-coach` frontmatter + = name / model:sonnet / color:magenta; `post-feedback-monitor` = name / model:opus / color:lime — both + match the CLAUDE.md table exactly. +- State-writer (axis-d): `recordFirstHourPlan` exported (`state-updater.mjs:235`), additive, `$`-safe + (function-replacements), stamps `last_firsthour_date` + `firsthour_active`, writes `## First-Hour Plans` + — exactly as Step 4 claims. `writeState` / `clipboard-helper.mjs` / both ref files present. +- Delegation sound (axis-d): CEA / 5x5x5 / first-hour / target-selection genuinely owned by + `engagement-coach.md` (whales / inner-circle, velocity table). Hand-off coach→monitor present (Step 5). +- Axis-c: the draft-comment band is consistent with the SSOT AI-slop suppression + the 15+-word floor. +- Principle 2 "comments rank above reactions" (`:111`) IS supported — SSOT `:38` + `saves > shares > quality comments > reactions/likes` + `:160`. (My initial narrow grep was a false + negative — withdrawn.) + +**THE FIX (2 findings, 3 surgical edits, confined to prose + footer).** +1. [REWORK · axis-b] `~70%`-stat MISATTRIBUTED to "the first 60 min". The command (desc `:4`, body `:22`) + said the first hour / "first 60 minutes" decide ~70% of reach. SSOT `algorithm-signals-reference.md:81` + pins ~70% to the **first 15–30 min** sub-window (golden window = 60–90 min); the command even cites + that SSOT (`:118`) → it contradicted its own source. Fix: desc + body re-attributed to "first 15–30 + minutes decide ~70%", framing the hour as the golden window you work. (Operational guidance was already + front-loaded — the 5/15/30/60 velocity checkpoints were left unchanged.) +2. [REWORK-low · axis-a] Reference-label: `engagement-frameworks.md` does NOT contain CEA. The footer + labelled it "hook types, CEA, engagement hierarchy" — but CEA lives in `algorithm-signals-reference.md:162` + + `engagement-coach.md`; the ref carries hook types + "Engagement Quality Hierarchy" only. Fix: dropped + "CEA" from the footer line (CEA already covered by the Step 2 coach-delegation + the SSOT pointer). + +**VERIFY.** +- Re-grepped the final file: `~70%` is now attributed to "15–30 minutes" at desc `:4` + body `:22`; no + "first 60" / "60 minutes set" attribution remains (`:66` "5/15/30/60-minute" is the velocity-checkpoints + table, unchanged). CEA now only at `:6 / :44 / :54 / :65 / :112` (desc + Step 2 + Step 3 + principle), + GONE from the footer; footer `:118` = "hook types, engagement hierarchy". +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`; counts **29/19/26** unchanged + (count / version / model / stat-consistency guards passed; .md-only edit, node/analytics tests not + triggered). +- **One NICE left (anti-gold-plating):** no degradation note for an `engagement-coach`-Task failure / + absent voice-samples (thin orchestrator, fully coach-dependent) — consistent with the suite's posture, + below the axis-pass bar, left. +- Disposition: **FIXED** (3 edits, all in `commands/firsthour.md`) · 0 deferrals · axes a/b/c/d all PASS + post-fix. + +--- + +## Session 13 — Hardening (`calendar`) + +> S13 status: read-and-show of every mechanical dependency against the actual `queue-manager.mjs` + +> `state-updater.mjs` BEFORE talk. Class agreed steps 1–2: **stateful queue / publish command** — +> view + manage the scheduling queue + run the publish action. **NOT post-emitting / feed-draft.** +> It loads the queue (`queueToday/Upcoming/Overdue/Count/FormatSummary`), renders a 14-day view, +> offers actions (mark-published / reschedule / cancel / view-draft), and on publish does +> `queueUpdateStatus('published')` → `updatePostTracking` (state) → a quick first-hour plan that +> defers the full sprint to `/linkedin:firsthour` + `post-feedback-monitor`. It DISPLAYS hook previews +> of already-queued drafts → it emits no feed content → the feed-format predicates (hook 110–140, +> 1,200–1,800 band, no-body-link, buzzwords, rotation) do NOT apply. Axis-a/d heavy. + +**WHAT VERIFIED CLEAN (no change).** +- Queue wiring (axis-a/d): Step 1 (`queueToday/Upcoming/Overdue/Count/FormatSummary`), 3c + Cancel + (`queueUpdateStatus`) all match exports in `queue-manager.mjs:41–112` exactly. +- State-writer (axis-d): `updatePostTracking(stateContent, { postDate, postTopic, hookText, charCount, + format })` (`state-updater.mjs:55`) + `writeState` (`:346`) — signature matches Step 3d's call verbatim. +- Agent wiring (axis-d): `subagent_type: linkedin-studio:post-feedback-monitor` (`:156`) namespaced, + agent file present, `Task` in `allowed-tools`. Hand-off to `/linkedin:firsthour` (`:138`) = S12 chain. +- Graceful degradation (axis-d): missing/corrupt queue → `ensureQueue()` + `readQueue()` try/catch → `[]` + (`queue-manager.mjs:12–27`); the "No posts scheduled" branch (`:106–111`) covers the empty case. +- Algorithm bar (axis-b): the quick first-hour plan (`:151` "15+ engagements in first 60 min", check at + 30/60) is deliberately light and delegates the full sprint to `/linkedin:firsthour`. No ~70%-reach + misattribution (the S12 bug is absent here). SSOT `:81` golden window 60–90 min / first 15–30 min — not + contradicted. PASS. +- Content-quality (axis-c): emits no feed content; the hook "preview" is a truncated display of an + existing draft, not a generated hook → feed predicates N/A → PASS. + +**THE FIX (1 finding, 1 surgical edit, confined to the Reschedule action).** +1. [REWORK · axis-a/d] The **Reschedule** action (`:164–170`) was under-determined vs its sibling actions: + prose-only with NO bash snippet (unlike Cancel `:177–180` and publish 3c `:116–118`), and it referenced + `queue_update_status + queue_add` — **snake_case that does not match the exports** `queueUpdateStatus` / + `queueAdd` (`queue-manager.mjs:63,82`). An agent executing it had no concrete command and the names + would not resolve if copied literally. Fix: replaced step 3 with a concrete `queueAdd` one-liner using + the **same id** (since `queueAdd` filters out the prior id first, `:64`, re-adding moves the post in + place — no duplicate), carrying the unchanged fields from the Step-2 entry. Mirrors the Cancel snippet + pattern. + +**VERIFY.** +- Re-grepped the final file: no `queue_update_status` / `queue_add` remains; the `queueAdd` reschedule + snippet is at `:174`; every `queue*` reference in the file is now camelCase and matches an export. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit, node/analytics tests not triggered — unchanged from S12). +- **One NICE left (anti-gold-plating):** the quick first-hour plan could name the SSOT "first 15–30 min" + sub-window, but it deliberately defers to `/linkedin:firsthour`; duplicating it would be scope creep — + left. +- Disposition: **FIXED** (1 edit in `commands/calendar.md`) · 0 deferrals · axes a/b/c/d all PASS post-fix. + +--- + +## Session 14 — Hardening (`import`) + +> S14 status: read-and-show of the analytics CLI (`csv-parser.ts`, `cli.ts handleImport`, +> `storage.ts`, `alerts.ts`) BEFORE simulate, then a GROUNDED run against a throwaway fixture +> (`ANALYTICS_ROOT=/private/tmp/…`, 5-row CSV, discarded after). Class agreed steps 1–2: +> **analytics ingest** — CSV export → structured weekly JSON via `cli.ts import`, delegating all +> analysis to `/linkedin:report` (Step 6). **NOT post-emitting** → the feed-format predicates +> (hook 110–140, length band, no-body-link, buzzwords, rotation) do NOT apply. Axis-a/d heavy +> (path-seam consistency + graceful degradation + the over-promise). + +### /linkedin:import — CSV export → structured weekly JSON, intra-batch anomaly surface, analysis delegated to report + +**WHAT VERIFIED CLEAN (no change).** +- Path-seam (axis-a): `getAnalyticsRoot()` (`storage.ts:67–72`) → `getDataRoot("analytics")` + (`:54–59`) = `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics` = the exact path + the prose tells the user to use (`import.md:37,62,91`); identical `LINKEDIN_STUDIO_DATA` override. + `ANALYTICS_ROOT` back-compat override (`storage.ts:68`) confirmed (validates the throwaway isolation). +- saves/dwell honesty (axis-c): grounded run → EU `"5.234"`→5234, US `"8,710"`→8710; + `parseOptionalCount` (`csv-parser.ts:71–85`) kept saves 42/128 on data rows, **blank → no `saves` + field** (unknown, not 0); `engagementRate` excludes saves (`:203–208`); **0 dwell field** anywhere. + README boundary blockquote (`import.md:26–30`) intact. +- Wiring (axis-d): `/linkedin:report` → `commands/report.md` present; **0** `subagent_type` + (CLI-delegating, correct); state-file Step 6b = `~/.claude/linkedin-studio.local.md`. +- Degradation (axis-d): `npm install` pre-step (`:114–121`) covers fresh-clone `ERR_MODULE_NOT_FOUND`; + file-not-found exit 1 (`cli.ts:67–71`) ↔ Error Handling; empty title (file line 5) + bad date + (line 6) **skipped** in the run (`csv-parser.ts:186–195`). + +**THE FIX (1 finding, 5 surgical edits, all in `commands/import.md`).** +1. [REWORK · axis-a/d] **Over-promise: baseline / metadata infrastructure the code never had.** + import.md repeatedly promised baseline comparison + rolling 4-week averages + a `baselines.json` / + `metadata.json` the importer "creates" + ">2 sigma from baseline" alerts. Grep over the whole repo + (`scripts/analytics/src` + all `*.mjs/ts/py/js`) = **NONE writes either file**; `saveBatch` writes + only `posts/*.json` (`storage.ts:157`); `detectAlerts` (`alerts.ts:18–69`) flags **intra-batch** + std-dev from the batch's own mean, not a stored baseline. Concrete bug: Step 5b's + `cat baselines.json 2>/dev/null` is always empty → the command falls forever to "first import — + baselines will be established", on every import. Fix: rewrote 5 sections to the truth — Step 5 + (real CLI fields + `YYYY-MM-DD-.json` filename; dropped "Duplicates skipped" / "340% above + baseline" / "Viral threshold"), Step 5b (honest intra-batch surfacing; cross-week analysis deferred + to `/linkedin:report`), Step 7 (Next-Steps conditioned on the report's trend, not a local baseline), + State Tracking + Reference Files (no `baselines.json` / `metadata.json`; correct batch filename). + +**VERIFY.** +- Re-grepped the final file: `metadata.json` / `YYYY-WXX.json` (filename) / "Duplicates skipped" / + "340%" / "Viral threshold" all GONE; the only `YYYY-WXX` left is `:226` (`last_import_week` ISO-week + state field — legitimate); the 3 remaining "baseline" hits are the new honest negations ("keeps no + baseline file", "no persisted baseline"). +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit, node/analytics tests not triggered). +- Grounded simulation passes the failing axis: the rewritten prose matches the observed CLI output + verbatim (filename `2026-06-02-batch-17.json`, "No anomalies detected in imported data.", + "Saves entered: 170 across 2 post(s) (manual)"). +- Disposition: **FIXED** (5 edits, all in `commands/import.md`) · 0 deferrals · axes a/b/c/d all PASS + post-fix. + +--- + +### /linkedin:report — imported analytics → weekly/monthly/heatmap report, alerts surfaced, interpretation delegated to analytics-interpreter + +**WHAT VERIFIED CLEAN (no change).** +- Path-seam (axis-a): `getAnalyticsRoot()` (`storage.ts:67–72`) → `getDataRoot("analytics")` (`:54–59`) + = the exact `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics` paths the prose reads; + `ANALYTICS_ROOT` override confirmed (the grounded sim ran isolated in `/private/tmp`). +- CLI surface (axis-a): `report --week`/`--month`, `trends --period --metric`, `heatmap` all dispatch + (`cli.ts:447–459`); grounded sim produced verbatim weekly report + trends + heatmap on a 3-row fixture. +- Wiring (axis-d): `subagent_type: linkedin-studio:analytics-interpreter` (`report.md:305`) resolves — + `agents/analytics-interpreter.md` present with report-mode (`:10,40,295`); one namespaced `Task`. +- Degradation (axis-d): `trends`/`heatmap` "No posts found. Import some data first." exit 1 + (`cli.ts:245–248,397–400`); weekly report try/catch exit 1 (`:191–194`); Step 1 import-nudge; + npm-install Step 1b for fresh-clone `ERR_MODULE_NOT_FOUND`. +- saves/dwell honesty (axis-c): `totalSaves` surfaced only when present, labelled "(manual entry — top + engagement signal)" (`cli.ts:144–146,342–344`; `weekly.ts:172–173`); grounded run — FIXTURE B's blank + Saves cell produced **no** saves suffix (unknown≠0); never folded into engagement. **dwell** absent, + explicit "do not fabricate a dwell field" (`types.ts:26–28`). + +**THE FIX (3 findings, 6 surgical edits, all in `commands/report.md`).** +1. [REWORK · axis-a/d] **Over-promise: `baselines.json` + `metadata.json` infrastructure the code never + had** (the S14 import twin). Step 5c told the model to `cat baselines.json` and flag ">2 standard + deviations above/below mean"; Reference Files listed both files. Grep over `scripts/analytics/src` = + **NONE writes either**; `storage.ts` writes only `posts/-.json`, + `weekly-reports/.json`, `monthly-reports/.json`; `ensureDirectories` (`:77–90`) makes no + baselines dir; a full import+report cycle in the fixture created **NEITHER**. The real alerts are + `detectAlerts` (intra-week) + `detectWeeklyAlerts` (WoW) surfaced in the report's own `alerts[]` + (`weekly.ts:222–238`). Fix: Step 5c rewritten to read `alerts[]` straight from the generated + weekly-report JSON; Reference Files dropped both phantoms, fixed the posts filename to + `YYYY-MM-DD-.json`, added the real `monthly-reports/` file. +2. [REWORK · axis-a] **Broken metric flag `engagement_rate` (snake_case, `:172`).** Grounded: + `--metric engagement_rate` → exit 1 "Invalid metric"; the CLI accepts only camelCase `engagementRate` + (`cli.ts:47,202–209`). Fix: `:172` snake→camel (the other call, old `:363`, was already correct). +3. [REWORK · axis-a] **Step 4 / Step 6 schema field names that don't exist.** Step 4 claimed JSON fields + `dateRange`, `postCount`, `aggregateMetrics`; the real `WeeklyReport` (`weekly.ts:124–149`) top keys + are `week, generatedAt, summary, topPerformers, underperformers, trends, alerts` (no dateRange, no + aggregateMetrics, no top-level postCount — it is `summary.totalPosts`). Fix: Step 4 rewritten to the + real schema; Step 6 template `({dateRange})` dropped, `{postCount}`→`{summary.totalPosts}`, + eng-rate→`{summary.avgEngagementRate}`; Deep-Dive post-search fixed from the non-existent + `posts/.json` to iterating the real per-batch `posts/*.json`. + +**VERIFY.** +- Re-grepped the final file: `engagement_rate` / `baselines.json` / `metadata.json` / `aggregateMetrics` + / `({dateRange})` / `{postCount}` / `posts/.json` all GONE; corrected strings present + (`metric engagementRate` `:171,366`; `get('alerts', [])` `:227`; `posts/YYYY-MM-DD-.json` + `:414`; `monthly-reports/YYYY-MM.json` `:416`; Step 4 `summary` schema `:143`; Step 6 + `{summary.totalPosts}`/`{summary.avgEngagementRate}` `:271–272`); no residual `dateRange`/`postCount`. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit). +- Grounded simulation (throwaway `ANALYTICS_ROOT=/private/tmp/claude-report-fixture`, discarded): import + seeded 3 fake W25 posts → `report --week 2026-W25` printed "Total saves: 10 (manual entry…)", FIXTURE B + (blank saves) showed no suffix, empty alerts (no baseline consulted); `trends --metric engagementRate` + ok, `--metric engagement_rate` exit 1; heatmap rendered. +- Disposition: **FIXED** (6 edits, all in `commands/report.md`) · 0 deferrals · axes a/c/d all PASS + post-fix (b N/A — not feed-emitting). + +--- + +### /linkedin:analyze — diagnose why content underperforms (reach drop / low traction / low engagement) → recovery plan, analytics grounded via analytics-interpreter + +**INTENT.** Prose-diagnosis + conditional agent-delegation — **NOT CLI-backed** (`allowed-tools: Read, +AskUserQuestion, Task`, no Bash, `analyze.md:9–12`). Measure-journey diagnostic. Steps 1–8 are a +faithful restatement of `references/troubleshooting-guide.md` (drop-% tiers, 14-day recovery protocol, +timeline table, prevention checklist). + +**SIMULATE.** ICP author, scenario 1 (reach −50 %, 1 wk ago, 2 off-pillar posts, 1 body-link). Step 1 +picks "Reach suddenly dropped" (`:32`); Step 2 analytics present → `Task +subagent_type: linkedin-studio:analytics-interpreter` interpret-mode (`:41`); Step 3 → pattern "Good +Content, Low Reach" (`:95–102`); Step 4 checklist flags external link + topic drift — **and, before fix, +asked "5+ hashtags?" (`:149`)**; Step 5 → "Algorithmic suppression likely" → 14-day recovery +(`:167–173`); Step 6 → `/linkedin:profile` (`:189`); Step 7 → "Moderate 50–70 %: 2–3 wks initial" +(`:231`). Friction: exactly one unsourced checklist item. + +**EVALUATE (4 axes; analytics-class predicate = graceful degradation + saves/dwell honesty).** +- **axis-a (intent): PASS** — faithful render of the cited guide; AskUserQuestion → framework → recovery + flow intact. +- **axis-b′ (algorithm-claim vs SSOT — feed-bar reframed since not feed-emitting): 1 FUNN.** `:149` + "Did you use 5+ hashtags in posts?" is a penalty check absent from **both** the SSOT + (`grep hashtag references/algorithm-signals-reference.md` = NONE — hashtags deliberately omitted) **and** + the cited `references/troubleshooting-guide.md` (NONE; its own penalty checklist is 6 items `:101–106`). + Command-only unsourced folklore carrying a concrete number ("5+"). +- **axis-c (saves/dwell honesty): PASS** — no Bash; never claims to read analytics itself; delegates + honestly; no saves/dwell mention anywhere → no over-promise. +- **axis-d (wiring + degradation): PASS** — `linkedin-studio:analytics-interpreter` resolves; + interpret-mode is real/default (`agent:6,39,141`); shared seam `${LINKEDIN_STUDIO_DATA:-…}/analytics/` + (`analyze:41` ↔ `agent:49`); `/linkedin:profile` resolves (`commands/profile.md`); "if analytics exists + → delegate, else self-report" IF-branch (`:41`); all 4 referenced files exist. + +**HARDEN (1 surgical edit, `commands/analyze.md`).** +1. [REWORK · axis-b′] Dropped `:149` "Did you use 5+ hashtags in posts?" — unsourced in both the SSOT and + the cited guide. Step 4 checklist 8 → 7 items; **kept** `:150` "tag unrelated people for reach" + (defensible — aligns with the SSOT bait/pod-suppression direction, no numeric claim). + +**Out-of-scope (logged follow-up, NOT touched in S16).** `troubleshooting-guide.md:127` "External link +overuse — LinkedIn penalizes directing traffic away" contradicts the SSOT (`:69–75`: LinkedIn *denies* an +intentional penalty; soften to "body links correlate with lower reach"). `analyze.md:102` only echoes the +milder guide wording verbatim (`guide:15`) — softening the command alone would drift it from its own cited +source, so the real fix belongs to the reference-consistency pass. + +**VERIFY.** +- Re-grepped final file: `grep hashtag commands/analyze.md` → **NONE**; Step 4 checklist now 7 items + (`:143–149`), `:149` = "tag unrelated people for reach" (survived). +- `git diff --stat` = 1 file, **1 deletion**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit). +- Disposition: **FIXED** (1 edit, `commands/analyze.md`) · 0 deferrals · axes a/c/d PASS, b′ PASS post-fix. + +--- + +### /linkedin:audit — quarterly content-strategy audit (top/bottom posts · topic & format mix · trends · milestone) → report + routes to strategy/profile + +**INTENT.** Prose-heavy, self-contained quarterly audit — **NOT CLI-backed, NOT agent-delegated** +(`allowed-tools: Read, Glob, Grep, AskUserQuestion` post-fix, no Bash, no Task, `audit.md:8–12`). +Measure-journey (reachable via `/linkedin:measure`). The 8 steps (0–8 + 5.5) faithfully cover the +description scope: top/bottom performers, topic distribution, format mix, engagement trends, milestone +progress, profile alignment — quarterly cadence. Names the gap; routes the fix to `/linkedin:strategy` +(trajectory) + `/linkedin:profile` (profile audit). + +**SIMULATE.** ICP author, scenario A (partial data). Step 0 reads state + `plans/` + checks `analytics/` ++ audience-insights/examples, asks user for top-5/bottom-5 + screenshot; Steps 1–2 tabulate vs 7 factors +(`:41–49`); Step 3 topic-mix vs pillars (`:69–81`); Step 4 format mix → emits "Increase carousel content +(**highest save rate**)" (`:93`, before fix); Step 5 trends; Step 5.5 milestone if `follower_count>0`; +Step 6 routes to `/linkedin:profile` (`:151`); Step 7 report; Step 8 AskUserQuestion. Scenario B (fresh +adopter, no data): Step 0 `:26` "Check for any" = soft, audience files absent → degrades to user-provided +data; Step 5.5 `:141` `follower_count` not >0 → "Skip this step. Add note: …not configured" — no crash. +Friction: one orphan tool-grant + one unsourced format-claim. + +**EVALUATE (4 axes; analytics-class predicate = graceful degradation + saves/dwell honesty).** +- **axis-a (intent): PASS** — 8 steps faithfully cover the description scope; routing-by-delegation (not + agent delegation) is honest ("names the gap, strategy/profile prescribe the fix", `:145–156`). +- **axis-b′ (algorithm/format claim vs SSOT): 1 FUNN.** `:93` "(highest save rate)" — the specific + metric-attribution is absent from the SSOT (which sources "carousels = **top organic format**", + `algorithm-signals-reference.md:55`, and "**saves** = top engagement signal", `:42`, but never + "carousels = highest *save rate*") AND from the cited refs (playbook `:248` "Carousels: 1.6x", `:192` + "60–70 % reduced reach"; no save-rate superlative). Postulated mechanic embedded in an example block. +- **axis-c (quality rules): PASS** — emits no content; no format/length advice contradicting CLAUDE.md. +- **axis-d (wiring + degradation): 1 FUNN.** No Task → claims no agent delegation (`grep + subagent_type|Task` = NONE) ✅; `/linkedin:strategy` + `/linkedin:profile` resolve ✅; graceful + degradation present (Step 5.5 explicit skip + user-provided-data primary path) ✅; data-seam consistent + (`${LINKEDIN_STUDIO_DATA:-…}/` data, `~/.claude/linkedin-studio.local.md` state, `${CLAUDE_PLUGIN_ROOT}/` + refs) ✅; saves/dwell not over-promised (no Bash; never claims auto-track) ✅. **FUNN: WebFetch orphan + tool-grant** — `:12` granted it; body `:20–234` never fetches (grep = declaration line only). + +**HARDEN (2 surgical edits, `commands/audit.md`).** +1. [REWORK · axis-d] Dropped `WebFetch` from allowed-tools — granted but never invoked; audit reads local + files + asks user + routes, fetches no URL. Tool-surface now matches behavior (5 → 4 tools). +2. [REWORK · axis-b′] `:93` → `:92` "(highest save rate)" → "(top organic format)" — aligned to the SSOT's + sourced framing; dropped the unsourced save-rate superlative. + +**Out-of-scope (logged, NOT touched in S17).** `:61` "off-topic (topic-relevance penalty)" — SSOT frames +topic-relevance as a positive distribution driver (`:30,89`, high), not a named penalty; but off-topic → +weaker relevance match → less reach is defensible and "topic-relevance signal" is plugin convention +(CLAUDE.md rule #5). Left as-is (NICE, directionally correct diagnostic parenthesis). + +**VERIFY.** +- Re-grepped final file: `grep WebFetch commands/audit.md` → **NONE** (allowed-tools now + Read/Glob/Grep/AskUserQuestion, `:9–12`); `grep "highest save rate"` → **NONE**; `:92` = "Increase + carousel content (top organic format)". +- `git diff --stat` = 1 file, **+1 / −2**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit). +- Disposition: **FIXED** (2 edits, `commands/audit.md`) · 0 deferrals · axes a/c PASS, b′ + d PASS post-fix. + +--- + +### /linkedin:ab-test — design/log/analyze/history/suggest A/B experiments → managed test files, challenger rewrite delegated to content-optimizer + +**INTENT.** Stateful analytics command — the most composite class in the queue so far. CLI-backed (Bash +`ls`/`cat`/`mkdir -p` + `Write` of `[test-name].md` test files under the data-dir), agent-delegated +(`content-optimizer` via `Task subagent_type: linkedin-studio:content-optimizer`, `:119`), AND routing +(`/linkedin:post` `:448`, `/linkedin:report` `:459`, `/linkedin:analyze` `:467`). Measure-journey +(reachable via `/linkedin:measure`). Five lifecycle modes (2a–2e) map 1:1 to the description: design · +log · analyze · history · suggest. (STATE predicted `analytics-interpreter` delegation; actual is +`content-optimizer`.) + +**SIMULATE.** ICP author → analyze an active hook experiment. Throwaway 2-experiment fixture in +`/private/tmp/claude-abtest-fixture/analytics/ab-tests/` (ACTIVE 6-post + COMPLETED), run via +`LINKEDIN_STUDIO_DATA=` to exercise the real seam. Seam-discovery (`:37`,`:270`) found both +files; analyze arithmetic computed correct (eng-rate `:236` per-post 3.0%/5.0%; variant avg + %-diff +`:278–279` → eng +66.7%, comments +200%, impressions/reposts +0%); directional test (`:311`) cleared +20% AND consistent across all 3 paired posts → "Adopt B (directional)"; significance honesty (`:320–331`) +correctly flagged 3/variant << ~50 → directional, NOT significant. History-parse targets all present in +COMPLETED fixture. Empty `weekly-reports/` + `posts/` → graceful degradation (`:485–488`). Fixture +discarded (`find -delete`). + +**EVALUATE (8 predicates across 4 axes; class = stateful + analytics + agent-wiring + routing).** +- **P1 (axis-d wiring): PASS** — `content-optimizer` resolves; `agents/content-optimizer.md` `name:` + matches the namespaced invocation form. +- **P2 (routing): PASS** — `post`/`report`/`analyze`.md all resolve. +- **P3 (intent refs): PASS** — `ab-testing-framework.md` + `algorithm-signals-reference.md` exist. +- **P4 (axis-b′ SSOT): 1 FUNN.** ② 20%-threshold **verbatim** from framework SSOT (`fw:173` = `ab:311`) + ✅; ③ significance honesty under-claims (a real significance test needs more than ~50/variant → the + framing can only make the "directional not significant" verdict safer) ✅; **① `:128` "(Tue-Thu, 8-9 AM + optimal)"** — sourced (cites the ref) but "optimal" over-claims precision against the SSOT's own `:23` + "trust the *ordering*, test the *number*", and Thu is 9 AM–1 PM in SSOT (`alg:144`), not 8 AM; it is the + default slot held constant while testing another variable, so "optimal" is the wrong word. +- **P5 (data-seam): PASS** — all data via `${LINKEDIN_STUDIO_DATA:-…}/`; `:44` = state file (correct), not + a leak; refs via `${CLAUDE_PLUGIN_ROOT}/`. +- **P6 (graceful degradation): PASS** — empty weekly-reports/posts → proceed test-only (`:485–488`); 4 + error modes (`:474–493`). +- **P7 (saves/dwell honesty): PASS** — no saves/dwell mention; eng-rate `:236` excludes saves (S16-consistent). +- **P8 (stateful artifact trace): PASS** — see SIMULATE; full analyze + history path sound against a + realistic file. + +**HARDEN (1 surgical edit, `commands/ab-test.md`).** +1. [REWORK · axis-b′] `:128` "(Tue-Thu, 8-9 AM optimal)" → "(Tue-Thu mornings)" — dropped the precision + over-claim ("optimal" + the inaccurate 8 AM window); the line stem already cites + `algorithm-signals-reference.md`, so source attribution is retained. + +**Out-of-scope (logged, NOT touched in S18).** `:284` "baseline comparison" cross-ref vs `weekly-reports/` +— echoes the S15 "baselines/metadata phantom" vocabulary, but here it reads an existing artifact (the +report command's weekly reports), optional + graceful-degraded; legitimate soft context, not a phantom. +Left as-is (low, no-change). + +**VERIFY.** +- Re-grepped final file: `grep "8-9 AM optimal"` → **NONE**; `:128` = "...based on + algorithm-signals-reference.md (Tue-Thu mornings)". +- `git diff --stat` = 1 file, **+1 / −1**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit). +- Disposition: **FIXED** (1 edit, `commands/ab-test.md`) · 0 deferrals · P1–P3,P5–P8 PASS, P4 PASS post-fix. + +--- + +### /linkedin:strategy — phase-aware growth + authority-building plan (S19) + +**INTENT.** Grow-journey front-door (CLAUDE.md elevates `onboarding`/`strategy` as the Start/Grow +front-doors) **and** atomic command. Prose-heavy, **no Bash** (`allowed-tools`: Read·Glob·Grep· +WebFetch·AskUserQuestion·Task). Reads state (`follower_count` → phase auto-detect) + 8 local refs, +delegates the growth recommendation to `strategy-advisor`, routes the profile audit to +`/linkedin:profile`. Most composite non-CLI class: prose + state-read + agent-delegation + routing + +axis-b′. + +**SIMULATE (ICP author, grounded prose-trace — no fixture; touches no real state/data).** "What should +I focus on at my level?" → Step 0.5 auto-detects phase from `follower_count` (graceful: `:43` falls +through to Step-1 questions if 0/missing) → AskUserQuestion → Step 3 delegates to +`linkedin-studio:strategy-advisor` (`:154`) → phase strategy + Step 3.5 trajectory overlay (graceful: +`:285` "Skip this step. Note: …" when no milestone data) → Step 3.6 authority-building (Phase 2+). +Friction: none surfaced; phase bands consistent between Step 0.5 (`:38–42`) and the Step 2 headers +(`:79/:93/:107/:122/:137`). + +**EVALUATE (4 axes; mechanical predicate per axis).** +- **(a) intention: PASS** — body delivers exactly the description: phase-specific guidance 0–1K→10K+, + trajectory overlay, Phase 2+ signature-content compounding. +- **(b) algorithm/format-bar (axis-b′): PASS** — topic-relevance is the SSOT signal (`:88/:175/:520`); + growth rates / timelines are roadmap-sourced (command loads `growth-roadmaps.md` + + `trajectory-strategy-adjustments.md`), not postulated in-command; saves-honesty intact (`:300/:408` + "read from native LinkedIn analytics — not auto-tracked by this tool"). +- **(c) quality rules: PASS** — no buzzwords, no body-links; prose-only command (no post emission). +- **(d) agent-wiring + graceful degradation: 1 FINDING** — P-checks: agent `linkedin-studio:strategy-advisor` + (`:154`, namespaced) → `agents/strategy-advisor.md` **resolves**; routing `/linkedin:profile` + (`:293/:361`) → `commands/profile.md` **resolves**; 8/8 referenced files exist; graceful degradation + present (`:43/:285/:408`). **allowed-tools over-grant:** `:16` `WebFetch` is the ONLY occurrence in + the file (`grep -n WebFetch` = `:16` only) — never used in the body (no URL fetch anywhere; body reads + local refs + state, delegates, AskUserQuestion); delegate `strategy-advisor` is Read+Glob only, so it + doesn't need it either. Exact S17 pattern (`62b7081`). Borderline Grep/Glob left as-is (local, + low-risk, plausibly used for state/analytics reads) — only the external-network WebFetch trimmed. + +**HARDEN (1 surgical edit, `commands/strategy.md`).** +1. [REWORK · axis-d] `:16` removed `- WebFetch` from `allowed-tools` — orphan grant (external-network + capability, zero usage). allowed-tools now Read·Glob·Grep·AskUserQuestion·Task. + +**VERIFY.** +- Re-grepped final file: `grep -n WebFetch commands/strategy.md` → **NONE**; allowed-tools block = + Read/Glob/Grep/AskUserQuestion/Task. +- `git diff --stat` = 1 file, **−1**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit). +- Disposition: **FIXED** (1 edit, `commands/strategy.md`) · 0 deferrals · axes a/b/c PASS, axis-d PASS post-fix. + +--- + +### /linkedin:competitive — niche thought-leader analysis → landscape map · gap matrix · differentiation plan (S20) + +**INTENT.** Grow-journey atomic command (with `monetize`/`outreach`/`profile`). Prose-heavy guided +analysis — **not** post-emitting, **not** routing (no `/linkedin:Y` emitted), **not** own-data +analytics. Six-step flow (Step 0–6) producing prose output (no persisted artifact: no `Write`/`Task`). +User names 3–5 competitor profiles (or asks for help finding them) → competitive map + gap analysis + +differentiation plan. Ethics Note (`:155-157`) steers toward inspiration, not copying. + +**SIMULATE (ICP author, grounded prose-trace — no fixture; touches no real state/data).** Two branches +proved against Step 0–6 (`:19-153`): **A** (names 3 profiles, choice 1 `:30`) → reads SKILL.md+state +(`:21-23`) → per-competitor template Step 2 (`:46-76`, all fields are `[placeholders]` the user/observation +fills — no fabricated data) → landscape map Step 3 → gap matrix Step 4 → differentiation plan Step 5 → +takeaways Step 6 → Ethics Note. **B** (choice 2 "help me find", `:31`) → triggers WebSearch (`:34`) → +same analysis loop. **No branch invokes WebFetch.** + +**EVALUATE (relevant axes; mechanical predicate each).** +- **(a) intention: PASS** — body delivers exactly the description: posting frequency, content types, + hooks, engagement strategy, gaps → differentiation. +- **(b) algorithm/format-bar (axis-b′): PASS** — `grep -nE "[0-9]+ ?(%|x|times)"` = **NONE**; format fields + (`:54` `text X%/carousel Y%/video Z%`) are letter-placeholders, not postulated stats; folklore grep hit + only `:8`/`:116` (false positives). No unsourced algorithm claim. +- **(c) quality rules: PASS** — prose-only command (no post emission), Ethics Note present. +- **(d) agent-wiring + graceful degradation: 1 FINDING** — no agent delegation (selbständig prose, + correct for class); 5/5 referenced files resolve (`:21,:161-163` + state PRESENT); graceful degradation + present (Step 1 AskUserQuestion 3 branches + WebSearch fallback `:34`). **allowed-tools over-grant:** + `:11` `WebFetch` was the ONLY occurrence (`grep -n WebFetch` = `:11` only) — never used in the body; + WebSearch (`:12` grant + `:34` real use) covers the entire legitimate external need (finding thought + leaders), and WebFetch against LinkedIn profile URLs cannot deliver the public feed content the grant + implies (login/anti-bot). Exact S17/S19 pattern. Borderline Glob (`:10` grant, no body use) left as-is + (local, low-risk read tool) — only external-network WebFetch trimmed. + +**HARDEN (1 surgical edit, `commands/competitive.md`).** +1. [REWORK · axis-d] `:11` removed `- WebFetch` from `allowed-tools` — orphan grant (external-network + capability, zero usage, not realizable for LinkedIn profiles). allowed-tools now + Read·Glob·WebSearch·AskUserQuestion. + +**VERIFY.** +- Re-grepped final file: `grep -n WebFetch commands/competitive.md` → **NONE**; allowed-tools block = + Read/Glob/WebSearch/AskUserQuestion. +- `git diff --stat` = 1 file, **−1**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit). +- Disposition: **FIXED** (1 edit, `commands/competitive.md`) · 0 deferrals · axes a/b/c PASS, axis-d PASS post-fix. + +--- + +### /linkedin:monetize — turn thought leadership into revenue (readiness → stage strategy → lead magnet → funnel → tracking) + +**INTENT.** Prose-heavy guided monetization strategist (Grow-tier atomic, unlocks ~1K followers). 9-step flow +(Step 0–8 `:22-487`): load refs → readiness scorecard → stage-specific strategy → lead-magnet blueprint → +funnel content → CTA optimization → Featured section → revenue model worksheet → tracking dashboard. Output is +prose templates the user fills in; **persists nothing**. + +**SIMULATE (aspiring monetizer, grounded prose-trace — no fixture; touches no real state/data).** Traced +Step 0–8 against the file. Step 1 collects follower count / engagement / offers / goals via **AskUserQuestion** +(`:37`) — does NOT read state, so it degrades gracefully (asks rather than depends). Stage-1 branch (0–1K, +score 0–30, `:94-112`) handles the sub-unlock case honestly ("Revenue is secondary… DO NOT sell aggressively"). +All scorecards/blueprints/worksheets are `[placeholder]` fill-ins — no fabricated user data. **No branch +invokes WebFetch, Write, Glob, or Grep.** + +**EVALUATE (relevant axes; mechanical predicate each).** +- **(a) intention: PASS** — body delivers the description (readiness scoring, stage strategy, lead magnets, + CTA A/B, funnel, consulting-inquiry tracking). 7/7 referenced files resolve (Step 0 + Reference Files). +- **(b) algorithm/conversion-bar (axis-b′): 1 FINDING** — `:344-346` postulated a generic time-decay curve + ("same-day ~half / next-day 20-30% / **3+ days <10%**"). SSOT (`opportunity-generation.md:302-310`) gives + **per-channel** rates (speaking 30-50% same-day, consulting 20-30% within-24h, podcast 70-80%) + qualitative + "speed matters", NOT a generic decay curve — and **`<10%` is invented**, no SSOT basis. Same over-precise + -unsourced pattern as S18. (`:495` external-links claim was already SSOT-sourced + softened — clean.) +- **(c) quality rules: PASS** — prose-only (no post emission); no buzzwords; `:495` correctly sourced. +- **(d) agent-wiring + graceful degradation: 2 FINDINGS (allowed-tools over-grant)** — no agent delegation + (selbständig prose, correct for class). **`:13` `WebFetch`** = orphan grant, ONLY occurrence (`grep -n + WebFetch` = `:13` only), zero body use, WebSearch not even granted → pure dead external-capability grant, + exact S17/S19/S20 pattern. **`:15` `Write`** = orphan grant, ONLY occurrence, zero body use — command + never persists a file; unused **mutation** capability (stronger least-privilege case than read grants). + Borderline `Glob`/`Grep` (`:11-12`, no body use) left as-is (local, low-risk read tools — S20 precedent). + +**HARDEN (2 surgical edits, `commands/monetize.md`).** +1. [REWORK · axis-d] removed `- WebFetch` and `- Write` from `allowed-tools` — orphan grants (zero usage; + external-fetch never realized, file never written). allowed-tools now Read·Glob·Grep·AskUserQuestion. +2. [REWORK · axis-b′] `:343-346` replaced the fabricated decay curve (incl. invented `<10%`) with the + SSOT-supported qualitative claim ("speed compounds conversion — same-day converts far better than + days-later") + cite to `references/opportunity-generation.md` for the real per-channel benchmarks. + `:313/:323` "within 2 hours" left as-is (defensible tactical advice, not a fabricated statistic — operator OK). + +**VERIFY.** +- Re-grepped final file: `grep -n WebFetch` → **NONE**; `grep -n Write` → **NONE**; `grep -n "roughly + half|<10%|3+ days|20-30% conversion"` → **NONE**; allowed-tools block = Read/Glob/Grep/AskUserQuestion. +- `git diff --stat` = 1 file, **+3 / −6**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit). +- Disposition: **FIXED** (2 edits, `commands/monetize.md`) · 0 deferrals · axes a/c PASS, axes b′/d PASS post-fix. + +--- + +### /linkedin:outreach — collab + speaking outreach orchestrator (track select → readiness → targets → format/pitch → production → pipeline persist) (S22) + +**INTENT.** Grow-tier **orchestrator** (unlocks ~1K), prose-heavy **+ agent-delegating + state-persisting**. +Two tracks (collab + speaking) under one paradigm, 11 steps (Step 0–10 `:65-1252`). Absorbed +`collab`/`speaking`/`authority` (capability checklist `:36-63` maps each predecessor function). **First in +the queue to BOTH delegate to an agent AND mutate real state** — shifts the weight onto axis-d. + +**SIMULATE (adversarial cold quality-reviewer; grounded prose-trace + throwaway state fixture — operator +chose the thorough path; touches no real state/data-dir).** Traced Step 0–10 against the file; verified every +mechanical claim (refs resolve, agent resolves, mutation exists, grants match body use, axis-b′ claims trace +to SSOT) with a tool, never by reasoning. + +**EVALUATE (relevant axes; mechanical predicate each).** +- **(a) intention: PASS** — checklist maps both predecessors; all 5 refs + SKILL.md resolve (Step 0 + + Reference Files); state path `~/.claude/linkedin-studio.local.md` correct post-M0 (per CLAUDE.md). +- **(b) algorithm/conversion-bar (axis-b′): 2 FINDINGS.** **B1** `:652` "DM amplification can boost + first-hour engagement **2-3x**" — over-quantified: 2-3x is SSOT for *collab reach* + (`collaborations-guide.md:32/165/187`), while the DM-loop's first-hour effect is held *qualitative* there + (`:335`). **B2** `:921-946` Step 7 pod-warning fabricated magnitudes — "**30-55%** reach reduction", + "shadow suppression", "account credibility score reduction", "penalizes", "**Post-March 2025** Update" + + "low dwell" detection (dwell unmeasurable per CLAUDE.md). SSOT (`algorithm-signals-reference.md:98`) states + ONLY: "auto-comments demoted out of 'Most Relevant', scoped to own network; repeat offenders restricted" + (Sachdeva 2026-02-16, high) + design rule "do not quote a percentage / soften penalty". Same S18/S21 + + follow-up-#1 pattern. +- **(c) quality rules: PASS** — prose templates only (no post emission); "thought leader"/"ecosystem" + appear only in search-queries / internal *goals* (`:230/:813/:820`), not in published post-copy (Rule 4). +- **(d) agent-wiring + grants + state + graceful: 3 FINDINGS.** **D1 PASS** — `Task subagent_type: + linkedin-studio:network-builder` (`:172`) resolves (`agents/network-builder.md`). **D2 PASS** — + `recordOutreachContact` is **NOT fabricated** (`state-updater.mjs:294`, CLI `--record-outreach :394`, + additive, `$`-safe). **D3 Bash MISSING** — Step 8c (`:1064`) runs `node …state-updater.mjs --record-outreach` + (needs Bash) but frontmatter didn't grant it; `firsthour.md` grants Bash for the same `node`-mutation + pattern → functional defect (command promised a mutation it couldn't execute). **D4 WebFetch orphan** + (`:19`, zero body use). **D5 Write orphan** (`:21`; `:410/:1040` are prose "Write a post", not tool use). + +**HARDEN (5 edits across 2 files — operator-approved scope extension to also fix the SSOT-diverging guide +source so the B2 rewrite can't be re-rendered from a stale reference).** +1. [REWORK · axis-d] `commands/outreach.md` frontmatter: dropped `- WebFetch` + `- Write` (orphans), + added `- Bash` (Step 8c needs it). allowed-tools now Read·Glob·WebSearch·AskUserQuestion·Task·Bash + (borderline Glob kept — S20 precedent). +2. [REWORK · axis-b′] `:652` (B1): re-attributed 2-3x to collab reach (where SSOT places it), kept the + first-hour effect qualitative + cite to `collaborations-guide.md`. +3. [REWORK · axis-b′] Step 7 (B2): replaced "Post-March 2025 Update" header + the fabricated + detection/penalties block with the SSOT crackdown statement (demote out of "Most Relevant" / scope to own + network / restrict repeat offenders) + cite; softened "penalizes", dropped "low dwell". +4. [REWORK · axis-b′, scope-extension] `references/collaborations-guide.md:349-370` (B2 propagation): same + SSOT rewrite of the "March 2025 Crackdown" section — dropped "shadow banning ALL future content / 90+ days + / 6+ months / permanent reach penalty", the date, and the speculative detection methods (browser-extension + tracking, linguistic fingerprinting). + +**VERIFY.** +- Re-grepped final files. `commands/outreach.md`: `grep WebFetch` → **NONE**, `^ - Write$` → **NONE**, + `^ - Bash$` → `:21`; B1 old form → **NONE** (new `:651`); B2 `30-55%|Post-March 2025|penalizes engagement + pods|credibility score|Low dwell|Shadow suppression` → **NONE** (SSOT text `:928-929`). + `references/collaborations-guide.md`: `90+ days|6+ months|never fully recover|permanent reach + penalty|Shadow banning affects ALL|March 2025 Crackdown` → **NONE** (SSOT text `:349/:364`). +- **D2/D3 throwaway fixture** (`/private/tmp/claude-*`, fictional, deleted after): `recordOutreachContact` + direct-call (no `writeState`), **10/10 assertions PASS** — additive insert after `last_post_date`, + `outreach_active` untouched when undeclared, pre-existing fields + custom section intact, newest-first + append, **`$`-safe** (`@kari$boss` preserved literally — S13 behavioral), section auto-created. +- `git diff --stat` = 2 files, **+20 / −28**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edits — node hook/analytics tests not triggered). +- Disposition: **FIXED** (5 edits, 2 files) · 0 deferrals · axes a/c PASS, axes b′/d PASS post-fix. + Note: state-updater HTML-comment lands between newest+older rows on repeat append = cosmetic, logged as + non-scope follow-up (the command calls the mutation correctly). + +--- + +### /linkedin:profile — profile/topic-relevance + profile-SEO audit walkthrough (7 sections → prioritized action plan); self-contained, no agent/routing/state (S23) + +**INTENT.** Grow-tier **atomic execution command**, prose-heavy + guided (`AskUserQuestion`). +**Self-contained** — no agent delegation, no `/linkedin:Y` routing, no state mutation. Audits the +profile for the topic-relevance ranking signal + profile search-SEO. + +**SIMULATE (adversarial cold quality-reviewer; grounded prose-trace — pure prose, NO fixture: command +reads/mutates no state).** Headline section (`:79-96`) traced against operator input +`"AI-rådgiver | KI-transformasjon | Innholdsprodusent"` → checklist `:85-88` yields NO audience / NO +outcome / vague titles → concrete non-generic rewrite direction. Every mechanical claim (grant grep, SSOT +read, pattern greps) from a tool, never by reasoning. + +**EVALUATE (relevant axes; mechanical predicate each).** +- **(a) intention: PASS** — desc + Grow-journey role coherent; 3 refs (`algorithm-signals-reference.md`, + `troubleshooting-guide.md`, `SKILL.md`) resolve. +- **(b) algorithm-bar (axis-b′): 1 systemic FINDING (B1) + 1 minor (B2).** **B1 — "profile-as-validated-object" + overclaim, 9 sites:** the file repeatedly asserts the relevance model **reads & validates your profile** + and gates distribution on it — `:5` "validates your profile BEFORE distributing content"; `:31` "the model + **evaluates five criteria** (see SSOT)" (**false SSOT attribution** — SSOT has no such breakdown); `:35-39` + "Impact if Missing: **HIGH/HIGH/MEDIUM…**" (postulated coefficients SSOT forbids, `:14/:23`); `:99` "first + signal telling … what you're **qualified** to discuss"; `:161` "relevance **validation**"; `:173` "**checks + if** you're connected"; `:230` "if LinkedIn's AI **read my profile**, would it **believe**"; `:51/:67` SEO + conflation ("**tell the relevance model** what you're expert in" / "the **relevance model's expertise + signal**"). SSOT (`algorithm-signals-reference.md:25-30/:85-90`) confirms ONLY: topic/interest relevance is a + ranking input — content matched to a **viewer's interests** is distributed incl. beyond your network (Jurka, + high); **no off-topic magnitude figure; no weighted profile criteria**. File was **internally inconsistent** — + its own soft, SSOT-true framing already at `:17/:25/:27`. Same class as follow-up #1 (`audit.md:61`) + + v4.0.0 "reconcile to one sourced statement". **B2** `:24` "Goes to **10% of audience**" — postulated + old-model number (`:131` "40%" is an illustrative impact-bullet example → PASS). +- **(c) quality rules: PASS** — no post emission (audit prose only); buzzword list cited correctly (`:73` + steers away from "thought leader/guru/ninja"). +- **(d) grants + graceful + wiring: PASS.** **Grant-hygiene REN (two-way):** `allowed-tools` = `Read` + + `AskUserQuestion` (`:11-13`), both used (`:21/:27/:31/:236` Read; `:77` AskUserQuestion); WebFetch / Write / + Bash / Task / subagent_type / .mjs → **NONE** (no orphans, no missing node/Bash grant). No agent/routing. + Graceful degradation OK (Q&A runs regardless of ref availability). +- **P3 — profile-SEO honesty (`:41-73`): largely defensible** (about LinkedIn *search*, not the ranking + model; correctly does NOT cite SSOT). One practitioner-claim-as-fact: "**highest-weight** search field" + (`:52` prose + `:61` table) — self-justified mechanically, softened not removed. + +**HARDEN (12 edits, 1 file — `commands/profile.md`; all surgical re-source to SSOT framing).** +1. [REWORK · b′] `:5` desc — "validates your profile BEFORE distributing content" → "a coherent, on-topic + profile reinforces the topic-relevance signal LinkedIn uses to decide how widely your content is distributed". +2. [REWORK · b′] `:24` (B2) — "Goes to 10% of audience" → "a slice of your network sees it". +3. [REWORK · b′] `:25/:27` — "content matched to **your demonstrated expertise**" → "**a viewer's interests**" + (SSOT wording) + added "LinkedIn confirms no off-topic reach-reduction figure — not a quantified penalty". +4. [REWORK · b′] `:31` + table `:33-39` — dropped "the model evaluates five criteria (see SSOT)" + HIGH/MEDIUM + "Impact if Missing" weights → "LinkedIn does **not** publish a profile-scoring breakdown … practitioner + heuristics", column → "Priority (heuristic)", High/Medium relabeled directional. +5. [REWORK · b′] `:99` — "first signal telling … qualified to discuss" → "the clearest place to state … the + strongest single contribution to a coherent topic signal". +6. [REWORK · b′] `:161` — "Critical for profile/topic-relevance validation" → "Your top skills are a strong, + searchable topic signal". +7. [REWORK · b′] `:173` — "relevance checks if you're connected" → "a network concentrated in your expertise + area reinforces your topic signal and social proof (heuristic — LinkedIn doesn't publish network as a + profile-ranking factor)". +8. [REWORK · b′] `:230` — "If LinkedIn's AI read my profile, would it believe I'm an expert" → "Does my + profile make it obvious — to a human and to LinkedIn's topic-matching". +9. [REWORK · b′] `:51/:67` SEO conflation — "tell the relevance model what you're expert in" → "make your + topic legible … to topic-relevance distribution"; "the relevance model's expertise signal" → "carry your + strongest on-topic signal". +10. [NICE · P3] `:52/:61` — "highest-weight search field" → "highest-leverage" (×2; rest self-justified, kept). + +**VERIFY.** +- Re-grepped final file. All overclaim/conflation patterns (`validates your profile before | evaluates five + criteria | first signal telling | qualified to discuss | checks if you're connected | relevance validation | + if linkedin's ai read | 10% of audience | highest-weight | demonstrated expertise | Impact if Missing | tell + the relevance model | relevance model's expertise signal`) → **NONE**. New SSOT text present (`:26/:28/:33/ + :38/:54/:178/:235`). +- Grant-hygiene unchanged: `allowed-tools` = Read + AskUserQuestion (`:11-13`); WebFetch/Write/Bash/Task/.mjs → **NONE**. +- `git diff --stat` = 1 file, **+28 / −22**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edits — node hook/analytics tests not triggered). +- Disposition: **FIXED** (12 edits, 1 file) · 0 deferrals · axis-b′ **FIXED**, axes a/c/d **PASS** · no + fixture needed (pure prose, no state). + +--- + +### /linkedin:create — content-creation front-door (guided "what to make?" → routes to the owning creation command); delegates only, no drafting (S24) + +**CLASS: routing front-door** (Create-journey, v4.1). Predicate: every emitted `/linkedin:Y` resolves to +`commands/Y.md`; bidirectional grant-hygiene; "delegates only" (never drafts). + +**TEST (tool-grounded).** +- Routing (main axis): **8/8 targets resolve** — `commands/{post,quick,react,carousel,video,multiplatform, + batch,newsletter}.md` all present (menu `:38-45` / table `:54-63`). +- Grant-hygiene (bidirectional): `Read` (`:13`) used at `:26` (fixed-path state read); `AskUserQuestion` + (`:15`) used at `:36`; **`Glob` (`:14`) ORPHAN** — zero body usage, only file access is the fixed-path + Read (`:26`; Read handles the "if exists" itself — no pattern-matching). No missing grants (routing = + narrative handoff, no tool). +- Delegates-only: all 4 "draft" hits are the no-draft promise (`:7/:22`, long-form lock `:66`) or + target-description (`:38` = what `/linkedin:post` does); no drafting leak. +- Persona (ICP author, no format named): Step 0 `:24-28` state glance → Step 1 `:30-45` AskUserQuestion + (8 opts) → Step 2 `:47-63` route into target workflow, steps not inlined; URL fast-path `:32-34` → + `/linkedin:react`. ✓ + +**HARDEN (1 edit, 1 file — `commands/create.md`).** +1. [REWORK · grant-hygiene] dropped orphan `Glob` from `allowed-tools` (`:14`); net = {Read, AskUserQuestion}. + +**VERIFY.** +- Re-grep final: `Glob` → **NONE**; `allowed-tools` = Read + AskUserQuestion (`:13-14`). +- `git diff --stat` = 1 file, **−1**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit). +- Disposition: **FIXED** (1 edit, 1 file) · 0 deferrals · routing axis **PASS**, delegates-only **PASS**, + grant-hygiene **FIXED** · no fixture (pure routing prose). +- Scope note: `Glob`-orphan may be systemic in sibling front-doors (`measure` S25, `linkedin` S26) — not + expanded (one command per session); each catches its own. + +--- + +### /linkedin:measure — performance front-door (guided "how am I doing?" → routes to the owning analytics command); delegates only, no number-crunching (S25) + +**CLASS: routing front-door** (Measure-journey, v4.1). Predicate: every emitted `/linkedin:Y` resolves to +`commands/Y.md`; bidirectional grant-hygiene; "delegates only" (never crunches numbers). + +**TEST (tool-grounded).** +- Routing (main axis): **5/5 targets resolve** — `commands/{import,report,analyze,audit,ab-test}.md` all + present (menu `:31-35` / table `:44-48` / order-note `:51`). +- Grant-hygiene (bidirectional): `AskUserQuestion` (`:13`) used at `:29`; `Glob` (`:12`) + `Read` (`:11`) — + neither named a tool in body, both hung only on the **optional Step 0** filesystem glance (`:22-25`, + pre-edit). UNLIKE `create` (Glob a pure orphan, no fs-check at all), measure HAS a Step 0 ("if + `…/analytics/` holds imported data, note last import"), giving `Glob` a real home — but the grant↔use + link was implicit (body named no tool). +- Delegates-only: 3 no-do promises (`:7`, `:20`, `:40`); all crunch/analysis hits are negations — no leak. +- Persona (ICP author, "how am I doing?"): Step 0 optional analytics glance → Step 1 AskUserQuestion (5 + opts) `:29` → Step 2 route into target workflow `:37-48`; order-note `:50-51` (no data → import first). ✓ +- No fixture (pure routing prose). + +**HARDEN (operator-chosen — explicit `Glob`, drop `Read`; 2 edits, 1 file — `commands/measure.md`).** +1. [REWORK · grant-hygiene] dropped `Read` from `allowed-tools` (`:11`, pre-edit) — redundant; the Step 0 + import-date need is met by a `Glob` listing of the analytics dir. +2. [REWORK · grant↔use traceability] made Step 0 explicit: "You may `Glob` `…/analytics/`; if it holds + imported data, note last import" (`:23`), giving the retained `Glob` grant a named body home. + Net grants = {Glob, AskUserQuestion}. + +**VERIFY.** +- Re-grep final: `Read` as a tool name → **NONE**; `allowed-tools` = Glob + AskUserQuestion (`:11-12`); + `Glob` now in body `:23`. +- Routing unchanged: 5 distinct targets, all present. +- `git diff --stat` = 1 file, **+1/−2**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only edit). +- Disposition: **FIXED** (2 edits, 1 file) · 0 deferrals · routing axis **PASS**, delegates-only **PASS**, + grant-hygiene **FIXED** (Glob retained + made explicit, Read dropped). +- Scope note: the `Glob`-orphan question is now resolved for `measure` (it had a home via Step 0; `create` + did not). `linkedin` (S26) still to check independently. + +--- + +### /linkedin:linkedin — top router (five-journey menu + status line + queue glance); routes to every command/agent, executes nothing (S26) + +**CLASS: routing** (the master router over the whole surface — the largest routing check: 28 command +pointers + 10 agent pointers). Predicate: every emitted `/linkedin:Y` resolves to `commands/Y.md`; every +"Suggest `X` agent" resolves to `agents/X.md`; bidirectional grant-hygiene; "delegates only" (shows +menu/status, never executes). + +**TEST (tool-grounded).** +- Routing (main axis): **28/28 distinct `/linkedin:Y` targets resolve** to `commands/Y.md` (menu `:66-127`, + ask-menu `:136-140`, direct-routing `:157-198`) — the full sub-command surface (+`linkedin` itself = 29). +- Agent pointers (axis d): **10/10** "Suggest `X` agent" resolve to `agents/X.md` (analytics-interpreter, + content-optimizer, content-planner, content-repurposer, differentiation-checker, engagement-coach, + network-builder, post-feedback-monitor, trend-spotter, voice-trainer). +- Grant-hygiene (bidirectional): `allowed-tools` = Read/Bash/AskUserQuestion (`:8-11`); each has a body home + — Read→state status line (`:20`), Bash→node queue-manager block (`:34-42`), AskUserQuestion→sub-choice + (`:145`); **no ungranted tool referenced, no Glob at all** — third front-door is clean (unlike create's + Glob-orphan / measure's Glob-via-Step-0). +- Delegates-only: body routes/suggests; `:146` "invoke it directly" = hand-off to the command, not + self-execution. No drafting / number-crunch. PASS. +- Graceful degrade (axis d): no-state fallback (`:28`) + empty-queue fallback (`:57`). PASS. +- Persona (ICP author types `/linkedin`): graceful-degrade path — no fixture (pure routing prose). + +**FINDING (terminology — operator rule surfaced this session): "thought leadership" must not appear in the +plugin (it is named LinkedIn Studio).** linkedin.md used it at `:4` (frontmatter description) + `:16` (body). + +**HARDEN (2 edits, 1 file — `commands/linkedin.md`).** +1. [REWORK · terminology] `:4` "Main router for LinkedIn **thought leadership** commands" → "Main router for + **LinkedIn Studio** commands". +2. [REWORK · terminology] `:16` "You are a LinkedIn **thought leadership** assistant" → "You are the + **LinkedIn Studio** assistant". + +**VERIFY.** +- Re-grep final: "thought leadership" in linkedin.md → **NONE**; `:4`/`:16` show "LinkedIn Studio". +- Routing/agent axes unchanged: 28 + 10 targets all resolve. +- `git diff --stat` = 1 file, **+2/−2**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only prose edit). +- Disposition: **FIXED** (2 edits, 1 file) · 0 deferrals · routing **PASS**, agent-wiring **PASS**, + grant-hygiene **PASS** (clean — no orphan; third front-door has no Glob), delegates-only **PASS**. + +**SURFACED (out of scope → queued as S29).** "thought leadership" appears plugin-wide on **85 lines across +~45 files** (commands, agents, references incl. the filename `references/thought-leadership-angles.md`, +skills, plus historical CHANGELOG/docs). Promoted to a dedicated terminology-scrub pass **S29** +(`docs/hardening/plan.md`); the user-facing-vs-historical boundary + the file rename are open scoping +questions for that pass. + +--- + +### S27 ref-consistency A — "penalty/penalizes" framing → SSOT (external links + topic-relevance) + +**CLASS: reference-consistency** (NOT re-hardening of ✅ commands — surgical SSOT-reconciliation of a +documented divergence-class across multiple files; same tool-grounded discipline: read-and-show → +grep-confirm → re-grep final). Predicate: no surface frames external-link reach-loss or off-topic +reach-loss as a hard *named penalty* — SSOT (`references/algorithm-signals-reference.md:69-75`, `:89-90`) +says links **correlate with lower reach** (LinkedIn denies an *intentional* penalty) and topic relevance is +a **positive ranking input** with **no quantified off-topic figure**. + +**TEST (tool-grounded).** Plugin-wide `grep -rniE "penaliz|penalty"` over commands/agents/references/skills. +The 3 plan-cited lines confirmed (line-nr drift ±1 since plan written: `analyze.md:101` not `:102`, +`audit.md:60` not `:61`). Catalog split into 4 buckets, operator-approved: +- **Bucket A (fix now):** 10 hard-penalty framings of link/topic-relevance in non-format files. +- **Bucket B (defer → S28):** 6 link-penalty lines in `references/linkedin-formats.md` (`:52,:91,:194,:198,:200,:432`) — S28 already opens that file for format magnitudes; reconcile link + format in one pass. +- **Bucket C (leave — already SSOT):** `first-comment-strategy.md:7`, `glossary.md:171` (def cites SSOT); glossary term `### Link Penalty :170` kept (def is correct). +- **Bucket D (surface → new S30):** other unsourced magnitudes (−68% hashtags, −25% short-post, −15-25% topic-gap, −30-50% bait, 55% AI-comment, −25%/post, poll-overuse) — a *different* class. Engagement-pod + AI-slop "penalized" left intact (officially confirmed, high confidence). + +**HARDEN (10 edits, 6 files — Bucket A).** +1. `commands/audit.md:60` `(topic-relevance penalty)` → `(topic relevance is a ranking input — off-topic gets lower reach)`. +2. `agents/trend-spotter.md:338` `ranking will penalize off-topic content` → `Topic relevance is a ranking input — off-topic content gets lower reach`. +3. `agents/trend-spotter.md:353` `topic-relevance penalty, damages authority` → `off-topic content gets lower reach, damages authority`. +4. `references/troubleshooting-guide.md:15` `External link penalizing reach` → `External links correlate with lower reach`. +5. `references/troubleshooting-guide.md:127` `LinkedIn penalizes directing traffic away` → `body links correlate with lower reach (LinkedIn denies an intentional penalty; lead with value)`. +6. `commands/analyze.md:101` `External link penalizing reach` → `External links correlate with lower reach`. +7. `references/first-comment-strategy.md:3` `without triggering algorithm penalties` → `without the lower-reach correlation of body links`. +8. `references/first-comment-strategy.md:10` `Avoids link penalty` → `Avoids the body-link reach correlation`. +9. `references/first-comment-strategy.md:147` `Avoid link penalty` → `Avoid body-link reach hit`. +10. `references/url-processing-templates.md:396` `Reach penalty` → `Correlates with lower reach`. + +**VERIFY.** +- Re-grep final: `topic-relevance penalty|will penalize off-topic` in audit/trend-spotter → **NONE**; + `link penalty|penalizing reach|penalizes directing|triggering algorithm penalties|Reach penalty` in the + 4 link-files → **NONE**. All 10 new SSOT-framed lines present. +- `git diff --stat` = **6 files, +10/−10**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** + unchanged (.md-only prose edits). +- Disposition: **FIXED** (10 edits, 6 files) · Bucket A complete · Bucket B → S28 · Bucket D → S30. + +**SURFACED (out of scope → queued).** Bucket D unsourced-magnitude class promoted to **S30 magnitude-scrub** +(`docs/hardening/plan.md`). Bucket B link-penalty lines folded into **S28**'s pass over `linkedin-formats.md`. + +--- + +### S28 ref-consistency B — format magnitudes + folded-in link lines → SSOT (`video.md` + `linkedin-formats.md`) + +**CLASS: reference-consistency** (NOT re-hardening — surgical SSOT-reconciliation of format-magnitude +divergences across `commands/video.md` + `references/linkedin-formats.md`, incl. the 6 link-penalty lines +folded in from S27 Bucket B; one pass over the file). Predicate: no surface states an unsourced hard +format coefficient as fact — SSOT (`references/algorithm-signals-reference.md:51-63`, the "trust ordering, +test the number — never hard coefficients" rule `:12-23`) carries **no completion-rate gate**, no video ER, +no poll/Live multiplier; native video = "captions are the enforceable spec, no hard gate". + +**TEST (tool-grounded).** Full read of `linkedin-formats.md` (674 l.) + `video.md:69` context. Plugin grep +`[0-9]+%|zero.distribution|penaliz|penalty|punish|multiplier|aspect ratio`. The "30% zero-distribution" +gate recurs **7×** (`video.md:69`; `formats:58,:291,:478,:543,:644,:662`). Catalog bucketed, +operator-approved (run buckets A+B+C+E now; D → S30): +- **A — "30% zero-distribution" hard-gate (7):** the named S28 divergence. +- **B — S27-folded link-penalty lines (6):** `formats:52,:91,:194,:198,:200,:432`. +- **C — other unsourced FORMAT magnitudes (6):** video 5.60% ER (`:56`), Live 12-24x (`:59,:594`), polls 1.64x (`:81`), 3x shares (`:273`), Creator-Mode 35% (`:674`). `:49` 24.42% left (already hedged + reconciled `:50`). +- **E — internal contradiction (1):** `:356` "3 seconds determine 70% of retention" contradicted the already-reconciled `:62/:100` folklore note → reframed. +- **D — S30-class in this file (deferred, operator's call):** `:6` 47-50% reach-decline, `:7` 15%→31%, `:176` AI-comment −30%/−55%, `:231/:279` hashtags −68%. **Left intact**; S30 re-scoped to cover them + the other files. + +**HARDEN (20 edits, 2 files — Buckets A+B+C+E).** +- **A:** `video.md:69` + `formats:58,:291,:478,:543,:644,:662` → "completion/dwell is a ranking input; shorter completes better; no published hard completion gate". +- **B:** `formats:52,:91,:198,:200,:432` → "correlate with lower reach" framing; `formats:194` heading `### The External Link Penalty` → `### External Links and Lower Reach`. +- **C:** `formats:56` `5.60% ER` → "engagement varies (no reliable single rate)"; `:59,:594` `12-24x` → "strong … (multiplier unverified)"; `:81` `1.64x` → "declining; audience research only (no reliable multiplier)"; `:273` `3x more shares` → "tend to earn more shares"; `:674` `35% more reach` → "tend to see more reach (figure unverified)". +- **E:** `formats:356` `3 seconds determine 70% of retention` → "opening seconds carry the most weight … three-second hook is folklore". + +**VERIFY.** +- Re-grep final: assertive 30%-gate forms (`requires 30%|30% minimum completion rate or|video gets zero distribution|…`) → **NONE** (remaining "completion gate" hits are the *negated* SSOT form "no hard completion gate"); link penalty/penalized/punished in `formats` → **NONE**; stray magnitudes (`5.60%|12-24x|1.64x|3x more shares|35% more reach`) → **NONE**; `:356` old → **NONE**. +- Bucket D preserved: `47-50%`, `15% to 31%`, `−68% reach`, AI-comment `30%/55%` still present (S30). +- `git diff --stat` = **2 files, +20/−20**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19** unchanged. +- Disposition: **FIXED** (20 edits, 2 files) · Buckets A+B+C+E complete · Bucket D → S30. + +**SURFACED (out of scope → queued).** Bucket D (this file's non-format coefficients) deferred to **S30 +magnitude-scrub**, which is re-scoped to: `linkedin-formats.md` D-lines (`:6,:7,:176,:231,:279`) + the other +files (`linkedin-growth-playbook`, `glossary`, `engagement-coach`, `firsthour`, `post-feedback-monitor`, +`ab-testing-framework`, `poll-strategy-guide`). Pod/AI-slop "penalized" stays intact (officially confirmed). + +--- + +### S29a terminology-scrub — "thought leadership" → neutral (`commands/`) + +**CLASS: terminology-scrub** (NOT re-hardening — surgical removal of the operator-banned brand phrase +"thought leadership", FORM A, from the user-facing command surface; memory `no-thought-leadership-phrase`). +First sub-pass of the plugin-wide S29 scrub. Predicate: no command surface uses the phrase "thought +leadership"; the plugin is named **LinkedIn Studio**. + +**SCOPE (operator-locked for ALL S29 sub-passes a–e).** +- **FORM A only** ("thought leadership"). **FORM B** ("thought leader(s)" as role — 38 plugin-wide, + incl. the buzzword denylist `post.md:134`) = **untouched**. +- **Layers = user-facing surface + forward-looking docs.** Pure history (CHANGELOG, `hardening/log.md`+ + `plan.md`, `voyage-build/*`, `remediation/*`) = untouched ("history → git"). Forward docs = + `docs/integration-test-guide.md` + `docs/expert-review/plan.md`. +- **Per-directory sub-passes, one per session.** Replacement map approved BEFORE edit. +- FORM A baseline = **140 in `.md`** (99 surface + 41 history) + 1 test-`.mjs`. + +**TEST (tool-grounded).** Plugin grep `thought.leadership` (case-insensitive). `commands/` = 26 hits; +split: **11 filename pointers** to `references/thought-leadership-angles.md` (`post×3, video×2, batch×2, +react×1, pipeline×2, competitive×1`) → deferred to **S29e** (atomic rename + pointer update); +`competitive.md`/`pipeline.md` carry only pointers → untouched here. **15 in-file phrase occurrences** in +9 files = S29a scope. Read-and-show of all 15 in context before proposing the map. + +**HARDEN (15 edits, 9 files — 1:1).** +- `post.md` (4): `:21` "LinkedIn thought leadership content creator" → "LinkedIn content creator"; + `:64` "Select Thought Leadership Angle" → "Select Content Angle"; `:98` "Standard Thought Leadership + Structure" → "Standard Post Structure"; `:136` "thought leadership test" → "the insight test". +- `video.md` (1): `:146` "8 thought leadership angles" → "8 content angles". +- `strategy.md` (3): `:67` "thought leadership & authority" → "authority & influence"; `:234` table row + "Thought leadership pieces" → "Signature pieces"; `:395` "[Thought leadership milestone]" → "[Authority + milestone]". +- `batch.md` (1): `:113` "[thought leadership angle]" → "[content angle]". +- `setup.md` (2): `:256` "Which thought leadership angle" → "Which content angle"; `:323` "thought + leadership & authority" → "authority & influence". +- `react.md` (1): `:22` "into thought leadership posts" → "into insightful posts". +- `outreach.md` (1): `:813` "Demonstrate thought leadership depth" → "Demonstrate subject-matter depth". +- `onboarding.md` (1): `:18` "a LinkedIn thought leadership onboarding guide" → "the LinkedIn Studio + onboarding guide". +- `monetize.md` (1): `:18` "turn their thought leadership into revenue" → "turn their LinkedIn authority + into revenue". + +**VERIFY.** +- Re-grep final, assertive non-filename FORM-A in `commands/` (`grep -v thought-leadership-angles`) → + **NONE**. Remaining `commands/` hits = 11, all filename pointers (S29e). +- FORM B in `commands/` count = **15**, unchanged (incl. `post.md:134` denylist). +- `git diff --stat` = **9 files, +15/−15** (1:1, no collateral). +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts + **29/19/26/6** + v0.5.0 unchanged. +- Disposition: **FIXED** (15 edits, 9 files). Commit `e6db0dd`; pushed 2026-06-19 22:38 (operator OK, in + window). + +**SURFACED (queued, not swept).** S29 remaining sub-passes: **S29b** `agents/` ← next · **S29c** +`references/` (in-file phrase; `thought-leadership-angles.md` body scrubbed but FILE renamed in S29e) · +**S29d** `skills/`+`config/`+`assets/`+`README.md`+`CLAUDE.md`+`hooks/prompts/`+forward-docs+test-`.mjs` · +**S29e** rename `references/thought-leadership-angles.md` + all 20 pointers atomically (new filename TBD). + +--- + +### S29b terminology-scrub — "thought leadership" → neutral (`agents/`) + +**CLASS: terminology-scrub** (NOT re-hardening — surgical removal of the operator-banned brand phrase +"thought leadership", FORM A, from the agent surface; memory `no-thought-leadership-phrase`). Second +sub-pass of the plugin-wide S29 scrub. SCOPE inherits the S29a operator-lock verbatim (FORM A only; +FORM B untouched; per-directory sub-pass, map approved before edit). + +**TEST (tool-grounded).** `grep -in "thought leadership"` (space) in `agents/` = **15 in-file phrase +occurrences / 7 files**; the 11 `thought-leadership-angles.md` filename pointers (hyphenated) are invisible +to the space-grep and deferred to S29e. Read-and-show of all 15 in context before proposing the map. +**Discovery during the header anchor-check (`grep "Value Test"`):** 2 same-class instances the phrase-grep +missed because abbreviated — `trend-spotter.md:192/:257` "TL Value Test" (TL = Thought Leadership). +Surfaced as an addendum (#16–#17), operator-approved BEFORE edit; scope not expanded silently. + +**HARDEN (17 edits, 6 files — 1:1).** Vocabulary consistent with S29a: "thought leadership angle(s)" → +"content angle(s)"; "thought leadership" (positioning/practice) → "authority …"; the whole "Value Test" +family → "Authority Value Test". +- `strategy-advisor.md` (1): `:219` "Thought leadership and cross-platform visibility" → "Authority + building and …". +- `content-planner.md` (1): `:24` "sustainable thought leadership growth" → "sustainable authority + building". +- `voice-trainer.md` (1): `:24` "voice for LinkedIn thought leadership" → "… for LinkedIn authority + building". +- `network-builder.md` (1): `:26` "compound their thought leadership reach and influence" → "compound + their authority, reach, and influence". +- `differentiation-checker.md` (4): `:47` search query `"[topic]" thought leadership 2025 2026` → + `"[topic]" expert insights 2025 2026` (functional-keyword judgment-call, operator chose option b); + `:147` "marketing as thought leadership" → "marketing posing as expertise"; `:203` header "## Thought + Leadership Value Test" → "## Authority Value Test"; `:327` in-file "thought leadership test" → "authority + value test" (filename pointer `thought-leadership-angles.md` left for S29e). +- `trend-spotter.md` (9): `:5` frontmatter desc "suggest thought leadership angles" → "suggest content + angles"; `:24` "establish thought leadership positioning" → "establish authority positioning"; `:33` + "strongest thought leadership angle per trend" → "strongest content angle …"; `:156` "map to the + strongest thought leadership angle" → "… content angle"; `:179` "Future Implication for thought + leadership" → "… for authority positioning"; `:334` "create thought leadership value" → "create + authority value"; `:344` "is the thought leadership" → "is the expertise"; `:192` header "### TL Value + Test" → "### Authority Value Test"; `:257` "Run TL Value Test" → "Run Authority Value Test". + +**VERIFY.** +- Re-grep final FORM A `thought leadership` (space) in `agents/` → **NONE**; `\bTL\b` → **NONE**. +- FORM B in `agents/` = **2**, unchanged (`analytics-interpreter.md:419` "(thought leader)", + `network-builder.md:562` "Thought Leader" search term). +- Filename pointers `thought-leadership-angles.md` in `agents/` = **11**, unchanged (S29e). +- 17 new replacement strings present (1:1 with edits). +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts + **29/19/26/6** + v0.5.0 unchanged. +- Disposition: **FIXED** (17 edits, 6 files). Commit `ebe56c5` (local; push parked per policy until operator OK). + +**SURFACED (queued, not swept).** `references/glossary.md:229` "### Thought Leadership Value Test" → its +own directory pass **S29c** (temporary cross-directory naming gap until then — inherent to the +per-directory design). `differentiation-checker.md:278` "### Value Test: [X]/3 passed" is already neutral +(no FORM A / TL) and stays — consistent with the renamed header. Remaining S29 queue unchanged: **S29c** +`references/` · **S29d** misc · **S29e** rename + 20 pointers. + +--- + +### S29c terminology-scrub — "thought leadership" → neutral (`references/`) + +**CLASS: terminology-scrub** (NOT re-hardening — surgical removal of the operator-banned brand phrase +"thought leadership", FORM A, from the reference-doc surface; memory `no-thought-leadership-phrase`). +Third and largest sub-pass of the plugin-wide S29 scrub (~2× the S29a/S29b volume). SCOPE inherits the +S29a/S29b operator-lock verbatim (FORM A only; FORM B untouched; per-directory sub-pass, map approved +before edit). + +**TEST (tool-grounded).** `grep -in "thought leadership"` (space) in `references/` = **35 phrase lines / +15 files** — 29 single-line across 14 files + the canonical `thought-leadership-angles.md` = 6 lines / 7 +instances (line 7 ×2) ≈ **36 FORM A instances**. The 3 hyphenated `thought-leadership-angles.md` filename +pointers are invisible to the space-grep and deferred to S29e. Read-and-show of every occurrence in +context before proposing the 34-row map. Anchor-check (`grep "Value Test"` / `#thought-leadership` / +`#the-standard-thought`): **no anchor links** to the changed headers anywhere in the plugin, and the only +"Value Test" / "Standard Thought Leadership Structure" term-hits were the two SSOT spots being edited — no +hidden same-class instances. + +**S29e filename locked (operator, this session): `thought-leadership-angles.md` → `content-angles.md`.** +The canonical file's in-file title/headers were therefore scrubbed now for consistency (H1 → +"# Content Angles"; "## The Authority Value Test"; "### Step 3: Test For Authority Value"); the file +rename + all 20 pointers remain S29e (atomic). + +**HARDEN (34 edits, 15 files — 1:1).** Vocabulary consistent with S29a/S29b: "thought leadership +angle(s)" → "content angle(s)"; "thought leadership" (positioning/practice) → "authority …"; content-type +labels (lists/tables) → "authority content / authority posts"; the "Value Test" family → "Authority Value +Test". +- Established cross-pass equivalents closed: `glossary.md:229` "### Thought Leadership Value Test" → + "### Authority Value Test" (closes the S29b cross-directory naming gap); `glossary.md:29` "8 universal + thought leadership angles" → "content angles"; `engagement-frameworks.md:137` "Standard Thought + Leadership Structure" → "Standard Post Structure" (matches S29a `post:98`). +- `thought-leadership-angles.md` (6 edits / 7 instances): H1 → "# Content Angles"; :3 "identifying + thought leadership angles" → "content angles"; :7 (×2) "Thought leadership isn't about…/can become + thought leadership" → "Authority isn't about…/can build authority"; :92 "Test For Thought Leadership + Value" → "Test For Authority Value"; :212 "Marketing disguised as thought leadership" → "expertise"; + :215 "## The Thought Leadership Test" → "## The Authority Value Test". +- `linkedin-formats.md` (5): :24/:79/:272/:309 content-type labels → "authority content/posts"; :295 + "Text-based thought leadership often outperforms…" → "Text-based content …" (avoids authority…authority + echo). +- `linkedin-visual-style.md` (4): :3 "For thought leadership," → "For building authority,"; :7 "thought + leadership niche" → "authority niche"; :82 label → "authority content"; :123 "myth for thought + leadership content" → "authority content". +- `trajectory-strategy-adjustments.md` (3): :167 "Shift to thought leadership" → "authority content"; + :202 "100% thought leadership and signature content" → "100% authority and signature content"; :217 + "thought leadership deepening" → "authority deepening". +- `linkedin-monetization-strategies.md` (3): :285 "Inbound from thought leadership" → "authority + building"; :624 "80% thought leadership" → "authority content"; :658 "Breaks the thought leadership + positioning" → "authority positioning". +- `ai-content-framework.md` (2): :3 "build thought leadership in the AI space" → "authority"; :380 "main + LinkedIn thought leadership skill" → "content skill" (judgment-call; avoids awkward "authority skill"). +- Single-edit files (8 edits / 8 files): `ab-testing-framework.md:9` → "authority building"; + `growth-roadmaps.md:222` "Thought leadership pieces" → "Authority pieces"; + `linkedin-growth-playbook-2025-2026.md:822` → "Reflective authority content"; + `algorithm-signals-reference.md:158` (SSOT) "Text for thought leadership" → "Text for authority" + (phrase only; magnitudes = S30); `scheduling-strategy.md:35` → "Mid-week authority content"; + `poll-strategy-guide.md:179` "thought leadership angle" → "content angle"; `video-strategy-guide.md:3` + → "authority content"; `video-strategy-guide.md:580` "8 thought leadership angles" → "8 content + angles". + +**KEPT BY DESIGN (operator-approved).** `video-strategy-guide.md:429` ironic quote "Your 'thought +leadership' content isn't leading anyone's thoughts." — scrubbing destroys the point. +`video-strategy-guide.md:532` "TL;DW" = false positive (too long, didn't watch). + +**VERIFY.** +- Re-grep final FORM A `thought leadership` (space) in `references/` → only the kept ironic quote + (`video-strategy-guide.md:429`); canonical-file in-file FORM A → **NONE**. +- FORM B in `references/` = **4**, unchanged (`collaborations-guide.md:211`, `analytics-tools-guide.md:227`, + `opportunity-generation.md:81`, `linkedin-visual-style.md:134`). +- Filename pointers `thought-leadership-angles.md` in `references/` = **3**, unchanged (S29e). +- New terms present: "Authority Value Test" (`glossary.md:229` + `thought-leadership-angles.md:215`, + consistent), "Standard Post Structure" (`engagement-frameworks.md:137`). +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts + **29/19/26/6** + v0.5.0 unchanged. +- Disposition: **FIXED** (34 edits, 15 files). Commit `b9b769c` (local; push parked per policy until + operator OK). + +**SURFACED (queued, not swept).** Remaining S29 queue: **S29d** `skills/` + `config/` + `assets/` + +`README.md` + `CLAUDE.md` + `hooks/prompts/` + forward-looking docs + test `.mjs` · **S29e** rename +`thought-leadership-angles.md` → `content-angles.md` + all 20 pointers (atomic; filename locked). The +canonical file's in-file phrase scrub is done, so S29e is now a pure rename + pointer-update with no +remaining FORM A inside the file. + +--- + +### S29d terminology-scrub — "thought leadership" → neutral (`skills/` + `config/` + `assets/` + `hooks/`-test) + +**CLASS: terminology-scrub** (NOT re-hardening — surgical removal of the operator-banned brand phrase +"thought leadership", FORM A, from the remaining surfaces; memory `no-thought-leadership-phrase`). Fourth +sub-pass of the plugin-wide S29 scrub. SCOPE inherits the S29a-S29c operator-lock verbatim (FORM A only; +FORM B untouched; per-directory sub-pass, map approved before edit). + +**TEST (tool-grounded).** `grep -in "thought leader"` (A+B) across the S29d scope — `skills/`, `config/`, +`assets/`, `README.md`, `CLAUDE.md`, `hooks/prompts/`, `hooks/scripts/__tests__/clipboard-helper.test.mjs`, +and the forward-looking docs (`docs/integration-test-guide.md`, `docs/expert-review/plan.md`). Result: +**8 FORM A instances / 7 files** + **8 FORM B** + 21 hyphenated filename pointers (S29e). The forward- +looking docs were clean. The test fixture (`clipboard-helper.test.mjs:61`) was included deliberately — +STATE pre-listed it. Anchor-check: `\bTL\b` / `value test` in scope = NONE; a broad `(#…thought…)` +anchor-link grep across the whole plugin = **NONE**, so renaming the three skill headers breaks no internal +link (the S29c anchor grep started with `#thought-leadership` and would have missed +`#step-2-identify-thought-leadership-angles`; this run used a `thought`-anywhere anchor pattern). The only +text cross-refs to the changed header phrases were the three instances being edited. + +**HARDEN (8 edits, 6 files — 1:1).** Vocabulary consistent with S29a-S29c. +- `skills/linkedin-content-creation/SKILL.md` (3): :75 "### Step 2: Identify Thought Leadership Angles" → + "Identify Content Angles"; :109 "**Standard Thought Leadership Post (…):**" → "**Standard Post (…):**" + (matches the S29a/S29c "Standard Post Structure" equivalent); :192 "**Axis 2: The 8 Thought Leadership + Angles**" → "The 8 Content Angles". (lines 77/193 filename pointers → S29e.) +- `skills/linkedin-strategy/SKILL.md:176` phase-focus cell "Thought leadership, cross-platform" → + "Signature content, cross-platform" (judgment-call; the Authority phase's column-1 is already + "Authority", so "Signature content" avoids the echo). +- `config/user-profile.template.md:35` "1. [ ] Build thought leadership & authority" → "Build authority & + influence" (judgment-call; "& authority" already present, so a straight map would duplicate). +- `assets/checklists/quality-scorecard.md:136` "Passes thought leadership test:" → "Passes Authority Value + Test:" (consistent with the renamed test; its 3 questions ARE the Authority Value Test). (line 132 + buzzword denylist = FORM B, untouched.) +- `assets/case-studies/case-study-template.md:173` "**Best for:** Thought leadership, showing expertise" → + "Authority content, showing expertise". +- `hooks/scripts/__tests__/clipboard-helper.test.mjs:61` unicode fixture "'🚀 Thought leadership → impact'" + → "'🚀 Authority → impact'" (judgment-call; the test asserts unicode/emoji handling — 🚀 + → retained, so + coverage is unchanged; pathguard allows `__tests__/*.mjs`). + +**KEPT BY DESIGN (FORM B = 8, operator-locked).** Five buzzword-denylist entries that MUST list the banned +word "thought leader" (`skills/linkedin-studio/SKILL.md:78`, `assets/checklists/quality-scorecard.md:132`, +`CLAUDE.md:124`, `README.md:217`, `hooks/prompts/content-quality-gate.md:13`) + three role usages +(`skills/linkedin-networking/SKILL.md:9` & `:185`, `README.md:158` "niche thought leaders"). + +**VERIFY.** +- Re-grep final FORM A `thought leadership` (space) in the S29d scope → **NONE**. +- FORM B in scope = **8**, unchanged. +- Filename pointers `thought-leadership-angles.md` in scope = **21**, unchanged (S29e). +- New terms present (1:1 with edits): "Identify Content Angles", "Standard Post", "The 8 Content Angles", + "Signature content, cross-platform", "Build authority & influence", "Authority Value Test", "Authority + content, showing expertise", "🚀 Authority → impact". +- `node --test clipboard-helper.test.mjs` → **pass 13 / fail 0** (the fixture change kept the test valid). +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts + **29/19/26/6** + v0.5.0 unchanged. +- Disposition: **FIXED** (8 edits, 6 files). Commit `b804a04` (local; push held — the STATE.md-tracking + commit `773340b` is parked at the tip, so a normal push would carry it; awaiting operator decision). + +**SURFACED (queued, not swept).** Remaining S29 queue: **S29e** rename `thought-leadership-angles.md` → +`content-angles.md` + all pointers (atomic; filename locked; the file's in-file FORM A is already gone, so +S29e is a pure rename + pointer-update). After S29e: **S30** magnitude-scrub. The plugin-wide FORM A scrub +is now complete except the canonical filename itself (S29e) and the one kept ironic quote +(`references/video-strategy-guide.md:429`). + +--- + +### S29e terminology-scrub — rename `thought-leadership-angles.md` → `content-angles.md` (canonical file + all pointers) + +**CLASS: terminology-scrub** (NOT re-hardening — the final S29 sub-pass: rename the canonical reference +file whose *name* still carried FORM A "thought leadership", plus every pointer to it). The file's in-file +title/headers were already FORM A-scrubbed in S29c (H1 = "Content Angles"), so S29e is a **pure rename + +pointer-update** — no FORM A remained inside the file. SCOPE inherits the S29a-S29c operator-lock verbatim +(FORM A only; history = URØRT — and here that explicitly covered the dangling filename mentions a rename +leaves in CHANGELOG/log/plan: an honest record of a past state, not a runtime load). + +**TEST (tool-grounded).** `grep -rl "thought-leadership-angles"` across the whole plugin (ALL file types, +not just `.md`) for the FULL pointer list before the rename: **21 files** carried the hyphenated filename +token. No non-`.md` pointer exists (the render/migrate scripts do not reference it). Target-collision check: +`references/content-angles.md` did not yet exist. FORM A *inside* the canonical file = NONE (S29c). After +categorising: **17** functional/forward-looking pointer files in scope; CHANGELOG.md + `docs/hardening/log.md` ++ `docs/hardening/plan.md` = history URØRT; STATE.md = own state (rewritten at session end). + +**HARDEN (rename + 17 pointer files, 29 occurrences — 1:1).** +- Rename: `git mv references/thought-leadership-angles.md references/content-angles.md` (git: 100% rename). +- Pointer token `thought-leadership-angles` → `content-angles` (replace_all per file; every occurrence is + the filename inside a path, so the replace is uniform and judgment-free): + - `references/` (2): ai-content-framework (1), glossary (2). + - `agents/` (7): content-repurposer (2), strategy-advisor (1), network-builder (1), content-planner (2), + trend-spotter (1), video-scripter (3), differentiation-checker (1). + - `commands/` (6): pipeline (2), video (2), post (3), competitive (1), react (1), batch (2). + - `skills/` (1): linkedin-content-creation/SKILL (3). + - `docs/` (1, forward-looking): integration-test-guide (1). + +**KEPT URØRT (history, operator-locked).** `CHANGELOG.md` (1), `docs/hardening/plan.md` (2), +`docs/hardening/log.md` (18 — this journal) keep the old filename: they record the file as it was named +when written, and are prose/log mentions, not runtime `Read` targets. + +**VERIFY.** +- No `thought-leadership-angles*` file remains (use `ls ` — BSD `find` returns exit 0 even with no + match, so `find … && echo FAIL` reads as a false FAIL); `references/content-angles.md` present (16.7 KB). +- Residual `thought-leadership-angles` across the plugin = **4 files only**: STATE.md + CHANGELOG.md + + `plan.md` + `log.md` (history + own state). **0** in commands/agents/references/skills/integration-test-guide. +- `content-angles` pointers now resolve: 29 occurrences across the 17 files (1:1 with the removed token). +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19/26/6** + + v0.5.0 unchanged; render-chain propagation + $-safety + data-dir convention green. +- Disposition: **FIXED** (rename + 17 files, one atomic commit). Commit `e8b7967` (local; push held with the + parked pile — ahead 6 — awaiting the config-audit cross-repo brief's STATE-push decision). + +**S29 COMPLETE (a–e).** The plugin-wide FORM A "thought leadership" scrub is done: the brand phrase is gone +from every user-facing + forward-looking surface, the canonical filename is renamed, and history is left as +an honest record. The only FORM A left anywhere is the one kept ironic quote +(`references/video-strategy-guide.md:429`) and the historical mentions in CHANGELOG/log/plan. Next: **S30** +magnitude-scrub. + +--- + +### S30 magnitude-scrub (discrete-% class) — unsourced reach/engagement penalties → SSOT + +**CLASS: magnitude-scrub / hardening** (NOT re-hardening — surgical SSOT-reconciliation of discrete +percentage penalties/declines asserted as fact with no primary source in the SSOT +`references/algorithm-signals-reference.md`; same discipline as S27/S28: read-and-show → grep-confirm → +re-grep final). Re-scoped from S27 Bucket D + S28 Bucket D. Predicate: the SSOT encodes ordering + two +officially-named signals + directional/sourced estimates — never hard coefficients; links "correlate +with lower reach" (~38%, contested band), topic alignment is a real input with **no discrete off-topic +figure**, AI content carries a **~45% correlational engagement gap (medium)**; no hashtag / post-length / +posting-frequency / engagement-bait figure exists. + +**TEST (tool-grounded).** Plugin-wide grep for the discrete-% class (penalty/decline/underperformance/ +engagement-%). The plan's stored catalog had drifted and was incomplete — re-grep surfaced same-class +siblings not listed (`linkedin-growth-playbook:225` 32% post-length, `:435` 25% twice/24h, `:828` 25% +multiple/3h, `:158` a SECOND 47-50% decline; `engagement-coach:455` AI-comment table row) + one adjacent +multiplier (`:221` 1.17x). All surfaced and operator-approved before edit. The first broad sweep silently +failed on a zsh `--include=*.md` glob (must quote `"*.md"`); re-run quoted. + +**HARDEN (20 edits, 7 files).** Reconciliation vocabulary 1:1 with S27/S28. +- `linkedin-formats.md` (5): :6 "47-50% decline" + :7 "15%→31% feed share" → directional; :176 "AI + comments reduce reach 30% / engagement 55%" → "~45% less engagement (correlational), no separate reach + figure"; :231/:279 "5+ hashtags = -68%" → "diminishing returns; no discrete figure". +- `linkedin-growth-playbook` (6): :158 (47-50% twin of formats:6), :166 (-68% hashtags), :224/:225 + (25%/32% post-length), :435/:828 (25% posting-frequency) → directional. **:221 1.17x multiplier folded + in per operator approval (adjacent); the rest of the multiplier class → S31.** +- `glossary.md` (2): :91 bait -30-50% + :235 topic-gap -15-25% → "correlate with lower reach; no discrete + figure". +- `engagement-coach.md` (2): :195 55% + :455 -30%/-55% → "~45% less engagement (correlational), actively + suppressed". +- `post-feedback-monitor.md` (1): :330 -25%/post → "tends to split your own audience". +- `ab-testing-framework.md` (1): :66 -68% → "no discrete figure". +- `poll-strategy-guide.md` (2): :20 / :205 poll-overuse penalty → "declining effectiveness (directional)". + +**KEPT INTACT.** Operator-locked: engagement-pod + AI-slop "suppressed" framing (SSOT high-confidence, +`engagement-coach:456`, glossary pod def). Different class / no magnitude: `firsthour:112` (no number), +`poll:206` / `poll:3` (qualitative), `growth:567` (conversion rate, not reach), `formats:268` (list item). + +**VERIFY.** +- Re-grep discrete-% penalty/decline class across the 7 files → **NONE**. +- Leave-items intact (firsthour:112, poll:206/:3, growth:567, engagement-pod) → confirmed. +- `git diff --stat` = **7 files, +20/−20**. +- `bash scripts/test-runner.sh` → `Passed: 81 · Failed: 0 · Warnings: 0`, **exit 0**; counts **29/19/26/6** + + v0.5.0 unchanged. +- Disposition: **FIXED** (20 edits, 7 files). Commit `55de831` (local; push held — `open/` is a public + namespace, STATE.md must not go there; all parked). + +**SURFACED → S31 (multiplier-scrub, new cataloged pass — operator: run everything, multiple sessions).** +The unsourced **"Nx" reach/format multiplier** class — ~50 instances across ~15 files (`poll:7` 1.64x, +`growth:175/205/234/247-250` 1.6x/1.4x/1.64x, `:493` 5.6x, `:636` 40x, `collaborations-guide` 2-24x, +`report:249` 3.4x, `articles-strategy-guide:28-29` 2-3x/10x, `video-strategy-guide:308`, `ab-testing:49` +1.17x-1.6x, …). S31 MUST distinguish (a) SSOT-backed engagement-order multipliers that STAY ("save ≈ 5x a +like", "quality comment ≈ 2x a like" — SSOT medium) and (b) frequency counts that are NOT coefficients +("3-5x/week", "10-15x daily"). Also catalog the descriptive-% sub-class: suppression-severity bands +(`analyze:230`, `troubleshooting-guide:180` "50-70% drop") + engagement-rate benchmark (`growth:198` "11% +engagement rate") + `report:215` "content format multipliers" reference. + +--- + +### S31a multiplier-scrub (reference-docs) — unsourced "Nx" reach/format multipliers → SSOT + +**CLASS: multiplier-scrub / hardening** (surgical SSOT-reconciliation of unsourced "Nx" reach/format/ +growth multipliers asserted as fact; same discipline as S27/S28/S30: read-and-show → grep-confirm → +re-grep final). Operator: "run everything, multiple sessions"; classification + batching (S31a refs → +S31b commands/agents → S31c descriptive-%) operator-approved before edit. + +**TEST (tool-grounded).** Plugin-wide grep `[0-9]+(\.[0-9]+)?(-[0-9]+(\.[0-9]+)?)?x` (214 raw hits) +classified into KEEP (SSOT-backed save ≈ 5x / comment ≈ 2x / ≈ 1x), OUT (frequency counts, pixel dims, +pricing + size-ratio multipliers), TARGET (the Nx reach/format/growth class), and a DESCRIPTIVE-% sub-class +→ S31c. + +**HARDEN (26 edits, 7 reference files).** Vocabulary 1:1 with S30 ("tend to / more reach (multiplier +unverified)", "no reliable multiplier", format ranking kept + number dropped → SSOT). +- `collaborations-guide` (10): :3 10x reach, :32 2-3x normal, six per-format "Reach multiplier: Nx" fields + (:165/:187/:210/:235/:252/:277), :476 10x growth + the label-only :292 (no number) folded in for + consistency → "Reach:". +- `linkedin-growth-playbook` (10): :57/:132 3x faster growth, :175 1.6x reach, :205 1.4x engagement, + :234 1.64x poll, :246-251 format-multiplier list → SSOT ranking (numbers dropped), :386 3.6x respond, + :422 3x engagements, :493 5.6x follower growth, :636 40x search. +- `poll-strategy-guide:7` (1.64x), `articles-strategy-guide:28-29` (2-3x/10x), `video-strategy-guide:308` + (2-3x distribution), `ab-testing-framework:49` (1.17x-1.6x), `trajectory:40` (2-3x saves) + `:85` (3x). + +**KEPT INTACT.** SSOT-backed engagement-order multipliers. OUT: frequency (5x5x5, Nx/week), pixel dims +(1080x1350), pricing (2-5x market rate), size-ratio (2-3x follower count). + +**SURFACED → S31c (not edited here).** `growth:58` (130% follow), `growth:177/178` (Buffer 178%/247%), +`trajectory:39` (30-40%). Flagged absolute-figure class outside S31: `growth:421/423`. Sourcing overclaim +to revisit: `growth:492` "Official LinkedIn data". + +**VERIFY.** Re-grep residual targets in 7 files → 0 (the `:292` label caught by re-grep, folded per +operator nod). Routed siblings + KEEP intact. `git diff --stat` = 7 files, +32/−32. Gate 81/0/0 exit 0, +counts 29/19/26 + v0.5.0. Commit `02a8ffd` (local; push held — `open/` public). + +### S31b multiplier-scrub (commands + agents) — generic "Nx" claims → SSOT + +**CLASS: multiplier-scrub / hardening** (S31 batch b). Generic-claim Nx multipliers in commands/agents. + +**TEST.** Grep the 6 S31b files for the multiplier class → no new siblings beyond catalog. Close read +revealed a real distinction (see KEPT): some "Nx" lines report/hypothesize from the USER's own measured +analytics, not a platform norm. + +**HARDEN (5 edits, 5 files).** `report:249` (3.4x reach multiplier) → top organic format; `outreach:652` +(2-3x reach, ref collab) → above-baseline, aligned w/ S31a; `engagement-coach:120` (3.6x respond) → far +more likely (figure unverified); `strategy-advisor:241` (3x faster growth) → faster (figure unverified); +`content-repurposer:591` ("Expected reach multiplier: [2-5x original]") → "Expected reach: above the +original (no reliable multiplier)". + +**KEPT — measured-data class (operator-ruled, NEW RULE for S31c).** `report:303` ("[format] … 2x more +shares" example), `report:335` ("your one carousel got 3x" rendered example), `ab-test:418` ("Your data +shows … 2x … test whether this holds"), `outreach:1234` ("Average reach multiplier (collab vs solo): +[X]x" output field). These report/hypothesize from the user's OWN analytics — the number is an +illustrative-computed example or a pure placeholder; removing it adds no honesty. Also KEPT: +`engagement-coach:148` SSOT comment ≈ 2x. OUT: `strategy-advisor:239` 4-5x/week frequency. + +**VERIFY.** Re-grep generic-claim targets → 0; measured-data class + KEEP/OUT intact; gate 81/0/0 exit 0, +counts 29/19/26 + v0.5.0. Commit `e28e357` (local; push held). + +**SURFACED → S31c (descriptive-% sub-pass, queued).** Suppression bands (`analyze:230` / `troubleshooting:180` +"50-70% / 75%+ drop"), engagement-rate benchmark (`growth:198` "11%", Socialinsider-sourced), `report:215` +"per 2026 content format multipliers" reference, `video-strategy-guide:304` "20-30% more feed real estate", +`trajectory:39` "30-40% of new follower growth", `glossary:216` "130% follow probability", `growth:58/177/178` +(routed from S31a), "2.7x pattern interrupts" (`engagement-frameworks:84` / `glossary:153/198`). **S31c MUST +apply the measured-data-class rule:** distinguish a platform-norm % (scrub) from the user's measured/computed +% or a sourced benchmark (keep). + +--- + +### S31c multiplier-scrub (descriptive-% sub-pass, final) — platform-norm percentages asserted as fact → SSOT + +**CLASS: descriptive-%-scrub / hardening** (S31 batch c, final). Percentages asserting platform / +algorithm / audience behavior as fact, unsourced — distinct from S30 discrete-% (already swept), from +content-mix ratios (70/20/10), goal-progress benchmarks (50–80% of goal), and example-hook copy. Same +discipline as S27/S28/S30/S31a/b: read-and-show (Read tool) → grep-confirm → re-grep final; 8-step gate; +classification its own gate (NOT pre-approved with S31a/b — operator "Enig" before edit). + +**TEST (tool-grounded).** Per-claim exact greps over `commands` / `agents` / `references` / `skills`. The +catalog (S31a/S31b SURFACED) was the seed; the plugin-wide sweep surfaced a LARGER new sibling set beyond +it (the 64% / wrong-window-70% / Stage-2 / mobile / retention clusters) — surfaced + operator-approved +into S31c before edit (scope is its own gate; not silently widened). Measured-data / sourced-benchmark +rule applied: a platform-norm % is scrubbed; the user's measured/computed % or a cited benchmark stays. + +**HARDEN (24 edits, 12 files, +26/−26).** Vocabulary 1:1 with S30/S31a/b ("tends to / figure unverified", +"proportion unverified", "multiplier unverified", directional kept + number dropped → SSOT pointer). +- **Catalog (A):** suppression bands `analyze:230-231` (50-70% / 75%+ drop → partial / sharp reach loss), + `troubleshooting:180` (50-70% → partial); follow-probability `growth:58` + `glossary:216` (130% → + follow-graph signal, figure unverified); `report:215` ("per 2026 content format multipliers" → SSOT + pointer); `video-strategy-guide:304` (20-30% feed real estate → proportion unverified); `trajectory:39` + (30-40% follower growth → major driver, proportion unverified); "2.7x pattern interrupts" `glossary:153/198` + + `engagement-frameworks:84` (→ markedly more, multiplier unverified). +- **New siblings (B), operator-approved in:** "64% more follow-up" `engagement-coach:52/81/199/313` + + `engagement-frameworks:350` (5× → tends to earn more, figure unverified); "70% reach" with the WRONG + window (60min/1h) `engagement-coach:75` / `post-feedback-monitor:71` / `glossary:125` / `linkedin-formats:164` + → reconciled to the SSOT window (first 15–30 min decide ~70%; the **~70% KEPT** — SSOT-sourced l.81, high); + Stage-2 distribution `post-feedback-monitor:72` + `glossary:130` (6-10% of connections → small slice, + proportion unverified). +- **Borderlines (C), operator-ruled:** "70% of retention" (video) `video-strategy-guide:80` + + `content-creation/SKILL:235` → disproportionately determines (number dropped); "70% mobile" + `linkedin-formats:147` → the majority. **KEPT C1:** "~45% less engagement" (AI comments) + `engagement-coach:195/455` + `linkedin-formats:176` — already hedged "(correlational, medium + confidence)" + SSOT-tied (l.96); the hedge does the honesty work. +- **Straggler folded in (surfaced, not silent):** "~3% of posts reach save-worthy status" `glossary:216` + (same sentence as the 130% edit) + `growth:59` → "a small fraction". Not in the original map; surfaced + + folded with operator veto open. + +**KEPT INTACT.** Sourced / computed benchmarks: `growth:177-178` (Buffer 178% / 247%, computed from the cited +14,001 vs 5,033 / 381 vs 110), `growth:198` (11% engagement rate, Socialinsider 2024). SSOT `~70%` reach +figure (kept; only the window corrected). `firsthour:4/22` already used the SSOT window → untouched. OUT +(not the class): content-mix %, goal-progress benchmarks, scoring-rubric percentiles, word-count ratios, +example-hook copy ("73% / 84% / 87% / 67% / 40% more"). + +**VERIFY.** Per-claim re-grep of every scrubbed pattern over the FINAL files → 0 residual; SSOT `~70%` remains +only in the 5 reconciled-window lines + SSOT + firsthour. Gate 81/0/0 exit 0, counts 29/19/26 + v0.5.0. +`git diff --stat` = 12 files, +26/−26. Commit local (push held — `open/` public). + +**S31 COMPLETE (a + b + c).** The "Nx" multiplier + descriptive-% platform-norm class is fully reconciled to +SSOT. Hardening queue → post-hardening expert panel (`docs/expert-review/plan.md`) · command testing · GUI. + +--- diff --git a/docs/hardening/plan.md b/docs/hardening/plan.md index 24998c2..1e22f54 100644 --- a/docs/hardening/plan.md +++ b/docs/hardening/plan.md @@ -89,12 +89,55 @@ that exercises the command's real path: | S3 react | S10 headless-review | S17 audit | S24 create | | S4 multiplatform | S11 pivot | S18 ab-test | S25 measure | | S5 carousel | S12 firsthour | S19 strategy | S26 linkedin | -| S6 video | S13 calendar | S20 competitive | | -| S7 batch | S14 import | S21 monetize | | -| S8 pipeline | S15 report | S22 outreach | | +| S6 video | S13 calendar | S20 competitive | S27 ref-consistency A | +| S7 batch | S14 import | S21 monetize | S28 ref-consistency B | +| S8 pipeline | S15 report | S22 outreach | S29 terminology-scrub | +| | | | S30 magnitude-scrub | *S9 newsletter (16-phase) may split into S9a/S9b. Otherwise one command = one session. +**S27/S28 — reference-consistency pass (added 2026-06-19, operator-approved; promotes open +follow-up #1 to scheduled work).** NOT full re-hardening of already-✅ commands — surgical +SSOT-reconciliation of the documented divergences, same tool-grounded discipline +(read-and-show → grep-confirm → re-grep final): + +- **S27 (A) — "penalty/penalizes" framing → SSOT ✅ DONE (2026-06-19, 10 edits/6 files; see `log.md`).** + The 3 cited lines (`troubleshooting-guide.md:127`, `analyze.md:101`, `audit.md:60` — nr drifted ±1) + + 7 same-class siblings (trend-spotter topic-relevance ×2, troubleshooting-guide :15, first-comment-strategy + ×3, url-processing-templates :396) reconciled to "correlate with lower reach" / "topic relevance is a + ranking input — off-topic gets lower reach". Bucket B (link-penalty lines in `linkedin-formats.md`) folded + into S28; Bucket D (other unsourced magnitudes) promoted to S30. +- **S28 (B) — format magnitudes → SSOT ✅ DONE (2026-06-19, 20 edits/2 files; see `log.md`).** + `commands/video.md:69` + `references/linkedin-formats.md` reconciled in one pass: the "30% zero-distribution" + gate (7×) → "completion/dwell is a ranking input, no hard gate"; the 6 S27-folded link-penalty lines → + "correlate with lower reach"; 6 other unsourced format magnitudes (video 5.60% ER, Live 12-24x, polls + 1.64x, 3x shares, Creator-Mode 35%); 1 internal contradiction (`:356` 3-sec = 70% retention). Bucket D + (this file's non-format coefficients) deferred to S30. + +**S29 — terminology scrub: "thought leadership" → "LinkedIn Studio" / neutral (added 2026-06-19, +operator-approved during S26).** The phrase must not appear anywhere in the plugin (it is named LinkedIn +Studio; see auto-memory `no-thought-leadership-phrase`). S26 fixed the 2 occurrences in +`commands/linkedin.md`; **~85 lines across ~45 files remain** (commands, agents, references — incl. the +filename `references/thought-leadership-angles.md` — skills, plus historical CHANGELOG/docs). Open scoping +questions for this pass: (a) user-facing surface only, or include historical CHANGELOG/docs too? (b) the +`thought-leadership-angles.md` rename is its own sub-step — every pointer to the filename must update with +it. Same tool-grounded discipline (read-and-show → grep-confirm → re-grep final); hardening-class. + +**S30 — magnitude scrub: unsourced reach-coefficients → SSOT (surfaced during S27, re-scoped during S28, +2026-06-19).** A *different* class from S27's link/topic-relevance framing: discrete percentage penalties +with **no primary source in the SSOT**. **Incl. `linkedin-formats.md` Bucket-D lines left by S28** +(`:6` 47-50% reach-decline, `:7` 15%→31%, `:176` AI-comment −30%/−55%, `:231`+`:279` hashtags −68%), plus +the other files — `−68%` (5+ hashtags: `linkedin-growth-playbook:166`, `ab-testing-framework:66`), +`−25%` (<1000 chars: `linkedin-growth-playbook:224`), `−15-25%` (topic-gap: `glossary:235`), `−30-50%` +(engagement-bait: `glossary:91`), `55%`/`45%` (AI-comment: `engagement-coach:195`, `firsthour:112` — SSOT +carries ~45% *correlational engagement gap* at medium confidence, not a 55% reach penalty), `−25%/post` +(multiple posts/3h: `post-feedback-monitor:330`), poll-overuse (`poll-strategy-guide:20,:205`). **Leave +intact (officially confirmed, high confidence): engagement-pod + AI-slop "penalized" framing.** Full grep +catalog in `log.md` S27 entry, Bucket D. Same discipline; hardening-class. + +Run after S26; order adjustable (S27 ✅ → S28 → S29 → S30). These edit already-hardened files surgically and +are hardening-class (commit local, no push). + ## End-of-session ritual (every session — STATE.md handoff baked in) 1. `bash scripts/test-runner.sh` → `Failed: 0` + counts 29/19/25/6. diff --git a/docs/hardening/review-R2a.md b/docs/hardening/review-R2a.md new file mode 100644 index 0000000..4a68c4e --- /dev/null +++ b/docs/hardening/review-R2a.md @@ -0,0 +1,207 @@ +--- +type: cold-review +batch: R2a +journey: "Create — atomic emitters" +scope: "FROZEN committed files vs HEAD 9567689 (no pending diff; post-hardening cold pass)" +method: "2 independent cold Opus reviewers per surface (intent + correctness), no cross-feed; every mechanical claim tool-grounded (anti-fabrication mandate); reviewers carry NO drafting-session context" +surfaces: [post, react, carousel, video, multiplatform] +reviewers: + - "intent-lens (conformance: intent delivery + cross-ref resolution + class predicates + terminology)" + - "correctness-lens (internal consistency + bound-vs-canonical + checklist arithmetic + structure)" +status: "COMPLETE — all 5 surfaces reviewed (post, react, carousel, video, multiplatform)" +verdict: REWORK +counts: { BLOCKER: 0, MAJOR_systemic: 1, MAJOR_surface: 1, MINOR: 13, SUGGESTION: 8 } +--- + +# Cold review — R2a (Create · atomic emitters) + +Independent post-hoc cold review of the 5 atomic Create-journey emitters, on the FROZEN committed +files. Mirrors the S1 `review.md` model (the one cold-review method that did **not** fabricate): +read-and-show before assert, every `file:line` tool-confirmed. The per-command interactive gate +(S2–S26, `log.md`) already passed these; this pass adds the **independent** axis that gate never had. + +**Independence cross-check worked:** on every WAVE-1 surface the two blind lenses converged on the +same real defects (personal-stories band in post; "full angle set below" in react; slide-scaffold + +slide-count in carousel) — convergence from two no-cross-feed reviewers is the signal that a finding +is real, not an artifact of one reviewer's framing. + +--- + +## ★ Cross-cutting finding (systemic — spans all 10 content commands) + +### MAJOR (systemic) — `printf '%s' ''` clipboard pattern silently corrupts content containing an apostrophe +- **Pattern (verified by main, independent of reviewers):** `printf '%s' '' | node ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/clipboard-helper.mjs`, followed by an **unconditional** `Then confirm: "Copied to clipboard."` +- **Blast radius — all 10 content-emitting commands** (grep-confirmed): `post.md:153`, `quick.md:162`, `react.md:149`, `carousel.md:211`, `video.md:175`, `multiplatform.md:121`, `pipeline.md:139`, `first-post.md:141`, `firsthour.md:72`, `onboarding.md:221`. +- **Mechanism:** the executing model substitutes the draft into the **single-quoted** bash argument. English LinkedIn drafts almost always contain an apostrophe ("it's", "don't", "here's"); a literal `'` terminates the bash string → printf receives word-split fragments → clipboard gets garbled/truncated text (`printf '%s' 'It's a test'` → `Itsatest`). The command gives **no escaping guidance**. +- **Why it matters (trust-breaking):** the step runs "silently" then **unconditionally** reports success, so on the most common content shape the clipboard is corrupt while the user is told the copy worked. It is the headline convenience feature of every content command. +- **Severity note:** flagged MAJOR (not BLOCKER) — it doesn't crash the session, and a careful executing model *might* escape; but the instruction's default path fails. Independently surfaced by `rev-react-intent` (MAJOR) and `rev-post-intent` (SUGGESTION, "convention-level"). +- **Disposition (NOT fixed here — review finds, operator decides; 10-file change = own go):** switch the convention to a no-in-content-quoting form — write the draft to a temp file and feed via stdin (`node …/clipboard-helper.mjs < "$tmp"`), or a quoted heredoc. Fix once, consistently, across all 10. **Recommend treating this as the first fix that comes out of R2a.** + +--- + +## post.md — VERDICT: REWORK (1 MAJOR · 2 MINOR · 1 SUGGESTION) + +Class: post-emitting (primary) + guided/stateful (8-step). Both lenses confirm intent delivered; +all cross-refs resolve (2 agents, 2 routes, 2 scripts, 7 assets — tool-confirmed); no "thought leadership". + +### MAJOR — Step 3 "Personal stories" band (1,000–1,400) contradicts the file's own Step 5 gate + canonical SSOT (1,200–1,800) +- `post.md:87` — Step 3 assigns "Personal stories | Medium text post (1,000-1,400 chars)" — a third band present nowhere else. +- `post.md:128` — Step 5 quality checklist requires "Character count: 1,200-1,800 (optimal range)" for the same post. +- `hooks/prompts/content-quality-gate.md:17` — canonical: "Standard posts: 1,200-1,800". A personal story is a standard text post (not quick 150–500). +- **impact:** a personal-story draft written to Step 3 at ~1,050 chars passes Step 3 but FAILS the Step 5 checklist and the save-time quality-gate hook. The command self-contradicts. +- **Both lenses flagged this** (rev-post-correct MAJOR, rev-post-intent MINOR). Recorded at the higher severity: it hits a real gate path, not just advisory text. +- **disposition:** raise line 87 floor to 1,200–1,800, OR (if shorter personal stories are intentional) push the sub-band to the canonical SSOT first and reconcile `:128` + `content-quality-gate.md:17` — never leave a divergent band only in this file. + +### MINOR — Step 4 component minimums sum below the 1,200 optimal floor +- `post.md:100-104` — Hook 110-140 + Context 200-300 + Insight 400-800 + Implication 200-300 + CTA 50-100. Minimums sum to **960** (110+200+400+200+50); maximums to 1,640. +- **impact:** following every section at its minimum yields a 960-char post, below the 1,200 floor asserted at `:7/:86/:128` and canonical `:17`. Loose guidance, not a hard gate, but a writer hugging the low end lands under-length. +- **disposition:** accept as-is, or lift Insight/Context minimums so the component floor reaches ~1,200. + +### SUGGESTION — clipboard apostrophe breakage → see ★ cross-cutting finding (`post.md:153`). + +--- + +## react.md — VERDICT: REWORK (1 MAJOR · 3 MINOR · 1 SUGGESTION) + +Class: post-emitting + graceful-degradation on bad/empty URL (delivered, `react.md:60,194`). Intent +(URL→post pipeline) delivered; all 7 cross-refs resolve; no "thought leadership". + +### MAJOR — clipboard apostrophe breakage → see ★ cross-cutting finding (`react.md:148-151`). +(Originally surfaced here by rev-react-intent; promoted to the cross-cutting section.) + +### MINOR — `/linkedin:summarize` trigger has no backing command +- `react.md:9` — the frontmatter description lists "/linkedin:summarize" among triggers. `ls commands/summarize.md` → does not exist; not among the 29 commands. +- **impact:** advertises a slash-command alias that resolves to nothing; a user typing it gets no command. Dead/aspirational trigger. +- **disposition:** remove `/linkedin:summarize` from the trigger list, or add a summarize alias command. + +### MINOR — "the full angle set below" is a dead in-file locator +- `react.md:95` — "present 2-3 alternatives from **the full angle set below**." No enumerated full angle set appears below this line; Step 4's table (`:81-86`) lists only 4 preferred + 4 fallback; the 8 universal angles live in `references/content-angles.md` (`:273`), not "below." +- **Both lenses flagged this** (rev-react-intent + rev-react-correct). +- **impact:** dangling locator on the "try a different angle" path; the model must guess "below" means the reference file. +- **disposition:** change "the full angle set below" → "the 8 universal angles in `references/content-angles.md`". + +### MINOR — "medium post" label diverges from canonical "standard" band +- `react.md:119` — "Character target: 1,200-1,800 chars (medium post)". The **number is correct** (matches canonical standard band `content-quality-gate.md:17` + CLAUDE.md rule 2), but canonical/CLAUDE.md label it "standard"; no "medium" tier is defined anywhere. +- **impact:** cosmetic; the active length gate is unaffected. Risk is reader confusion that a separate "medium" tier exists. +- **disposition:** accept as-is, or relabel "(standard post)". + +--- + +## carousel.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 5 MINOR · 4 SUGGESTION) + +Class: guided/stateful (content-emitting). Intent (5–8 slide deck + caption, optional image render, +text-only degradation) delivered; all cross-refs resolve (differentiation-checker, templates, +algorithm-signals, clipboard + state scripts, mcp-image params valid); hook bound `:97` matches SSOT; +no "thought leadership". Clean on all blocking dimensions — findings are polish. + +### MINOR — slide-count minimum stated two ways (6 vs 5) +- `carousel.md:41-45` — Step 1 offers all 5 templates as "(6-8 slides)"; `carousel.md:114` — Step 5 gate checks "5-8 slides total (7 is optimal)". Minimum disagrees (6 vs 5). +- **Both lenses flagged this.** Mirrors the upstream split in `assets/templates/carousel-templates.md:11` ("5-8") vs per-template headers ("6-8"). +- **impact:** a 5-slide deck passes Step 5 but was never offered in Step 1. Cosmetic guidance, not a hard break. +- **disposition:** align the floor (pick 5 or 6 across Step 1 + Step 5); ideally fix the source too. + +### MINOR — inlined Step 5 checklist drops an item vs the source it cites (7 vs 8) +- `carousel.md:109` says "Run against the Carousel Quality Checklist from carousel-templates.md", then inlines 7 items (`:111-117`, `grep -c` = 7). The cited source has 8 (`carousel-templates.md:276-283`); the dropped one is `:283` "Exported as PDF, under 100 MB". +- **impact:** the export/size check only resurfaces in Step 6's text-only branch (`:184`); when image generation succeeds, the PDF/100 MB constraint is never surfaced in the gate. +- **disposition:** add the PDF/under-100 MB item to the Step 5 list, or stop claiming verbatim fidelity to the source. + +### MINOR — slide body scaffold provides 5 line-slots but the rule permits up to 7 +- `carousel.md:73-77` models 5 BODY lines (4-5 optional); `carousel.md:87` + `carousel-templates.md:10` permit "5-7 lines". +- **Both lenses flagged this.** +- **impact:** a slide legitimately needing 6-7 lines has no scaffold slot; the literal template caps generation at 5. +- **disposition:** extend the scaffold to 7 optional lines, or tighten the rule/template to "max 5". + +### MINOR — caption voice-guardian safety-net claim doesn't engage in this flow +- `carousel.md:123` — "(The voice-guardian hook scores the caption on save.)" The PreToolUse gate fires only on Write|Edit of LinkedIn content, but this command never Writes the caption — Step 6 pipes it to clipboard (`:210-212`), Step 7 mutates state via `node -e` (`:222-231`). No save → hook never scores the caption. +- **impact:** overstates a backstop that doesn't fire here; could justify under-doing the in-command De-AI check (Step 5). +- **disposition:** drop the parenthetical or qualify it ("only if you later save the caption to a file"). + +### MINOR — no-external-link rule (Content Quality Rule #3) absent from caption guidance +- `carousel.md:93-105, 121-125` — the caption is feed text on the same reach mechanics, but neither Step 4 nor the De-AI gate mentions the no-body-link rule. +- **impact:** a caption with an inline link incurs the documented reach penalty with nothing in this surface catching it. +- **disposition:** add "no links in the caption body (put links in first comment)" to the De-AI gate or Step 4. + +### SUGGESTION — orphan provenance comment for an unused capability +- `carousel.md:18` — ``. Mermaid is never referenced anywhere in the file (only mcp-image is used). +- **disposition:** remove the vestigial marker (or move the note to a design doc). + +### SUGGESTION — dual slide-count framing (6-8 vs 5-8); locally-defined per-slide bounds (header "max 8 words" `:70`, body "max 50 chars" `:73-77`, no canonical SSOT — no overlap with post bounds, noted for completeness); buzzword list 8 words `:123` faithful to CLAUDE.md Rule #4 while canonical hook flags 10 (gap lives between CLAUDE.md + hook, not in this file). + +--- + +## video.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 3 MINOR · 1 SUGGESTION) + +Class: guided/stateful (8-step script build) + post-emitting sub-surface (the 200–400 char caption). +Intent (paced 30s/60s/90s/2min video script + on-video captions + thumbnail + post caption + first +comment, delegated to `video-scripter`) delivered; `video-scripter` + `differentiation-checker` +resolve; word-budget math internally consistent (30/60/90/120s × 2.5 wps = 75/150/225/300, `:64-67`); +no "thought leadership". + +### MINOR — muted-watch statistic stated two ways +- `video.md:100` "~85% watch without sound" vs `video.md:120` "~80–85% watch muted" — same claim, two figures in one frozen file. +- **Both lenses flagged this.** 85% sits inside 80–85% so not a hard contradiction, but reads as unreviewed precision in a quality-gate checklist. +- **disposition:** pick one figure (recommend "~80–85%") in both places. + +### MINOR — post caption (200–400) is a third length band vs canonical quick (150–500) +- `video.md:121,158` specify "200-400 chars"; `content-quality-gate.md:18` quick = 150–500. 200–400 is a narrower subset (no hard conflict) but a band not present in the SSOT. +- **impact:** a reader can't tell from video.md whether 200–400 is intentional or drift. +- **disposition:** accept if intentional (captions deliberately shorter), but add a one-line note that 200–400 is a deliberate sub-band of the 150–500 quick range. + +### MINOR — caption has no mobile-cutoff / first-line hook discipline +- `video.md:122,129,156-158` — the caption (feed text, truncated at the same "...see more" cutoff as any post) gets a length band + De-AI + no-body-link + buzzword strip, but NO instruction to front-load value within the ~110–140 cutoff. (`:101` "first line reads on-screen" is the muted-autoplay test on the VIDEO's on-screen text, not the feed caption.) +- **impact:** vs text-post parity (SSOT hook 110–140) the caption's truncation is unguarded; a buried lede underperforms in-feed. +- **disposition:** defensible to accept (video is primary content), or add "caption's first line should land value before the mobile cutoff". + +### SUGGESTION — clipboard apostrophe breakage → see ★ cross-cutting finding (`video.md:175`). + +--- + +## multiplatform.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 3 SUGGESTION) + +Class: routing + guided/stateful (content-adaptation). **post-emitting predicate does NOT apply** — +this command consumes a LinkedIn post and emits adaptations for OTHER platforms (Twitter/slides/ +YouTube), so the LinkedIn quality-gate (hook 110–140 / length band / no-body-link / topic→pillars) is +out of scope; the buzzword check IS carried (`:46-48`, mirrors CLAUDE.md rule #4 exactly). Intent +delivered (3 promised platforms = 3 AskUserQuestion options = 3 templates); routing resolves +(`/linkedin:newsletter` at `:6,:34,:36,:132` → `commands/newsletter.md` exists); no subagent refs; +graceful degradation present (`:27-29`, forbids fabricating source); no "thought leadership". + +### MINOR — clipboard apostrophe breakage → see ★ cross-cutting finding (`multiplatform.md:121`) +- Elevated exposure noted: Twitter threads + YouTube CTAs are contraction-heavy (don't/it's/I'll), so this surface is *more* exposed to the systemic bug than most. + +### SUGGESTION — Twitter "280 chars max" is locally-defined (no LinkedIn SSOT) +- `multiplatform.md:53` — the only numeric bound in the file; an X/Twitter limit, correct value, cannot diverge from the LinkedIn SSOT. Noted for completeness. + +### SUGGESTION — "write once, publish everywhere" tagline overstates the command +- `multiplatform.md:4-5` — the tagline implies publishing; the command only adapts + saves to drafts (`:118`) + copies to clipboard. No publish action exists. +- **disposition:** accept, or soften to "adapt once, post everywhere" / "draft for every platform". + +### SUGGESTION — Step 1 always asks platform even when the trigger already names it +- `multiplatform.md:38-41` unconditionally invokes AskUserQuestion, yet triggers include "adapt for twitter"/"turn into thread" (`:7`) that already pin the platform — against the commands-section principle to minimize interactive steps / infer from context. +- **disposition:** accept, or "skip if the platform is already evident from the user's request". + +--- + +## Gate decision — R2a COMPLETE (5 surfaces) + +| Surface | Verdict | BLOCKER | MAJOR | MINOR | SUGGESTION | +|---|---|---|---|---|---| +| post | REWORK | 0 | 1 (+systemic) | 1 | — | +| react | REWORK | 0 | (systemic) | 3 | — | +| carousel | ALLOW | 0 | 0 | 5 | 4 | +| video | ALLOW | 0 | 0 | 3 | 1 | +| multiplatform | ALLOW | 0 | 0 | 1 | 3 | +| **★ cross-cutting** | — | 0 | **1 (systemic, 10 files)** | — | — | + +**Batch verdict: REWORK** — 2 of 5 surfaces (post, react), driven by **2 distinct MAJORs**: +(1) the systemic clipboard-`printf` corruption (10 content commands), and (2) post's personal-stories +band contradicting its own Step 5 gate + the canonical SSOT. **0 BLOCKER anywhere.** carousel / video / +multiplatform are ALLOW (polish only). + +**Independence verdict:** every WAVE-1 REWORK/MINOR was independently surfaced by **both** blind lenses +(clipboard, personal-stories band, "full angle set below", slide-scaffold, slide-count, muted-stat) — +the convergence signal that these are real defects, not single-reviewer framing. + +Cold review **finds**; it changes no code. Each fix is its own operator-gated decision. Recommended +first fix out of R2a: the systemic clipboard pattern (one change, 10 files, highest blast radius). +Local-only (hardening-class), not pushed. diff --git a/docs/hardening/review-R2b.md b/docs/hardening/review-R2b.md new file mode 100644 index 0000000..d79b954 --- /dev/null +++ b/docs/hardening/review-R2b.md @@ -0,0 +1,281 @@ +--- +type: cold-review +batch: R2b +journey: "Create — orchestrators & front-door" +scope: "FROZEN committed files at HEAD 5474df5 (clean tree; post-hardening cold pass)" +method: "2 independent cold Opus reviewers per surface (intent + correctness), no cross-feed; every mechanical claim tool-grounded (anti-fabrication mandate); reviewers carry NO drafting-session context" +surfaces: [create, batch, pipeline, newsletter] +reviewers: + - "intent-lens (conformance: intent delivery + cross-ref resolution + class predicates + terminology)" + - "correctness-lens (internal consistency + bound-vs-canonical + checklist arithmetic + structure)" +status: "COMPLETE — all 4 surfaces reviewed (create, batch, pipeline, newsletter)" +verdict: REWORK +counts: { BLOCKER: 0, MAJOR: 3, MINOR: 10, SUGGESTION: 6, systemic_patterns: 2 } +--- + +# Cold review — R2b (Create · orchestrators & front-door) + +Independent post-hoc cold review of the 4 Create-journey orchestrators/front-door, on the FROZEN +committed files (HEAD `5474df5`). Mirrors the S1 `review.md` + R2a model (the cold-review method that +did **not** fabricate): read-and-show before assert, every `file:line` tool-confirmed, reviewers carry +no drafting-session context. The per-command interactive gate (`log.md`) already passed these; this +pass adds the **independent** axis that gate never had. + +**Independence cross-check — two outcomes worth noting this batch:** +1. **Convergence** (the R2a pattern): both blind lenses independently surfaced the same real defect on + several surfaces — batch's bare-vs-prefixed reference path, batch's 3a/3b component-band tension, + newsletter's `allowed-tools` omission, create's 8-option `AskUserQuestion`. +2. **Divergence resolved by grounding** (new this batch, the strongest argument FOR the two-lens + method): on `newsletter` the intent-lens **asserted** the resumption table "maps every currentPhase + to the correct resume step"; the correctness-lens **counted** the rows (17) against the canonical + `_doc.phases` (18) and found the `contract-gate` row missing. Main re-grounded it independently + (below) → the correctness-lens is right. The independence axis caught a real MAJOR that one lens had + asserted away. + +--- + +## ★ Cross-cutting finding #1 (systemic) — 5-component draft scaffold (960–1,640) cannot satisfy the 1,200–1,800 standard band it is gated against + +- **Pattern (verified by main, independent of reviewers — `grep` blast radius):** the identical + 5-component "standard post" breakdown — `Hook 110-140` + `Context 200-300` + `Insight 400-800` + + `Implication 200-300` + `CTA 50-100` — appears in **exactly 3 files**: `post.md:100-104`, + `batch.md:88-92`, `pipeline.md:58-62`. (`grep -rln "Insight.*400" commands/` → those three only; + `quick`/`first-post`/`react` carry the lone `Hook 110-140` line, NOT the full scaffold, so are + unaffected.) +- **Grounded arithmetic:** component **min-sum = 110+200+400+200+50 = 960** · **max-sum = + 140+300+800+300+100 = 1,640**. Each file then gates the SAME post against the canonical standard band + **1,200–1,800** (`content-quality-gate.md:17`; `post.md:128`, `batch.md:95`, `pipeline.md:77`). +- **Mechanism (two-sided):** (a) a draft built at the component minimums is **960 chars — 240 below** + the 1,200 floor enforced by the next step AND the live PreToolUse `content-quality-gate` hook; + (b) the component maximums sum to **1,640 — 160 below** the 1,800 ceiling, so the scaffold literally + cannot reach the upper half of its own target band. +- **Why it matters:** on the most common path (drafting a standard text post) the drafting recipe and + the acceptance test are mutually incompatible, with no transition/connective slack to close the + 960→1,200 gap. In `batch` the defect is sharper: the 3a scaffold is **format-blind** (always the + 5-component standard layout) while the 3b gate is **format-aware** (`batch.md:114` `format: + [text/carousel/video]`, rotation `:76`), so a `quick`-format post built from the scaffold (960–1,640) + also blows the quick band (150–500). In `pipeline` the contradiction is between two **adjacent** + steps (Step 2 draft → Step 3 scorecard). +- **Severity:** recorded **MAJOR** in `batch` and `pipeline` (real adjacent-step / gate contradiction + on every standard post). **Reconciliation note for R2a:** R2a recorded the same scaffold in `post.md` + at **MINOR** ("component minimums sum below the 1,200 optimal floor") and flagged only the min-side. + The batch/pipeline analysis shows it is a two-sided, gate-hitting contradiction, not merely loose + guidance — the post.md instance is arguably under-rated and should be reconciled in the same fix. +- **Disposition (NOT fixed here — review finds, operator decides; 3-file change = own go):** one + consolidated fix across all 3 files — raise the component floors so min-sum ≥ 1,200 (e.g. widen + Context/Insight) AND either lift the ceiling or accept ~1,640, AND scope the standard 5-component + scaffold explicitly to standard-format posts where the command is format-aware (`batch`), adding + per-format draft guidance for quick/carousel/video. **Recommend treating this as the second + consolidated fix out of the sweep, after the R2a clipboard fix.** + +## ★ Cross-cutting finding #2 (recurring) — bare relative reference paths vs `${CLAUDE_PLUGIN_ROOT}/` + +- **Pattern:** several `Read`/reference paths are written **bare** (resolved against the runtime cwd) + while the same file's appendix and most other sites prefix `${CLAUDE_PLUGIN_ROOT}/`. `batch.md:43` + (`references/content-angles.md`) vs `batch.md:206` (prefixed, same file) — flagged by **both** batch + lenses. `pipeline.md:31,55,70,75` bare vs `pipeline.md:28,64,107` + appendix `:204-209` prefixed — + `content-angles.md` is bare at `:55` but prefixed at `:204`. +- **Impact:** the files exist (not dead refs), but a bare path fails the `Read` when cwd ≠ plugin root, + on real paths run every invocation (angle-select, optimize). Self-recoverable via Glob, latent. +- **Disposition:** normalize all bare reference/asset paths to `${CLAUDE_PLUGIN_ROOT}/`. Cheap, + mechanical; fold into the consolidated fix pass. (Worth a repo-wide grep for the same pattern in the + other 25 commands during the eventual fix.) + +--- + +## create.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 0 SUGGESTION) + +Class: **routing** (pure delegating front-door). Both lenses confirm intent delivered: Step 0 context → +Step 1 intent-ID → Step 2 route, with explicit "you do not draft here / do NOT inline the target's +steps" (`create.md:49-51,64-65`) — delegation purity intact, single source of truth preserved. All 8 +routed targets resolve (`post/quick/react/carousel/video/multiplatform/batch/newsletter`, `:37-44` + +`:55-62`, `ls`-confirmed); the three enumerations (description `:7`, menu `:37-44`, route table +`:55-62`) are mutually consistent (8/8/8, same order); no `subagent_type` refs (correct — it routes to +commands); no "thought leadership". Correctness-lens: **0 findings**. + +### MINOR — Step 1 directs one `AskUserQuestion` carrying 8 options; documented support is 2–4 +- `create.md:35-44` — "use `AskUserQuestion`" immediately followed by 8 numbered options (`grep -cE + "^[0-9]+\. \*\*"` → 8). Grounded against the plugin-dev reference + `command-development/.../interactive-commands.md:469` ("2-4 options per question") + `:906`. +- **impact:** on the PRIMARY interactive path (user names no format) the front-door instructs a single + question with double the documented option range. +- **anti-fabrication caveat (carried from the reviewer, honestly):** grounded = (a) 8 options + instructed, (b) the documented 2–4 range. NOT grounded = whether the live `AskUserQuestion` runtime + hard-rejects >4 vs silently truncates/degrades. **If the runtime hard-rejects, this escalates to + MAJOR/BLOCKER** on the no-format-named path; if it only degrades, the picker is over-long. Worth a + runtime check before the fix. +- **disposition:** group the 8 intents into ≤4 options (e.g. Short-form / Reaction / Visual / Long-form + & batch) with a drill-down, or split into two questions. + +--- + +## batch.md — VERDICT: REWORK (0 BLOCKER · 1 MAJOR · 3 MINOR · 2 SUGGESTION) + +Class: **guided/stateful + routing** ("create a full week of content"). Intent delivered: the Step 0→5 +flow traces the frontmatter promise; all cross-refs resolve (`trend-spotter`, `content-planner` → +`agents/`; `/linkedin:calendar` → `commands/`; `queue-manager.mjs` + `ical-generator.mjs` exports + +the 8-arg `queueAdd` call/signature match; all 6 reference/asset paths + `SKILL.md`); graceful +degradation present; no "thought leadership". + +### MAJOR — Step 3a component scaffold contradicts the Step 3b band gate → see ★ cross-cutting #1 (`batch.md:88-92` vs `:95`) +Sharper here than elsewhere: 3a is **format-blind** (always the 5-component standard layout, 960–1,640) +while 3b is **format-aware** (`:114` `format:[text/carousel/video]`, rotation `:76`), so a `quick`-format +post built from 3a also blows the quick band 150–500. **Both lenses flagged this** (intent-lens MINOR, +correctness-lens MAJOR — recorded at the higher severity: it hits a real gate on every standard post). + +### MINOR — bare reference path → see ★ cross-cutting #2 (`batch.md:43` vs `:206`). Both lenses. + +### MINOR — `weekly_goal` cadence decoupled from the fixed "3–5 posts" headline +- `batch.md:5,65` fix the output at "3–5 posts"; `batch.md:52` schedules against `weekly_goal` slot + templates (2x/3x/4x/5x). At `weekly_goal=2x`, 3–5 posts against 2 weekly slots overflow into the next + week (`:53` "next available slot after today") — ~2.5 weeks of content under a "full week" label. +- **impact:** non-breaking (scheduling rolls forward), but "full week" + "3–5" is internally + inconsistent with the 2x cadence. +- **disposition:** tie post count to `weekly_goal`, or note that overflow rolls into following weeks. + +### MINOR — orphan sub-step marker `5b` with no `5a` +- `batch.md:172` `### 5b. Generate Calendar File`; `grep -n "5a" batch.md` → no match (exit 1). Step 3 + has 3a/3b/3c/3d; Step 5 jumps straight to 5b. +- **disposition:** renumber to `5a`, or drop the letter. + +### SUGGESTION — `weekly_goal` default (3x) lives only in the referenced `scheduling-strategy.md:15`, never stated in `batch.md`. Optional one-line "default 3x" for self-evident degradation. + +### SUGGESTION — `planned_date` metadata never computed +- `batch.md:109` writes `planned_date: YYYY-MM-DD` into each draft header, but Step 2 only computes + `scheduled_date`/`scheduled_time` (`:54`); `planned_date` is introduced nowhere upstream. +- **disposition:** drop `planned_date`, or define where it is derived. + +--- + +## pipeline.md — VERDICT: REWORK (0 BLOCKER · 1 MAJOR · 4 MINOR · 2 SUGGESTION + clipboard pointer) + +Class: **post-emitting + guided/stateful + routing** ("full end-to-end pipeline"). Intent delivered: +Steps 0–8 map to every named stage. All post-emitting predicates present (hook 110–140 `:58,:76` · +length band `:77` · no-body-link `:78` · buzzword check `:79` · topic→expertise `:49,:55,:80`); all +cross-refs + function signatures resolve (`content-planner`, `trend-spotter`; `/linkedin:calendar`, +`/linkedin:analyze`; `queueAdd` 8-arg call/signature; `writeState`/`updatePostTracking`); no "thought +leadership". + +### MAJOR — Step 2 component scaffold cannot satisfy the Step 3 total-length gate → see ★ cross-cutting #1 (`pipeline.md:58-62` vs `:77`) +Adjacent-step contradiction: Step 2 partitions into 960–1,640; the very next step's scorecard asserts +"Total 1,200–1,800". Correctness-lens, grounded arithmetic. + +### MINOR — inline buzzword checklist enumerates 8, canonical gate enumerates 10 +- `pipeline.md:79` lists 8 terms (= CLAUDE.md rule 4); SSOT `content-quality-gate.md:13` adds + 'actionable insights' + 'best practices' = 10. A draft passing the inline list can still trip the + Write hook. (Gate-vs-rule divergence, not unique to this file — also noted on carousel in R2a.) +- **disposition:** align to the 10-term canonical list, or reference the gate instead of duplicating. + +### MINOR — bare reference paths → see ★ cross-cutting #2 (`pipeline.md:31,55,70,75`). Intent-lens. + +### MINOR — Step 4 deferred/queued path falls through into the immediate Publish steps +- `pipeline.md:97-101` offers "Schedule / Add to queue / Save as draft (no schedule)"; Steps 5–8 + (`:120` "15-20 min BEFORE posting", `:135` Publish, `:158` first-hour, `:171` post-analysis) then run + with **no branch**. A user who queued/deferred is marched through Pre-Engagement → Publish → + Monitoring, contradicting the just-made defer decision. +- **disposition:** add an early-exit after Step 4 for options 2–4 ("if scheduled/queued, end here; Steps + 5–8 run at publish time"). + +### MINOR — Step 7 inlines a static first-hour checklist instead of routing to the stateful surface +- `pipeline.md:158-169` inlines a 5-item plan; the dedicated `/linkedin:firsthour` delegates to + `engagement-coach`, persists via `recordFirstHourPlan`, hands off to `post-feedback-monitor` — strictly + richer (Step 8 already routes to `/linkedin:analyze`, so the inline first-hour is the inconsistent one). +- **disposition:** route to `/linkedin:firsthour`. + +### SUGGESTION — over-provisioned `allowed-tools`: `:13` declares `WebFetch` but no body step fetches (trend-spotter does its own). Drop unless a URL-ingest step is intended. + +### SUGGESTION — hardcoded Norwegian peak times: `:92-95` bakes "European/Norwegian audience" peak windows into the body while `scheduling-strategy.md` (read at `:107`) is the SSOT for slots; conflicts with the domain/audience-general principle. Source from the reference/config. + +### (pointer) clipboard `printf '%s'` systemic bug — `pipeline.md:139` confirmed present (the only R2b surface in the 10-content-command set). Folds into the R2a ★ cross-cutting clipboard finding; no new derivation. + +--- + +## newsletter.md — VERDICT: REWORK (0 BLOCKER · 1 MAJOR · 2 MINOR · 2 SUGGESTION) + +Class: **guided/stateful + routing + heavy subagent orchestration** (long-form 18-phase pipeline, +~110 KB). Intent delivered: all 18 phases present, ordered, `[GATE]`/`[OPERATØR]`-marked. **18-phase +count confirmed by both lenses** (`0,1,1.5,2,2.5,3a,3b,4,4.5,5,5.5,6,6.5,7,7.5,8,9,10`; headline `:25` +matches body + template `_doc.phases` + build-status). All 7 longform agents (fact-checker, +editorial-reviewer, persona-reviewer, voice-scrubber, content-reviewer, language-reviewer, fact-reviewer) +present in `agents/` AND invoked; gate sequence ordered before lock (`:1570`): skeleton 2.5 → spine 3a → +fact-check 5 → editorial 5.5 → persona 6 → headless 6.5 → visual 7.5 → LOCK 8 → hook 9. All `subagent_type` +carry the `linkedin-studio:` namespace (the 5 prefix-less grep hits are line-wraps). All ~25 cross-refs +(agents, commands, scripts, configs, render, docs) resolve. Pivot heuristic, flag caps, step-label +5.5/6.5 consistency all clean. No "thought leadership". + +### MAJOR — deterministic resumption table omits the contract-gate phase (Step 4.5) → breaks resume between Step 4 and Step 5 +- **Verified by main (independent re-grounding of a lens disagreement):** the resumption table + `newsletter.md:209-228` has **no `contract-gate` row** (`grep contract-gate` over the table region → + none). The canonical `_doc.phases` it claims to mirror (`:230-231`) **does** define it — + `config/edition-state.template.json` lists `"contract-gate — … (Step 4.5)"` between + `consistency-quality` (Step 4) and `factcheck-sweep` (Step 5). Step 4.5 actually writes it: + `newsletter.md:988` "Set `currentPhase: "contract-gate"`". +- **Two concrete breakages on the multi-session resume path (the file's core premise, `:200-204`):** + 1. **Gate skipped on resume.** The rule (`:203-204`) is "run the step AFTER the recorded phase." Row + `:219` maps `consistency-quality → Step 5` (Fact-check), but the step after Step 4 is Step 4.5 + (contract-gate), not Step 5. A session aborting after Step 4 resumes **past** the deterministic + contract-gate, never running it. + 2. **Unrecognized phase on resume.** A session aborting after Step 4.5 has `currentPhase: + "contract-gate"`, absent from the table → falls into the `:232-234` fallback ("missing or + unrecognized → do NOT guess … confirm with the operator"), defeating the deterministic-resumption + guarantee the section is built on. +- **Note:** the linear next-pointers are correct (`:918` "next: contract-gate", `:989` "next: + fact-check"); only the resume **table** is short one row — the defect surfaces solely on abort/resume + between Step 4 and Step 5. +- **Independence note:** the intent-lens asserted this table "maps every currentPhase to the correct + resume step"; the correctness-lens counted (17 rows vs 18 phases) and found the gap. Main confirmed + the correctness-lens. Two-lens method earned its keep here. +- **disposition:** insert a `contract-gate → Step 5 — Fact-check sweep` row, and repoint + `consistency-quality → Step 4.5 — Contract-gate`. + +### MINOR — Step 1 says the brief is first persisted "in Step 2"; the rest of the file says Step 1.5 +- `newsletter.md:303` "Record the resolved brief inline (you will persist it to edition-state in **Step + 2**)" contradicts `:287-289`, `:412-418`, `:494-496` (all: first durable write is the **Step 1.5** + checkpoint). Stale "Step 2" — almost certainly predates the Fix #2 Step 1.5 insertion; non-breaking + (Step 1 only records inline either way). +- **disposition:** change `:303` "in Step 2" → "at the Step 1.5 checkpoint". + +### MINOR — `allowed-tools` omits `SendUserFile` (body-primary operator gate) + `mcp__mcp-image__generate_image` (default image route) +- `newsletter.md:11-19` declares `Read, Glob, Grep, WebFetch, Bash, AskUserQuestion, Task, Write`. The + body names `SendUserFile` as the **primary** operator gate at Steps 5.5/6.5/7.5 (13 uses, e.g. `:1138`) + and mcp-image as the **default** image route (`:1471`); neither is declared. **Both lenses flagged + this** (intent-lens MINOR, correctness-lens SUGGESTION — recorded at the higher: the declared "default" + path can't execute under the frontmatter as written). Every use guards with a fallback ("`SendUserFile` + if available, else a markdown `file://` link"), so it degrades gracefully → not load-bearing. +- **disposition:** add `SendUserFile` (+ optionally mcp-image) to `allowed-tools`, or downgrade the body + wording from "default/primary" to "if permitted". + +### SUGGESTION — undefined "LTL plugin" acronym: `newsletter.md:36,725` ("the LTL plugin" / "the LTL rule"); repo-wide the bare phrase appears only here, no expansion; the plugin is canonically "LinkedIn Studio". (The env vars `LTL_SERIES_ROOT`/`LTL_BRAND` `:48,154-156` ARE a legit convention consumed by `render/build-*.mjs` — not a defect.) Rename to "LinkedIn Studio plugin", or define once. + +### SUGGESTION — "leveraged" in doc prose (`newsletter.md:1785`, note-only): ordinary verb in the command's own explanatory prose, not generated post content; CLAUDE.md rule 4 targets generated posts. Not a real violation; optionally swap to "drew on / built on". + +--- + +## Gate decision — R2b COMPLETE (4 surfaces) + +| Surface | Verdict | BLOCKER | MAJOR | MINOR | SUGGESTION | +|---|---|---|---|---|---| +| create | ALLOW | 0 | 0 | 1 | 0 | +| batch | REWORK | 0 | 1 | 3 | 2 | +| pipeline | REWORK | 0 | 1 | 4 | 2 | +| newsletter | REWORK | 0 | 1 | 2 | 2 | +| **★ cross-cutting #1** (scaffold, 3 files) | — | 0 | (counted in batch + pipeline; spans post.md from R2a) | — | — | +| **★ cross-cutting #2** (bare paths) | — | 0 | 0 | (counted in batch + pipeline) | — | + +**Batch verdict: REWORK** — 3 of 4 surfaces (batch, pipeline, newsletter), each with **1 MAJOR**: +(1) the systemic 5-component scaffold contradicting the 1,200–1,800 band (batch + pipeline; spans +post.md from R2a), and (2) newsletter's resumption table missing the contract-gate phase. **0 BLOCKER +anywhere.** `create` is ALLOW (one option-count MINOR with a runtime caveat). + +**Independence verdict:** convergence on batch path-prefix / batch 3a-3b band / newsletter allowed-tools +/ create 8-option (both lenses) — plus one **divergence resolved by main's grounding** (newsletter +resumption table: intent-lens asserted complete, correctness-lens counted the gap, main confirmed). Both +the convergence and the resolved divergence are signals these are real defects, not single-reviewer +framing. + +**Systemic findings now span R2a+R2b:** clipboard `printf` (R2a, 10 files) · component scaffold (R2b, 3 +files incl. post.md from R2a) · bare reference paths (R2b, 2 files, worth a repo-wide grep). Cold review +**finds**; it changes no code. Each fix is its own operator-gated decision. Recommended consolidated-fix +order out of the sweep so far: (1) clipboard `printf` [R2a, 10 files, highest blast radius], (2) +component scaffold [3 files], (3) bare reference paths [grep-driven], then the per-surface items. +Local-only (hardening-class), not pushed. diff --git a/docs/hardening/review-R3.md b/docs/hardening/review-R3.md new file mode 100644 index 0000000..203f2aa --- /dev/null +++ b/docs/hardening/review-R3.md @@ -0,0 +1,218 @@ +--- +type: cold-review +batch: R3 +journey: "Engage — post-publish & longform-support surfaces" +scope: "FROZEN committed files at HEAD 2b70660 (clean tree; post-hardening cold pass)" +method: "2 independent cold Opus reviewers for the round (intent + correctness), each covering all 4 surfaces, no cross-feed; every mechanical claim tool-grounded (anti-fabrication mandate); reviewers carry NO drafting-session context" +surfaces: [firsthour, calendar, headless-review, pivot] +reviewers: + - "intent-lens (conformance: intent delivery + cross-ref resolution + class predicates + graceful degradation + terminology)" + - "correctness-lens (internal consistency + bound-vs-canonical + checklist/phase arithmetic + allowed-tools completeness)" +status: "COMPLETE — all 4 surfaces reviewed (firsthour, calendar, headless-review, pivot)" +verdict: REWORK +counts: { BLOCKER: 0, MAJOR: 1, MINOR: 3, SUGGESTION: 5 } +--- + +# Cold review — R3 (Engage · post-publish & longform-support) + +Independent post-hoc cold review of the 4 Engage-journey surfaces, on the FROZEN committed files +(HEAD `2b70660`). Mirrors the S1 `review.md` + R2a + R2b model (the cold-review method that did **not** +fabricate): read-and-show before assert, every `file:line` tool-confirmed, reviewers carry no +drafting-session context. The per-command interactive gate (`log.md`) already passed these; this pass +adds the **independent** axis that gate never had. + +**Independence cross-check — both outcomes recurred this batch:** +1. **Convergence:** both blind lenses independently surfaced the same real defect on `headless-review` + (`SendUserFile` invoked on the primary surfacing path but absent from `allowed-tools`). +2. **Divergence resolved by grounding** (the strongest argument FOR the two-lens method, recurring from + R2b's newsletter): on `calendar` the intent-lens flagged a **MAJOR** (the publish/reschedule/cancel + actions key off `id`/`draft_path`/`character_count` that the queue load never surfaces), while the + correctness-lens passed the surface as ALLOW — its structural pass found the step/option arithmetic + reconciled but did **not** trace the data-flow from load → display → action placeholders. Main + re-grounded `queueFormatSummary`'s actual output independently (below) → the intent-lens is right. The + independence axis caught a real MAJOR one lens never probed. + +--- + +## Connections to existing systemic findings (no NEW ★ cross-cutting this batch) + +R3 surfaces **connect to** the two systemic patterns already recorded in R2a/R2b rather than adding new +ones. Both connections were re-grounded by main on the R3 files: + +- **★ cross-cutting #1 (clipboard `printf '%s'`, R2a, 10 files) — firsthour confirmed present.** + `firsthour.md:72` `printf '%s' '' | node …/clipboard-helper.mjs` + `:75` the + unconditional "Copied your draft comments to clipboard." This is the exact systemic pattern: a + single-quoted shell string corrupts any draft text containing an apostrophe (`it's`, `don't` — common + in natural comment copy), and the "Copied" confirmation is unconditional. firsthour is one of the 10 + files STATE already lists; **no new derivation — folds into the R2a ★ #1 consolidated fix.** +- **★ cross-cutting #2 (bare relative reference paths vs `${CLAUDE_PLUGIN_ROOT}/`, R2b) — firsthour adds + 3 sites.** `firsthour.md:110` (prose parenthetical), `:118`, `:119` (Reference-Files pointer list) are + bare `references/…` while the same file's **executable** blocks correctly prefix `${CLAUDE_PLUGIN_ROOT}/` + (`:72`, `:84`) and sibling commands prefix their Reference-Files lists too (`calendar.md:206-207`). + **Lower impact than the R2b instances** (firsthour's bare paths are in a pointer list + one prose + mention, not inside an executable `Read`), so latent rather than active — but a real parity break worth + catching in the same repo-wide grep pass. Counted as a per-surface MINOR below. + +**Recurring (SUGGESTION-class, NOT elevated to ★) — `allowed-tools` over-declaration.** Three of the four +surfaces declare a tool the body never invokes: `firsthour` (`Glob`/`Grep`), `calendar` (`Write`/`Edit`), +`pivot` (`Grep`). Harmless (over-declaration widens permission surface but breaks nothing; +*under*-declaration would be the real risk and there is none). Noted per-surface; optional minimal-surface +trim, fold into the consolidated fix if touched. + +--- + +## firsthour.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 2 SUGGESTION + clipboard pointer) + +Class: **guided/stateful + subagent orchestration** ("post-publish first-hour / reply-loop sprint"). +Intent delivered: Step 0 load → Step 1 identify post → Step 2 delegate to `engagement-coach` → Step 3 +present (timeline / targets / drafts / velocity) → Step 4 `recordFirstHourPlan` persist → Step 5 +`post-feedback-monitor` handoff. Both subagent targets carry the `linkedin-studio:` namespace and resolve +(`agents/engagement-coach.md`, `agents/post-feedback-monitor.md`); `recordFirstHourPlan` signature +(`planDate, postTopic, targets, draftComments, plan`) matches the call (`:85-91` vs `state-updater.mjs:235`); +6 steps (`grep -cE '^## Step'` = 6), sequential, no orphan markers; Step 2→3 value-flow reconciles (coach +asked for target-list / self-comments / timeline / velocity, Step 3 presents exactly those four); no +"thought leadership". Empty-state degradation present. + +### MINOR — bare reference paths → see Connections (★ #2) (`firsthour.md:110, :118, :119` vs prefixed `:72, :84`). Intent-lens. + +### SUGGESTION — Step 0 voice-samples read has no stated fallback +`firsthour.md:29` reads voice-samples "so every draft comment is in the user's voice," but no path is +specified when the file is absent (progressive onboarding suppresses voice until 5+ samples, CLAUDE.md rule +7). Non-breaking (the coach can still draft), but the degradation is unstated. Add "if absent, draft in a +neutral first-person register and skip voice-matching." + +### SUGGESTION — `allowed-tools` over-declares `Glob`/`Grep` (`:12-13`); body invokes neither. Correctness-lens. See Recurring note. + +### (pointer) clipboard `printf '%s'` systemic bug — `firsthour.md:72, :75` confirmed present → folds into ★ #1 (R2a). No new derivation. + +--- + +## calendar.md — VERDICT: REWORK (0 BLOCKER · 1 MAJOR · 0 MINOR · 1 SUGGESTION) + +Class: **guided/stateful + routing** ("view/manage scheduling queue + publish action"). The **view** side +(14-day calendar, format mix, pillar balance) delivers; the **action** side has a load-bearing data gap. +Step/sub-step/option arithmetic all reconcile (correctness-lens: Steps 1–4 sequential; sub-markers 3a–3f +present and ordered; 5 options offered with 4 handlers + explicit no-op, no dangling branch; Quick-Routing +anchor `:89` exists); empty/missing-queue degradation present and correct (`queue-manager.mjs:12-27` +returns `[]`; body 3a routes "no posts" → `/linkedin:batch`/`quick`); no "thought leadership". + +### MAJOR — the queue load surfaces none of the `id`/`draft_path`/`character_count` the publish/reschedule/cancel actions require (`calendar.md:31-43, :117, :169-174, :185` vs `queue-manager.mjs:112-122`) +- **Verified by main (independent re-grounding of the lens divergence):** Step 1 loads the queue + **exclusively** through `queueFormatSummary` (`:31-43`). `queueFormatSummary` (`queue-manager.mjs:112-122`, + read in full) emits only ` {date} {time} | {hook…} | {pillar} ({fmt}) [{status}]` — it exposes **no** + `id`, **no** `draft_path`, **no** `character_count`. The Step 2 display (`:52-71`) mirrors that field set. +- **Three concrete breakages on the action paths:** + 1. **Mark-as-published (the PRIMARY route — Quick-Routing `:25` jumps straight here)** calls + `queueUpdateStatus('[post-id]', 'published')` (`:117`) — `[post-id]` was never surfaced. Step 3d + also needs `charCount: NNNN` (`:129`), likewise un-surfaced. + 2. **Reschedule** (`:174`) calls `queueAdd('[post-id]','[draft_path]', …, [charCount])` (8-arg signature + confirmed `queue-manager.mjs:63`) and is **explicitly told** to "carry the unchanged fields + (draft_path, pillar, format, hook preview, char count) from **the entry shown in Step 2**" + (`:169-172`) — but Step 2 provably shows none of `draft_path`/`char count`/`id`. A direct + contradiction: the instruction points at a view that lacks the fields it says to carry. + 3. **Cancel** (`:185`) likewise needs the un-surfaced `[post-id]`. +- **Self-recovery caveat (honest):** `queue.json` is in Reference Files (`:208`) and `Read` is allowed, so + a capable agent *could* read raw entries to recover `id`/`draft_path`/`char_count`. But the body never + instructs that, and the reschedule text actively **mis-directs** to Step 2. Latent-but-real on the + primary route → MAJOR, not MINOR. +- **Independence note:** intent-lens flagged MAJOR; correctness-lens passed the surface ALLOW (its + arithmetic/structure pass reconciled but did not trace load→display→action data-flow). Main grounded + `queueFormatSummary`'s output → intent-lens confirmed. Two-lens method earned its keep (same shape as + R2b's newsletter resumption table). +- **disposition:** in Step 1 also dump raw entries (e.g. `console.log(JSON.stringify(queueUpcoming(14)))`, + or a `queueRead()` dump exposing `id`/`draft_path`/`character_count`), and re-point the reschedule text + from "the entry shown in Step 2" to "the raw queue entry loaded in Step 1." Surface the display ordinal → + queue-`id` mapping so 3b/reschedule/cancel can fill `[post-id]`. + +### SUGGESTION — `allowed-tools` over-declares `Write`/`Edit` (`:13-14`); every mutation routes through `Bash` node one-liners, "View draft" uses `Read`. Correctness-lens. See Recurring note. + +--- + +## headless-review.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 2 MINOR · 1 SUGGESTION) + +Class: **guided/stateful + heavy subagent orchestration + routing** (cold 5-archetype package on a frozen +draft → one operator-gated report). Intent delivered: Step 1 resolve-from-disk → Step 2 freeze (`cp` +snapshot) → Step 3 parallel fan-out (the `--type`→`subagent_type` table `:141-145` maps to the 5 real cold +review modes: content / language / fact / persona-resonance / persona-conversion) → Step 4 consolidate → +Step 5 surface + optional `edition-state.json` persist. All reviewer agents resolve; the writing-contract +fallback chain terminates in `references/longform-quality-rules.md` (present); degradation well-handled +(missing `--draft` → edition-state or ask; `cp` unavailable → live draft + note; degraded reviewer +re-runs). "five archetypes" reconciles with the 5-row `--type` table; 5 flags all consumed, no orphan; no +"thought leadership". + +### MINOR — `SendUserFile` invoked on the primary surfacing path but absent from `allowed-tools` (`:208, :221` vs `:19-25`). BOTH lenses. +- `allowed-tools` (`:19-25`) = Read, Glob, Grep, Bash, AskUserQuestion, Task, Write — no `SendUserFile`; + body uses it 2× (`grep -c` = 2), as the documented **primary** operator-gated delivery ("operator-gated + via SendUserFile"). Held at MINOR (not MAJOR) by two guards: `:208` carries an in-text fallback ("else a + markdown `file://` link") and the report is independently persisted via the declared `Write` (`:206`), so + surfacing degrades rather than breaks. +- **disposition:** add `SendUserFile` to `allowed-tools` (if a real tool in the target harness), or soften + the body wording from "primary/operator-gated via SendUserFile" to "surface via a `file://` link (or + `SendUserFile` if available)." + +### MINOR — `v3.1.0` reload anchor misleads on the post-reset version line (`:81-82`) +- **Verified by main:** `:81-82` says the three cold archetypes "were added in **v3.1.0** — if the session + predates them, reload." Current `plugin.json` version = **0.5.3** (`:3`); CHANGELOG `[0.4.0]` (2026-05-31) + records the **honest version reset 4.1.0 → 0.4.0**, so `v3.1.0` is a *pre-reset* tag no longer on the + current line. A reader on 0.5.3 comparing numerically (0.5.3 < 3.1.0) would wrongly conclude they + "predate" the agents and must reload — when 0.5.3 is post-reset and already ships all three (they are in + CLAUDE.md's 19-agent list). Harmless if followed (an unnecessary reload), but the version anchor misleads. +- **disposition:** anchor by event/date, not the dead tag — e.g. "added with the cold-review package + (CHANGELOG 3.1.0, pre-reset); reload if your session predates those agents." + +### SUGGESTION — fan-out N-count unit left implicit: `persona-resonance` issues "one call per active persona" (`:144`) while the header counts " archetypes" / " run in parallel" (`:172, :219`). Pin whether N counts review-modes (5) or Task-calls (≥5). Non-breaking. Correctness-lens. + +--- + +## pivot.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 0 MINOR · 1 SUGGESTION) + +Class: **guided/stateful + routing (no subagent orchestration by design)** ("re-open a long-form edition so +cleared gates re-run before lock"). Intent delivered: Step 1 load+locate (stops if `articles.NN` absent) → +Step 2 measure scope + classify → Step 3 append `pivots[]`, reset `currentPhase`, un-lock, invalidate +downstream verdicts → Step 4 write `STATE.md` + point at `/linkedin:newsletter`. **Unusually +well-reconciled** (correctness-lens, all main-checkable): 4 steps sequential; the >20%/>2-sections +heuristic stated identically in 3 places (`:8-9, :54-55, :92`); the worked example's arithmetic checks out +(+42% = (1992−1400)/1400 ✓; "added 2 sections … at the boundary of '>2'" correctly attributes the trigger +to the 20% arm since `2` is not `>2`); the off-by-one phase map is explicitly reconciled (`:102-108`, +`to-phase` = last *completed* phase, newsletter resumes at the step after); `gatesToRerun` (4 entries `:123`) +matches the summary + STATE line (`:163, :149-151`). `allowed-tools` correctly **omits `Task`** (delegates +gate-running to `/linkedin:newsletter`, never spawns). All 3 Reference-File targets resolve; degradation +present (Step 1 stop-on-missing-article; Step 2.2 absent-baseline → ask operator). No "thought leadership". + +### SUGGESTION — `allowed-tools` over-declares `Grep` (`:18`); the only grep in the body is a *shell* `grep -c '^## '` inside a `Bash` block (`:89`), not the `Grep` tool. `Glob` plausibly resolves the series root — keep it. Correctness-lens. See Recurring note. + +--- + +## Gate decision — R3 COMPLETE (4 surfaces) + +| Surface | Verdict | BLOCKER | MAJOR | MINOR | SUGGESTION | +|---|---|---|---|---|---| +| firsthour | ALLOW | 0 | 0 | 1 | 2 | +| calendar | REWORK | 0 | 1 | 0 | 1 | +| headless-review | ALLOW | 0 | 0 | 2 | 1 | +| pivot | ALLOW | 0 | 0 | 0 | 1 | +| **★ #1 clipboard** (pointer, firsthour) | — | — | — | (folds into R2a) | — | +| **★ #2 bare paths** (firsthour, 3 sites) | — | 0 | 0 | (counted in firsthour) | — | + +**Batch verdict: REWORK** — 1 of 4 surfaces (calendar) carries **1 MAJOR**: the queue load surfaces none of +the `id`/`draft_path`/`character_count` that publish/reschedule/cancel require, and the reschedule step's +"carry from the entry shown in Step 2" is a direct contradiction. **0 BLOCKER anywhere.** firsthour / +headless-review / pivot are ALLOW (pivot notably clean — every count, the heuristic boundary case, and the +off-by-one phase map reconcile). + +**Independence verdict:** convergence on headless-review `SendUserFile` (both lenses) + one **divergence +resolved by main's grounding** (calendar: intent-lens flagged the data-gap MAJOR, correctness-lens passed +it on structural arithmetic, main grounded `queueFormatSummary`'s output and confirmed the MAJOR). Lens-B +also uniquely caught the `v3.1.0` dead anchor + the over-declaration pattern; Lens-A uniquely caught the +bare paths + the clipboard pointer. Both lenses earned their keep. + +**Systemic findings now span R2a+R2b+R3:** clipboard `printf` (R2a, 10 files incl. firsthour) · component +scaffold (R2b, 3 files) · bare reference paths (R2b+R3, now 3 files incl. firsthour's 3 sites). **New this +batch (SUGGESTION-class, not ★):** `allowed-tools` over-declaration on 3 of 4 R3 surfaces. Cold review +**finds**; it changes no code. Each fix is its own operator-gated decision. Recommended consolidated-fix +order unchanged: (1) clipboard `printf` [R2a, 10 files, highest blast radius], (2) component scaffold [3 +files], (3) bare reference paths [grep-driven, now incl. firsthour], then the per-surface items (calendar +queue-data MAJOR, headless `SendUserFile`/`v3.1.0`, over-declaration trims). Local-only (hardening-class), +pushed per the 2026-06-30 operator delegation (public catalog, no secrets). + +**Cumulative cold-review coverage: 17/29** (review.md S1=4 · R2a=5 · R2b=4 · R3=4). diff --git a/docs/hardening/review-R4.md b/docs/hardening/review-R4.md new file mode 100644 index 0000000..7914a9d --- /dev/null +++ b/docs/hardening/review-R4.md @@ -0,0 +1,293 @@ +--- +type: cold-review +batch: R4 +journey: "Measure — analytics & performance surfaces" +scope: "FROZEN committed files at HEAD 69f37ba (clean tree; post-hardening cold pass)" +method: "2 independent cold Opus reviewers for the round (intent + correctness), each covering all 6 surfaces, no cross-feed; every mechanical claim tool-grounded (anti-fabrication mandate); reviewers carry NO drafting-session context. Divergences re-grounded by main before registration." +surfaces: [import, report, analyze, audit, ab-test, measure] +reviewers: + - "intent-lens (conformance: intent delivery + cross-ref resolution + analytics class predicates + graceful degradation + terminology)" + - "correctness-lens (internal consistency + bound-vs-canonical + step/phase arithmetic + allowed-tools completeness + metric-definition cross-check)" +class: "analytics — extra predicate: graceful degradation present · saves/dwell honesty intact (parseOptionalCount → unknown/never 0; dwell unmeasurable; saves NOT folded into engagementRate; analytics I/O via getAnalyticsRoot seam)" +status: "COMPLETE — all 6 surfaces reviewed (import, report, analyze, audit, ab-test, measure)" +verdict: REWORK +counts: { BLOCKER: 0, MAJOR: 1, MINOR: 4, SUGGESTION: 6 } +--- + +# Cold review — R4 (Measure · analytics & performance) + +Independent post-hoc cold review of the 6 Measure-journey surfaces, on the FROZEN committed files +(HEAD `69f37ba`). Mirrors the S1 `review.md` + R2a + R2b + R3 model (the cold-review method that did **not** +fabricate): read-and-show before assert, every `file:line` tool-confirmed, reviewers carry no +drafting-session context. The per-command interactive gate (`log.md`) already passed these; this pass +adds the **independent** axis that gate never had. This is the largest batch (6 surfaces) and the only +**analytics-class** batch, so the round carries the extra class predicate (graceful degradation + +saves/dwell honesty) alongside the standard intent/correctness lenses. + +**Analytics-class predicate — PASSES across all 6 surfaces (the headline R4 result).** Both blind lenses +independently confirmed the honesty contract holds wherever a surface touches the metric: **saves** are +consistently framed as native-only / count-only / ~Sept 2025-onward / no self-serve API / manual-entry +(`report.md:143,:241`; `import.md:30,:138,:148`; matches `cli.ts:144-146` + `csv-parser.ts:71` where +`parseOptionalCount` → blank/non-numeric/negative becomes `undefined` = unknown, never 0) and are **never +folded into `engagementRate`** (`csv-parser.ts:205-208` numerator = reactions+comments+shares+clicks, no +saves); **dwell** is consistently called unmeasurable/internal-to-LinkedIn (`report.md:241`, +`import.md:30`); no surface claims to import or compute either. The `getAnalyticsRoot()` per-user data-dir +seam (`storage.ts`) is described accurately wherever quoted. **No analytics-honesty violation anywhere in +R4.** + +**Independence cross-check — both outcomes recurred this batch (the case for two lenses, again):** +1. **Convergence (×2):** both blind lenses independently surfaced (a) `import.md` Step 6a's invalid + `trends` flags (`--period 4w` / `--metric engagement_rate`) and (b) `ab-test.md:236`'s manual + engagement-rate formula excluding clicks. Two real defects, found twice without cross-feed. +2. **Divergence resolved by grounding (×3, in BOTH directions):** + - **intent over-rated, main corrected down** — on `import` the intent-lens flagged the Step 6a CLI block + **MAJOR** (→ REWORK), the correctness-lens flagged the same defect **MINOR** (→ ALLOW, "off-primary, + descriptive"). Main re-grounded `import.md:194-217`: the executable instruction is the **delegation** + (`:200` "Run /linkedin:report"); the bash block (`:207-210`) is import's *description* of report's + internals, not import's own step → latent, errors only if copy-run → **MINOR**, import = ALLOW. + - **correctness uniquely caught, main confirmed** — on `report` the correctness-lens flagged a **MAJOR** + (heatmap branch routes to a nonexistent "Step 6c"); the intent-lens was silent (its lens probes + agent/command cross-refs, not internal step-jump arithmetic). Main grounded the step inventory → no + `6c` exists, real handler is `2c` → **MAJOR confirmed**. This is the batch's load-bearing defect. + - **correctness uniquely caught, main confirmed** — on `analyze` the correctness-lens flagged a **MINOR** + (two non-reconciling severity scales); the intent-lens was silent → main grounded `:155-178` vs + `:227-231` → confirmed **MINOR**. + Both lenses earned their keep: intent over-rated one finding (corrected by grounding), correctness + uniquely caught the two structural defects intent's lens never traced. + +--- + +## Connections to existing systemic findings (no NEW ★ cross-cutting this batch) + +All connections re-grounded by main against the R4 files: + +- **★ #1 (clipboard `printf '%s'`, R2a, 10 files) — R4 adds nothing.** `grep -nE "printf '%s'|clipboard-helper"` + across all 6 R4 files → NONE. The analytics surfaces do not auto-copy to clipboard (they ingest/report + data, they don't emit post text), so this systemic pattern simply does not reach the Measure journey. +- **★ #2 (5-component scaffold band-mismatch, R2b, 3 files) — R4 adds nothing.** The length-band tokens + that appear (`analyze.md:201` "1,200-1,500", `:217` "1,500-1,800"; `report.md:331` example impressions; + `ab-test.md:80` test-variable "Short (500) vs standard (1,200-1,800) vs long (2,500+)") are + recovery-protocol guidance / illustrative numbers / a test variable — none is a component scaffold that + sums outside the standard band. No defect. +- **★ #3 (bare reference paths vs `${CLAUDE_PLUGIN_ROOT}/`, R2b+R3) — R4 connects lightly (lowest impact).** + `analyze.md:22,:23,:93,:259,:260,:261` and `report.md:241` carry bare `references/…` — but **none is + inside an executable `Read`/`cat`** (`grep -nE "(Read|cat) .*references/"` → NONE executable); all are + pointer-list entries or prose mentions, the same lowest-impact class as R3's firsthour bare paths. + Latent parity break worth catching in the same repo-wide `${CLAUDE_PLUGIN_ROOT}/` grep pass; not elevated + to a per-surface finding (neither lens raised it; cosmetic on these surfaces). + +**Recurring (SUGGESTION-class, NOT elevated to ★) — `allowed-tools` over-declaration now spans R3+R4.** +Four of six R4 surfaces declare a tool the body never invokes: `import`/`report` (`Glob` — listing done via +Bash `ls`/`find`), `audit` (`Grep` — no grep call). Combined with R3's 3-of-4, the pattern now touches ~7 +surfaces. Harmless (over-declaration widens the permission surface but breaks nothing; *under*-declaration +is the real risk and there is one true instance this batch — `report` Step 8b, recorded as MINOR below). +Optional minimal-surface trim; fold into the consolidated fix if touched. + +**New R4 cluster (not ★, analytics-specific) — sibling-command interface/metric-definition drift.** Two +of the four MINORs are the same shape: an analytics surface quotes another surface's CLI interface or a +shared metric definition and drifts from the SSOT — `import.md` Step 6a's stale `trends` flags vs +`report.md`'s correct ones, and `ab-test.md:236`'s manual engagement-rate (clicks excluded) vs the CLI's +`engagementRate` (clicks included, `csv-parser.ts:205`). Both are latent (cross-reference paths, not +primary execution) but both are real consistency debt between siblings. Worth a single reconciliation note +in the consolidated fix: pin the canonical `engagementRate` definition + CLI flag vocabulary once, and make +the descriptive blocks point at it rather than restate it. + +--- + +## import.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 1 SUGGESTION) + +Class: **analytics (import orchestrator)**. Intent delivered: primary artifact is the structured JSON batch +written by `cli.ts import` (Step 4 invokes it; output surfaced Step 5), then analysis delegated to +`/linkedin:report` (Step 6). Step inventory `1·1b·2·3·4·5·5b·6·6a·6b·7·8` sequential, no gaps; Step 1b/Step 3 +option lists each carry a Skip/Cancel disposition. `allowed-tools` (`:10-15` Bash/Read/Glob/Write/AskUserQuestion) +— Bash/Read/Write/AskUserQuestion all invoked. Cross-refs resolve (`report.md`, `setup.md`, `quick-import.mjs`, +`assets/analytics/README.md` all exist; no `subagent_type`). Degradation present (no-CSV, nothing-anywhere, +missing-deps `npm install`, skipped rows on empty-title/unparseable-date matching `csv-parser.ts:187,193`). +saves/dwell honesty intact (`:30,:138,:148`). No "thought leadership". + +### MINOR — Step 6a documents report's CLI calls with invalid period + metric, contradicting the real owner (`import.md:200, :206, :207-210`) +- **Verified by main (the intent/correctness severity divergence, re-grounded `:194-217`):** `:207-210` + shows `trends --period 4w --metric impressions` and `--metric engagement_rate` inside a bash fence. The + CLI accepts period `week|month|quarter|all` (`cli.ts:217`, validated → `process.exit(1)` `:219-221`) and + metric `…|engagementRate` camelCase (`cli.ts:202-209`, validated → `process.exit(1)` `:233-234`) — both + `4w` and `engagement_rate` would error. The real owner `report.md:153,:171` uses the correct + `--period month --metric engagementRate`. `:200` "(period: 4w)" and `:206` "Read expertise_areas" also + mis-describe report (it takes no period arg, never reads expertise_areas). +- **Why MINOR not MAJOR (intent-lens rated MAJOR; main grounds down):** the **executable** instruction on + this path is the delegation at `:200` ("Run /linkedin:report") → routes to `report.md`, which is correct. + The bash block (`:207-210`) is import's *narrative description* of report's internals ("`/linkedin:report` + will: … 2. Call `trends`…"), not a step import itself runs. Latent (errors only if a reader copy-runs the + illustrative block) and the primary delegation path is unaffected → MINOR, not MAJOR. Both lenses + converged on the defect's existence; only the severity diverged. +- **disposition:** drop the illustrative bash + the "(period: 4w)"/"expertise_areas" description, or mirror + report's real invocations (`--period month --metric engagementRate`). Fold into the sibling-drift + reconciliation note. + +### SUGGESTION — `allowed-tools` over-declares `Glob` (`:13`); directory listing uses Bash `ls`/`find` (`:37,:47`). Both lenses. See Recurring note. + +--- + +## report.md — VERDICT: REWORK (0 BLOCKER · 1 MAJOR · 1 MINOR · 1 SUGGESTION) + +Class: **analytics (report orchestrator)**. Intent delivered: produces weekly/monthly/heatmap report JSON +via `cli.ts report`/`heatmap` + a formatted presentation (Step 6) + an analytics-interpreter handoff +(`subagent_type: linkedin-studio:analytics-interpreter` `:308` → `agents/analytics-interpreter.md` ✓, `Task` +declared `:14`). Trends flags `--period month --metric engagementRate` (`:153,:171,:366`) all valid vs CLI. +Degradation present (no-data, npm install, week-not-found/empty-week/ERR_MODULE_NOT_FOUND `:387-400`). +saves/dwell honesty exemplary (`:143,:241`). No "thought leadership". + +### MAJOR — the heatmap report type routes to a nonexistent "Step 6c" (`report.md:72`) +- **Verified by main (correctness-lens caught it; intent-lens silent — lens gap, not contradiction):** the + step inventory (`grep -nE '^### Step|^## Step'`) is `1·1b·2·2b·2c·3·4·5·5b·5c·6·7·8·8b` — **there is no + Step 6c**. `:72` ("If heatmap (option 3): Run the heatmap CLI command and jump to **Step 6c**") points a + reader nowhere. The real heatmap handler is **Step 2c** (`:106`, immediately below the monthly Step 2b), + which itself "jump[s] to Step 7" (`:114`). The two sibling branches are correct (monthly `:71`→2b; + weekly→inline), so heatmap — one of three top-level report types in Step 2 — is the lone misroute. +- **Severity:** a provably-wrong cross-reference on a **primary menu branch** (top-level report-type + selection, not a deep-dive). Recoverable (`:72` also says "Run the heatmap CLI command," and 2c sits + right under 2b, so a capable agent recovers by proximity) — hence MAJOR, not BLOCKER — but it is the + batch's one load-bearing navigation defect. +- **disposition:** retarget `:72` from "Step 6c" to "Step 2c". + +### MINOR — Step 8b markdown export under-declares its write tool (`report.md:429-431` vs `:9-15`) +- **Verified by main:** Step 8b (reached via Step 8 option 4, "Export report as markdown file") instructs + "Format the data using this template and **write to file**" / "Save to: …`-report.md`" (`:429-431`). + Frontmatter `allowed-tools` (`:9-15`) = Bash/Read/Glob/AskUserQuestion/Task — **no `Write`**. This is the + one genuine *under*-declaration this batch (the real-risk class). Held at MINOR by two mitigations: the + step is an optional deep-dive (not the primary path), and the declared `Bash` can satisfy the write via a + heredoc/`cat >`. Sibling `ab-test.md:12-19` declares `Write` for the same `.md`-save, so the omission + reads as an oversight/parity break. +- **disposition:** add `Write` to `allowed-tools` (parity with ab-test) or rephrase Step 8b to write via the + declared `Bash`. + +### SUGGESTION — `allowed-tools` over-declares `Glob` (`:12`); listing uses Bash `ls` (`:30,:197`). Correctness-lens. See Recurring note. + +--- + +## analyze.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 1 SUGGESTION) + +Class: **analytics-adjacent (read-only diagnostic; no CLI)**. Intent delivered: diagnosis + recovery plan +from reference files + AskUserQuestion (Steps Load-Context·1-8). Cross-refs resolve +(`subagent_type: linkedin-studio:analytics-interpreter` `:41` → agent ✓, `Task` declared `:12`; +`/linkedin:profile` `:188` → `commands/profile.md` ✓). Degradation present (analytics delegation is +conditional `:41` with a self-report fallback; functions with zero data). No saves/dwell claims → nothing to +contradict. No "thought leadership". + +### MINOR — two non-reconciling severity scales in one command (`analyze.md:155-178` vs `:227-231`) +- **Verified by main (correctness-lens; intent-lens silent — lens gap):** Step 5 grades reach drop on four + percentage bands (`<25` / `25-50` / `50-75` / `75%+`, `:155-178`). Step 7's timeline table (`:227-231`) + uses three rows on a *different* axis — "Moderate (link/off-topic)", "Moderate (partial reach loss)", + "Severe (sharp reach loss)". The two scales share no common key, so a user holding a Step 5 result (e.g. + "Down 50-75% → algorithmic suppression likely") cannot map it to a Step 7 timeline row. Advisory/usability + inconsistency, low-confidence; nothing breaks. +- **disposition:** cross-label the Step 7 rows to the Step 5 bands, or state explicitly that they are + independent axes. + +### SUGGESTION — the `:41` existence check ("If imported analytics data exists `…/analytics/`") implies Glob/Bash, neither declared (`allowed-tools` `:9-13` = Read/AskUserQuestion/Task). Softer than report's Write gap: no explicit tool call is written at `:41` (it's a conditional prose phrase) and the real data access is delegated to `analytics-interpreter` via `Task`. Correctness-lens. + +--- + +## audit.md — VERDICT: ALLOW (0 BLOCKER/MAJOR/MINOR · 1 SUGGESTION) + +Class: **analytics-adjacent (read-only strategy auditor; no CLI)**. Intent delivered: audit report (Step 7 +template) + action items (Step 8). Steps `0·1·2·3·4·5·5.5·6·7·8` sequential. Routing resolves +(`/linkedin:strategy` → `commands/strategy.md` ✓, `/linkedin:profile` ✓; no `subagent_type`, `Task` +correctly absent from `allowed-tools`). Degradation present (Step 0 checks for analytics data + asks for +screenshots/metrics; milestone block self-skips with no data `:140`). **Correctness cross-check (both +lenses):** the state fields the milestone block reads — `follower_count`, `monthly_growth`, +`growth_rate_needed` (`:105,:109,:128`) — all exist in `config/state-file.template.md:23,26,28`, so it reads +real fields. No saves/dwell claims. No "thought leadership". + +### SUGGESTION — `allowed-tools` over-declares `Grep` (`:11`); Step 0 uses Read/Glob, no grep call in the body. Both lenses (convergence). See Recurring note. + +--- + +## ab-test.md — VERDICT: ALLOW (0 BLOCKER/MAJOR · 1 MINOR · 2 SUGGESTION) + +Class: **analytics (experiment manager; manual metric entry, no CLI)**. Intent delivered: primary artifact +is the test-plan markdown written to `analytics/ab-tests/[name].md` (Step 2a.8) + running comparison / +analysis (2b/2c). Step inventory `0·1·2a(.1-.8)·2b·2c·2d·2e·3` consistent; Step 1's 6 intents map to 2a-2e + +option 6 self-handles (`:57`). Post-count arithmetic coherent ("3 per variant / 6 total" `:127`; 6-row +execution table `:155-162`; "X of 6" `:255`). `allowed-tools` (`:12-19` +Read/Glob/Write/Bash/AskUserQuestion/Task) — Read/Write/Bash/AskUserQuestion/Task(→`content-optimizer` `:119` +✓) all invoked. Degradation present (Error Handling: No-Tests-Directory, Incomplete-Data, Missing-Analytics, +Corrupted-files `:472-493`). Statistical honesty notably correct — 2c.4/Confidence-Level (`:311,:320-331`) +explicitly demotes small-sample results to "directional, not significant." No saves/dwell claims. No +"thought leadership". + +### MINOR — manual engagement-rate formula excludes clicks, diverging from the canonical `engagementRate` (`ab-test.md:236`) +- **Verified by main (both lenses converged):** `:236` computes ER as + `(reactions + comments + reposts) / impressions * 100` — clicks excluded (and 2b.3 `:228-234` never + collects clicks). The CLI's `engagementRate` includes clicks: `totalEngagement = reactions + comments + + shares + clicks` (`csv-parser.ts:205-208`). Step 2c.3 (`:282-288`) cross-references the A/B numbers against + the CLI weekly reports, where the two rates will not match. +- **Severity:** internally consistent within a single test (same formula on both variants, and A/B is + variant-relative), so the verdict-within-a-test is unaffected → MINOR, latent on the cross-reference path. +- **disposition:** add a one-line "clicks excluded by design" caveat, or align the manual formula to the + canonical definition. Fold into the sibling-drift reconciliation note. + +### SUGGESTION — bare agent name in a user-facing suggestion (`ab-test.md:468`): "use the `content-optimizer` agent" surfaces a bare name where the canonical form (correctly used at `:119`) is the namespaced `subagent_type`. Cosmetic. Intent-lens. + +### SUGGESTION — `allowed-tools` over-declares `Glob` (`:14`); scans use Bash `ls` (`:37,:209`). 2b.4/2c.5 append/update are edit-shaped but the declared `Write` covers them via full rewrite (no `Edit` gap). Correctness-lens. See Recurring note. + +--- + +## measure.md — VERDICT: ALLOW (0 findings — clean) + +Class: **front-door router (delegate-only)**. Intent delivered **and structurally enforced** (both lenses +converged): `allowed-tools` (`:10-13`) = `Glob` + `AskUserQuestion` only — no Bash/Read/Task — so the +command *cannot* run analysis itself; it identifies intent (Step 1, 5 intents `:30-34`) and routes (Step 2 +table `:41-47`, one row per intent). All five routes — `/linkedin:import`, `/linkedin:report`, +`/linkedin:analyze`, `/linkedin:audit`, `/linkedin:ab-test` — resolve to existing command files. Degradation +present (Step 0 glob optional, "Do not block on it"; order-note `:49-50` routes to import first when nothing +imported). No dangling branch, no analysis logic to drift, no saves/dwell claims, no "thought leadership". +The thinnest, cleanest surface in the batch — the delegate-only contract is enforced by the tool whitelist, +not just asserted. + +--- + +## Gate decision — R4 COMPLETE (6 surfaces) + +| Surface | Verdict | BLOCKER | MAJOR | MINOR | SUGGESTION | +|---|---|---|---|---|---| +| import | ALLOW | 0 | 0 | 1 | 1 | +| report | REWORK | 0 | 1 | 1 | 1 | +| analyze | ALLOW | 0 | 0 | 1 | 1 | +| audit | ALLOW | 0 | 0 | 0 | 1 | +| ab-test | ALLOW | 0 | 0 | 1 | 2 | +| measure | ALLOW | 0 | 0 | 0 | 0 | +| **TOTAL** | **REWORK** | **0** | **1** | **4** | **6** | + +**Batch verdict: REWORK** — 1 of 6 surfaces (report) carries **1 MAJOR**: the heatmap report type routes to +a nonexistent "Step 6c" (real handler Step 2c) — a provably-wrong cross-reference on a primary menu branch. +**0 BLOCKER anywhere.** The other five are ALLOW (measure notably clean — its delegate-only contract is +enforced by the `allowed-tools` whitelist, not merely asserted). + +**Analytics-class predicate verdict: PASS.** The saves/dwell honesty contract holds on every surface that +touches the metric — saves framed as manual/count-only/no-API and never folded into `engagementRate`, dwell +called unmeasurable, neither claimed as imported; `parseOptionalCount` semantics (`csv-parser.ts:71`) and the +`getAnalyticsRoot` seam described accurately wherever quoted. Graceful degradation present on all six. This +was the batch-specific axis and it is clean. + +**Independence verdict:** two convergences (import Step 6a flags; ab-test ER-omits-clicks) + three +divergences resolved by main's grounding in **both directions** — intent over-rated import's Step 6a +(MAJOR→MINOR on grounding that the executable path delegates), while correctness uniquely caught report's +Step 6c misroute (MAJOR) and analyze's twin severity scales (MINOR) that intent's lens never traced. The +two-lens method earned its keep again: had only the intent-lens run, report would have shipped ALLOW with a +broken primary branch; had only the correctness-lens run, import would have over-escalated to REWORK. + +**Systemic findings now span R2a+R2b+R3+R4:** clipboard `printf` (R2a, 10 files — **R4 adds none**) · +component scaffold (R2b, 3 files — **R4 adds none**) · bare reference paths (R2b+R3+R4 — R4 adds 7 +lowest-impact pointer/prose sites in analyze/report, not executable). **New this batch:** (a) `allowed-tools` +over-declaration now spans R3+R4 (~7 surfaces, SUGGESTION-class, harmless) with one true *under*-declaration +(report Step 8b `Write`, MINOR); (b) a small **sibling interface/metric-definition drift** cluster (import's +stale `trends` flags + ab-test's clicks-excluded ER) — reconcile once against the CLI SSOT. Cold review +**finds**; it changes no code. Each fix is its own operator-gated decision. Recommended consolidated-fix +order unchanged: (1) clipboard `printf` [R2a, 10 files, highest blast radius], (2) component scaffold [3 +files], (3) bare reference paths [grep-driven], then the per-surface items — now including **report Step 6c +misroute (the one R4 MAJOR)**, report Step 8b `Write`, the sibling-drift reconciliation (import 6a + +ab-test ER), analyze severity scales, and the over-declaration trims. Local-only (hardening-class), pushed +per the 2026-06-30 operator delegation (public catalog, no secrets). + +**Cumulative cold-review coverage: 23/29** (review.md S1=4 · R2a=5 · R2b=4 · R3=4 · R4=6). Remaining: **R5 +(Grow+Router)** — strategy · competitive · monetize · outreach · profile · linkedin (6 surfaces) → 29/29. diff --git a/docs/hardening/review-R5.md b/docs/hardening/review-R5.md new file mode 100644 index 0000000..28d8dec --- /dev/null +++ b/docs/hardening/review-R5.md @@ -0,0 +1,258 @@ +--- +type: cold-review +batch: R5 +journey: "Grow + Router — growth/authority surfaces + the command router (FINAL round)" +scope: "FROZEN committed files at HEAD 4109fe7 (clean tree; post-hardening cold pass)" +method: "2 independent cold Opus reviewers for the round (intent + correctness), each covering all 6 surfaces, no cross-feed; every mechanical claim tool-grounded (anti-fabrication mandate); reviewers carry NO drafting-session context. Divergences re-grounded by main before registration." +surfaces: [strategy, competitive, monetize, outreach, profile, linkedin] +reviewers: + - "intent-lens (conformance: intent delivery + cross-ref resolution + class predicates + graceful degradation + thought-leadership terminology ban)" + - "correctness-lens (internal consistency + bound-vs-canonical + step/phase arithmetic + allowed-tools completeness + dead-ref / executable-path checks)" +class: "guided/stateful (strategy·competitive·monetize·outreach·profile — primary artifact produced · subagent targets resolve · graceful degradation) + routing (linkedin — every emitted /linkedin:Y resolves)" +status: "COMPLETE — all 6 surfaces reviewed (strategy, competitive, monetize, outreach, profile, linkedin). R5 completes 29/29 cold-review coverage." +verdict: MINOR +counts: { BLOCKER: 0, MAJOR: 0, MINOR: 3, SUGGESTION: 2 } +--- + +# Cold review — R5 (Grow + Router) · FINAL round → 29/29 coverage + +Independent post-hoc cold review of the 6 remaining surfaces — the five Grow-journey +guided/stateful commands (`strategy`, `competitive`, `monetize`, `outreach`, `profile`) plus the +`linkedin` router — on the FROZEN committed files (HEAD `4109fe7`). Mirrors the S1 `review.md` + +R2a + R2b + R3 + R4 model (the cold-review method that did **not** fabricate): read-and-show before +assert, every `file:line` tool-confirmed, reviewers carry no drafting-session context. The +per-command interactive gate (`log.md`) already passed these; this pass adds the **independent** +axis that gate never had. **R5 completes the sweep: 23/29 + 6 = 29/29 cold-review coverage.** + +**Resolution integrity — PASS across all 6 surfaces (the headline R5 result).** Both blind lenses +independently confirmed, target-by-target against their own `ls`/`test -f`: +- **2/2** `subagent_type: linkedin-studio:X` refs resolve — `strategy-advisor` (`strategy.md:153`), + `network-builder` (`outreach.md:171`). +- **28/28** unique `/linkedin:Y` route tokens resolve to `commands/Y.md` (router + cross-command + suggestions). +- **11/11** router-suggested agents (named in `linkedin.md`) resolve to `agents/*.md`. +- **2/2** helper-script invocations resolve: `outreach.md`'s `state-updater.mjs --record-outreach` + (flags `--date/--track/--partner/--stage/--next/--due` match `state-updater.mjs:394-411` + byte-for-byte) and `linkedin.md`'s `queue-manager.mjs` import (`queueUpcoming`/`queueOverdue`/ + `queueFormatSummary` exist at `queue-manager.mjs:52,94,112`). +- **0 under-declared tools** — every body-invoked tool (Task/Read/Write/Edit/Bash/WebSearch/ + AskUserQuestion) is in the surface's `allowed-tools`; only harmless `Glob`/`Grep` over-declarations. +- **0 dead executable Read/Bash targets** — every `references/*`, `${CLAUDE_PLUGIN_ROOT}/skills/…`, + and script path on an executable path verified present. + +**No broken invocation, no missing primary artifact, no failing gate, no runtime-breaking +contradiction anywhere in R5 → 0 MAJOR, verdict MINOR (advisory, not REWORK).** This is the +cleanest batch of the sweep. + +**Independence cross-check — both outcomes recurred (the case for two lenses, again):** +1. **Convergence:** both blind lenses independently surfaced the same `monetize.md` description↔body + scope mismatch and the same `thought leader` terminology cluster — high confidence these are real. +2. **Divergence (re-grounded by main):** + - **Terminology severity** — intent-lens called the `thought leader` hits MINOR (it owns the + conformity predicate; memory `no-thought-leadership-phrase` makes this a *standing plugin rule*, + not a nicety), correctness-lens called them SUGGESTION (no runtime break). **Main ruling: + MINOR** — a direct violation of an explicit plugin terminology rule in user-facing strings is a + conformity defect; it is not MAJOR (no runtime break). + - **`monetize` scope mismatch severity** — intent-lens SUGGESTION ("description under-claims + body"), correctness-lens MINOR ("description contradicts its own 0-1K Stage 1"). **Main ruling: + MINOR** — it is a genuine self-contradiction about the command's scope, not a mere under-claim. + - **Lens-unique:** correctness-lens alone caught the `monetize` Audience-Size scorecard + arithmetic; intent-lens alone caught the bare-relative-path robustness gap. Both re-grounded by + main below and kept. + +--- + +## strategy.md — VERDICT: MINOR + +- **Intent delivered:** yes. Phase auto-detect from state (`:32-44`), phase strategy + delegation + to `strategy-advisor` (`:153`), trajectory overlay (`:250-284`), authority building Phase 2+ + (`:286-419`), stall points (Step 4), 90-day plan (Step 5), metrics (Step 6). +- **Resolution:** all resolve — `subagent_type: linkedin-studio:strategy-advisor` (`:153`) → + `agents/strategy-advisor.md` ✓; routes `/linkedin:profile` (`:292,:360`) ✓; `Task` declared. +- **Class predicate (guided/stateful):** growth-plan artifact produced ✓; subagent resolves ✓; + graceful degradation present ✓ (`follower_count` 0/missing handled `:42`; "If no milestone data: + Skip this step" `:284`; authority skipped in Phase 0-1 `:290`). +- **Arithmetic (correctness-lens, grounded):** 5 phases (0–4) consistent between Step 0.5 + auto-detect ranges and Step 2 headers; step numbering monotonic (0.5,1,2,3,3.5,3.6,4,5,6), no + gaps/dupes. PASS. +- **allowed-tools:** declared {Read, Glob, Grep, AskUserQuestion, Task}; under-declared {} ✓; + over {Glob, Grep} (harmless). +- **Findings:** + 1. **[MINOR]** `strategy.md:371` "Engaging with other thought leaders" — `thought leader` + terminology-ban hit (off-primary checklist label). Part of the cross-cutting cluster. + 2. **[MINOR]** bare relative paths for all file loads; **0×** `${CLAUDE_PLUGIN_ROOT}` (grounded + `grep -c` = 0, vs 1–10× in competitive/monetize/outreach/linkedin) → `Read` resolves against + cwd, not plugin root. Has `Glob` in allowed-tools as a fallback so it degrades, not fatal. + Folds into systemic finding #3 (bare ref-paths). + +## competitive.md — VERDICT: MINOR + +- **Intent delivered:** yes. Competitor analysis template (Step 2), landscape map (Step 3), + gap/opportunity matrix (Step 4), differentiation plan (Step 5), inspired takeaways (Step 6), + ethics note. +- **Resolution:** fully self-contained — **no** `subagent_type`, **no** `/linkedin:` routes; + `allowed-tools` (Read/Glob/WebSearch/AskUserQuestion) correctly omits `Task`. ✓ +- **Class predicate (guided/stateful):** competitive-analysis artifact produced ✓; no subagents to + resolve ✓; graceful degradation ✓ (Step 1 user-input/WebSearch-driven, runs with no state). +- **Arithmetic:** Steps 0–6 linear/monotonic; no stated totals to miscount. PASS. +- **allowed-tools:** declared {Read, Glob, WebSearch, AskUserQuestion}; under {} ✓; over {Glob}. +- **Findings:** + 1. **[MINOR]** `thought leader(s)` appears **4×** — including the frontmatter `description` + (`:4`, **user-facing**, shows in command listings) and the opening promise (`:17`), plus + `:31,:34`. **Worst terminology offender of the six.** Conformity defect, no runtime break. + +## monetize.md — VERDICT: MINOR + +- **Intent delivered:** yes. All 8 steps present: scorecard (1), stage strategy (2), lead-magnet + blueprint (3), funnel calendar (4), CTA + A/B variants (5), Featured optimization (6), revenue + model (7), tracking dashboard (8). +- **Resolution:** all resolve — no `subagent_type`; routes `/linkedin:post` + `/linkedin:pipeline` + (`:367`) both exist. ✓ +- **Class predicate (guided/stateful):** monetization-plan artifact produced ✓; no subagents ✓; + graceful degradation ✓ ("Stage 1: Visibility (0-1K followers)" `:92` serves brand-new users). +- **Gating honesty:** does NOT hard-gate; serves 0-1K. The real state-read gate is the router's + soft prepend (`linkedin.md:175`, `<1000` → prepend, continue anyway) — consistent with outreach. +- **Findings:** + 1. **[MINOR]** **description↔body scope self-contradiction** — `monetize.md:6` "Works from 1K+ + followers" contradicts its own Stage 1 "Visibility (**0-1K** followers, score 0-30)" (`:92`, + `:81`) and the router's "they work at any follower count" (`linkedin.md:126`). The body + genuinely covers sub-1K; the description misstates scope. Off-primary (a frontmatter string) + but user-facing. **Strongest non-terminology R5 finding.** Fix: align `:6` to "any follower + count (compounds at 1K+)". + 2. **[MINOR]** `thought leaders` in the frontmatter `description` (`:4`, user-facing). Part of the + cross-cutting cluster. + 3. **[SUGGESTION]** Audience-Size scorecard arithmetic (`:51-56`): sub-items +5/+10/+15/+5/+5 sum + to **+40** against a **`/25`** cap if read additively, whereas the other three categories each + sum to exactly 25 (e.g. Engagement Quality 5+5+10+3+2=25). The follower tiers (1K/5K/10K) are + clearly intended mutually-exclusive (one tier) but aren't marked as such → a literal additive + read overflows. AI-interpreted (not machine-summed) so intent is recoverable; cosmetic, but it + feeds the band that selects the stage. Fix: mark the three follower tiers "(pick one)". + +## outreach.md — VERDICT: PASS + +- **Intent delivered:** yes, thoroughly. Two-track (collab + speaking) orchestrator with a + Capability Checklist mapping every predecessor function to a step (`:31-62`) + 10 steps + state + persistence. +- **Resolution:** all resolve — `subagent_type: linkedin-studio:network-builder` (`:171`) ✓; + routes `/linkedin:strategy` (`:112`), `/linkedin:firsthour`+`/linkedin:outreach` (`:1078`), + `/linkedin:calendar` (`:1097`) all exist ✓; **Bash executable path sound** — Step 8c's + `state-updater.mjs --record-outreach` (`:1084-1092`) matches the script's handler + (`state-updater.mjs:294,:394-411`) byte-for-byte. ✓ +- **Class predicate (guided/stateful):** outreach-plan + persisted pipeline produced ✓; subagent + + script resolve ✓; graceful degradation ✓ (Step 2a "Not ready: <3 met → build foundation first" + + recommend `/linkedin:strategy` `:112`). +- **Arithmetic (correctness-lens, grounded):** "12 collab formats" → 12 (`FORMAT 1–12`); "4 talk + templates" → A–D; "5 phases" → PHASE 1–5; scorecards 4×/25=/100 and 5×/5=/25 check out; step + numbering monotonic. PASS. +- **allowed-tools:** declared {Read, Glob, WebSearch, AskUserQuestion, Task, Bash}; under {} ✓; + over {Glob}. +- **Gating honesty:** "1K+ followers" self-report (`:100,:143`) consistent with router's `~1K` soft + gate (`linkedin.md:120,:176`) and monetize's 1K. ✓ +- **Findings:** + 1. **[SUGGESTION]** `thought leader` inside a WebSearch query template (`:229`, + `"[your niche] linkedin thought leader"`). It is a *search string* targeting how others + self-label (intentional — to find such profiles), so the **lowest-priority** instance of the + terminology cluster — but the literal string is still in the plugin. + +## profile.md — VERDICT: PASS + +- **Intent delivered:** yes. Relevance-model context (`:20-44`), Profile SEO + per-section keyword + targets (`:46-79`), 7-section audit walkthrough (`:82-199`), profile-content alignment check + (`:200-212`), prioritized action plan (`:214-231`), alignment test (`:232-238`). +- **Resolution:** self-contained — no `subagent_type`, no `/linkedin:` routes; `allowed-tools` + Read/AskUserQuestion (tightest frontmatter of the six). ✓ +- **Class predicate (guided/stateful + topic-relevance-audit):** the topic-relevance audit **is** + actually performed (the body *is* that audit) ✓; artifact (audit + action plan) produced ✓; + graceful degradation ✓ (every step AskUserQuestion-driven, runs with zero state). Notable + verification discipline: explicitly refuses to fabricate a scoring breakdown (`:28,:32-36,:179`). +- **Arithmetic:** Sections 1–7 monotonic; profile-field limits (headline 220, About 2,600) are + LinkedIn field limits, NOT post hook/length bounds — no canonical contradiction. PASS. +- **Terminology — IMPORTANT NON-VIOLATION:** the two `thought leader` hits (`:79,:101`) are + **legitimate negative examples** — the command explicitly tells the user to AVOID the phrase + (`:79` lists it with "guru"/"ninja" as keyword-wasters; `:101` is a "Weak example"). Both lenses + agree; main confirmed by reading both lines. **profile.md models the correct behavior.** +- **Findings:** none of defect class. + 1. **[Note — systemic #3]** bare relative paths; **0×** `${CLAUDE_PLUGIN_ROOT}` and no `Glob` + fallback → most-exposed instance of the bare-path robustness item. BUT correctness-lens + verified all three referenced files exist (`test -f` OK), so this is a cwd-robustness concern, + not a dead ref. Folds into systemic #3; profile.md is otherwise the cleanest surface of R5. + +## linkedin.md (router) — VERDICT: MINOR + +- **Intent delivered:** yes. Status line (`:19-29`), upcoming/overdue posts via queue (`:31-57`), + five-journey menu with front-doors (`:59-127`), gating rule (`:122-127`), interactive menu + (`:129-146`), direct-routing table (`:156-198`). +- **Resolution (routing class):** **all 28 unique route tokens resolve** to `commands/*.md` + (verified token-by-token vs `ls commands/`); all 11 suggested agents resolve; `queue-manager.mjs` + node call (`:35-42`) uses exports that all exist (`:52,:94,:112`). **The router advertises nothing + that doesn't exist.** ✓ Correctly has no `Task` (delegate-only). +- **Class predicate (routing):** every route resolves ✓; graceful degradation ✓ (missing state + "No LinkedIn state tracked yet" `:28`; empty queue `:57`; follower segment only if + `follower_count > 0` `:30`). +- **Gating honesty/consistency:** `:175-176` give monetize + outreach the **same** soft state-read + gate (`<1000` → prepend, continue anyway); `:122-127` document the soft-gate design honestly + ("they work at any follower count… competitive is **not** gated"). Consistent with both command + bodies. ✓ +- **Findings:** + 1. **[MINOR]** `linkedin.md:118` "Competitive analysis of other thought leaders" — `thought + leader` terminology-ban hit in the **user-facing** routing-menu table. Part of the cluster. + +--- + +## Summary table + +| Surface | Verdict | MAJOR | MINOR | SUGGESTION | +|---|---|---|---|---| +| strategy.md | MINOR | 0 | 2 | 0 | +| competitive.md | MINOR | 0 | 1 | 0 | +| monetize.md | MINOR | 0 | 2 | 1 | +| outreach.md | PASS | 0 | 0 | 1 | +| profile.md | PASS | 0 | 0 | 0 (1 systemic-#3 note) | +| linkedin.md | MINOR | 0 | 1 | 0 | +| **R5 total** | **MINOR** | **0** | **3 distinct** | **2 distinct** | + +(Per-surface MINOR counts include shared cross-cutting findings; the 3 *distinct* R5 MINOR findings +are: the terminology cluster, the monetize scope contradiction, and the bare-path robustness item.) + +## Cross-cutting findings (for the consolidated fix-pass) + +1. **[MINOR · NEW R5 systemic] `thought leader` terminology cluster — 5 surfaces, 9 instances.** + competitive `:4`(description, user-facing)/`:17`/`:31`/`:34` · monetize `:4`(description, + user-facing) · linkedin `:118`(menu, user-facing) · strategy `:371`(checklist) · outreach + `:229`(WebSearch string, lowest priority). **profile `:79,:101` are NON-violations** (correct + negative examples — do not "fix" them). Memory `no-thought-leadership-phrase` confirms this is a + standing plugin terminology rule. **Recommended fix:** one terminology sweep replacing the + user-facing instances first ("thought leaders" → "creators"/"experts"/"voices in your niche"), + leaving profile's avoid-list intact and the outreach search-string as lowest priority. No runtime + risk — the buzzword gate scopes to post content, not command markdown. + +2. **[MINOR · per-flate] `monetize.md:6` description↔body scope self-contradiction.** "Works from + 1K+ followers" vs the body's own 0-1K Stage 1 (`:92`) and the router's "any follower count" + (`linkedin.md:126`). Fix: align the description to the body's actual sub-1K-onward range. + +3. **[MINOR · folds into existing systemic #3] bare ref-paths — strategy + profile (0× + `${CLAUDE_PLUGIN_ROOT}`).** profile most exposed (no `Glob` fallback). Refs verified to exist, so + cwd-robustness not a dead ref. Reconcile in the repo-wide path-style pass already scoped by + systemic #3 (R3/R4 found the same pattern in batch/pipeline/firsthour/analyze/report). + +4. **[SUGGESTION · per-flate] `monetize.md:51-56` Audience-Size scorecard sums to +40 vs `/25` + cap.** Follower tiers intended mutually-exclusive but unmarked. Fix: annotate "(pick one)". + +**R5 adds NO new ★ systemic finding to the existing three** (clipboard, scaffold-band, bare-paths) — +the only genuinely new cross-cutting item is the terminology cluster, which is a conformity sweep, +not a code-behavior defect. + +## Verification + +- Spot-checks re-grounded by main (this file): `grep -rniE 'thought.?leader'` over the 6 (9 hits, + classifications above); `sed -n '1,8p'`/`'92p'`/`'48,64p' monetize.md` (scope + scorecard); + `grep -c CLAUDE_PLUGIN_ROOT` per surface (strategy 0, competitive 4, monetize 10, outreach 10, + profile 0, linkedin 1). +- `bash scripts/test-runner.sh` → see STATE telling (expected 138 passed / 0 failed, floor 123) — + unchanged by this review (review is read-only; no command edits). + +## Sweep status after R5 + +**29/29 cold-review coverage reached** (S1 `review.md` + R2a + R2b + R3 + R4 + R5). The independent +cold-review phase is COMPLETE; the v1.0.0 review blocker is lifted. Remaining v1.0.0 work: the +consolidated fix-pass (operator-gated — review FINDS, fix is a separate decision) and the GUI. diff --git a/docs/integration-test-guide.md b/docs/integration-test-guide.md index ec9caf2..3be64bf 100644 --- a/docs/integration-test-guide.md +++ b/docs/integration-test-guide.md @@ -20,7 +20,7 @@ Before testing, ensure: 1. Run `/linkedin:pipeline` 2. Verify Step 0 loads: state file read, status displayed (posts/week, streak) 3. Choose "Generate ideas for me" when prompted -4. Verify 3 topic suggestions appear, drawn from `thought-leadership-angles.md` +4. Verify 3 topic suggestions appear, drawn from `content-angles.md` 5. Select a topic → verify angle selection (2-3 options) 6. Choose format → verify draft follows structure (hook/context/insight/implication/CTA) 7. Verify optimization checks run: diff --git a/docs/m0/log.md b/docs/m0/log.md new file mode 100644 index 0000000..51dcf0f --- /dev/null +++ b/docs/m0/log.md @@ -0,0 +1,162 @@ +# M0 — Implementation Log + +Running record of decisions, deviations, and out-of-scope follow-ups discovered +during M0 execution. Plan: `docs/m0/plan.md` (18 steps). History → git; this file +captures only what the commit messages cannot. + +## Session 4 — Steps 14–18 (2026-06-18) + +### Step 14 GATE outcome — the D3 convention works; edit count is ~1:1, not reduced + +Prototyped `references/data-path-convention.md` on the voice-readers family: +**38 refs across 19 files repointed** — exactly the plan's prediction. The measured +answer to brief D3's open question (*can a convention reduce edits, or do commands +need literal paths for Claude to act on?*): command/agent prose that tells Claude to +**read** a file needs a resolvable path **on the line**. A "the data dir's +`voice-samples/` (see convention doc)" reference adds a lookup hop and is not directly +actionable. The inline `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/…` +token is both self-resolving **and** points at the doc. So the convention does **not** +cut edit count below ~1 per ref — it makes every edit a **uniform mechanical token +swap** (vs bespoke per-line decisions), with the doc as single source of truth. +**GATE = proceed** (convention confirmed working); Step 15 applies the same uniform +token to the remaining families. This is D3 realized as one token — NOT a re-decision +to literal-edit (alt. a). Applied via an ordered swap (prefixed `${CLAUDE_PLUGIN_ROOT}/ +assets/voice-samples/` form before bare `assets/voice-samples/`, so the bare pass can't +corrupt the prefixed one). + +### Designed inter-step red lint (Step 14 → Step 16) + +After Step 14 the structure lint is **Failed: 1** — `references/*.md: 26 (expected 25)`, +the new convention doc as the 26th ref file. `EXPECT_REFS` bumps to 26 in **Step 16** +(plan Session-4 scope forbids touching tests). This session lands 14→18 in one go, so +the lint is restored to green at Step 16 — no red is left at session end. The only +surviving bare `assets/voice-samples/` is inside the convention doc itself (it documents +the in-plugin placeholder-scaffold location for the fallback rule) — Step 16's +no-bare-path assertion must exempt `references/data-path-convention.md`. + +### Step 15 — 130 refs / 34 files repointed; shipped read-only preserved + +Rule-based repoint (negative-lookahead per subdir): analytics 50, drafts 24, +audience-insights 14, profile/D1 12, examples 11, plans 6, my-post-templates 4, +frameworks 3, case-studies 3, network 2, repurposing-tracker 1. Counts match the +plan's family estimates once the **shipped exclusions** are accounted for +(frameworks 6→3 drops `framework-template.md`; case-studies 5→3 drops +`case-study-template.md`; analytics drops `README.md`; the Step-11 `ANALYTICS_ROOT` +pins were already gone). **Style-A `${CLAUDE_PLUGIN_ROOT}` preserved** for shipped +read-only: `analytics/README.md`, all `*-template.md` seeds, `assets/checklists/`, +`assets/quick-post-resources.md`, the shipped `assets/templates/*` (only the +`my-post-templates.md` *instance* repointed), `config/*.template.*`, and every +`scripts/analytics` / `hooks/scripts` CODE path. **profile/D1:** `config/user-profile.local.md` +→ `${…}/profile/user-profile.md` (path + filename change, drops `.local`, per +MOVE_FILES). **ab-tests/** routes under `${…}/analytics/ab-tests/` (brief §7.1); +**plans/** at `${…}/plans/` (top-level). Verify: the only in-plugin data-dir paths +left are the three shipped exclusions; full lint Failed:1 = EXPECT_REFS only. + +### Step 15 scope notes — two additions, one deferral + +- **network/ + repurposing-tracker.md repointed (additions beyond the plan's named + families).** Both are **code-invisible data classes** the agents *write* + (`network-builder.md`, `content-repurposer.md` save trackers into the plugin tree). + They fit Step 15's stated goal ("route the code-invisible data classes so they don't + silently orphan when the default flips"); the plan named ab-tests/plans as examples, + not an exhaustive list. As **write-targets** they are self-sufficient — the agent + creates the file external on first write, no migration entry needed. +- **`config/personas.local.md` deferred — OUT of M0 scope.** Unlike the write-targets, + this is a **read fallback** in a resolution chain (edition-state → series file → + plugin `personas.local.md` → template). Repointing the read external without a + migration dest would break the read, and adding it to `MOVE_FILES` is a `.mjs` change + Step 15's scope fence forbids. Personas are newsletter/series production data (already + external via `$LTL_SERIES_ROOT`); the plugin-level `personas.local.md` is a deliberate + in-plugin fallback library. Left as-is; Step 16's no-bare-path lint targets the + *migrated* data classes only, so it does not flag personas. Track as a follow-up if a + full personas externalization is wanted later. + +### Step 16 — lint extended 74→81 checks; the no-pin guard caught 5 real survivors + +Added to `test-runner.sh`: `EXPECT_REFS` 25→26 + "delta = exactly +1 (data-path-convention.md)" +assert (m3/m11); **Section 13** (no command/agent/skill/hook prose references a migrated +user-data path in-plugin — bare or `${CLAUDE_PLUGIN_ROOT}`-pinned — R1; + the no-`ANALYTICS_ROOT`- +pin sibling M3; + the SC2 `git status --porcelain --ignored` dry-run), each with a +non-vacuity self-test mirroring Section 8; **Section 14** assertion-count anti-erosion +floor (pre-M0 baseline 74, count may only grow — SC6 / assumption 3). + +**The no-pin guard earned its keep on first run:** it flagged **5 surviving in-plugin +`ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"` pins** that Step 11 missed — +`agents/analytics-interpreter.md` (×2) + `skills/linkedin-analytics/SKILL.md` (×3). Step 11 +scoped the pin-drop to the *commands* (`import.md`/`report.md`); the agent + skill invoke the +CLI directly and kept the pin, which would force the analytics CLI to read in-plugin even +after the default flipped external (the exact M3 failure). Dropped all 5 (surgical: removed +only the `ANALYTICS_ROOT=… ` prefix, kept the `node --import tsx` form — no CLI-style change), +matching how Step 11 left the commands. Lint now **81/0**. + +### Step 18 — release 0.4.0 → 0.5.0 (minor, non-breaking) + +Bumped `.claude-plugin/plugin.json` + the three lint-grepped literals (README +`version-0.5.0-blue` badge, CLAUDE.md `(v0.5.0)` header, CHANGELOG `## [0.5.0]`). +Also synced the README `reference_docs-25→26` badge (not lint-checked, but stale after +the convention doc) and refreshed the now-false "user data still lives inside the plugin" +prose in README + CLAUDE.md to "M0 done — data external." Version-consistency lint green; +the only remaining `0.4.0` refs are CHANGELOG history + the `docs/m0/` planning docs that +describe the `0.4.0 → 0.5.0` transition itself (correct, not stale declarations). + +**Plan deviation — root marketplace docs NOT touched (scope).** Plan Step 18 listed +`/Users/ktg/.claude/plugins/marketplaces/ktg-plugin-marketplace/{README,CLAUDE}.md` as +"root docs." Skipped: that path is the **installed** marketplace artifact, **outside this +polyrepo**, and STATE.md scope is "KUN denne pluginnen" (+ global "don't write in other +repos"). The binding gate (the lint) checks only this plugin's four declarations, all +green. **Follow-up (separate catalog repo task):** reflect linkedin-studio v0.5.0 in the +marketplace catalog README if/when desired — operator's call, not this plugin's M0. + +## Session 3 — Steps 11–13 (2026-06-18) + +### Environment reality vs. plan assumptions + +The plan was authored assuming the operator's **real `.local.md` runtime data** +sat in the plugin tree (227-line voice profile, analytics exports, draft queue). +On this machine that data is **absent** — it is a clean clone: + +- `assets/voice-samples/`: only the PII-free placeholder `authentic-voice-samples.md` + (+ `.template.md`). No `.local.md` source. +- `assets/drafts/`: only `.gitkeep`. `assets/analytics/`: only `README.md` + empty `ab-tests/`. +- The 4 tracked D2 scaffold instances DO exist (`high-engagement-posts.md`, + `demographics.md`, `engagement-patterns.md`, `my-post-templates.md`). + +**Consequence for Step 12 (live migration):** every `MOVE_FILES` / `MOVE_DIRS` +entry resolved to an absent source → clean no-op. Only the 4 `COPY_FILES` +scaffolds were relocated. Result: `migrated — moved 0, copied 4, skipped 0`; +`.migrated` marker written; idempotent re-run confirmed `already-migrated`. + +**Step 12 verify adapted:** the plan's literal check +(`test -f .../voice-samples/authentic-voice-samples.md`) cannot pass without a +`.local.md` source to move, so it was replaced with the achievable post-condition: +`.migrated` marker present + 4 scaffolds external + `migrateData` wired into +`session-start.mjs` + idempotency. The voice MOVE correctly no-op'd. (Operator +pre-approved this adaptation before the run.) + +### OUT OF M0 SCOPE — git-history scrub of the leaked post (FOLLOW-UP) + +`assets/examples/high-engagement-posts.md` held the operator's **real** LinkedIn +post at HEAD (the "Ralph Wiggum / vibe-coding" post — real names, real engagement +metrics, real personal narrative). Step 13 scrubbed the **working-tree** content +to a generic placeholder (0 `## Post N` sections → personalization score 0, no +PII). **This does not remove the post from git history.** A history rewrite +(`git filter-repo` / BFG) on `assets/examples/high-engagement-posts.md` is a +separate, explicit operation — **deferred, not done here** (brief §13: out of M0 +scope). Track until the repo is published. + +### Note — external instance retains pre-scrub content (by design) + +The Step-12 migration copied `high-engagement-posts.md` to +`~/.claude/linkedin-studio/examples/high-engagement-posts.md` **before** Step 13 +scrubbed the in-plugin file (B3 ordering). The external copy therefore still holds +the migrated content. That is the operator's private data dir (outside any repo) — +the operator may curate it; not an M0 concern. + +### D2 scaffolds completed + +All 6 scaffolds now have a read-only `*-template.*` seed: `case-study-template.md` +and `framework-template.md` already shipped; Step 13 added the 4 missing ones +(`high-engagement-posts-template.md`, `demographics-template.md`, +`engagement-patterns-template.md`, `my-post-templates-template.md`). The 3 +already-generic instances seeded their templates verbatim; the high-engagement +template is a freshly-authored generic seed (the old instance was the leak). diff --git a/docs/okf-convergence-brief.md b/docs/okf-convergence-brief.md new file mode 100644 index 0000000..828be74 --- /dev/null +++ b/docs/okf-convergence-brief.md @@ -0,0 +1,190 @@ +# Brief — Cross-plugin second-brain convergence on OKF-compatible form + +> Created 2026-06-26. **Reference-design brief — not an implementation order.** Captures the operator-locked +> direction for converging three plugins' user-owned second brains onto one shared, interoperable form, +> with **linkedin-studio's brain as the reference design** and **Google OKF as a thin interop layer only**. +> Cross-cutting: most rollout lands in sibling repos and requires its own per-repo go (see §8). State-of-play +> in `STATE.md`. Companion design docs (read alongside): `okr/docs/okf-second-brain-note-2026-06.md`, +> `ms-ai-architect/docs/okf-second-brain-brief-2026-06.md`. + +## 1. Locked decision (operator, 2026-06-26) + +Converge on **the user's own context** (not the plugins' domain reference files), driven by **interop** — +**not** standard-adoption for its own sake. + +- **linkedin-studio's brain is the reference design** — the most mature of the three (provenance-weighted + learning, episodic/semantic split, evidence-threshold promotion). The siblings rise toward its maturity; + it is **not** levelled down to bare OKF. +- **OKF is the thin interop veneer** — add `type:` + per-level `index.md`; keep all rich fields as extension + keys (OKF consumers MUST preserve unknown keys). No capability is sacrificed. +- **Staged:** ship a shared **spec/convention first**; build a shared **skill only if measured divergence + justifies it** (okr's retrieval already works — see §4). + +## 2. Premise corrections (verified — these overturn the old STATE/memory framing) + +The pre-existing framing ("greenfield shared ingest skill; inbox→classify→convert→emit OKF; mdcode is the +key tool") rested on three premises that ground-truth checks **disproved**. Verified against the live +`GoogleCloudPlatform/knowledge-catalog` repo (research agent, 2026-06-26, file+URL log retained) and the +sibling repos. + +1. **`mdcode` is NOT an OKF tool.** It is a **Google Cloud Dataplex** git-sync tool whose on-disk `kb` + markdown carries a *different* frontmatter schema (`id`/`resource.name`/`createTime`/`links`) than OKF + (`type`/`title`/`description`/`tags`/`timestamp`). They are not interchangeable. Do **not** plan `kcmd` + to emit or sync OKF bundles. +2. **"OKF has no ingest" is true of the *format*, not the *repo*.** The repo ships an OKF *producer* + (`okf/src/reference_agent`, BigQuery+web→OKF) — but it reads a BigQuery dataset + seed URLs, **not** a + document folder, and is Gemini/GCP-bound. The genuinely reusable, GCP-free parts are the **SPEC**, the + **emit/serialize/validate** core (`OKFDocument`), and the `index.md` synthesis. +3. **Classify/convert of arbitrary documents is exactly what the repo provides *nothing* for** — those + stages are 100% build-yourself. And — decisive — **the sibling design docs never asked for them.** Both + frame the work as *OKF as the storage format for a user-owned second-brain wiki* + a **retrieval skill** + + a **maintenance mechanism**, with ingest being light ("onboarding writes OKF-conformant"), not + auto-classification. + +## 3. Landscape — the three consumers have already diverged + +| Plugin | Second-brain status | Maturity | +|---|---|---| +| **okr** | **Built.** `scripts/okf-index.mjs` + `okf-check.mjs` (conformance checker) + `lib/frontmatter.mjs` + skill `okr-second-brain-search` **v1.6.0** ("OKF-compatible markdown wiki") + tests + fixtures (`okf-minimal/`, `okf-realistic/`) + `inject-okr-context.mjs`. | Structured + retrieval (built) | +| **ms-ai-architect** | **Designed, not built.** `docs/okf-second-brain-brief-2026-06.md` (operator-confirmed) + `ref-kb-direction-note` + `ref-kb-workflow-plan`. No retrieval skill yet. | Designed | +| **linkedin-studio** | **Built, richer non-OKF schema.** `brain/` hub + `ingest/{inbox,published}` + `journal/` (episodic) + two-layer `profile.md` (semantic), provenance-weighting, evidence-threshold promotion, temporal validity. Engine: `scripts/brain/`. | Provenance-weighted learning system (most mature) | + +**Reading of the siblings' own docs:** they chose OKF because their second brains lived in ad-hoc `org/*.md` +**with no retrieval mechanism** — for them OKF (really: *structured markdown + a retrieval skill*) was an +upgrade from nothing. linkedin-studio is already past that point. So the convergence is "siblings rise to +the reference," not "everyone adopts a new format." + +## 4. okr already supplies the reference checker + +`okr/scripts/okf-check.mjs` implements **exactly the minimal contract** this brief recommends, and is the +de-facto reference implementation to align the shared spec with: + +- **Only `type:` is required** on a concept file (`.md` except `index.md`); ≥1 file without `type` → fail. +- Recommended fields (`resource`/`title`/`description`/`timestamp`) → **warnings, not errors**. +- Root `index.md` carries an `okf_version` marker, echoed for human comparison — **no auto-fetch** (hooks + are no-network). + +This means okr has **both** a writer and a checker in production. The shared artifact should generalize +okr's checker semantics, not reinvent them. (Reading okr's code is fine; **writing** okr is a separate go.) + +## 5. OKF v0.1 — verified core contract + +Source: `github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md` (v0.1, 12 June 2026, +"a starting point, not a finished standard"). + +- **Bundle** = a directory tree of markdown files, **one concept per file**. **Concept ID** = file path + minus `.md`. +- **Frontmatter:** required `type` (free string); recommended `title`, `description`, `resource` + (canonical source URI), `tags`, `timestamp`. **Consumers MUST preserve unknown keys and tolerate unknown + `type` values.** (Note: the Google *reference producer* is stricter than the spec — it also requires + `title`/`description`/`timestamp`. Build to the spec; supply the rest where cheap.) +- **Reserved filenames:** `index.md` (directory enumeration, **no frontmatter**, progressive disclosure) + and `log.md` (change log). Optional `okf_version` lives in the bundle-root `index.md`. +- **Cross-links:** plain markdown links (bundle-relative `/...` or relative); relation type is conveyed by + prose. **Consumers MUST tolerate broken links.** +- **Permissiveness is the whole point for us:** OKF is a *minimal superset-friendly* contract. Conforming + costs `type` + `index.md`; our rich fields ride along untouched as extension keys. + +## 6. The deliverable — "OKF-compatible second-brain form" + +A spec (document, not code) that all three plugins' user-data conforms to: + +1. **Minimal contract:** every concept file carries `type:`; each directory level has an `index.md`; + bundle-root `index.md` carries `okf_version`. Recommended fields where cheap. (= okr's `okf-check` + semantics, generalized.) +2. **Rich fields survive as extension keys.** linkedin-studio's brain keeps `provenance`, `first_seen`, + `last_seen`, `evidence_count`, `status`, episodic/semantic distinction — all as extra frontmatter keys + OKF must preserve. The model-collapse guard (`provenance=published` only) is unaffected. +3. **Mapping for our brain** (illustrative; verify writers in §10): + - `brain/index.md` → bundle-root index + `okf_version`. + - `brain/profile.md`, `operations.md`, `journal/*.md`, tributary summaries → concept files; each gains a + `type` (e.g. `Profile`, `Operations`, `JournalEntry`, `TributarySummary`) + retains its existing rich + frontmatter. + - `ingest/inbox/` stays the **manual drop-zone** (already exists) — the "inbox folder" mechanism, with + no heavy auto-classifier built now. + +## 7. Staged plan + +- **Stage 1 — Shared spec/convention (cheap, delivers interop).** Author "OKF-compatible second-brain form" + as a cross-cutting document; align it with okr's `okf-check`. Each plugin's user-data conforms; one + reader can traverse all three. **This alone meets the interop goal.** +- **Stage 2 — Measure divergence.** Do the per-plugin retrieval skills (okr's built one; architect's + planned one; linkedin-studio's in-context reads) diverge enough to hurt? Only a *measured* yes justifies + Stage 3 (operator anti-pattern: "ambitious initiatives where a config tweak suffices"). +- **Stage 3 — Conditional shared skill.** If justified: extract/generalize okr's working + `second-brain-search` into one home (see §9), with a discovery convention for where each plugin's brain + lives. + +## 8. Home decisions + +- **The spec** is cross-cutting → **catalog/marketplace level** (owned by no single plugin). +- **A future shared skill** (Stage 3 only) → a **standalone marketplace plugin** (own repo, release-tagged, + catalog-pinned), installable alongside the others, serving **consumer (a) — the user's own context — + directly**. Rejected alternatives: duplicate-per-plugin (drift risk); user-level `~/.claude/skills/` + (unversioned, outside the catalog). + +## 9. Per-repo scope boundaries (each its own explicit go) + +| Repo | This initiative's work | Status | +|---|---|---| +| **linkedin-studio** (here) | (1) Be the reference design (mostly exists in `docs/second-brain/architecture.md`). (2) Make our own brain emit OKF-compatible form (`type` + per-level `index.md` + root `okf_version`) without dropping rich fields. | **In scope — (2) ✅ LANDED 2026-06-26** | + +> **Stage-1 outcome (2026-06-26).** Brain writers now emit OKF-compatible form: `serializeProfile` +> leads with `type: Profile` frontmatter (constant → round-trip-safe), `operations.md` seed leads with +> `type: Operations`, `brain/index.md` carries an `okf_version: 0.1` marker, and `brain/journal/index.md` +> is scaffolded (per-level index). **Premise refinement (verified):** the brain is *deliberately* +> YAML-free with a byte-exact round-trip invariant on `ingest/published/*.md` (SC2) that a frontmatter +> block would break — so the OKF concept-bundle is scoped to **`brain/` only**; the round-trip-critical +> **`ingest/` tributary is excluded** and pointed to from the hub index. We **emit** frontmatter, adding +> no YAML parser. 5 new tests (`tests/okf-conform.test.ts`); full brain suite **132/132**; cross-tool +> proof — `okr/scripts/okf-check.mjs` validates `brain/` (exit 0). **Finish (same day):** the cheap +> recommended fields `title`/`description` added to the concept frontmatter (`timestamp`/`resource` stay +> out — a timestamp would break the pure serializer, `resource` is N/A for an internal concept); and the +> transient `brain/pending-diff.md` now carries `type: PendingDiff` so the bundle passes `okf-check` even +> mid-propose (re-verified exit 0 with a pending-diff present). Brain suite **134/134**. +| **okr** | Optional form-conformance alignment (already has writer + checker). | **Separate go** | +| **ms-ai-architect** | Build its retrieval skill against the shared spec. | **Separate go** | +| **catalog** | Host the shared spec. | **Separate go** (catalog only via `release-plugin.mjs`) | +| **new standalone plugin** | Stage-3 shared skill, if justified. | **Separate go** | + +Per scope-guard + "never write in other repos without explicit instruction": this session touches +**linkedin-studio only**. + +## 10. Key assumptions + tests (plan-quality mandate) + +| Assumption | Test (before relying on it) | +|---|---| +| OKF preserves unknown keys → our rich brain fields survive conformance | **✅ Verified:** `okf-check.mjs` exits 0 on `brain/`; `profile.md` round-trips (`parseProfile` skips the frontmatter, `parse ∘ serialize` identity holds). | +| Our brain is already near-OKF (conformance is a small writer change) | **⚠️ Refined → verified:** brain is *deliberately* YAML-free and `ingest/published` is round-trip-critical, so a literal frontmatter target conflicts there → bundle scoped to `brain/`, `ingest/` excluded (tributary). 4 surgical writer touchpoints (3 scaffold seeds + `serializeProfile`); we EMIT frontmatter, add no parser. | +| okr's `okf-check` semantics generalize as the shared conformance contract | Diff okr's contract (only-`type`-required, recommended=warnings, `okf_version` echo) against OKF SPEC §9 conformance → confirm it is a faithful, slightly-laxer subset. | +| A shared skill is *not yet* justified | Stage-2 measurement, deferred — do not build Stage 3 before it. | + +## 11. Open choices (resolve in `/trekbrief` or measurement, not now) + +- **Retrieval mechanism:** native Grep/Glob/Read (skill instruction "search the wiki first, open only + relevant") vs. a dedicated fileskb MCP server. Both sibling docs lean **native** (Claude Code's + Grep/Glob/Read already cover OKF's list/search/read). Genuine doubt → "build both, measure" candidate. +- **Degree of OKF formalism:** full v0.1 conformance vs. "OKF-compatible form" (frontmatter + `index.md` + only). Lean to the lightest that yields smart retrieval. +- **Inbox auto-classify/convert:** **defer.** OKF gives nothing for it; the manual inbox seam already + exists. Build only on demonstrated need. +- **Discovery convention:** how a shared skill finds each plugin's brain root. +- **OKF version-bump tracking:** how to catch v0.1 → later without manual polling (hooks are no-network). + +## 12. Success criterion (operator, inherited from both sibling tracks) + +Measured against **user value** (does the plugin retrieve the right personal/org context in chat and +commands?) + **maintenance reliability** — **not** against formal OKF conformance for its own sake. + +## 13. References + +- OKF SPEC v0.1: `github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md` +- Sibling design docs: `okr/docs/okf-second-brain-note-2026-06.md`, + `ms-ai-architect/docs/okf-second-brain-brief-2026-06.md`, + `ms-ai-architect/docs/ref-kb-direction-note-2026-06.md` +- okr reference implementation: `okr/scripts/okf-check.mjs`, `okr/scripts/okf-index.mjs`, + `okr/skills/okr-second-brain-search/SKILL.md` +- Our reference design: `docs/second-brain/architecture.md`; engine `scripts/brain/` +- Verified-OKF research log (files+URLs fetched on `main`): retained in session transcript, 2026-06-26 +- Memory: `google-okf-open-knowledge-format`, `profile-evolution-second-brain`, + `plugin-vs-maskinrommet-division`, `plugin-is-domain-general` diff --git a/docs/research-engine/brief-re-r1.md b/docs/research-engine/brief-re-r1.md new file mode 100644 index 0000000..eb8969f --- /dev/null +++ b/docs/research-engine/brief-re-r1.md @@ -0,0 +1,89 @@ +# Brief — RE-R1: lift the research engine's item-schema + triage-scorer from agent prose to tested code + +> **Slice:** RE-R1 (research-engine rung-2, slice 1) — the first build slice of the daily research motor. +> **Status:** drafted, awaiting go-gate. **Predecessors:** "slice 2a" = the scoring SSOT `references/trend-scoring-modes.md`; "slice 2b" = the trend store `scripts/trends/` (24/24). This slice adds the **scoring/normalization code those two anticipate** (`trend-scoring-modes.md:96-100` names "any future research-engine pass that scores candidates before writing them to the trend store" as a consumer). +> **Substrate:** `docs/research-engine-concepts.local.md` §1, §3 (B1/B2), §5 ("anbefalt minste neste slice"). **TDD-order:** RED tests + RED gate self-test land before code. + +## 1. Operator decision context (2026-06-23) + +The operator lifted the **research engine to Tier-1**. Rationale, verbatim: *"hele min arbeidsflyt hviler på at jeg får en jevn strøm av gode forslag til tema å skrive om."* A/moat (lived-specifics · positioning · shareability · analytics→loop) remains the long-term nordstjerne but is built **interactively in Maskinrommet from lived use**, not as a TDD slice now. + +This slice is exactly the one `research-engine-concepts.local.md` §5 named as the minimal next move *if research is lifted*: **B1 (uniform item-schema, tested) + B2 (triage-scorer as code with threshold config)**. It moves "the only untestable layer" from agent prose into gated code, and is the foundation every later rung (brief artifact, surfacing, freshness, deepening) hangs on. + +## 2. The gap — grounded in code + +- **Storage exists; scoring does not.** `scripts/trends/src/store.ts` (183 LOC) stores/dedupes/queries `TrendRecord {id,title,url,source,capturedAt,topics[],summary?}` (`types.ts:26-41`). Its header states scoring "lives in the agent/command layer; this module only stores, dedupes, and serves." There is **no scoring/routing/saturation/digest code anywhere** in the repo. +- **The arithmetic is prose the LLM runs in its head.** `references/trend-scoring-modes.md` (SSOT, 101 lines) defines two modes — **kortform** (Pillar .30 / Audience .25 / Timing .20 / Angle .15 / Authority .10) and **long-form** (Pillar .30 / Depth .25 / Angle .20 / Authority .15 / Currency .10) — a weighted composite on a shared 0–10 scale, and a composite→band map (8.0–10 Immediate · 6.0–7.9 High · 4.0–5.9 Medium · 2.0–3.9 Low · 0–1.9 Skip), each band carrying a kortform + long-form action. `agents/trend-spotter.md:124-137` tells the agent to "score each dimension 1–10, take the weighted composite, rank highest-first, apply the bands" — **deterministic arithmetic with no test and no code.** +- **Ingress is ad hoc.** Nothing normalizes a source's raw output into one item envelope before it reaches `addTrend`; downstream would branch on source-type. B1 is the "one schema downstream never branches on" the concepts doc calls "the most load-bearing idea — build it first." + +## 3. Scope — what is IN (RE-R1) + +### B1 — canonical item schema + normalizer (`scripts/trends/src/item.ts`, NEW) +A `TrendItem` **ingress envelope** `{ source, title, url, publishedAt?, topics[], summary? }` — what a source/adapter emits — plus a pure `normalizeItem(raw): { ok: true, item } | { ok: false, errors }` and `normalizeItems(raw[]): { items, errors }` (batch partition). Deterministic: required-field validation (`source/title/url` present and non-empty → else a structured `{ok:false}` error naming the field, never a silent partial), whitespace normalization (reuse `normalizeField` from `store.ts`), topic normalize + dedupe, `publishedAt` ISO-8601-validate-**if-present** (the source's publish date — carried for forward-compat, **not persisted in R1**, and distinct from the store's `capturedAt`). **No `id` field:** the envelope carries none and the store already derives it via `addTrend`→`trendId`. Vocabulary kept as `topics` (≡ the concepts doc's `tags`; see §8). **The item→store bridge** (injecting `capturedAt`, persisting `publishedAt`) is **R2 orchestration — out of scope here**; R1 delivers the validated envelope + normalizer only. + +### B2 — deterministic triage-scorer (`scripts/trends/src/score.ts`, NEW) +`ScoreMode = 'kortform' | 'long-form'`; per-mode weight constants **encoded from the SSOT** (with the SSOT's "ordering is the signal, not a measured coefficient" caveat as a comment). `DimensionScores` (mode-specific keys). `composite(scores, mode): number` — weighted sum, validates each dimension ∈ [1,10]. `band(composite): { priority, kortformAction, longformAction }` — the five-band map. `triage(scored, { mode, threshold }): { kept, dropped }` — kept = composite ≥ threshold, ranked composite-desc, each annotated with composite + band; dropped below. `threshold` is a single config parameter (default — see §8). The module owns **only** the arithmetic / bands / threshold; producing the five dimension scores stays model judgment (the deterministic/judgment split from the concepts doc §5 / SSOT analysis). + +### CLI surface (`scripts/trends/src/cli.ts`, EDIT existing) +Two new subcommands over the new modules — both **read their JSON payload from stdin** (a raw item/batch for `normalize`; items-with-dimension-scores for `score`) and **emit JSON to stdout**, so they do **not** overload the existing `--json` *output* toggle (`cli.ts:79`). `score` takes `[--mode kortform|long-form] [--threshold N]` flags. Exit codes: **2** for a malformed invocation (missing required flag, unparseable stdin JSON) — matching `cli.ts`'s usage-error precedent (`cli.ts:54-63`); **0** for a well-formed call, even when the payload carries content-invalid items (reported as `{ok:false,...}` entries in the output, never via the exit code). + +### Wiring + gate +- `agents/trend-spotter.md` (EDIT): replace the "do the composite + bands yourself" prose at L124-137 with a pointer to the scorer CLI as the owner of the composite/band/threshold step (the lift becomes real and grep-able; the agent still supplies the five judgment scores). Kept domain-general. +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (recount live at land; it **stays inside the `if [ -x …/tsx ]` deps guard** — conditional, never hoisted out). Add a new **grep-only** Section **16g**, placed after Section 17 (de-niche) and **before** Section 18 (anti-erosion, which must run last so it sees every prior check), with deps-absent-safe checks (no `tsx`): (1) `grep` that `score.ts` encodes both mode weight-sets; (2) `grep -qF "src/cli.ts score"` that `trend-spotter.md` references the scorer CLI; (3) a non-vacuity self-test for those greps, per the house pattern (every sibling section 16c–17 has one). These are **unconditional** → bump `ASSERT_BASELINE_FLOOR` 84 → **live recount** (expected ~+3, not a pinned number). The arithmetic proof (`composite(all-tens)=10.0` ⇒ weights sum to 1.0) lives as a **unit test** in `score.test.ts` (conditional, counts toward the trends floor), since it needs the `tsx` runtime. + +## 4. Non-goals — what is OUT (deferred) + +- **The dated brief artifact / morning-brief file (B3)** — R2. *This is the rung that makes the stream visible to the operator; R1 is the foundation beneath it, not the stream itself.* +- **Session-start surfacing** of the brief (hull 4) — R2. +- **Store-schema migration / new persisted fields** (publishedAt, relevance, saturation, status — hull 3/5) — R2. R1 keeps `SCHEMA_VERSION = 1` untouched (no migration risk). +- **Freshness window / dedup-vs-seen-log / autonomous trigger (B4)** — R3. +- **Research-deepening (plan → isolated workers → synthesis, A1–A4)** — R3. +- **Adapter sub-agents / MCP fetch fan-out** — R2/R3. +- **Producing the five dimension scores in code** — stays model judgment, by design. + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law:** the failing `item`/`score` tests **and** the failing gate self-test land BEFORE the implementation. RED proofs recorded (logic-RED, not import-RED). +- **No store-schema change:** `SCHEMA_VERSION` stays 1; `TrendRecord` untouched; no data migration. +- **Domain-general:** de-niche guard (Section 17) stays green; no vendor/sector tokens enter the edited `trend-spotter.md` prose. +- **SSOT discipline:** `trend-scoring-modes.md` remains the human source of truth; `score.ts` mirrors it; a test pins **the exact per-mode weights, the four band thresholds (8.0/6.0/4.0/2.0), and the ten band action strings** against the SSOT values, and asserts each mode sums to 1.0 — so silent drift in *any* of them (not just the weights) fails loudly. (A markdown-parsing cross-check of the SSOT table itself is deferred — out of scope.) +- **House conventions:** ESM + `node:test` + `tsx`; data-seam stays inline (no new shared util — the documented idiom); no new `.mjs` under `hooks/scripts/`; `.gitignore` already covers `scripts/trends/{node_modules,build}`. +- **No new `references/*.md`** (would trip the named-additions guard) and **no new agent/command** (counts stay 19/29/27). Brief+plan live under `docs/` (uncounted), TRACKED like `docs/second-brain/*` (general feature design). +- **Counts recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1** — `normalizeItem` returns a canonical item from a well-formed raw item; a missing/empty required field returns a structured `{ok:false}` error naming the field (not a silent partial); whitespace + topic dedupe applied; `publishedAt` validated-if-present, undefined-if-absent. `normalizeItems` partitions a batch into `{items, errors}`. +- **SC2** — `composite()` equals the SSOT formula exactly for both modes: all-tens → **10.0** (proves Σweights=1), and the **asymmetric golden vector `{10, 8, 6, 4, 2}` (in dimension order) → 7.0** for both modes (`10·.30 + 8·.25 + 6·.20 + 4·.15 + 2·.10`) — asymmetric so a weight↔dimension transposition is caught; a dimension outside [1,10] throws. +- **SC3** — `band()` returns the correct priority + **the exact mode-appropriate action string** (pinned against the SSOT) at every boundary (8.0, 6.0, 4.0, 2.0 edges). +- **SC4** — `triage()` keeps composite ≥ threshold, drops below, ranks kept composite-desc, annotates each kept item with composite + band. +- **SC5** — CLI `normalize`/`score` read JSON from stdin, emit valid JSON; exit **2** on a malformed invocation (missing flag / unparseable stdin), **0** on a well-formed call (content-invalid items reported in the payload, never via exit code). +- **SC6** — `(cd scripts/trends && npm test)` green at the bumped floor; gate's new unconditional section green; `ASSERT_BASELINE_FLOOR` bumped to the live recount; overall gate `FAIL=0`. +- **SC7** — `trend-spotter.md` references the scorer for the deterministic step (gate grep green); de-niche guard green; structure counts unchanged (19/29/27). + +## 7. Verification + +**Deterministic (gate):** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; new self-test + wiring-grep pass; Section 17 de-niche green; ref/agent/command counts unchanged. +**Behavioural (manual):** run `npm run start -- normalize --json ''` and `... score --json '' --mode kortform --threshold 4.0`; eyeball that kept/dropped/bands match a hand-computed expectation on 3–4 items. + +## 8. Open questions for the go-gate + +1. **Default threshold.** Propose **4.0** — the SSOT's Medium-band floor and the agent's existing "score 4.0+" angle-mapping cutoff (`trend-spotter.md:179,274`). Confirm or set otherwise. +2. **Vocabulary `topics` vs `tags`.** The concepts doc envelope says `tags`; the codebase standardized on `topics`. Propose **keep `topics`** (consistency, no rename) and note `tags ≡ topics`. Confirm. +3. **CLI tests now?** Trends has no `cli.test.ts` today; siblings (brain, contract-gate) do. Propose **add light cli tests** for the two new subcommands (happy path + exit codes). Confirm vs defer. +4. **Wire `trend-spotter.md` now?** Propose **yes** — a prose pointer so the lift is real and gate-grep-able; full orchestration (fan-out, brief assembly) stays R2. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts. **scope-guardian: ALIGNED** (every SC1–SC7 traces to a step; zero scope creep; NON-goals fully respected). **brief-reviewer: PROCEED_WITH_RISKS.** **plan-critic: REVISE (1 blocker, 6 majors).** All findings folded: + +- **[BLOCKER, folded]** `TrendItem`→`TrendInput` does not map directly — live `TrendInput` requires `capturedAt` and has no `publishedAt`; the item has the opposite. → §3 B1 now scopes R1 to the validated envelope only; the item→store bridge (`capturedAt` injection, `publishedAt` persistence) is **R2**. A scope-tightening, not an addition. +- **[MAJOR, folded]** CLI `--json` is already an *output* toggle → §3 now reads payload from **stdin**, `--json` untouched. +- **[MAJOR, folded]** Gate-section placement + anti-erosion-last → §3 pins **Section 16g, before Section 18**; `TRENDS_TESTS_FLOOR` stays inside the deps guard. +- **[MAJOR, folded]** `ASSERT_BASELINE_FLOOR` was pinned to 86 against the recount-live rule, and the house self-test pattern makes it +3 → §3 now says **live recount (~+3), not pinned**. +- **[MAJOR, folded]** Only weights were drift-guarded → §5 SSOT discipline now pins **weights + band thresholds + the ten action strings**. +- **[MAJOR, folded]** Wiring grep literal unpinned → §3 pins `grep -qF "src/cli.ts score"`. +- **[testability, folded]** SC2 unnamed vector → **pinned `{10,8,6,4,2}`→7.0**; SC5 exit-code ambiguity → **explicit 2-vs-0 contract**; `id` hedge → **envelope carries no id**; `normalizeItems` batch shape → **SC1 extended**. +- **[minor, folded in plan]** RED-stub split per assertion type; explicit `node --import tsx` invocation (not `npm run start`); commit grouping fixed; STATE.md noted as land-bookkeeping; `config/trends-sources.template.md` added to the scope fence. + +See `plan-re-r1.md` §Plan-critic — folded for the per-step resolution. diff --git a/docs/research-engine/brief-re-r2a.md b/docs/research-engine/brief-re-r2a.md new file mode 100644 index 0000000..ce6e185 --- /dev/null +++ b/docs/research-engine/brief-re-r2a.md @@ -0,0 +1,208 @@ +# Brief — RE-R2a: capture bridge (item→store) + publishedAt persistence + +> **Slice:** RE-R2a (research-engine rung-2, slice 2 — the data layer of R2). The first of the two +> halves R2 ("the visible topic-stream") was split into at the 2026-06-24 go-gate: **R2a** = the pure +> `scripts/trends/` data layer (this brief); **R2b** = the dated morning-brief artifact + session-start +> surfacing (separate brief, after R2a lands). The split was chosen because (b)+(c) are code-independent +> of (a), R2a mirrors R1's pure-TDD shape exactly (lowest risk), and it closes the capture loop the store +> was built for. +> **Predecessor:** RE-R1 (`brief-re-r1.md`) delivered the validated ingress envelope (`item.ts`: +> `normalizeItem`/`normalizeItems`) and the triage scorer (`score.ts`) behind a CLI seam, and **explicitly +> deferred the item→store bridge** ("injecting the store's `capturedAt` and persisting `publishedAt` — is +> R2 orchestration", `brief-re-r1.md` §3 B1). R2a builds exactly that deferred bridge. +> **Substrate:** `docs/research-engine-concepts.local.md` §1 (hull 3 "store-schema mangler felt" / the +> item→store gap), §3 B1 (one schema downstream never branches on). **TDD-order:** RED tests land before code. + +## 1. Operator decision context (2026-06-24) + +The research engine is **Tier-1** (operator, 2026-06-23): *"hele min arbeidsflyt hviler på at jeg får en +jevn strøm av gode forslag til tema å skrive om."* At the R2 go-gate (2026-06-24) the operator chose to +build R2 as **two sequenced slices, foundation-first**: R2a (this — the bridge + schema, pure trends/) before +R2b (the visible brief + surfacing). R2a delivers no *visible* change; it closes the capture loop so the +store accumulates publish-dated history that R2b's brief and R3's freshness window will rank on. + +## 2. The gap — grounded in code + +- **`normalize` (R1) and the store are disconnected.** `item.ts` validates a raw source item into the + canonical `TrendItem` envelope `{source,title,url,publishedAt?,topics[],summary?}` (`item.ts:21-38`), but + **nothing folds that envelope into the store.** The only ingress to the store is `cli.ts add` (`cli.ts:111-135`), + which builds a `TrendInput` from raw flags — it never calls `normalizeItem`, so a capture path that polls → + normalizes → stores **does not exist**. The bridge is the missing link R1 named and deferred. +- **`publishedAt` is carried but dropped.** The envelope carries the source's own publish date + (`item.ts:28-33`, "NOT persisted in R1"), but `TrendRecord` (`types.ts:26-41`) has no `publishedAt` field and + `addTrend` (`store.ts:111-130`) never persists it. So every capture **loses** the publish date — the field + B4 freshness (R3) and R2b's "fresh today" sort will both need. R2a persists it now, before history accumulates + undated, so the loop is publish-dated from this slice forward. +- **`SCHEMA_VERSION` is pinned at 1 with no migration path exercised.** `loadStore` (`store.ts:74-81`) reads + `parsed.schemaVersion ?? SCHEMA_VERSION` but the store has never had a second version, so the forward-migration + path is untested. Adding `publishedAt` is the first additive-optional schema bump (v1→v2) and the right moment + to prove a lossless migrate-on-load. + +## 3. Scope — what is IN (RE-R2a) + +### B-bridge — `itemToInput` (pure mapping, `scripts/trends/src/item.ts`, EDIT) +A pure `itemToInput(item: TrendItem, capturedAt: string): TrendInput` that maps the validated envelope to a +store input by **injecting `capturedAt`** (the store's "when WE saw it", supplied by the caller — never derived +inside the pure function) and **carrying `publishedAt`/`topics`/`summary`/`title`/`url`/`source` through verbatim**. +It does **not** re-validate (the item is already validated by `normalizeItem`) and does **not** derive an `id` +(the store owns id via `addTrend`→`trendId`). Lives in `item.ts` (it is the item's mapping to the store; `item.ts` +already imports from `store.ts`) — **not** a new single-function `bridge.ts` (single-use abstraction). + +### Schema migration — `publishedAt` persisted (`types.ts` + `store.ts`, EDIT) +- `types.ts`: `SCHEMA_VERSION 1 → 2`; add `publishedAt?: string` to `TrendRecord` (after `capturedAt`), with a + comment distinguishing it from `capturedAt` (source publish-date; forward-compat for B4 freshness). +- `store.ts`: `TrendInput` gains `publishedAt?: string`; `addTrend` persists it when present (same + conditional-spread idiom as `summary`, `store.ts:126`). **On re-capture/merge it is NOT overwritten** — + it joins the store's documented "FIRST sighting's source + capturedAt are kept (provenance of first sight)" + rule (`store.ts:108-110`); no back-fill (a deferred enhancement, §4). +- `loadStore`: **forward migrate-on-load** — stamp the in-memory store to `Math.max(onDisk, SCHEMA_VERSION)`, + where `onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION` (a string / + `NaN` / absent version falls back to current, never crashes). v1→v2 is purely additive-optional (old records are + already valid v2 records that simply lack the optional field), so the migration is the stamp alone — **no record + rewrite; lossless + idempotent for any well-formed v1/v2 store**. A subsequent `saveStore` persists the v2 stamp. + **The existing non-array `trends` coercion (`store.ts:79`, `Array.isArray(parsed.trends) ? … : []`) is UNCHANGED + and out of R2a's migration scope** — losslessness is claimed only for well-formed stores, not for a corrupt + `trends` field. (No downgrade branch: a hypothetical future on-disk > current can't happen yet — Karpathy #2, no + handling for impossible scenarios; noted as a deferred risk.) + +### CLI `capture` subcommand (`scripts/trends/src/cli.ts`, EDIT) +`echo '' | node --import tsx src/cli.ts capture [--store ] [--json]` — reads the raw +JSON payload **from stdin** (same stdin contract as `normalize`/`score`, so `--json` stays an *output* toggle), +runs it through `normalizeItem`/`normalizeItems`, maps each valid item via `itemToInput(item, today())`, folds +each into the store via `addTrend`, and `saveStore`s once. Emits a summary — `{added, duplicates, merged, errors}` ++ store path/count — human-readable by default, JSON with `--json`. Content-invalid items are reported in the +summary (`errors[]`), **never via the exit code**; exit **2** only on a malformed invocation (unparseable/empty +stdin), matching `normalize`/`score`. Note the contract difference from `add`: `capture` items must carry `source` +(the normalizer requires it — no "manual" default), because a capture is from a real source. + +### Wiring + gate (see Open Question #1 — included by default, trimmable to minimal) +- `agents/trend-spotter.md` (EDIT): re-point **Step 4.5** (`trend-spotter.md:282-301`) from N× flag-based `add` to + a single batch `capture` — the agent builds a raw-item JSON batch (it already builds JSON for `score`) and pipes + it to `capture` in one call. Strictly better than `add` (it normalizes + carries `publishedAt`; one call, not N). + The replacement prose carries the literal `src/cli.ts capture`. Kept domain-general (no vendor/sector tokens). +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (62 → **live recount**, stays inside the deps guard). + Add **Section 16h** ("Trends Capture Wiring"), mirroring Section 16g's three-check shape, placed **after Section + 17 / before Section 18** (anti-erosion last): (1) a non-vacuity self-test; (2) `grep` that `cli.ts` has a `capture` + handler (deps-absent proof the path exists); (3) `grep -qF "src/cli.ts capture" agents/trend-spotter.md`. These are + **unconditional** → bump `ASSERT_BASELINE_FLOOR` 87 → **live recount** (expected ~90). + +## 4. Non-goals — what is OUT (deferred) + +- **The dated morning-brief artifact (B3)** — R2b. *R2a closes the capture loop; R2b makes the stream visible.* +- **Session-start surfacing** of the brief (hull 4) — R2b. R2a does **not** touch `hooks/**`. +- **`publishedAt` back-fill on re-capture** (fill an absent existing `publishedAt` from a later sighting) — deferred; + first-sight provenance is kept, matching the existing merge rule. Revisit if undated-first-sight proves common. +- **`add --published-at` flag** — deferred (was a proposed "minor"; folded out at light-Voyage). The flag-based + `add` is the *manual* single-trend path; it stays publish-date-free for now. The store layer still GAINS + `publishedAt` (on `TrendInput` + `addTrend`) — that is what `capture` needs — but exposing it on the `add` CLI + (and the `isValidIso`-export-vs-inline decision it would force) is out of R2a. Trivial to add any later slice. +- **`relevance` / `saturation` / `status` / lifecycle fields** (hull 5) — R3. R2a adds **only** `publishedAt`. +- **Freshness window / dedup-vs-seen-log / autonomous trigger (B4)** — R3. +- **Research-deepening (A1–A4), adapter sub-agents, MCP fetch fan-out** — R2b/R3. +- **A store-reading brief / ranking on accumulated relevance** — R2b/R3 (needs persisted scores). + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law:** the failing migration / `itemToInput` / `capture` tests land BEFORE the implementation. + RED proofs recorded (logic-RED, not import-RED). **NB:** only the **v1→v2** load + round-trip are genuinely RED + (old `loadStore` returns 1); v2-idempotence + missing/non-numeric-`schemaVersion` pass against old code, so they + are **GREEN-only regression guards**, not RED cases (see plan Step 1). +- **Lossless migration (well-formed stores):** every existing **well-formed** v1 store loads as v2 with **records + intact** (no `publishedAt` invented, no topic/summary/capturedAt/url/title change); idempotent (v2 → v2). A + corrupt `trends` field is coerced by the existing (unchanged) `Array.isArray` guard — explicitly **out of R2a's + losslessness claim**, not a regression introduced here. +- **First-sight provenance preserved:** re-capturing an existing trend never overwrites its `publishedAt`, + `capturedAt`, or `source` — only topics union (unchanged from R1). +- **No scoring change:** `score.ts` and `references/trend-scoring-modes.md` (the SSOT) are untouched — R2a is the + data layer, not the scorer. +- **Domain-general:** de-niche guard (Section 17) stays green; no vendor/sector tokens enter the edited + `trend-spotter.md` prose. +- **House conventions:** ESM + `node:test` + `tsx`; data-seam stays inline; **no new `.mjs` under `hooks/scripts/`** + (R2a touches no hook); `.gitignore` already covers `scripts/trends/{node_modules,build}`. +- **No new `references/*.md`**, **no new agent/command** (counts stay 19/29/27); no new `.ts` source file + (bridge lives in `item.ts`). Brief+plan live under `docs/` (uncounted), TRACKED like `docs/second-brain/*`. +- **Counts recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (bridge)** — `itemToInput(item, "2026-06-24")` returns a `TrendInput` with `capturedAt` injected (`=== "2026-06-24"`), + `publishedAt`/`topics`/`summary`/`title`/`url`/`source` carried verbatim, and **no `id`**. Absent `publishedAt` + on the item → absent on the input (key omitted, not `undefined`-valued). **Field-confusion guard:** for an item + whose `publishedAt` differs from the injected `capturedAt`, the result's `capturedAt !== result.publishedAt` + (proves the bridge never confuses the two dates — the whole point of the slice). +- **SC2 (persist)** — `addTrend` with a `publishedAt` persists it on the new record; a record without it omits the + key. On a re-capture (same title+url) the existing `publishedAt` is **unchanged** (first-sight kept), and only + topics union — `merged` reflects topic change alone. **No back-fill:** a re-capture carrying a `publishedAt` onto + a record that **lacked** one does **not** add it (Open Q#2) — the absent→present case is the one where "first-sight + kept" is counterintuitive, so it is pinned by a test. +- **SC3 (migrate)** — `loadStore` on a v1 store fixture (`{schemaVersion:1, trends:[…without publishedAt]}`) returns + `schemaVersion === 2` with every record intact and **no `publishedAt` invented**. Also pinned: a store with + **missing** `schemaVersion` → stamped 2 (records intact); an **empty/absent** store → `{schemaVersion:2, trends:[]}`; + a **non-numeric** `schemaVersion` (`"weird"`/`NaN`) → falls back to 2 (records intact); idempotent on a v2 store; + a round-trip `loadStore`→`saveStore` writes `schemaVersion: 2`. +- **SC4 (capture CLI)** — `echo '' | … capture` normalizes + folds: a well-formed item is added (or reported + duplicate/merged), a content-invalid item appears in the summary `errors[]`, the store file is written once, and + the summary counts are correct — **`added + merged + duplicates + errors.length === payload.length`** (the tally + is derived from `AddResult {added, merged}`, which has no `duplicates` field: `added`=`res.added`, + `merged`=`!res.added && res.merged`, `duplicates`=`!res.added && !res.merged`). The captured record's `capturedAt` + is a today-shaped ISO date **distinct from the item's `publishedAt`** (field-confusion guard at the ingress; the + exact-value assertion lives in `item.test.ts` with an injected date, since `capture` reads the wall clock). Exit + **2** on empty/unparseable stdin; **0** on a well-formed call even with content-invalid items. `--json` emits the + summary object. +- **SC5 (gate)** — `(cd scripts/trends && npm test)` green at the bumped `TRENDS_TESTS_FLOOR`; new Section 16h green; + `ASSERT_BASELINE_FLOOR` bumped to the live recount; overall gate `FAIL=0`. +- **SC6 (wiring + de-niche)** — `trend-spotter.md` references `src/cli.ts capture` (Section 16h grep green); de-niche + guard (Section 17) green; structure counts unchanged (19/29/27). *(If Open Q#1 → minimal: SC6 drops the wiring/16h + clauses; de-niche + counts still asserted.)* + +## 7. Verification + +**Deterministic (gate):** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; new Section 16h +self-test + greps pass; Section 17 de-niche green; ref/agent/command counts unchanged. +**Behavioural (manual):** `echo '<2-item batch incl. one publishedAt + one invalid>' | node --import tsx src/cli.ts +capture --store /tmp/r2a-trends.json --json`; confirm the valid item lands with `publishedAt` persisted, the invalid +one is in `errors[]`, and `node … list --store /tmp/r2a-trends.json --json` shows the persisted `publishedAt`. +Then load an authored v1 fixture and confirm `status --json` reports it migrated (count intact). + +## 8. Open questions for the go-gate + +1. **Wire `trend-spotter.md` + add Section 16h, or keep R2a minimal? — RESOLVED at go-gate (operator, 2026-06-24): WIRE.** + Build R2a with the agent re-point (Step 4.5 `add`→`capture`) + Section 16h grep/self-test + ASSERT-floor bump. The + minimal alternative (bridge code + tests only, no agent edit) was declined — wiring mirrors R1's "the lift is real + and grep-able" discipline and makes the bridge actually used (one normalizing `capture` vs N× `add`). +2. **`publishedAt` merge policy.** Proposed **keep first-sight** (no back-fill), matching the existing source/capturedAt + provenance rule (now pinned by the absent→present SC2 test). Confirm, or request back-fill-if-absent (adds a branch + + a `merged` semantic question). +3. **`add --published-at` flag — RESOLVED at light-Voyage: deferred** (see §4). The manual `add` stays publish-date-free; + `capture` is the path that carries `publishedAt`. Noted here only so the resolution is traceable; no go-gate action. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED** +(every SC1–SC6 traces to a step; zero creep; all §4 non-goals held; 2 minors). **brief-reviewer: +PROCEED_WITH_RISKS** (2 majors, 4 minors). **plan-critic: REVISE** (1 blocker, 5 majors, 5 minors). All findings +folded; see `plan-re-r2a.md` §Plan-critic — folded for the per-step resolution: + +- **[BLOCKER, folded]** "v2 load idempotent" cannot be a RED case — old `loadStore` (`store.ts:78`) returns + `parsed.schemaVersion ?? SCHEMA_VERSION`, so a v2 fixture already loads as 2 against unchanged code. → §6 SC3 + + §5 now split the migration cases: **only v1→2 + round-trip are RED**; v2-idempotence + missing/non-numeric are + **GREEN-only regression guards**. +- **[MAJOR, folded]** "lossless / byte-for-byte intact" over-claimed vs `loadStore`'s non-array `trends` coercion + (`store.ts:79`). → §5 + §3 scope losslessness to **well-formed** v1/v2 stores; the coercion is unchanged + out of + scope (not a regression). +- **[MAJOR, folded]** `capturedAt` injection unverified at the new `capture` ingress (the field-confusion the slice + exists to prevent). → SC1 + SC4 now pin `capturedAt !== publishedAt` (item.test exact-value; cli.test shape + + distinct). +- **[MAJOR, folded]** `capture` tally `{added, duplicates, merged}` mis-maps onto `AddResult {added, merged}` (no + `duplicates` field). → SC4 pins the exact derivation + `sum === payload.length`. +- **[MAJOR, folded]** `add --published-at` validation referenced the non-exported `isValidIso` (`item.ts:51`). → + **`add --published-at` deferred entirely** (§4), removing the export-vs-inline decision and keeping `item.ts`'s + edit to `itemToInput` only. +- **[MAJOR, folded]** README "Capture … `add`" framing would contradict the agent's `add`→`capture` re-point. → + plan Step 5 now corrects the README framing (`add` = manual single-trend; `capture` = normalizing batch), not just + appends. +- **[MAJOR, folded]** empty "folded" placeholders shipped in the doc bodies. → this section + plan §Plan-critic now + filled. +- **[MINOR, folded in plan]** missing-/non-numeric-`schemaVersion` test, absent→present back-fill test, stub-replacement + note, `import type { TrendInput }` line, `grep -qF` for the 16h literal, `TRENDS_TESTS_FLOOR` breakdown-comment update, + README in the code commit, headless per-step clauses N/A (in-session execution). diff --git a/docs/research-engine/brief-re-r2b.md b/docs/research-engine/brief-re-r2b.md new file mode 100644 index 0000000..4eefbe2 --- /dev/null +++ b/docs/research-engine/brief-re-r2b.md @@ -0,0 +1,309 @@ +# Brief — RE-R2b: dated morning-brief artifact + session-start surfacing + +> **Slice:** RE-R2b (research-engine rung-2, slice 2 — the *visible* layer of R2). R2 ("the visible +> topic-stream") was split at the 2026-06-24 go-gate into **R2a** (the pure `scripts/trends/` data layer — +> capture bridge + `publishedAt` persistence, landed `7a15803`) and **R2b** (this — the dated morning-brief +> artifact + session-start surfacing). R2a delivered **no visible change**; it closed the capture loop so the +> store now accumulates publish-dated history. R2b makes that stream visible: a deterministic, dated Markdown +> brief ranked over the store, surfaced at session-start. +> **Predecessor:** RE-R2a (`brief-re-r2a.md`) persisted `publishedAt` first-sight and shipped the `capture` +> CLI; it **explicitly deferred** the dated brief artifact (B3) and session-start surfacing (hull 4) to R2b +> (`brief-re-r2a.md` §4). R2b builds exactly those two deferred pieces. +> **Substrate:** `docs/research-engine-concepts.local.md` §3 **B3** (dated-digest as a flat plain-text +> artifact: `YYYY-MM-DD.md`, diffbar/grep-bar/lastbar; delivery is a separate later step) + **hull 4** +> (surfacing at session-start) + §1 hull list (2)(4)(7). **TDD-order:** RED tests land before code. + +## 1. Operator decision context (2026-06-24) + +The research engine is **Tier-1** (operator, 2026-06-23): *"hele min arbeidsflyt hviler på at jeg får en jevn +strøm av gode forslag til tema å skrive om."* At the R2 go-gate the operator chose foundation-first: R2a (the +bridge + schema) before R2b (the visible brief + surfacing). **R2b is the slice that delivers the visible +value** — the operator opens a session and *sees* a dated brief of fresh, on-pillar topic signals, without +running anything. The autonomous nightly trigger that would *regenerate* it unattended stays R3 (hull 1); in +R2b the brief is produced **on demand** (a trend scan, or the `brief` CLI) and **surfaced** whenever one +exists. Confirmed at format sign-off (2026-06-24): **D1** on-demand generation + session-start surfaces the +latest (no tsx in the hook to regenerate); **D2** deterministic ranking on pillar-overlap → recency (no AI / +no `score.ts` in the brief path — scores aren't persisted yet); **D3** freshness window = 7 days. + +## 2. The gap — grounded in code + +- **The store accumulates publish-dated history nobody reads.** Post-R2a, `TrendRecord` carries + `publishedAt?` (`types.ts:43`) and the `capture` CLI folds polled trends in (`cli.ts:237-263`), but **no + artifact ranks or presents the accumulated store.** The read surfaces today are `query` (topic-scoped, + `cli.ts:143-164`) and `list` (time-scoped, `cli.ts:166-185`) — both are interactive CLI dumps, neither is a + persisted, dated, surfaced brief. The "morgen-brief" rung the engine is aimed at (substrate §1, hull 2) does + not exist. +- **`publishedAt` is persisted but never ranked on.** `queryByTopic` ranks `topicOverlap desc → capturedAt + desc` (`store.ts:155-157`) — it sorts on *when WE saw it*, never on *when the source published*. So even the + freshest source item is ordered by capture time, not publish time. The field R2a persisted specifically for + freshness ranking has no reader. R2b's `rankForBrief` is that reader. +- **Session-start surfaces staleness, not signal.** The SessionStart hook already reads the store directly + (zero-tsx) for the B-S3 staleness *nudge* (`session-start.mjs:38-52, 376-380`) — "trend signals are N days + old, scan to refresh." It tells the operator the store is *stale*; it never tells them *what is in it*. + Hull 4 (surfacing the actual brief) is unbuilt — the seam (a direct store/data-dir read in the hook) is + already proven and reusable. + +## 3. Scope — what is IN (RE-R2b) + +### B-rank + B-render — `scripts/trends/src/brief.ts` (NEW `.ts`) +Two **pure** functions + the brief's own format-version const + its result types. A new module is justified +(cohesive brief layer — ranking + rendering + types — not a single-use function; contrast R2a's `itemToInput`, +which belonged inside `item.ts`). No fs, no clock, no AI: `today` and `pillars` are injected by the caller. + +- `rankForBrief(store: TrendStore, pillars: string[], today: string, opts?: { freshDays?: number }): BriefRanking` + — for each trend computes `overlap` (count of `pillars` present in `trend.topics`, **case-insensitive, the + same idiom as `queryByTopic` `store.ts:151-152`** — computed inline, `queryByTopic` is NOT refactored), + `matchedPillars` (the actual matched names), `effectiveDate = publishedAt ?? capturedAt`, and + `ageDays = Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000)` (a **local** day-delta, + **not** imported from `cli.ts`'s `daysBetween` `cli.ts:107-109` — that would invert the dependency direction, + since `cli.ts` imports `brief.ts`, not the reverse). Groups: **`topMatches`** (`overlap ≥ 2` AND `ageDays ≤ + freshDays`), **`singleMatches`** (`overlap === 1` AND fresh), **`olderMatched`** (`overlap ≥ 1` AND NOT + fresh). `overlap === 0` is **excluded entirely** (off-pillar noise). Within each group: sort `overlap desc`, + then `effectiveDate desc` (freshest first), then `title asc`, then **`url asc`** — a **total order** (two + records can share title+effectiveDate+overlap but never title+url, since title+url is the dedupe id + `store.ts:66-68`; the `url` key makes order independent of store insertion / V8 sort stability). Returns + `{ today, freshDays, totals: { trends, matched, fresh }, topMatches, singleMatches, olderMatched }`. + `freshDays` default **7** (D3). +- `renderBrief(ranking: BriefRanking): string` — produces the full Markdown artifact: a YAML frontmatter block + (`date`, a one-line **`summary`** the hook surfaces verbatim, `store: { trends, matched, fresh }`, a + `ranking:` descriptor, `schemaVersion: `) + the body. **Body entry line (pinned):** + `### . ` then ``- Kilde: <source> · Publisert: <effectiveDate> (<ageDays>d) · Pillarer: <matched, + joined> `` then optional summary then `🔗 <url>` (single-matches/older render as one-line bullets: + `- <title> — «<pillar>» · <effectiveDate> (<ageDays>d) · 🔗 <url>`). The empty case (no fresh matches) still + renders a valid brief with a "no fresh on-pillar signals" `summary`. The frontmatter `summary` is produced by + the shared **`briefSummary(ranking)`** (below), NOT re-derived — one source for the frontmatter line and the + CLI `--json`. **Deterministic:** same `(store, pillars, today, freshDays)` → byte-identical output (total-order + sort, no clock/env inside the pure functions). +- `briefSummary(ranking: BriefRanking): string` — the **single** source of the one-line summary, used by + `renderBrief` (frontmatter) AND the CLI `--json`. Output is a **single line, column-0 in frontmatter, with no + embedded `"` and no newline** (titles in «», fields separated by `·`) — so the hook's `extractYaml` regex + (`^summary: *"?([^"\n]*)"?`, `session-start.mjs:20`) captures it whole. Fresh>0 → + `<fresh> ferske tema-signaler matcher pillarene dine. Topp: «<top title>» (<top pillar> · <age>d).`; + else → `Ingen ferske tema-signaler på pillarene dine (av <trends> i lager).` +- `defaultBriefDir(): string` — **derives from `defaultStorePath()`** (`store.ts:190-193`): `join(dirname( + defaultStorePath()), "morning-brief")` → `<root>/trends/morning-brief`. **One** root resolution (reuses + `defaultStorePath`, imported from `store.js`); no independent re-resolution of `LINKEDIN_STUDIO_DATA` in + `brief.ts` (M4 — kills the duplication the first draft introduced). Colocated with the store the brief reads + (Open Q#2). Pure path computation, no fs. +- `BRIEF_SCHEMA_VERSION = 1` — the artifact format version (distinct from the store's `SCHEMA_VERSION`). + +### CLI `brief` subcommand (`scripts/trends/src/cli.ts`, EDIT) +`node --import tsx src/cli.ts brief [--pillars <a,b,c>] [--fresh-days <N>] [--store <path>] [--out <dir>] +[--json]` — flag-driven (reads the **store**, not stdin — unlike `normalize`/`score`/`capture`). Resolves +`pillars = splitTopics(flags.pillars)` (the caller supplies the user's pillars — same edge-injection pattern as +`capture` injecting `today()`; resolving pillars from the profile is a thin caller concern, §4), `freshDays` +from `--fresh-days` (default 7; non-numeric → `usage()`→exit 2, mirroring `--limit`/`--threshold`), `store = +loadStore(storePath)`, `outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir()` (the +`!== "true"` guard is **required** — a bare `--out` with no value yields the string `"true"` via `parseFlags` +`cli.ts:52-53`, so `flags.out ?? …` would write to `./true/`). Runs `rankForBrief(store, pillars, today(), +{ freshDays })` → `renderBrief(ranking)` → writes `<outDir>/<today()>.md` (`mkdirSync({recursive})` + +`writeFileSync` at the CLI edge — `brief.ts` stays pure). Human output: the written path + `(M matched, K +fresh)`; `--json` emits `{ path, date, totals, summary }` where `summary = briefSummary(ranking)` (the **same** +source as the frontmatter). Exit **2** only on malformed invocation; **0** +otherwise, **including empty `--pillars`** (writes a valid no-match brief — the operator who hasn't set pillars +still gets a dated, surfaceable file telling them to set pillars). Add a `brief …` line to `usage()` and the +header doc comment. + +### Session-start surfacing (`hooks/scripts/session-start.mjs`, EDIT — zero-tsx) +A module-private `latestMorningBrief(briefDir)` (mirroring `trendsNewestCapture` `session-start.mjs:38-52` and +`brainLastRun` `:57-65`): absent dir → `null`; else `readdirSync` → keep `/^\d{4}-\d{2}-\d{2}\.md$/` → sort +desc → read the newest → extract `date` + `summary` via the existing `extractYaml` (`:19-23`) → return +`{ date, summary, file }` (or `null` on any read failure — never throws). `extractYaml`'s capture is +`[^"\n]*` + `.trim()` (`:20-23`), so the extracted `date`/`summary` are **newline-free by construction** → +the surfaced block needs **no** `.replace(/\n/g,'\\n')` treatment (unlike the multi-line state sections +`:320`); the static structure uses the literal `\\n` idiom (`:309-321`). **Pure fs + regex; never spawns +`tsx`** (the analytics fresh-clone-crash invariant; the store schema/brief frontmatter are stable, so a direct +read is safe — identical reasoning to the B-S3 comment `:32-37`). Injected as its own block **after the +brain-missing nudge (`:500-504`)**, unconditional on a brief existing (so it surfaces on the fresh-install +branch too, like the brain nudge): `## Morning Brief (<date>)` + the `summary` + `→ Full brief: <file>`. The +brief dir is `join(getDataRoot('trends'), 'morning-brief')` — the **twin** of `defaultBriefDir()` (same +established pattern as the store-path twin, `:376`). Heading kept English to match the existing 14 hook +sections (Open Q#3); the `summary` body stays Norwegian (operator-facing). + +### Wiring + gate (Open Q#1 — WIRE by default, mirrors R2a Open Q#1) +- `agents/trend-spotter.md` (EDIT): after the Step 4.5 `capture` (re-pointed in R2a), add a step that runs + **`node --import tsx scripts/trends/src/cli.ts brief --pillars <the pillars the agent already scans>`** so a + trend scan *produces* today's dated brief — closing the visible loop **scan → capture → brief → surfaced next + session**. Replacement prose carries the literal `src/cli.ts brief`. Domain-general (pillars are the user's + config; no vendor/sector tokens). Keep the "skip silently if no deps" escape hatch. +- `scripts/trends/README.md` (EDIT): document the `brief` subcommand + the `trends/morning-brief/YYYY-MM-DD.md` + artifact + its frontmatter shape (honest CLI/artifact doc). +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` — set it to the `tests N` line reported by + `(cd scripts/trends && npm test)` after Steps 1–3 (it stays **inside** the deps guard; 79 today, `store.ts` + comment `:697` is per-slice), and **append** `+ RE-R2b: brief +N + cli +M (morning-brief)` to that inline + breakdown comment so number and comment can't drift. Add **Section 16i** ("Trends Brief Wiring"): insert it + **immediately after Section 16h** (currently the **last** section before Section 18 — file order is 17→16g→ + 16h→18, `:943/:1010/:1074/:1118`), i.e. after 16h's closing `fi`/`echo ""` (~`:1116`), **before** the Section + 18 block (`:1118`). Mirror 16h's shape: **unconditional**, deps-absent-safe `grep -qF` + a non-vacuity + self-test — (1) self-test; (2) `command === "brief"` in `cli.ts`; (3) `src/cli.ts brief` in + `agents/trend-spotter.md`; (4) `latestMorningBrief` in `session-start.mjs` (surfacing is wired, not merely + documented). These are unconditional → bump `ASSERT_BASELINE_FLOOR` 90 → **live recount** (expected ~94). + Update the header-enumeration **prose chain** (`:33-46`) by inserting the 16i clause between the 16h clause + (`:43-45`) and the Section-18 clause (`:46`), preserving sentence flow. +- **Hook suite (a SEPARATE gate, not run by `test-runner.sh`):** new + `hooks/scripts/__tests__/session-start-morning-brief.test.mjs` (mirrors + `session-start-trends-staleness.test.mjs`: subprocess + `LINKEDIN_STUDIO_DATA` fixture), green under + `node --test hooks/scripts/__tests__/` (the command that runs the 136-test hook suite). `test-runner.sh` has + no `HOOK_TESTS_FLOOR` and no root `package.json` — so SC6 asserts the hook test under its **own** command, + never as part of `bash scripts/test-runner.sh`. + +## 4. Non-goals — what is OUT (deferred) + +- **Autonomous nightly trigger** (cron/launchd, hull 1) — **R3**. R2b's brief is generated on demand (a scan / + the CLI); the hook only *surfaces* the latest. *No scheduler enters the repo in R2b.* +- **Freshness as a persisted seen-log / dedup-vs-seen (B4)** — R3. R2b's freshness is a **read-time filter** + (`effectiveDate ≤ freshDays` at rank time), not an append-only seen-log. +- **Relevance / saturation / status / first-mover scoring fields (hull 5)** — R3. R2b ranks on **pillar-overlap + + recency only**; the B2 triage scorer (`score.ts`) stays **out of the brief path** (its output isn't + persisted on records yet — that's R3). No AI in the brief path (D2). +- **Research-deepening (A1–A4), adapter sub-agents, MCP fetch fan-out** — R3. +- **Pillar resolution from the state file** (`expertise_areas`) — OUT; pillars arrive via `--pillars` (the + agent/caller supplies them). Wiring state→pillars is a thin follow-up, not this slice. +- **Brief history surfacing / diffing ("yesterday vs today")** — OUT. The artifact is dated and accumulates on + disk (hull 7 storage is satisfied), but R2b surfaces only the **latest**; diffing is later. +- **Delivery (Slack/email)** — OUT. B3 keeps delivery a separate step; R2b's only "delivery" is session-start + surfacing. No delivery channel, no push-window guard (that mechanism is R3/B4). +- **Store schema change** — none. R2b is **read-only** over the store; `types.ts`/`store.ts` record shape and + `SCHEMA_VERSION` (2) are untouched (only a pure `defaultBriefDir` path helper is added, in `brief.ts`). +- **No new agent/command** — counts stay 19/29/27. `brief` is a CLI subcommand; surfacing is a hook edit; + generation is wired into the existing `trend-spotter`. + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law:** the failing `brief.ts` tests (`rankForBrief` grouping/freshness/sort + `renderBrief` + frontmatter/`summary`/empty-case + `defaultBriefDir`) and the `cli.ts brief` tests land **BEFORE** the + implementation. RED is logic-RED (throwing stub for the not-yet-existing exports so cases fail on assertion, + not module-not-found). +- **Purity:** `rankForBrief`/`renderBrief`/`defaultBriefDir` touch no fs, no clock, no env-at-call, no AI — + `today`/`pillars`/`freshDays` are injected. All fs lives at the CLI edge (`cli.ts`) and in the hook. +- **Determinism:** same `(store, pillars, today, freshDays)` → byte-identical brief (stable three-key sort). +- **Hook stays tsx-free:** surfacing is a plain `readdir` + `readFile` + `extractYaml` (the established + zero-dep pattern); it **never** shells out to `tsx` (analytics fresh-clone-crash invariant). A fixture run + with no `node_modules/tsx` in `scripts/trends` must still surface the brief (SC5). +- **Domain-general:** Section 17 de-niche guard stays green; the `trend-spotter.md` edit + the brief artifact + carry **no vendor/sector tokens**; pillars are the user's config, never hardcoded. +- **No scoring change:** `score.ts` + `references/trend-scoring-modes.md` (SSOT) untouched. +- **No store schema change:** `types.ts`/`store.ts` record shape + `SCHEMA_VERSION` unchanged; `queryByTopic` + NOT refactored (overlap is recomputed in `brief.ts`). +- **Pathguard:** `brief.ts` is a NEW `.ts` (allowed); **NO new `.mjs` under `hooks/scripts/`** (surfacing edits + the existing `session-start.mjs`); `.gitignore` already covers `scripts/trends/{node_modules,build}`. +- **House conventions:** ESM + `node:test` + `tsx`; brief+plan docs live under `docs/` (uncounted, TRACKED like + `docs/second-brain/*`). +- **Counts** (refs/agents/commands 27/19/29) unchanged; `brief.ts` is the only new source file. **Recounted + live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (rank/group)** — `rankForBrief(fixtureStore, pillars, today)` puts only `overlap ≥ 2 & fresh` in + `topMatches`, `overlap === 1 & fresh` in `singleMatches`, `overlap ≥ 1 & NOT fresh` in `olderMatched`; + excludes `overlap === 0`; within each group orders `overlap desc → effectiveDate desc → title asc`; + `matchedPillars` lists the actual matched names (case-insensitive match, original-case pillar preserved); + `totals.matched` = sum of the three groups, `totals.fresh` = top+single, `totals.trends` = `store.trends.length`. +- **SC2 (freshness)** — `effectiveDate = publishedAt ?? capturedAt`; an item whose `publishedAt` is within + `freshDays` but whose `capturedAt` is older is **fresh** (and the reverse via the fallback when `publishedAt` + is absent); the boundary `ageDays === freshDays` is **fresh** (`≤`); `freshDays` is configurable (a non-7 + value re-buckets). +- **SC3 (render/frontmatter)** — `renderBrief(ranking)` output begins with a YAML frontmatter block carrying + `date`, a **column-0, single-line `summary` with no embedded `"` and no newline** (so `extractYaml` reads it + whole), `store: { trends, matched, fresh }`, and `schemaVersion: 1`; `renderBrief`'s frontmatter `summary` + equals `briefSummary(ranking)` byte-for-byte (one source); the body has the three sections in order with the + pinned entry-line shape (§3); the **empty-matches** ranking renders a valid brief whose `summary` is the + "no fresh on-pillar signals" line (still surfaceable); identical input → identical bytes (determinism, total + order). `briefSummary(emptyRanking)` returns the no-fresh line; `briefSummary(freshRanking)` names the top + entry. +- **SC4 (CLI brief)** — `… brief --pillars a,b --store <tmp> --out <tmpdir>` writes `<tmpdir>/<today>.md` + (today-shaped `/^\d{4}-\d{2}-\d{2}$/`) and prints the path; `--json` emits `{ path, date, totals, summary }`; + `--fresh-days xyz` → exit **2**; **empty/absent `--pillars`** → writes a no-match brief, exit **0**; `--out` + overrides the dir; the default dir (no `--out`) is `defaultBriefDir()`. +- **SC5 (surfacing)** — running `session-start.mjs` (subprocess) with `LINKEDIN_STUDIO_DATA` pointing at a + fixture root containing `trends/morning-brief/<date>.md` → `additionalContext` contains `## Morning Brief + (<date>)`, the brief's `summary`, and `→ Full brief: <file>`, and carries **no raw newline** inside that + block (single-escaped `\n` idiom held); an absent brief dir → **no** block and **no crash** (`continue: + true`); the surfacing works with **no `tsx`/`node_modules` present** (zero-dep proof). **Path cross-check:** a + file written by the CLI at `defaultBriefDir()` (under a given `LINKEDIN_STUDIO_DATA`) is the one the hook + finds via `join(getDataRoot('trends'),'morning-brief')` — the CLI-write/hook-read paths resolve equal (the + store-path twin equivalence already guarded by `__tests__/data-root.test.mjs`). +- **SC6 (gate + wiring + de-niche) — TWO separate gate commands:** + **(a)** `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the bumped `TRENDS_TESTS_FLOOR`; new + **Section 16i** green (`command === "brief"` in `cli.ts`, `src/cli.ts brief` in `trend-spotter.md`, + `latestMorningBrief` in `session-start.mjs`, non-vacuity self-test); `ASSERT_BASELINE_FLOOR` = live recount; + Section 17 de-niche green; counts 27/19/29. + **(b)** `node --test hooks/scripts/__tests__/` → the new `session-start-morning-brief.test.mjs` green (the + hook suite is **not** part of `test-runner.sh`). + *(If Open Q#1 → minimal: SC6(a) drops the `trend-spotter.md`/16i-wire clause; brief.ts + cli + surfacing + + de-niche + counts still asserted.)* + +## 7. Verification + +**Deterministic (two gates):** (a) `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; new +Section 16i self-test + greps pass; Section 17 de-niche green; ref/agent/command counts unchanged. (b) +`node --test hooks/scripts/__tests__/` → the new `session-start-morning-brief.test.mjs` green (hook suite is a +separate command, not part of `test-runner.sh`). +**Behavioural (manual):** +1. `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d + ago>"},{"source":"tavily","title":"B","url":"https://e/b","topics":["ai"],"publishedAt":"<~20d ago>"}]' | + node --import tsx src/cli.ts capture --store /tmp/r2b.json` (seed the store). +2. `node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r2b.json --out /tmp/r2b-brief --json` → + confirm A in `topMatches` (overlap 2, fresh), B in `olderMatched` (overlap 1, stale), the written path, and a + `summary` naming A. +3. Place that brief at `<root>/trends/morning-brief/<today>.md` and run `LINKEDIN_STUDIO_DATA=<root> node + hooks/scripts/session-start.mjs` → confirm the `## Morning Brief` block + the `summary` appear in + `additionalContext`, with `tsx` absent. + +## 8. Open questions for the go-gate + +1. **Wire `brief` generation into `trend-spotter.md` (after capture) + Section 16i, or keep R2b to + machinery+surfacing only?** **Recommend WIRE** (mirrors R2a Open Q#1): a scan then *produces* the brief, so + the surfacing isn't surfacing an artifact nothing creates — it closes the scan→capture→brief→surfaced loop. + Minimal alt: `brief.ts` + CLI + surfacing + tests, no agent edit (the operator runs `brief` by hand). +2. **Brief dir = `trends/morning-brief/`** (colocated with the store it reads) — recommend. **Load-bearing** + (not a cheap toggle): it is baked into `defaultBriefDir()`, the hook twin, and SC5's path assertions. + Alternatives `research/morning-brief/` (the `docs/research-engine` naming the mock showed) or `morning-brief/` + at the data-root (substrate §3 B3 literal) would re-touch `brief.ts` + the hook + SC5. Confirm the path; a + different choice means updating those three places. +3. **Surfacing heading: English `## Morning Brief`** (matches the existing 14 hook section headings — + "Posting Reminders", "Queue Summary", "Brain") with a Norwegian `summary` body — recommend. Alt: the mock's + Norwegian `## 🌅 Morgen-brief`. (The block is `additionalContext` for the model, not direct user UI; the + user-facing *artifact* body stays Norwegian either way.) The one genuinely-cosmetic question here. +4. **`summary:` frontmatter field on the artifact** — **confirm** (this is a decision baked in, not a free + choice): the entire zero-tsx surfacing (SC3/SC5, `extractYaml(content,'summary')`) depends on it; without a + `summary` frontmatter line the hook would have to parse the body (which the no-tsx invariant forbids), so the + slice cannot ship without it. The approved visible *body* is unchanged; this only adds one frontmatter line + the hook reads. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED** +(every SC1–SC6 traces to a step; zero creep; all nine §4 non-goals held; counts 27/19/29 verified live; 0 +findings). **brief-reviewer: PROCEED_WITH_RISKS** (1 real risk + minors). **plan-critic: REVISE** (2 blockers, +5 majors, 4 minors). All findings folded; see `plan-re-r2b.md` §Plan-critic — folded for per-step resolution: + +- **[BLOCKER, folded]** `--json` summary source was an unresolved either/or ("re-derive OR expose"). → a + committed **`briefSummary(ranking)`** export is now the single source for both `renderBrief`'s frontmatter and + the CLI `--json` (§3; SC3 pins equality). Step 1 asserts against the named export. +- **[BLOCKER, folded]** Section 16i placement was ambiguous. → pinned: immediately after Section 16h (the last + section before 18; file order 17→16g→16h→18), before the Section 18 block (`:1118`). +- **[MAJOR, folded]** the throwing-stub RED claim contradicted "fail on assertion, not module-not-found". → + Step 1 now creates **wrong-but-non-throwing** stubs (empty buckets / `""`) so `brief.test.ts` fails on + *assertion*; `cli.test.ts` brief cases are logic-RED against the existing dispatch (unknown command → exit 2). +- **[MAJOR, folded]** brief §3 `outDir = flags.out ?? defaultBriefDir()` would write `./true/` for a bare + `--out`. → corrected to the `flags.out !== "true"` guard (`parseFlags` `cli.ts:52-53`); a bare-`--out` + cli.test case added. +- **[MAJOR, folded]** `TRENDS_TESTS_FLOOR` recount was ambiguous (full `tests N` vs additive). → pinned: the + `tests N` line after Steps 1–3; the inline `:697` comment appends `+ RE-R2b: brief +N + cli +M`. +- **[MAJOR, folded]** `defaultBriefDir` re-resolved the data root independently (triple-twin drift). → it now + **derives from `defaultStorePath()`** (`join(dirname(defaultStorePath()), "morning-brief")`) — one root + resolution; the hook-vs-CLI path equivalence is the store-path twin already guarded by `data-root.test.mjs`; + SC5 adds a write-then-read path cross-check. +- **[MAJOR, folded]** the surfacing newline-escape was unproven. → §3 + SC5 state `extractYaml`'s `[^"\n]*` + + `.trim()` makes `date`/`summary` newline-free → no `.replace` needed; SC5 asserts no raw newline in the block. +- **[brief-reviewer MAJOR, folded]** SC6 attributed "hook-suite at recount" to `bash scripts/test-runner.sh`, + which neither runs nor counts the hook suite (no `HOOK_TESTS_FLOOR`, no root `package.json`). → SC6 + §7 now + split into **two** gate commands: `test-runner.sh` (trends/16i/ASSERT/de-niche/counts) and + `node --test hooks/scripts/__tests__/` (the new hook test). +- **[MINOR, folded]** non-total sort → added `url asc` final tie-break (true total order, §3/SC1). **[MINOR, + folded]** local `ageDays` math stated as a deliberate non-import (dependency direction `cli.ts → brief.ts`). + **[MINOR, folded]** `.md$`-anchored filename filter confirmed. **[MINOR, folded]** header-enumeration is a + prose chain → insert the 16i clause between the 16h and Section-18 clauses (`:43-46`). **[brief-reviewer + MINOR, folded]** body entry-line age format pinned in §3 + SC3. **[brief-reviewer MINOR, folded]** Open Q#2/#4 + reframed as load-bearing confirmations (§8). diff --git a/docs/research-engine/brief-re-r3a.md b/docs/research-engine/brief-re-r3a.md new file mode 100644 index 0000000..2bdb7b0 --- /dev/null +++ b/docs/research-engine/brief-re-r3a.md @@ -0,0 +1,357 @@ +# Brief — RE-R3a: persist the relevance score + rank the morning brief on it + +> **Slice:** RE-R3a (research-engine rung-2, R3 slice 1 — research-*deepening*). R3 ("deepen the +> research engine") is an **arc** of 5 open hulls (substrate §1: autonomous trigger · freshness-as-seen-log · +> relevance/saturation/status scoring · brief history+diff · A1–A4 fan-out). R3a takes the first: the +> **relevance** half of hull 5 (and the remainder of hull 3 — "the store schema lacks fields a brief ranks +> on"). It persists the composite relevance score the `trend-spotter` agent ALREADY computes, onto the store +> record, and makes `rankForBrief` order on it. +> **Predecessor:** RE-R1 (`score.ts`, B2 triage-scorer — built, tested, deterministic) + RE-R2a (`capture` +> bridge + `publishedAt`, schema v1→v2) + RE-R2b (`brief.ts` dated artifact + surfacing). R2b explicitly +> deferred this in its §4: *"the B2 triage scorer stays out of the brief path — its output isn't persisted on +> records yet — that's R3."* R3a is exactly that R3 step. +> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (3) (schema fields a brief ranks on: +> relevance/...) + (5) (relevance scoring) + §B2 ("scoring/filtering as a gate before expensive work — the +> output is the rank key") + §A2 ("curate/score before synthesis — the writer sees ranked material"). +> **TDD-order:** RED tests land before code — but as **two phases** (light-Voyage BLOCKER fold): the +> store/brief/cli tests are true logic-RED against the pre-edit code (they build fixtures inline, import no new +> symbol); the score/item tests reference not-yet-existing `score.ts` exports, so under Node16 ESM a missing +> named import throws at module-load (not on assertion) — they are RED against **non-throwing stubs** landed as +> the first GREEN-prep sub-step. See plan Step 1. + +## 1. Operator decision context (2026-06-24) + +The research engine is **Tier-1** (operator, 2026-06-23): *"hele min arbeidsflyt hviler på at jeg får en jevn +strøm av gode forslag til tema å skrive om."* RE-R2 made the stream **visible** (a dated morning brief surfaced +at session-start). R2b ranks that brief on **pillar-overlap + recency only** — a coarse proxy for "good topic +to write about." The actual relevance judgment (audience pull, timing, angle potential, authority, depth) lives +in the five 1–10 dimension scores the `trend-spotter` agent produces in Step 2 and pipes to the `score` CLI — +and is then **thrown away** before the trend reaches the store (Step 4.5 builds a *separate*, score-free capture +batch). R3a stops discarding it: persist the composite + band on the record, and rank the brief on composite +first. **The slice the operator chose** ("scoring inn i briefen", 2026-06-24) — the highest-leverage next step +on the core need (better-ordered suggestions), built on already-shipped-but-dormant code (`score.ts` is tested +and unused on records). The bigger R3 arcs (autonomous trigger / seen-log / saturation+status / A1–A4 fan-out) +stay later slices. + +**Go-gate resolutions — CONFIRMED (operator "Go", 2026-06-24; baked into the plan):** **D1** persist the +**4-field** `TrendScore { mode, dimensions, composite, priority }` (composite+priority to rank/display, mode to +disambiguate the instrument, dimensions for audit + lossless re-weight). **D2** composite is the **primary +within-bucket sort** (buckets still assigned by overlap+freshness; composite orders *inside* a bucket). **D3** +score is **first-sight** (set on add, never updated on re-capture — matches the store's provenance discipline; +re-score-on-recapture pairs with the R3b seen-log/status slice). **D4** ship persist+rank as **one** slice (the +operator named the visible payoff; splitting would land an invisible schema-only cut like R2a). + +## 2. The gap — grounded in code + +- **The score the agent computes never reaches the store.** `trend-spotter.md` scores each candidate's five + dimensions and pipes them to the `score` CLI (`agents/trend-spotter.md:134-140`), which returns + `{composite, band}` per candidate (`score.ts:110-122` `triage`). But Step 4.5's capture batch + (`agents/trend-spotter.md:291-298`) is **built separately and carries no score** — `source/title/url/topics/ + publishedAt/summary` only. `TrendItem` (`item.ts:22-39`) and `TrendRecord` (`types.ts:26-48`) have **no score + field**. The relevance judgment is recomputed for the digest and discarded for persistence. +- **`score.ts` is built, tested, deterministic — and unconsumed on records.** It exports `composite()` + (`score.ts:77-88`) and `band()` (`score.ts:91-97`) as pure functions, pinned to the SSOT + (`references/trend-scoring-modes.md`, by `score.test.ts:12-30` weights + the band-string assertions). Nothing + persists their output. `TrendRecord`'s own doc-comment anticipates the field: *"can gain fields (relevance + score, first-mover timing, status) in a later slice"* (`types.ts:21-23`). +- **The brief ranks on a proxy.** `rankForBrief` sorts each bucket `overlap desc → effectiveDate desc → + title asc → url asc` (`brief.ts:94-104`). Overlap (a hard pillar count) is *part* of what the composite + already weights (Pillar Fit 30 %, `trend-scoring-modes.md:43`), but the composite also captures audience/ + timing/angle/authority — signal the brief currently can't see. `brief.ts`'s own header already names this as + the next slice: *"A persisted relevance/saturation score … (R3)"* (`brief.ts:11-14`). + +## 3. Scope — what is IN (RE-R3a) + +### S-score — `scripts/trends/src/score.ts` (EDIT) +- **`export interface TrendScore { mode: ScoreMode; dimensions: DimensionScores; composite: number; priority: + Priority }`** — the persist-ready envelope. Lives in `score.ts` (the score domain owns it); `types.ts` imports + it (one-way: `score.ts` imports nothing — verified leaf, `:1-17` — so no cycle). +- **`export function requiredDimensions(mode: ScoreMode): string[]`** — `Object.keys(WEIGHTS[mode])` + (`score.ts:37-40`). **Contract: ordered** — the keys come back in the SSOT weight-literal order (kortform + `["pillar","audience","timing","angle","authority"]`, long-form `["pillar","depth","angle","authority", + "currency"]`, `score.ts:20-35`); SC1 deep-equals that ordered array, and `score.test` pins the order so a + silent SSOT reorder fails loudly. `normalizeItem` consumes it as a **set** (membership), which is order-safe + either way. `WEIGHTS` stays private; the keys are exposed via this function. +- **`export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore`** — composes the + existing pure functions: `const c = composite(dimensions, mode); return { mode, dimensions, composite: c, + priority: band(c).priority }`. **No new arithmetic** — `composite()`+`band()` stay the single owners (SSOT + discipline). It throws (via `composite`, `score.ts:83`) on an out-of-range dimension — that is its + **contract**, exercised directly by SC1/SC2; on the capture path it is unreachable because `normalizeItem` + pre-validates (below). + +### S-types — `scripts/trends/src/types.ts` (EDIT) +- `import type { TrendScore } from "./score.js";` +- `TrendRecord` gains **`score?: TrendScore;`** (optional — pre-R3a records simply lack it; the `add` manual + path and unscored adopters omit it). Doc-comment updated to mark `score` as the now-realized field the + `:21-23` note anticipated. +- **`SCHEMA_VERSION = 2 → 3`** (`types.ts:62`). The bump is the only schema signal; the record shape change is + additive-optional, so the migration is the version-stamp alone (below). + +### S-store — `scripts/trends/src/store.ts` (EDIT) +- `TrendInput` (`store.ts:26-35`) gains **`score?: TrendScore;`** (imported from `score.js`). +- `addTrend` (`store.ts:120-140`): on a **new** record, persist `score` first-sight via the existing + conditional-spread idiom (`...(input.score !== undefined ? { score: input.score } : {})`, mirroring + `publishedAt`/`summary` `:134,136`). On a **duplicate**, score is **NOT** updated (D3 — first-sight, like + `source`/`capturedAt`/first `publishedAt`); topics still union (`:124-126`, unchanged). `AddResult` is + unchanged (no new flag). +- `loadStore` migrate comment (`:79-84`): extend to *"v1→v2→v3 are all purely additive-optional (an old record + is already a valid record that simply lacks the optional field), so the migration is the version stamp alone — + records pass through untouched."* **No code change** to the migration logic (`Math.max(onDisk, SCHEMA_VERSION)` + `:87` already does v2→v3 correctly; `saveStore` `JSON.stringify` `:95` preserves the `score` field — no field + stripping); only `SCHEMA_VERSION` (in `types.ts`) and the comment move. + +### S-item — `scripts/trends/src/item.ts` (EDIT) +- `TrendItem` (`item.ts:22-39`) gains **`score?: { mode: ScoreMode; dimensions: DimensionScores };`** — the + ingress envelope carries the agent's *judgment* (five scores + mode), **not** a precomputed composite (the + store computes it, so the composite has one owner). `import type { ScoreMode, DimensionScores } from + "./score.js"` + `import { requiredDimensions } from "./score.js"`. +- `normalizeItem` (`:86-119`): if `r.score` present, **validate structurally** (returns a structured error into + `errors[]`, never throws — the existing discipline, like the `publishedAt` ISO check `:99-106`): `score` is a + **non-array** object; `mode ∈ {kortform, long-form}`; `dimensions` is a **non-array** object; **each key in + `requiredDimensions(mode)` is present and a number in [1,10]**. On any failure → `errors.push("invalid score: + …")`. On success carry the **validated** `score = { mode, dimensions }` forward (the validated dimensions + object, not raw `r.score.dimensions`). Absent/null/invalid → key omitted. This guarantees the *capture path* + (`cli.ts:246-254`: `normalizeItems` → `itemToInput`) never reaches `composite` with bad dims. +- `itemToInput` (`:129-139`): if `item.score` present → add `score: scoreEnvelope(item.score.mode, + item.score.dimensions)` to the returned `TrendInput` (conditional spread, key omitted when absent). The + item→store bridge is the natural place to turn judgment into the persisted envelope. `itemToInput` is a public + function: called directly (e.g. in a test) with unvalidated dims it **throws by contract** (defense-in-depth); + the no-throw guarantee is a property of the *capture path*, not of `itemToInput` in isolation (§5). + +### S-brief — `scripts/trends/src/brief.ts` (EDIT) +- `rankForBrief` (`:72-114`): **composite becomes the primary within-bucket sort key** (D2). The comparator + (`:94-98`) gains a leading term: + `(b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) || <existing overlap desc → effectiveDate + desc → title asc → url asc>`. **Sentinel `-1`, not `-Infinity`** — composite is a weighted sum of [1,10] + dims so it is always ≥ 1.0 (min = 1×Σweights = 1.0, verified); `-1` sorts every unscored record below every + scored one and subtracts cleanly (`-Infinity - -Infinity = NaN` would corrupt the comparator). **Buckets are + UNCHANGED** — assignment stays `overlap≥2 & fresh` / `overlap==1 & fresh` / `!fresh` (`:100-104`); composite + only re-orders *within* a bucket. Total order preserved: the `(title,url)` pair is unique per store (it is the + dedupe id, `store.ts:66-68`), so the final `url asc` tie-break makes the order insertion-independent even for + equal composites. +- `renderBrief` (`:152-191`): surface the band **and mode** where a record is scored (so a reader can tell a + kortform "High" from a long-form "High" — the two are different instruments). **Pinned line shapes:** + - Top-entry meta line (`renderTopEntry`, `:135`), scored: + `- Kilde: <source> · Publisert: <date> (<age>d) · <priority> (<mode>) · Pillarer: <matched>` + (the `· <priority> (<mode>)` token sits between `(<age>d)` and `· Pillarer`); **unscored: unchanged** (no + token). + - Bullet line (`renderBulletEntry`, `:144`), scored: + `- **<title>** — «<matched>» · <date> (<age>d) · <priority> (<mode>) · 🔗 <url>` + (token **before** `· 🔗`); **unscored: unchanged**. + - The `ranking:` frontmatter descriptor (`:160`) → the **exact** string + `composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays <N>` + (pinned verbatim; `brief.test` asserts it byte-for-byte). +- `briefSummary` (`:122-130`): the top mention names the **band only** (mode stays a body-entry detail to keep + the one-line headline clean) — fresh>0 with a **scored** top → `… Topp: «<title>» (<pillar> · <priority> · + <age>d).`; fresh>0 with an **unscored** top → `… Topp: «<title>» (<pillar> · <age>d).` (no token). **Still one + line, no `"`, no `\n`** — the `extractYaml` contract (`brief.ts:118-120`) holds; the band strings + (`Immediate`/`High`/…) are bare words. +- `BRIEF_SCHEMA_VERSION` stays **1** (no frontmatter *field* added/removed; the surfacing hook still reads + `date`+`summary`; only the `ranking:` descriptor *string* and body content change). Bumping is an Open Q (§8), + not required for correctness. + +### S-cli — `scripts/trends/src/cli.ts` (EDIT, doc-only behavior) +- The `capture` branch (`:243-269`) folds via `itemToInput` (`:254`) — so once `item.ts` threads `score`, + capture **automatically** persists it with **no logic change**. Update only the header doc-comment + (`:15-21`) to note capture now persists an optional relevance score. The `score` CLI (`:218-241`, the digest + path) and the `add` manual path (`:123-147`, score-free) are unchanged. *(Capture's `{added, merged, + duplicates, errors}` tally is left unchanged — a `scored` count is an Open-Q nice-to-have, §8.)* + +### Wiring (D-default — WIRE, mirrors R2a/R2b Open Q#1) +- `agents/trend-spotter.md` (EDIT): Step 4.5's capture batch (`:291-298`) gains a per-item **`"score": {"mode": + "kortform", "dimensions": {"pillar": N, "audience": N, "timing": N, "angle": N, "authority": N}}`** — the same + five judgment scores the agent computed in Step 2 (`:134`), carried into capture so the store persists them and + the brief ranks on them. Prose explains the carry ("don't discard the Step-2 scores — fold them into the + capture batch"). Mode defaults `kortform`; `long-form` when invoked from `/linkedin:newsletter` (long-form + dims `pillar/depth/angle/authority/currency`). Domain-general (dimensions are the rubric's, pillars are the + user's config; no vendor/sector tokens). Keep the "skip silently if no deps" escape hatch. **Verified + non-vacuous:** `agents/trend-spotter.md` does NOT currently contain the literal `"dimensions"`, so the + Section 16j grep passes only after the wire is added. +- `scripts/trends/README.md` (EDIT): document the item `score` field (judgment in), the persisted `TrendScore` + (composite/priority out), and that the brief now ranks on composite. +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (`:701`, currently 104) to the `tests N` line + reported after Steps 1–6, **append** `+ RE-R3a: score +N` to the inline breakdown comment (`:701`). Add + **Section 16j** ("Trends Score Wiring", RE-R3a) **after Section 16i's closing `echo ""` (~`:1171`), before the + Section 18 block (`:1173`)** (16i is the last 16x before anti-erosion; file order 17→16g→16h→16i→18, + `:947/:1014/:1078/:1122/:1173`). Mirror 16i's shape: **unconditional**, deps-absent-safe `grep -qF` + a + non-vacuity self-test emitting **one** pass/fail (so the count is exact) — (1) self-test; (2) `export + interface TrendScore` in `score.ts`; (3) `score?: TrendScore` in `types.ts`; (4) `"dimensions"` in + `agents/trend-spotter.md`; (5) `score?.composite` in `brief.ts`. **5 unconditional emitters → bump + `ASSERT_BASELINE_FLOOR` 94 → exactly 99** (`:1193`; "live recount" is the safety net, but the expected value + is the pinned 94 + 5 = 99). Update the header-enumeration **prose chain** by inserting the 16j clause between + the 16i clause (`:46-49`) and the Section-18 clause (`:49`), preserving sentence flow. + +## 4. Non-goals — what is OUT (deferred) + +- **Re-score on re-capture** (refresh the score when a trend is re-seen) — **R3b**. R3a is first-sight only + (D3). Re-score pairs naturally with the seen-log/status slice (the Timing dimension decays, so a refresh is a + real improvement — but it expands `addTrend`'s mutation surface and wants the status/lifecycle model alongside). +- **Mode-segmented / mode-normalized ranking** — OUT. R3a ranks **all** records by composite regardless of mode; + a kortform composite and a long-form composite are different instruments (different dimensions, + `trend-scoring-modes.md:50,68`), so the ranking is **mode-blind by design for R3a**. This is acceptable because + (a) almost all records are `kortform` (the default), and (b) the body entry line **shows the mode** (`<priority> + (<mode>)`) so the operator can see when two adjacent entries were scored on different instruments. A + mode-segmented brief (separate sections per mode) or a `--mode` filter is a later refinement. +- **Saturation / status (acted/skipped) / first-mover-as-a-field** (the rest of hull 5) — **R3b+**. R3a does the + **relevance** half of hull 5 only. +- **Autonomous nightly trigger** (cron/launchd, hull 1) — **R3 later**. No scheduler enters the repo. +- **Freshness as a persisted seen-log / dedup-vs-seen (B4)** — **R3 later**. +- **Brief history surfacing / diff ("yesterday vs today", hull 7)** — **R3 later**. +- **Research-deepening A1–A4** (plan → isolated parallel workers → gap loop → curate), adapter sub-agents, MCP + fetch fan-out — **R3 later** (the big slice). +- **A new `score` field in the `add` manual CLI path** — OUT. `add` stays the raw, score-free manual path; only + the normalizing `capture` path carries scores. +- **`BRIEF_SCHEMA_VERSION` bump** — OUT by default (no frontmatter field changes); Open Q#5. +- **New source file / new agent / new command** — none. R3a is all edits to the six existing `src/*.ts` + + one agent + README + gate. Counts stay 27/19/29. + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** the failing tests land **BEFORE** the implementation. `store`/`brief`/`cli` + tests are true logic-RED against the pre-edit code (inline fixtures, no new import). `score`/`item` tests + reference new `score.ts` exports → under Node16 ESM a missing named import throws at module-load, so they are + RED against **non-throwing stubs** landed first (the stubs return wrong-but-present values; the value + assertions then fail). The plan records the RED proof in two phases (Step 1); it does NOT claim a single + "all five fail on assertion before any code" run. +- **One composite owner:** `composite()` + `band()` (`score.ts`) stay the sole arithmetic; `scoreEnvelope` + *composes* them, never re-derives. The agent supplies judgment, the code computes the composite (SSOT + discipline, `score.test.ts:12-30` pins the weights/bands). +- **Purity:** `scoreEnvelope`/`requiredDimensions`/`rankForBrief`/`renderBrief` touch no fs, no clock, no env, + no AI. All fs stays at the CLI edge. +- **No throw on the capture path (not "everywhere"):** `normalizeItem` fully validates the score before + `itemToInput`, so the capture loop (`cli.ts:246-258`) never reaches `composite` with bad dims and never + crashes (a bad score → `errors[]`). `itemToInput`/`scoreEnvelope`/`composite` called **directly** with bad + dims throw by contract — that is the defense-in-depth boundary, asserted (SC2), not a leak. +- **Determinism:** same `(store, pillars, today, freshDays)` → byte-identical brief (the composite sort is a + total order via the unique `(title,url)` final tie-break; `-1` sentinel for unscored is deterministic). +- **Lossless additive migration (both directions):** a v2 store loads as v3 with records **untouched** (no + `score` invented); round-trip writes `schemaVersion: 3`; a v3 store is idempotent; a v3 store's new optional + `score` field **survives a load+resave** (no field stripping, `JSON.stringify` `store.ts:95`). Mirrors the R2a + v1→v2 proof (`store.test.ts:403-476`) + a new field-preservation case. +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only and **never shells out to tsx** + (analytics fresh-clone-crash invariant) — R3a touches neither the hook nor the frontmatter schema, so the + zero-tsx surfacing is unchanged. (No hook test added; the existing hook suite must still pass untouched as a + regression sanity.) +- **Domain-general:** Section 17 de-niche stays green; the `trend-spotter.md` edit carries the rubric's + dimension names + the user's pillars, **no vendor/sector tokens**. +- **No SSOT change:** `references/trend-scoring-modes.md` (weights/bands/actions) untouched; `score.ts` mirrors + it exactly as today. +- **No store-query change:** `queryByTopic`/`history`/`newestCaptureDate` untouched; the brief recomputes + overlap as before (`queryByTopic` NOT refactored). +- **Pathguard:** all edits are to **existing** files (no new `.mjs` under `hooks/scripts/`; no new `.ts` — + R3a adds *no* source file). `.gitignore` already covers `scripts/trends/{node_modules,build}`. +- **Counts** (refs/agents/commands 27/19/29) unchanged. **Recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (score envelope)** — `requiredDimensions("kortform")` **deep-equals (ordered)** `["pillar","audience", + "timing","angle","authority"]`; `requiredDimensions("long-form")` deep-equals `["pillar","depth","angle", + "authority","currency"]` (the `WEIGHTS` literal order, `score.ts:20-35`), and `score.test` pins the order so a + SSOT reorder fails. `scoreEnvelope("kortform", {pillar:8,audience:7,timing:9,angle:6,authority:5})` returns + `{ mode:"kortform", dimensions:<the five>, composite: composite(dims,"kortform"), priority: band(composite). + priority }` — composite/priority equal the existing functions' output byte-for-byte (one owner); a bad + dimension makes `scoreEnvelope` throw (via `composite`). +- **SC2 (item validation + bridge + the throw contract)** — `normalizeItem` on an item with a valid `score` + carries the **validated** dims; with a bad `mode`, a missing dimension, a dimension out of [1,10], a non-object + `score`, or an **array** `dimensions` → `{ ok:false, errors:["invalid score: …"] }` (structured, **never + throws**); absent `score` → key omitted. `itemToInput(validItemWithScore, capturedAt)` returns a `TrendInput` + whose `score` is `scoreEnvelope(mode, dimensions)` (composite/priority computed); without a score → no `score` + key; **`itemToInput` called directly with an out-of-range dim throws** (the defense-in-depth contract). +- **SC3 (first-sight persist)** — `addTrend(store, inputWithScore)` on a **new** title+url persists `score` on + the record; re-`addTrend` of the same title+url with a **different** score does **NOT** change the stored + score (first-sight, D3) while topics still union; an input **without** a score adds a score-free record. +- **SC4 (migration v2→v3, both directions)** — a `schemaVersion:2` store with records lacking `score` loads as + **v3**, records intact, **no `score` invented**; round-trip `loadStore→saveStore` writes `schemaVersion:3`; a + v3 store with `score` on records loads idempotent; **a v3 store's `score` field survives load+resave** (field + preservation of a new optional field — not covered by the mirrored v1→v2 block). Mirrors + `store.test.ts:403-476`, **retitled `(RE-R3a / score v2→v3)` with every `schemaVersion` assertion literal + flipped `2`→`3`.** +- **SC5 (brief ranks on composite)** — within a bucket, `rankForBrief` orders **composite desc** first + (a composite-9 record ahead of a composite-6 record at the **same overlap**); an **unscored** record sorts + **after** every scored record in its bucket (the `-1` sentinel) and then by the existing keys; buckets are + unchanged (still overlap+freshness); the order is a **total order** (same-title/diff-url, both unscored → + fixed by `url asc`); same input → byte-identical brief (determinism). +- **SC6 (render surfaces band + mode)** — `renderBrief` emits the **full pinned line shapes** (§3): a scored + top-entry shows `· <priority> (<mode>)` between `(<age>d)` and `· Pillarer`; a scored bullet shows `· + <priority> (<mode>)` before `· 🔗`; an **unscored** entry renders the **unchanged** line (no token) — both + asserted as **full lines, not substrings**. `briefSummary` names the band (no mode) on a scored top, omits the + token on an unscored top, and stays one line with no `"`/`\n` **even when the top title contains a guillemet/ + quote** (the only new code path touching the summary). The `ranking:` frontmatter descriptor equals the pinned + string verbatim. A store whose only fresh match is a **single-pillar unscored** record → `briefSummary` renders + with no `· <priority>` token, one line. +- **SC7 (CLI persists score end-to-end)** — `echo '[{…,"score":{"mode":"kortform","dimensions":{…valid…}}}]' + | … capture --store <tmp>` then `… list --store <tmp> --json` shows the record carrying a `score` with the + computed composite/priority; a batch with one **bad** score → that item in `errors[]`, the valid ones added, + **exit 0** (the run isn't failed). +- **SC8 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the + bumped `TRENDS_TESTS_FLOOR`; new **Section 16j** green (`TrendScore` in `score.ts`, `score?: TrendScore` in + `types.ts`, `"dimensions"` in `trend-spotter.md`, `score?.composite` in `brief.ts`, non-vacuity self-test); + `ASSERT_BASELINE_FLOOR` = **99** (94 + 5); Section 17 de-niche green; counts 27/19/29. + +## 7. Verification + +**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; new Section 16j +self-test + greps pass; `ASSERT_BASELINE_FLOOR` = 99; Section 17 de-niche green; ref/agent/command counts +unchanged. **Regression sanity:** `node --test hooks/scripts/__tests__/` → still green untouched (R3a touches no +hook; adds no hook test). +**Behavioural (manual):** +1. `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d ago>", + "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}, + {"source":"tavily","title":"B","url":"https://e/b","topics":["ai","gov"],"publishedAt":"<~2d ago>", + "score":{"mode":"kortform","dimensions":{"pillar":6,"audience":5,"timing":6,"angle":5,"authority":5}}}]' + | node --import tsx src/cli.ts capture --store /tmp/r3a.json` — both overlap-2 & fresh, A scored higher. +2. `node --import tsx src/cli.ts list --store /tmp/r3a.json --json` → confirm both records carry `score` + with computed composite/priority. +3. `node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r3a.json --out /tmp/r3a-brief --json` → + confirm **A precedes B** in `topMatches` (higher composite, same overlap+freshness), the entry line shows + `· <priority> (kortform)`, and the `summary` names A with its band. +4. Append a bad-score item (`"timing":99`) to the batch and re-`capture` → confirm it lands in `errors[]`, + the valid items still added, exit 0. + +## 8. Open questions for the go-gate — RESOLVED + +D1–D4 confirmed by the operator ("Go", 2026-06-24): **D1** 4-field `TrendScore`; **D2** composite primary within +bucket; **D3** first-sight; **D4** one slice (data-then-visible commit order within it). Two residual cosmetics, +both baked to the recommended default: +- **D5 — `BRIEF_SCHEMA_VERSION` 1→2?** No (no frontmatter field added/removed; the hook reads only + `date`+`summary`). Re-open only if the artifact should self-announce the ranking change. +- **D6 — mode in the per-entry render?** YES (folded from plan-critic #3): the body entry shows `<priority> + (<mode>)`; the summary shows the band only. This makes the mode-blind ranking honest (the reader can see the + instrument). + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED** +(every SC1–SC8 traces to a step; zero creep; all §4 non-goals held; counts 27/19/29 verified live; "no new +source file" verified — exactly 6 `src/*.ts` + 5 `tests/*.test.ts`, all edited, none added; 0 findings). +**brief-reviewer: PROCEED_WITH_RISKS** (all four load-bearing claims — score.ts-is-a-leaf/no-cycle, composite ≥ +1.0, version-stamp-only migration, single-owner arithmetic — verified TRUE; 6 MINOR). **plan-critic: REVISE** +(1 BLOCKER, 4 MAJOR, 4 MINOR). All findings folded; see `plan-re-r3a.md §Plan-critic — folded` for per-step +resolution. Headlines: + +- **[BLOCKER, folded]** the "all five test files fail on assertion after Step 1" RED claim is false for + `score`/`item` under Node16 ESM (a missing named import throws at module-load, not on assertion). → RED is now + **explicitly two-phase**: logic-RED for `store`/`brief`/`cli` against pre-edit code; stub-first then + assertion-RED for `score`/`item` (§5; plan Step 1; the header blockquote). +- **[MAJOR, folded]** the no-throw guarantee was overstated ("unreachable" — but `itemToInput` is public and + throws on direct bad-dim calls). → reworded **path-specific** (no throw on the capture path; direct calls throw + by contract); SC2 asserts both (§5, §6). +- **[MAJOR, folded]** mode-mixing was waved away and "mode shown per entry" contradicted the render spec (which + only showed priority). → the render now shows `<priority> (<mode>)` per body entry (D6); §4 states mode-blind + ranking is accepted for R3a with the mode visible; SC6 asserts the full line incl. mode. +- **[MAJOR, folded]** `requiredDimensions` order contract was ambiguous (SC1 hard-coded arrays vs membership + use). → pinned **ordered** (SC1 deep-equals the SSOT-order array; `score.test` pins order; `normalizeItem` uses + membership) (§3 S-score, SC1). +- **[MAJOR, folded]** `ASSERT_BASELINE_FLOOR` "~99" was not pinned. → pinned **99** (94 + 5 unconditional 16j + emitters; self-test emits one pass/fail like 16i) (§3 wiring, SC8). +- **[MINOR, folded]** SC4 ref `:403-471` stale + pointed at v2 assertions → `:403-476` + "flip every + `schemaVersion` literal 2→3" note (SC4). **[MINOR, folded]** R1 SSOT-pin cite was the doc-comment → now + `score.test.ts:12-30` (§2, §5; plan R1). **[MINOR, folded]** bullet `· <priority>` placement was substring-only + → full pinned line shape, priority+mode before `🔗`, asserted as a full line (§3, SC6). **[MINOR, folded]** + three diverging `ranking:` descriptor strings → one verbatim target, asserted byte-for-byte (§3, SC6). **[MINOR, + folded]** unscored single-match-top summary path untested → added as an SC6 case. **[MINOR, folded]** + `normalizeItem` non-array object case understated → "non-array" added to both object checks + SC2. **[MINOR, + folded]** header-chain line-ref tightened to the 16i clause `:46-49` / Section-18 `:49`. **[MINOR, folded]** R9 + DAG now lists the three new one-way `score.ts ←` edges. **[MINOR, folded]** SC4 forward-compat / + score-survives-round-trip added. **[MINOR, folded]** SC6 quote-safety regression (scored top title with a + guillemet) added. diff --git a/docs/research-engine/brief-re-r3b.md b/docs/research-engine/brief-re-r3b.md new file mode 100644 index 0000000..694ab03 --- /dev/null +++ b/docs/research-engine/brief-re-r3b.md @@ -0,0 +1,416 @@ +# Brief — RE-R3b: trend lifecycle — re-score on re-capture · status (acted/skipped) · seen-log + +> **Slice:** RE-R3b (research-engine rung-2, R3 slice 2 — the **lifecycle** slice: what happens to a trend +> AFTER first capture). R3 ("deepen the research engine") is an **arc** of 5 open hulls (substrate §1). R3a took +> the **relevance** half of hull 5 (persist the score, rank on it). R3b takes the rest of the *lifecycle* of a +> trend: **(i) re-score on re-capture** (R3a's explicit deferral — hull 3 remainder), **(ii) a status lifecycle** +> `new`/`acted`/`skipped` (hull 5), and **(iii) a seen-log** — `surfacedCount`/`lastSurfacedAt` accumulated on +> each record as the temporal foundation slices (c)+(b) build on (hull 5, B4 dedup-state). +> **Predecessor:** RE-R3a (`score?: TrendScore` persisted first-sight; `rankForBrief` orders on composite; +> `renderBrief` surfaces band+mode) + RE-R2b (`brief.ts` dated artifact + surfacing) + RE-R2a (`capture` bridge). +> R3a §4 deferred this exactly: *"Re-score on re-capture … R3b. R3a is first-sight only (D3). Re-score pairs +> naturally with the seen-log/status slice."* — R3b is that paired slice. +> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (5) (status/lifecycle: acted/skipped) + +> remainder of (3) (status as a schema field) + §B4 (*"freshness window + dedup-state (append-only seen-log → +> don't re-surface the same item)"*). The freshness window already exists (`freshDays`, R2b); R3b adds the +> dedup-state (status as the hard dedup; surfacedCount as the soft signal). +> **TDD-order:** RED before code, **two phases** (light-Voyage BLOCKER fold, inherited from R3a): the re-score + +> migration parts of `store.test`, all of `brief.test`, and `cli.test` are true logic-RED against the pre-edit +> code (inline fixtures / behaviour change / subprocess — no new import); the `setStatus`/`markSurfaced`/ +> `effectiveStatus` tests reference not-yet-existing `store.ts` exports, so under Node16 ESM a missing named +> import throws at module-load (not on assertion) — they are RED against **non-throwing stubs** landed first. See +> plan Step 1. + +## 1. Operator decision context (2026-06-25) + +The research engine is **Tier-1** (operator, 2026-06-23). R1→R3a built the deterministic spine: item-schema + +triage scorer (R1) → capture bridge (R2a) → dated morning brief + surfacing (R2b) → persisted relevance score + +composite ranking (R3a). What the spine still lacks is **memory of a trend's life after first sight**: the score +is frozen at first capture even as timing decays; a trend the operator already wrote about (or deliberately +passed on) **re-tops tomorrow's brief unchanged**; and nothing records that a trend has been *surfaced* N times +without action. The morning brief is meant to be a **work queue**, but today it is amnesiac — it cannot tell a +fresh unhandled signal from one the operator dealt with yesterday. + +R3b closes that gap with the **lifecycle layer** the operator chose as slice (a) of the full-R3 build-out +(2026-06-24, *"ALLE gjenstående R3-slices … i rekkefølge (a) → (c) → (b) → (d) → (e)"*). It is **the fundament +for everything temporal**: the autonomous trigger (c) must *never automate a loop that re-surfaces handled +items* — so it depends on (a)'s status+seen-log; saturation/first-mover (b) is *only meaningful with accumulated +seen-data* — which (a) starts accumulating. R3b is deliberately first in the sequence: correctness of the +lifecycle model before any automation reads it. + +**Architectural decisions — CONFIRMED (operator, AskUserQuestion 2026-06-25; baked into the plan):** +- **A1 — seen-log form = on-record + the brief records surfacing.** Three new optional fields on `TrendRecord` + (`status`, `surfacedCount`, `lastSurfacedAt`); the `brief` CLI, **after** the pure `rankForBrief` computes the + ranking, records surfacing on the rendered trends and re-saves the store. `rankForBrief` stays **pure** + (mutation only at the CLI edge). The store stays the **single source of truth** — no separate `seen-items.md`. + A `--no-mark` flag gives a side-effect-free dry run. *(This is exactly what slice (c) will automate and slice + (b) will read.)* +- **A2 — re-score on re-capture = last-score-wins.** On a duplicate capture carrying a fresh `score`, the stored + `score` is **replaced** by the freshly-computed envelope (composite re-derived by the one owner, + `composite()`+`band()`). `score` becomes **the one deliberately-mutable field**; provenance (`source`, + `capturedAt`, first `publishedAt`) stays first-sight. A re-score **does NOT reset status** — an `acted`/`skipped` + decision sticks. *(Rationale: the Timing dimension decays, so the newer judgment — even a lower one — is the + truer one; monotone "only if higher" would freeze stale optimism.)* +- **A3 — acted/skipped are EXCLUDED from the brief.** `rankForBrief` drops every record whose effective status + is not `new` from all three buckets — the brief is a work queue, not an archive. Full history stays available + via `list`/`query`. + +## 2. The gap — grounded in code + +- **The score is frozen at first sight, even as timing decays.** `addTrend`'s duplicate branch + (`store.ts:127-131`) unions topics and returns — it **never touches `score`** (R3a's D3, first-sight only). + The capture path already carries a fresh score on every re-capture (`item.ts:192` `itemToInput` → + `scoreEnvelope`; `cli.ts:257` folds it through `addTrend`), so the fresh judgment **reaches `addTrend` and is + silently discarded** for any trend already in the store. A trend re-polled a week later still ranks on its + week-old Timing score. +- **A handled trend re-tops the brief unchanged.** `rankForBrief` (`brief.ts:82-92`) iterates **every** store + record, dropping only off-pillar ones (`overlap === 0`, `:89`). There is no notion of "I already wrote about + this" — an `acted` trend with a high composite re-sorts to the top of `topMatches` tomorrow exactly as it did + today. `TrendRecord` has **no `status` field** (`types.ts:29-59`); the doc-comment anticipates it: *"can gain + fields (…, status) in a later slice"* (`types.ts:22`). +- **Nothing records that a trend has been surfaced.** The brief is a **pure read** (`brief.ts:1-15`: *"No fs, no + clock, no AI"*); generating it leaves no trace on the store. There is no `surfacedCount`/`lastSurfacedAt` — + so a future autonomous loop (slice c) has **no way to know** a trend was already shown, and saturation (slice b) + has **no accumulated signal** to read. B4's dedup-state (`docs/research-engine-concepts.local.md:63`) does not + exist yet. +- **The CLI has no lifecycle verbs.** `cli.ts` exposes `add`/`query`/`list`/`status`/`normalize`/`score`/ + `capture`/`brief` (`:5-13`) — all capture/read. There is **no way for the operator to mark** a trend `acted` + or `skipped`. + +## 3. Scope — what is IN (RE-R3b) + +### S-types — `scripts/trends/src/types.ts` (EDIT) +- **`export type TrendStatus = "new" | "acted" | "skipped";`** — the lifecycle states. +- `TrendRecord` gains **three optional fields** (all absent on pre-R3b records, all additive): + - **`status?: TrendStatus;`** — lifecycle. **Absent ⇒ `"new"`** (back-compat); set only by `act`/`skip`/`reset`, + **never on capture** (a freshly-captured trend is implicitly `new`). + - **`surfacedCount?: number;`** — the seen-log count: how many distinct days this trend has appeared in a + generated brief. **Absent ⇒ 0.** Incremented (per-day-idempotent) by the `brief` CLI. + - **`lastSurfacedAt?: string;`** — ISO date of the most recent surfacing. **Absent ⇒ never.** The per-day + idempotency key (re-running today's brief does not re-increment). +- Doc-comment: mark `status`/`surfacedCount`/`lastSurfacedAt` as the now-realized lifecycle fields the `:22` + note anticipated. +- **`SCHEMA_VERSION = 3 → 4`** (`types.ts:73`). Additive-optional; the migration is the version-stamp alone + (below), identical to v1→v2→v3. + +### S-store — `scripts/trends/src/store.ts` (EDIT) +- **`export function effectiveStatus(t: TrendRecord): TrendStatus`** — `return t.status ?? "new";`. The single + reader of the absent-⇒-new convention (pure; consumed by `addTrend` audit, `brief`, and the CLI). Imports + `TrendStatus` from `./types.js` (type-only). +- **Re-score in `addTrend`'s duplicate branch (`:127-131`, A2):** after the topic union, if `input.score !== + undefined` **and it differs from `existing.score`** (compared via `JSON.stringify` — the envelope is built in a + fixed key order by `scoreEnvelope`, so the compare is stable), set `existing.score = input.score` and mark the + record changed. `AddResult.merged` is **broadened** to *"the existing record was mutated — topics unioned and/or + score refreshed"*; `merged` is true iff **either** changed (a re-capture with an identical score → `merged:false`, + no false-positive). `status`/`surfacedCount`/`lastSurfacedAt` are **NOT touched** on re-capture (A2: re-score + doesn't reset status; surfacing is the brief's job, not capture's). The **new-record** branch (`:132-144`) is + unchanged — a new record omits all three lifecycle fields (status absent ⇒ new; never surfaced; no input.status + exists on the capture path). +- **`export function setStatus(store: TrendStore, id: string, status: TrendStatus): { store: TrendStore; found: + boolean }`** — find the record by `id`; if absent return `{ store, found: false }` (no throw); else set + `t.status = status` (set **explicitly**, including `"new"` for a `reset`) and return `{ store, found: true }`. + Mutates in place + returns the same store (the `addTrend` idiom). Pure (no fs). +- **`export function markSurfaced(store: TrendStore, ids: string[], today: string): { store: TrendStore; marked: + number }`** — for each record whose `id` is in `ids` **and** whose `lastSurfacedAt !== today` (per-day + idempotent), set `surfacedCount = (surfacedCount ?? 0) + 1` and `lastSurfacedAt = today`; count it. Records + already surfaced today, or not in `ids`, are untouched. Pure (no fs; `today` injected by the caller, like + `capturedAt`). Returns the count actually incremented. +- `AddResult` keeps its **2-flag shape** `{ store, added, merged }` (no new flag — `merged` is broadened, not + joined). `TrendInput` is **unchanged** (no `status`/`surfaced*` input — lifecycle is set post-capture, not + ingested). +- `loadStore` migrate comment (`:82-88`): extend the enumeration to *"v1→v2→v3→v4 are all purely + additive-optional"*. **No code change** (`Math.max(onDisk, SCHEMA_VERSION)` `:91` already stamps v4; + `saveStore` `JSON.stringify` `:99` preserves the three new fields). Only `SCHEMA_VERSION` (in `types.ts`) and + the comment move. + +### S-brief — `scripts/trends/src/brief.ts` (EDIT) +- **`rankForBrief` excludes handled trends (A3):** in the entry loop (`:82-92`), add **`if (effectiveStatus(trend) + !== "new") continue;`** immediately before the `overlap === 0` check (so acted/skipped never enter any bucket). + Import `effectiveStatus` from `./store.js` (brief.ts already imports `defaultStorePath` from there — `:19`; the + edge stays one-way, no cycle). `totals.trends` **still counts the full inventory** (`store.trends.length`, + `:116`) — honest "of N in store"; `totals.matched`/`fresh` naturally reflect the post-filter `entries`. +- **`renderBrief`/`renderTopEntry`/`renderBulletEntry` surface the trend `id` + a surfaced marker** (so the + operator can act on an entry, and a re-surfaced item is honest). **Pinned line shapes:** + - A shared **`surfacedToken(e)`** helper (mirrors `scoreToken`, `:142-145`): ` · sett <surfacedCount>x` when + `surfacedCount >= 2`, else `""` (only a genuinely re-surfaced item is flagged; this is a saturation **hint**, + not the saturation **scoring** of slice b). **Semantic (folded — plan-critic #3): the count is PRIOR-DAY** — + the brief renders from `surfacedCount` **before** the CLI records today's surfacing (the mutation runs after + `renderBrief`), so `· sett Nx` means *"shown on N prior distinct days"* (today's appearance is recorded but + not yet counted in this render). The `>= 2` floor therefore means "already shown on ≥2 earlier days". This is + documented in the README + asserted by a unit test that sets `surfacedCount` directly (the cross-day behaviour + is exercised by behavioural step §7). + - Top-entry meta line (`renderTopEntry`, `:150`): append **` · \`<id>\``** at the end (after `Pillarer: …`), + and `surfacedToken(e)` after the `scoreToken`: + `- Kilde: <source> · Publisert: <date> (<age>d)<scoreToken><surfacedToken> · Pillarer: <matched> · \`<id>\`` + - Bullet line (`renderBulletEntry`, `:159`): append **` · \`<id>\``** at the end (after `🔗 <url>`), with + `surfacedToken` after `scoreToken`: + `- **<title>** — «<matched>» · <date> (<age>d)<scoreToken><surfacedToken> · 🔗 <url> · \`<id>\`` + - The id is rendered in backticks so it is copy-paste-ready for `act --id <id>` / `skip --id <id>`. +- **`export function surfacedIds(ranking: BriefRanking): string[]`** — the ids of the entries `renderBrief` + **actually shows**: `topMatches ∪ singleMatches ∪ olderMatched.slice(0, 5)` (mirrors the `:199` `.slice(0, 5)` + older cap), mapped to `e.trend.id`. The CLI feeds this to `markSurfaced` so the seen-log records exactly what + the operator saw. Pure. +- **`ranking:` frontmatter descriptor (`:175`)** → the **exact** string + `composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays <N>; excludes + acted/skipped` (pinned verbatim; `brief.test` asserts byte-for-byte). The trailing `; excludes acted/skipped` + is the only descriptor change. +- `briefSummary` (`:129-139`) is **unchanged** (the headline still names the top fresh match's band + age; status + exclusion happens upstream in the ranking, so the summary already reflects only `new` trends). `BRIEF_SCHEMA_ + VERSION` stays **1** (no frontmatter *field* added/removed — `date`/`summary`/`store`/`ranking`/`schemaVersion` + unchanged; only the `ranking:` *string* and body content change; the surfacing hook still reads `date`+`summary`). + +### S-cli — `scripts/trends/src/cli.ts` (EDIT) +- **`act` / `skip` / `reset` subcommands** (set lifecycle status by id): + - `act --id <id> [--store <path>]` → `setStatus(store, id, "acted")`; `skip …` → `"skipped"`; `reset …` → + `"new"`. Each: load → setStatus → if `found` save + print `Marked <id> <status>` (exit 0); if **not found** + print `error: no trend with id: <id>` to stderr + **exit 2**. A missing/`true` `--id` → `usage('<cmd> needs + --id <id>')` (exit 2). **Exit-code contract broadened (folded — plan-critic #2):** a not-found id is exit 2, + which the existing contract documents as "usage error". Update the header doc-comment (`cli.ts:33`) to read + *"0 on success, 2 on usage error or a not-found id (act/skip/reset)"* — a wrong `--id` value is an + argument-class error, distinct from `capture`'s data-stream items (which stay in `errors[]`, never the exit + code). A new exit code is **not** introduced (the CLI keeps its two codes). +- **`brief` records surfacing (A1):** **hoist the load** (folded — plan-critic #1 / brief-reviewer #1): replace + the inline `rankForBrief(loadStore(storePath), …)` (`cli.ts:286`) with **`const store = loadStore(storePath); + const ranking = rankForBrief(store, pillars, day, { freshDays });`** — `cli.ts:286` does **not** currently bind + a `store` variable (verified), so the surfacing edit needs this hoist or it references an undefined identifier. + Then after `writeFileSync(path, md, …)` (`:290`), **unless `--no-mark`**: `markSurfaced(store, surfacedIds + (ranking), day)` then `saveStore(storePath, store)` — the **hoisted `store`** holds the full inventory, so + acted/skipped records (filtered from the ranking but still in the store) are preserved on resave; the `.md` is + rendered from the pure `ranking` **before** the mutation. `const mark = flags["no-mark"] !== "true";` (a bare + `--no-mark` → `"true"` → mark off). The `--json` output gains a **`marked`** count (trends whose seen-log this + run incremented; `0` when `--no-mark`). `rankForBrief`/`renderBrief` are untouched — the mutation is purely at + the edge. +- **`capture` tally comment (`cli.ts:251-252`)** (folded — plan-critic #4): the broadened `AddResult.merged` + (topics ∪ score-refresh) makes the existing comment *"a fold is … `merged` (existing gained topics)"* stale → + update it to *"`merged` (existing gained topics and/or a refreshed score)"*. No tally-logic change (the loop + already counts `res.merged`). +- **Usage + header doc:** add the three new verbs + `[--no-mark]` to the `usage()` block (`:82-91`) and the + header synopsis (`:5-13`); a one-line header note that `act`/`skip`/`reset` set a trend's lifecycle status, the + brief excludes handled trends and records surfacing, and re-capture refreshes the score. + +### Wiring (D-default — WIRE, mirrors R3a) +- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): Step 4.5 already emits the per-item `score` (R3a); + re-score is **automatic** (capture re-folds an existing trend with a fresh score → `addTrend` now refreshes it), + so **no batch-shape change**. Add one prose line: re-capturing a known trend now **refreshes** its relevance + score (timing decays), and the operator marks trends `acted`/`skipped` via the CLI so the brief stops + re-surfacing handled work. Domain-general (no vendor/sector tokens). *(If a Section-16k grep targets the agent, + it must be verified non-vacuous first; the recommended 16k greps target src files only — see gate below.)* +- `scripts/trends/README.md` (EDIT): document the status lifecycle (`new`/`acted`/`skipped` + `act`/`skip`/`reset`), + the seen-log (`surfacedCount`/`lastSurfacedAt`, per-day idempotent, brief-recorded), re-score-on-recapture + (last-wins), and the brief's exclude-handled behaviour + `--no-mark`. +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (`:705`, currently 146) to the `tests N` line + reported after Steps 1–6, **append** `+ RE-R3b: lifecycle +N` to the inline breakdown comment. Add + **Section 16k** ("Trends Lifecycle Wiring", RE-R3b) **after Section 16j's closing block, before Section 18** + (16j is the last 16x before the anti-erosion Section 18; preserve that order). Mirror 16j's shape: + **unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail). Recommended + **6 emitters** (all on tracked src — no `tsx`): (1) self-test; (2) `export type TrendStatus` in `types.ts`; + (3) `surfacedCount` in `types.ts` (seen-log field); (4) `export function markSurfaced` in `store.ts` (seen-log + writer); (5) `effectiveStatus` in `brief.ts` (the brief excludes handled); (6) `command === "act"` in `cli.ts` + (the lifecycle verb). **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 99 → exactly 105** (`:1259`; + "live recount" is the safety net; the expected value is the pinned 99 + 6 = 105). Update the header-enumeration + prose chain by inserting the 16k clause between the 16j clause and the Section-18 clause. + +## 4. Non-goals — what is OUT (deferred) + +- **Saturation scoring / first-mover-as-a-field** (the quantitative *use* of `surfacedCount`) — **slice (b)**. + R3b **accumulates** the seen-log and shows a minimal `· sett Nx` hint, but it does **not** compute a saturation + score, decay the composite by surfacings, or add a first-mover field. (b) reads R3b's accumulated data. +- **Autonomous nightly trigger** (cron/launchd, headless entry — hull 1+6) — **slice (c)**. R3b adds no scheduler; + it builds the lifecycle (c) will safely automate. +- **Brief history surfacing / diff** ("what's new since yesterday" — hull 7) — **slice (d)**. The seen-log records + *that* a trend was surfaced; the cross-brief **diff** is (d). R3b's `· sett Nx` is a per-record count, not a + day-over-day diff. +- **Research-deepening A1–A4** (plan → isolated workers → gap loop → curate) — **slice (e)**, behind the post-(d) + re-evaluation gate. +- **Mode-segmented ranking / `--mode` filter** — still OUT (R3a non-goal, unchanged). +- **Re-score semantics other than last-wins** (monotone / timing-only refresh) — OUT (A2 chose last-wins). +- **A `status`/`surfaced*` input on the capture/`add` path** — OUT. Lifecycle is set **post-capture** by + `act`/`skip`/`reset`; capture never ingests a status. `TrendInput` is unchanged. +- **`act`/`skip` by title/url** (deriving the id) — OUT for R3b; `--id` only (the id is shown in the brief + + `list --json`). A title/url alias is a later ergonomic nice-to-have. +- **Auto-acting on publish** (wiring `act` into `/linkedin:post` / the post-tracking flow) — OUT. R3b ships the + CLI verbs; auto-marking from the content commands is a separate plugin-surface slice. +- **`BRIEF_SCHEMA_VERSION` bump** — OUT (no frontmatter field changes); Open Q. +- **New source file / new agent / new command** — none. R3b is edits to **four** existing `src/*.ts` (`types`, + `store`, `brief`, `cli`) + their tests + one agent (prose) + README + gate. `score.ts` + `item.ts` are + **untouched** (re-score reuses the R3a capture path). Counts stay 27/19/29. + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — true logic-RED for + the re-score + migration parts of `store.test` (existing `addTrend`/`loadStore`, inline fixtures), all of + `brief.test` (behaviour change to existing `rankForBrief`/`renderBrief`), and `cli.test` (subprocess: `act`/`skip` + print a usage/unknown-command error today → assertion-RED). Phase B — `setStatus`/`markSurfaced`/`effectiveStatus` + reference new `store.ts` exports → land non-throwing stubs first (Node16 ESM throws a missing named import at + module-load), then record value-assertion RED against the stubs. The plan does **not** claim a single + "everything fails before any code" run. +- **`rankForBrief` stays pure (A1):** no fs, no clock, no env, no AI, **no store mutation**. The status filter is + a pure read of `effectiveStatus`. The seen-log **write** lives only in the `brief` CLI edge (after the pure + ranking), guarded by `--no-mark`. `markSurfaced`/`setStatus`/`effectiveStatus`/`surfacedIds` are all pure. +- **One composite owner (unchanged):** re-score reuses the **already-built** capture path + (`itemToInput`→`scoreEnvelope`→`composite`+`band`); R3b adds **no new arithmetic** and does not touch `score.ts`. +- **Provenance discipline (A2):** `source`, `capturedAt`, and the first `publishedAt` stay **first-sight**; only + `score` is mutable on re-capture; `status`/`surfacedCount`/`lastSurfacedAt` are mutated only by their own + owners (`setStatus`/`markSurfaced`), never by `addTrend`. +- **Per-day-idempotent surfacing:** running `brief` twice on the same `today` increments `surfacedCount` **at most + once** (`markSurfaced` skips records whose `lastSurfacedAt === today`). Re-generating today's brief is a no-op on + the seen-log. *(This is the determinism guarantee for the autonomous loop: an idempotent daily mark.)* +- **No false-merge on re-capture:** a re-capture with a **byte-identical** score → `merged:false` (the + `JSON.stringify` compare); only a genuine topic-union or score-change flips `merged`. +- **Determinism (brief):** same `(store, pillars, today, freshDays)` → byte-identical `renderBrief` output (the + status filter + `surfacedToken` + id are deterministic reads of the store; the composite sort total order from + R3a holds). The CLI's surfacing mutation is **outside** the pure render. +- **Lossless additive migration (both directions):** a v3 store loads as v4 with records **untouched** (no + `status`/`surfaced*` invented); round-trip writes `schemaVersion: 4`; a v4 store is idempotent; the three new + optional fields **survive a load+resave**. Mirrors the R3a v2→v3 proof (`store.test.ts`, `(RE-R3a / score + v2→v3)` block) with the literals flipped `3`→`4`. +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only and **never shells out to tsx**. + R3b touches neither the hook nor the frontmatter schema (`BRIEF_SCHEMA_VERSION` stays 1), so surfacing is + unchanged. The existing hook suite must still pass untouched (regression sanity; R3b adds no hook test). +- **Domain-general:** Section 17 de-niche stays green; the `trend-spotter.md` prose carries only generic + lifecycle wording (`acted`/`skipped`/"refresh the score"), no vendor/sector tokens. +- **No SSOT change:** `references/trend-scoring-modes.md` untouched (R3b changes no scoring math). +- **No store-query change:** `queryByTopic`/`history`/`newestCaptureDate` untouched. *(The CLI `status` + subcommand — the staleness reader — is unrelated to the new `TrendStatus` lifecycle type; the name collision is + pre-existing and not reconciled here.)* +- **Pathguard:** all edits are to **existing** files (no new `.mjs` under `hooks/scripts/`; no new `.ts` — R3b + adds no source file). `.gitignore` already covers `scripts/trends/{node_modules,build}`. +- **Counts** (refs/agents/commands 27/19/29) unchanged. **Recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (status field + effectiveStatus + setStatus)** — `effectiveStatus({…no status})` is `"new"`; + `effectiveStatus({…status:"acted"})` is `"acted"`. `setStatus(store, id, "skipped")` on a present id sets the + record's `status` and returns `{ found:true }`; on an absent id returns `{ found:false }` (no throw, store + unchanged); a `reset` sets `status:"new"` explicitly. +- **SC2 (re-score last-wins, no false-merge, status/provenance untouched)** — `addTrend(store, dupInput)` where + `dupInput` has the same title+url and a **different** `score` → the stored `score` is **replaced**, `merged:true`, + `added:false`, topics still unioned, and `source`/`capturedAt`/`publishedAt`/`status`/`surfacedCount` are + **unchanged**. A re-capture with a **byte-identical** score (and no new topics) → `merged:false`. A duplicate + with **no** `score` → stored score unchanged. A re-capture of an **acted** trend with a new score → score + updated, **status stays `acted`**. **At the CLI edge (folded — plan-critic #4):** a `capture` of a scored item, + then a `capture` of the same title+url with a **changed** score → the second `capture --json` reports + `merged:1`, and `list --json` shows the **updated** composite (a subprocess test, not only the manual §7 step). +- **SC3 (markSurfaced + per-day idempotency)** — `markSurfaced(store, [idA, idC], "2026-06-25")` increments + `surfacedCount` (absent⇒0→1) and sets `lastSurfacedAt:"2026-06-25"` on A and C only (B untouched), returns + `marked:2`; a second `markSurfaced` with the **same `today`** → `marked:0`, counts unchanged; a third with a + **later** `today` → increments again, `lastSurfacedAt` advances; an id not in the store is silently skipped. +- **SC4 (migration v3→v4, both directions)** — a `schemaVersion:3` store with records lacking the lifecycle + fields loads as **v4**, records intact, **no field invented**; round-trip `loadStore→saveStore` writes + `schemaVersion:4`; a v4 store with lifecycle fields loads idempotent; **the three new fields survive + load+resave** (byte-identical). Mirrors the R3a `(RE-R3a / score v2→v3)` block, retitled `(RE-R3b / lifecycle + v3→v4)`, every `schemaVersion` literal flipped `3`→`4`. +- **SC5 (brief excludes acted/skipped)** — given a store with `new`, `acted`, and `skipped` records all matching + pillars + fresh: `rankForBrief` places **only** the `new` ones in `topMatches`/`singleMatches`/`olderMatched`; + `totals.trends` still equals the **full** store count; a store whose only matches are `acted`/`skipped` → + empty buckets + the `briefSummary` "no fresh signals" line; the order among the surviving `new` records is the + R3a composite total order (unchanged). +- **SC6 (brief render: id + surfaced marker + descriptor)** — `renderBrief` emits the **full pinned line shapes** + (§3): a top entry ends with `· \`<id>\`` (after `Pillarer: …`); a bullet ends with `· \`<id>\`` (after `🔗 + <url>`); a record with `surfacedCount >= 2` shows `· sett <N>x` (after the score token), one with + `surfacedCount` 0/1/absent shows **no** surfaced token — both asserted as **full lines**. The `ranking:` + descriptor equals the pinned string ending `; excludes acted/skipped` verbatim. `surfacedIds(ranking)` returns + exactly the ids of `topMatches ∪ singleMatches ∪ olderMatched.slice(0,5)`. Two `renderBrief` calls on the same + input are byte-identical. +- **SC7 (CLI act/skip/reset)** — `act --id <id> --store <tmp>` then `list --store <tmp> --json` shows the record + with `status:"acted"`; `skip` → `"skipped"`; `reset` → `"new"`; an **unknown** id → stderr error + **exit 2**, + store unchanged; a missing `--id` → usage + exit 2. +- **SC8 (CLI brief marks surfaced + --no-mark + exclusion end-to-end)** — `brief --pillars … --store <tmp>` on a + store with fresh matches → the written `.md` **omits** any acted/skipped record; a following `list --store <tmp> + --json` shows the surfaced trends with `surfacedCount:1` + today's `lastSurfacedAt`, and the `--json` output + carries `marked:<n>`; a **second** `brief` the same day → `marked:0`, counts unchanged (idempotent); + `brief --no-mark --store <tmp>` on a fresh store → `marked:0`, **no `surfacedCount` written** (store's trends + unchanged save for nothing). +- **SC9 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the bumped + `TRENDS_TESTS_FLOOR`; new **Section 16k** green (`TrendStatus` + `surfacedCount` in `types.ts`, `markSurfaced` + in `store.ts`, `effectiveStatus` in `brief.ts`, `command === "act"` in `cli.ts`, non-vacuity self-test); + `ASSERT_BASELINE_FLOOR` = **105** (99 + 6); Section 17 de-niche green; counts 27/19/29; the hook suite still + green untouched. + +## 7. Verification + +**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; Section 16k self-test + +greps pass; `ASSERT_BASELINE_FLOOR` = 105; Section 17 de-niche green; ref/agent/command counts unchanged. +**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3b touches no +hook; adds no hook test). +**Behavioural (manual):** +1. `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"],"publishedAt":"<~2d ago>", + "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}]' + | node --import tsx src/cli.ts capture --store /tmp/r3b.json` — adds A. +2. Re-`capture` A with a **lower** timing (`"timing":3`) → `list --store /tmp/r3b.json --json` shows A's + composite **dropped** (re-score last-wins); the capture tally reports `merged:1`. +3. `node --import tsx src/cli.ts brief --pillars ai,gov --store /tmp/r3b.json --out /tmp/r3b-brief --json` → + confirm `marked:1`; `list --json` shows A with `surfacedCount:1` + today's `lastSurfacedAt`; the entry line + shows `· \`<id>\``. +4. Re-run the **same** `brief` → `marked:0` (idempotent); `surfacedCount` still 1. +5. `node --import tsx src/cli.ts act --id <A's id> --store /tmp/r3b.json` → re-run `brief` → A is **absent** from + the written `.md`; the summary reports no fresh signals (if A was the only match). +6. `node --import tsx src/cli.ts reset --id <A's id> --store /tmp/r3b.json` → A reappears in the brief. +7. `brief --no-mark` on a fresh store → `marked:0`, `surfacedCount` not written. + +## 8. Open questions for the go-gate + +Three architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-25): **A1** on-record seen-log, +the `brief` CLI records surfacing (`rankForBrief` pure, `--no-mark` dry-run); **A2** re-score last-wins (score the +one mutable field; status not reset); **A3** acted/skipped excluded from the brief. Residual decisions, all baked +to the recommended default — confirm or redirect with "Go": +- **D1 — status values `new`/`acted`/`skipped`, absent⇒new (omit on add)?** YES (rec). A 3-state lifecycle; a + freshly-captured trend is implicitly `new` (field omitted); `reset` sets `"new"` explicitly. Re-open only if a + `published`/`drafted` distinction is wanted (the plugin tracks posts elsewhere — kept out of the trend store). +- **D2 — `AddResult.merged` broadened (topics ∪ score-refresh), no new flag?** YES (rec). Keeps the 2-flag shape; + the capture tally's "N merged" honestly means "N existing records updated". Re-open only if `rescored` must be + counted **separately** from topic-merges in the CLI tally. +- **D3 — include `reset` (un-skip → new)?** YES (rec). Symmetric + cheap; the operator changes their mind. Drop + only to keep the verb set to two. +- **D4 — show the trend `id` in brief entries?** YES (rec). The status feature is **inoperable** otherwise — the + operator needs the id to `act`/`skip`. Shown in backticks for copy-paste. Alternative: omit, and require + `list --json` to find ids (clunky). +- **D5 — minimal `· sett Nx` marker when `surfacedCount >= 2`?** YES (rec). Keeps the seen-log **honest/visible** + in R3b (otherwise it is an invisible schema-only accumulation — the anti-pattern R3a warned of) without + straying into (b)'s saturation scoring or (d)'s diff. The `>= 2` floor means a first/second sighting is silent. + Drop only if any visible surfaced signal should wait for (b). +- **D6 — `act`/`skip` identify by `--id` only?** YES (rec). Store-native; the id is shown in the brief + + `list --json`. A title/url alias is a deferred nice-to-have. +- **D7 — which entries count as "surfaced"?** The entries `renderBrief` **actually shows**: `topMatches ∪ + singleMatches ∪ olderMatched.slice(0,5)` (rec). Matches what the operator saw; the older-bucket cap mirrors the + render's `.slice(0,5)`. +- **D8 — `BRIEF_SCHEMA_VERSION` 1→2?** NO (rec). No frontmatter field added/removed (the hook reads only + `date`+`summary`). Re-open only if the artifact should self-announce the exclude-handled change. +- **D9 — commit split?** Single code commit (rec) — R3b's lifecycle (re-score/status/seen-log) is tightly + coupled; the R3a data-then-visible split would land an invisible cut. Docs commit first, then one code commit. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED** +(every SC1–SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held; counts 27/19/29 verified live; +`score.ts`/`item.ts`-untouched verified — `itemToInput` already builds the envelope on every capture incl. +re-capture; A1/A2/A3 consistent across every step; the R3a-block reconcile is a necessary prerequisite, not creep; +2 MINOR plan line-cite nits). **brief-reviewer: PROCEED_WITH_RISKS** (all nine load-bearing claims verified TRUE — +incl. the v3→v4 reconcile complete for **every** breaking literal, enumerated; 1 MEDIUM + 3 LOW). **plan-critic: +PROCEED_WITH_RISKS (78/B)** (the two-phase RED, the atomic bump+reconcile, the `merged` broadening's +non-regression, the `surfacedIds` formula, and the gate arithmetic all verified correct; 1 MAJOR + 5 MINOR). +All findings folded; see `plan-re-r3b.md §Plan-critic — folded` for per-finding resolution. Headlines: + +- **[MAJOR/MEDIUM, folded — both reviewers] the `brief` CLI's `store` binding does not exist.** `cli.ts:286` + inlines `rankForBrief(loadStore(storePath), …)` — there is no `const store`, so the `markSurfaced(store, …)` / + `saveStore(storePath, store)` edit referenced an undefined identifier. → §3 S-cli + plan Step 5 now **hoist** + `const store = loadStore(storePath)` and pass it to `rankForBrief`; R5 wording corrected. +- **[MINOR, folded — plan-critic #2] not-found id → exit 2 contradicted the documented exit-code contract.** → + the `cli.ts:33` doc-comment is **broadened** to *"2 on usage error or a not-found id (act/skip/reset)"* (a wrong + `--id` is an argument-class error, distinct from `capture`'s data items); no third exit code introduced (§3 S-cli). +- **[MINOR, folded — plan-critic #3] `· sett Nx` off-by-one.** Render precedes the surfacing mutation, so the + token reflects the **prior-day** count. → the **prior-day semantic** is now stated explicitly (§3 S-brief + the + README): `· sett Nx` = "shown on N prior distinct days". +- **[MINOR, folded — plan-critic #4] `capture` tally comment stale + the re-score CLI tally untested.** → §3 S-cli + updates the `cli.ts:251-252` comment (`merged` = topics ∪ score-refresh); SC2 adds a subprocess assertion that a + re-captured changed-score item reports `merged:1` with the updated composite. +- **[MINOR, folded — plan-critic #5] Step 2 used `TrendStatus` before Step 3 defined it.** → the plan is + reordered: Step 2 adds the `TrendStatus` type + the three fields to `types.ts` **first** (then the `store.ts` + functions); Step 3 isolates the atomic `SCHEMA_VERSION` bump + the R3a-block reconcile. +- **[LOW, folded — brief-reviewer #4] forward-debt: the new R3b migration block hard-coded `4`** (perpetuating the + reconcile-cycle this slice pays for R3a). → the new block's **target + idempotent** assertions commit against + `SCHEMA_VERSION` (the hard-`4` is the Step-1 RED device only; the v3 **input** fixtures stay literal `3`), + breaking the cycle so R3c won't pay it. +- **[LOW, folded] cosmetic literal/title drift** — `store.test.ts:571`/`:598` titles + `:570` comment flipped to + "the current version"; `cli.test.ts:247`'s inert `schemaVersion:2` fixture added to the scope-fence enumeration; + the two plan line-cites corrected to `~:1235` (after 16j's block) / `:1237` (Section 18 header). diff --git a/docs/research-engine/brief-re-r3c.md b/docs/research-engine/brief-re-r3c.md new file mode 100644 index 0000000..0f78ae5 --- /dev/null +++ b/docs/research-engine/brief-re-r3c.md @@ -0,0 +1,424 @@ +# Brief — RE-R3c: autonomous trigger — scheduler + headless entry point + +> **Slice:** RE-R3c (research-engine rung-2, R3 slice 3 — the **autonomy** slice: the trigger that makes the +> daily loop *closed* and the headless entry that runs the deterministic morning brief with **no interactive +> session**). R3 ("deepen the research engine") is an **arc** of 5 open hulls (substrate §1). R3a took relevance, +> R3b took the lifecycle (status + seen-log + re-score). R3c takes hulls **(1) no autonomous trigger** + **(6) no +> headless entry point** — the *mechanism* that runs the existing deterministic brief on a schedule, built and +> tested deterministically **before** the autonomous AI fan-out (slice e) plugs into it. +> **Predecessor:** RE-R3b (`status` exclusion + per-day-idempotent `surfacedCount`/`lastSurfacedAt` — the +> dedup-state a nightly loop **depends on** so it never re-surfaces handled work) + RE-R2b (`brief.ts` dated +> artifact + the SessionStart surfacing the nightly run feeds) + RE-R3a (composite ranking). +> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (1) (*"ingen autonom trigger … zero cron/launchd/ +> scheduler i hele repoet"*) + (6) (*"ingen headless entry point"*) + §B4 (*"behavioral scheduling … a push/delivery +> window that gates delivery separately from the sweep"*) + §B3 (the dated digest as a flat plain-text artifact +> *"skrevet av Stop-hook eller cron-trigget headless-sesjon"*). R3c builds the cron-triggered headless path B3 +> anticipated and the scheduling-window discipline B4 names. +> **TDD-order:** RED before code, **two phases** (light-Voyage discipline, inherited): Phase A — assertion-RED via +> subprocess against the **existing** CLI (`schedule` is an unknown command today → `usage` exit 2; the wrapper +> file is absent → exit 127) — true assertion-RED on the exit-code/stdout assertions, not module-not-found. Phase B +> — `schedule.ts` is a NEW module whose exports the tests import; under Node16 ESM a missing named import throws at +> module-load, so land **non-throwing stubs** (`launchdPlist → ""`, etc.) first, then record value-assertion RED +> against them. See plan Step 1. +> **Architectural decisions — CONFIRMED (operator, AskUserQuestion 2026-06-26; baked into the plan):** +> - **C1 — deterministic brief-only.** The nightly headless run regenerates the dated brief from the **current +> store** (freshness-aging drops stale trends; `surfacedCount` accumulates per distinct day → feeds slice b). +> **NO AI capture.** Polling stays operator-driven; the autonomous AI fan-out is **slice (e)**, which plugs into +> (c)'s headless seam. Faithful to the operator's `(a)→(c)→(b)→(d)→(e)` sequence: build the trigger mechanism + +> headless plumbing (deterministic, testable) **before** the AI sweep it will eventually drive. *Honest framing: +> the visible autonomous-research payoff lands with (e); (c) is the mechanism.* +> - **C2 — print-first installer.** `schedule` **emits** the launchd plist (macOS) / crontab line (Linux) + the +> exact install command; the operator runs it. `--install` writes only the inert launchd plist FILE (never runs +> `launchctl`; never touches `crontab`). Matches the global `[voyage]` cron-persistence guard, the push-policy's +> operator-authorization, and the "confirm outward-facing/persistent actions" rule. + +## 1. Operator decision context (2026-06-26) + +The research engine is **Tier-1** (operator, 2026-06-23). R1→R3b built the deterministic spine **and** the +trend's life after capture: item-schema + triage (R1) → capture bridge (R2a) → dated morning brief + surfacing +(R2b) → persisted relevance + composite ranking (R3a) → status lifecycle + seen-log + re-score (R3b). The spine is +complete and the lifecycle is correct — **but nothing runs it on its own.** The morning brief exists only when the +operator interactively invokes the `brief` CLI (via the `trend-spotter` agent or by hand); the SessionStart hook +*surfaces* the latest dated brief (`session-start.mjs:534`) but **never generates one**. There is **zero +scheduler** in the repo (verified live: only `scripts/test-runner.sh` exists; no plist, no cron, no launchd in any +`.ts`/`.mjs`/`.sh`/config). The loop is open: a brief is only as fresh as the last time the operator remembered to +ask for one. + +R3c closes hulls **(1)** and **(6)** — the **autonomous trigger** and the **headless entry point** — which the +operator chose as slice (c) of the full-R3 build-out (2026-06-24, *"ALLE gjenstående R3-slices … i rekkefølge +(a) → (c) → (b) → (d) → (e)"*). It is sequenced **after** R3b for a load-bearing reason the operator named: an +autonomous loop **must never re-surface handled work**, so it depends on R3b's status-exclusion (acted/skipped +dropped from the brief) and its **per-day-idempotent** surfacing (a double-fire doesn't double-count). R3b made +the nightly regeneration *safe to automate*; R3c automates it. + +**What R3c is — and is not (C1).** R3c is the **mechanism**, not the AI sweep. The nightly run is the *existing +deterministic* `brief` generation — load store → rank → write the dated `.md` → record surfacing — run with no +interaction by a scheduler. It does **not** poll new sources (that is the AI fan-out, slice e). Its honest value +without (e): the brief is regenerated every morning from the current store, so SessionStart surfacing is always +fresh; freshness-aging drops trends past the window automatically; and `surfacedCount` accumulates day-over-day — +the temporal signal slice (b) reads — **without the operator running anything**. (e) later plugs an AI capture +step into the documented pre-brief seam to close the full `poll→score→capture→brief` loop. + +## 2. The gap — grounded in code + +- **No autonomous trigger (hull 1).** Repo-wide there is no scheduler: no launchd plist, no crontab artifact, no + `launchctl`/`cron` reference in any source or config (verified). Every brief is born of an interactive session. +- **No headless entry point (hull 6) — *almost*.** The `brief` subcommand (`cli.ts:297-328`) is **already + non-interactive**: it reads flags, writes `<outDir>/<day>.md`, records surfacing, and exits 0 — no prompts. What + is missing is a **robust invocation wrapper** that makes it runnable from a scheduler's *minimal* environment: + a launchd/cron job inherits **no shell profile** (no `PATH` from `~/.zshenv`, so a bare `node` is unresolvable), + has **no working directory** set to the repo (tsx resolves modules only from `scripts/trends/`), and has **no + logging**. Today nothing bridges that gap. +- **The brief is operator-pulled, never machine-pushed.** `session-start.mjs:60-77`/`:534` *reads* the latest + dated brief (`date`+`summary`, zero-tsx) and surfaces it — it is a pure consumer. Generation lives only in the + CLI, invoked by a human. B3's *"cron-trigget headless-sesjon"* writer does not exist. +- **The CLI has no scheduling verb.** `cli.ts` exposes `add`/`query`/`list`/`status`/`act`/`skip`/`reset`/ + `normalize`/`score`/`capture`/`brief` (`cli.ts:5-14`, `:134-330`) — capture/read/lifecycle, all interactive. + There is **no way to emit or install a daily schedule** for the brief. +- **The data-dir seam is solved, but only for two runtimes.** `store.ts:252` (`defaultStorePath`) and + `hooks/scripts/data-root.mjs:24` (`getDataRoot`) are *twins* of the one seam (`LINKEDIN_STUDIO_DATA ?? ~/.claude/ + linkedin-studio`). A scheduler entry running in **shell** needs the same seam for its log path — a **third + sanctioned twin**, exactly the inline `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/…` form + `references/data-path-convention.md` rule 1 prescribes. It does not exist yet. + +## 3. Scope — what is IN (RE-R3c) + +### S-schedule — `scripts/trends/src/schedule.ts` (NEW, pure module) + +Pure string emitters for the schedule artifacts — no clock, no fs, no env, no AI (the CLI injects every resolved +value). Mirrors `brief.ts`'s `renderBrief` purity → fully testable, byte-deterministic given inputs. + +- **`export interface ScheduleSpec`** — `{ platform: "launchd" | "cron"; label: string; nodeBin: string; + wrapperPath: string; args: string[]; hour: number; minute: number; logPath: string; workingDir: string; + env: Record<string, string>; }`. All paths are absolute, resolved by the CLI at generation time on the + operator's machine. **`env`** is the injected environment map (the CLI builds it — always `NODE_BIN` + + a **resolved-absolute** `LINKEDIN_STUDIO_DATA`); the emitter only *renders* it, so it reads no env itself + (folded — brief-reviewer #4 / plan-critic #3: the field is canonical, not mid-step). +- **`export function launchdPlist(spec: ScheduleSpec): string`** — the plist XML: `Label`, `ProgramArguments` + (`["/bin/bash", wrapperPath, ...args]`), `StartCalendarInterval` (`{ Hour: spec.hour, Minute: spec.minute }`), + `EnvironmentVariables` (rendered from `spec.env` only — purity), `WorkingDirectory` (`spec.workingDir`), + `StandardOutPath`/`StandardErrorPath` (`spec.logPath`), `RunAtLoad` false. A pinned, well-formed template + (`<?xml … !DOCTYPE plist …>`); `schedule.test` asserts both key-completeness **and** well-formedness + (balanced-tag/parse), `plutil -lint` is the deps-present manual check (folded — brief-reviewer #7). +- **`export function crontabLine(spec: ScheduleSpec): string`** — one line: + `<minute> <hour> * * * <env-prefix> /bin/bash <wrapperPath> <args…> >> <logPath> 2>&1 # <label>` where + `<env-prefix>` is `spec.env` rendered as cron's inline `K=V K=V` form. **The function returns the line as a + STRING; it never executes `crontab`** (the execution guard + C2; the literal `crontabLine` does not match the + guard's `\bcrontab\b` word-boundary pattern — §5). +- **`export function installInstructions(spec: ScheduleSpec, plistTargetPath?: string): string`** — the exact + operator commands. launchd: *"written to `<plistTargetPath>` — activate with `launchctl bootstrap gui/$(id -u) + <plistTargetPath>`"*. cron: *"add the line above with `(crontab -l 2>/dev/null; echo '<line>') | crontab -`"*. + Print-first surfaces these so the operator runs them. +- **`export function uninstallInstructions(spec, plistTargetPath?): string`** — symmetric removal (launchd: + `launchctl bootout …` + `rm <plist>`; cron: the line-removal `grep -v` recipe). +- **`export function defaultLabel(): string`** — `"com.linkedin-studio.trends.daily"` (the **plugin namespace**, + domain-general — not the user's domain; no vendor/sector token). + +### S-wrapper — `scripts/trends/run-daily.sh` (NEW, headless invocation wrapper) + +The single tested headless entry, invoked identically by **both** the launchd plist and the crontab line (one +entry → one test). Bash 3.2-compatible (operator's macOS: no `declare -A`, no `mapfile`, all expansions quoted, +ASCII-only). + +- Resolves **its own directory** (`DIR="$(cd "$(dirname "$0")" && pwd)"`) so it is relocatable — no hard-coded + repo path — and **`cd "$DIR"`** so `--import tsx` resolves `node_modules` from the package even under cron's + `$HOME` CWD (folded — brief-reviewer #1: the plist sets `WorkingDirectory`, but cron does not — `cd` makes the + one wrapper scheduler-agnostic). +- Resolves **node** from a minimal scheduler env: `NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null)}"`; + if still empty, fall back to common locations; exit 127 with a logged error if none. (The scheduler bakes + `NODE_BIN=<process.execPath>` so resolution always succeeds; the fallback is for a manual invocation.) +- Resolves the **log path** from the canonical inline seam — `LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/ + linkedin-studio}/trends/cron.log"` — the **fourth sanctioned data-path twin** (shell), the exact form + `references/data-path-convention.md` rule 1 prescribes (documented as such, like `data-root.mjs`'s twin comment). + The scheduler **always bakes a resolved-absolute `LINKEDIN_STUDIO_DATA`** into the artifact env, so a scheduled + run never evaluates `$HOME` (sidesteps the `set -u` `HOME`-unset edge — folded — plan-critic #9 / brief-reviewer + #9); the `$HOME` fallback is only for a manual invocation, where `HOME` is set. `mkdir -p` its dir. +- Runs the **deterministic** brief: `OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)"; + CODE=$?` then **compacts** `OUT` to one line (`tr '\n' ' ' | tr -s ' '`) — `brief --json` is **pretty-printed** + (`cli.ts:323` `JSON.stringify(…, null, 2)`), so the structured log line must collapse the newlines (folded — + plan-critic #1). The scheduler bakes `--pillars … --fresh-days N` into `"$@"`; the wrapper hard-codes the + `brief` subcommand and adds `--json`. Appends **one** line `<ISO-ts> exit=<CODE> <compact-OUT>` to `$LOG`; + `exit $CODE`. **No AI** — `brief` is the deterministic store→artifact path (C1). +- **The (e) seam (documented, not built):** a one-line comment marks where slice (e) will insert a pre-brief + capture step (`claude -p … trend-spotter | cli.ts capture`) before the `brief` call. R3c builds **only** the + deterministic path. + +### S-cli — `scripts/trends/src/cli.ts` (EDIT) — the `schedule` subcommand + +- **`schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto|launchd|cron] [--install] + [--uninstall] [--store <path>]`**: + - Resolves **platform**: `auto` (default) → `process.platform === "darwin" ? "launchd" : "cron"`. + - Resolves **time** from `--at` (default `07:00`); validates `HH ∈ 0–23`, `MM ∈ 0–59` → `usage` exit 2 on bad + input. `--pillars` is **required** (a schedule with no pillars is meaningless) → `usage` exit 2 if absent. + - Resolves the absolute paths **from the runtime**, never hard-coded: `nodeBin = process.execPath` (absolute); + `wrapperPath = join(dirname(fileURLToPath(import.meta.url)), "..", "run-daily.sh")` (`cli.ts` is at + `scripts/trends/src/`, so `..` → `scripts/trends/`); `workingDir = join(dirname(fileURLToPath(import.meta.url)), + "..")`; **`logPath = join(dirname(defaultStorePath()), "cron.log")`** — derived from `defaultStorePath()` + (`<root>/trends/trends.json` → `<root>/trends/cron.log`), **NOT** from the `--store` override, so it matches + the wrapper's data-root-anchored log exactly (folded — all three reviewers: a `--store` outside the data dir + must not split the plist `StandardOutPath` from the wrapper's own log file). + - Builds **`env`** (always): `{ NODE_BIN: process.execPath, LINKEDIN_STUDIO_DATA: <resolved-absolute root> }` + where the root = `process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio")` — baked + so the scheduled run is pinned to the install-time root **and** never evaluates `$HOME` (the wrapper's + `set -u` `HOME`-unset edge). + - Builds **`args`** = `["--pillars", <p>, "--fresh-days", String(N)]` (+ `["--store", storePath]` when an explicit + non-default `--store` was given, so the scheduled run targets the same store). **No leading `"brief"`** — the + wrapper hard-codes the `brief` subcommand (folded — plan-critic #8 / brief-reviewer #5: avoids + `cli.ts brief brief …`). + - Builds the `ScheduleSpec` and dispatches: + - **default / `--print`** → print the artifact (`launchdPlist` or `crontabLine`) **+** `installInstructions` + to stdout. **No fs.** Exit 0. + - **`--install`** → launchd: `mkdirSync` + `writeFileSync` the plist to `~/Library/LaunchAgents/<label>.plist` + (an **inert** file; reversible) and print the single `launchctl bootstrap` command — **the tool never runs + `launchctl`**. cron: print the line + the `crontab -` install command — **the tool never runs `crontab`** + (the global guard + C2). Exit 0. + - **`--uninstall`** → launchd: print the `launchctl bootout` command + (if the plist file exists) `rm` it; + cron: print the line-removal recipe. Exit 0. + - **Exit-code contract unchanged** (0 success / 2 usage). `schedule` introduces **no new exit code**: an autonomy + install never *runs* the system mutation, so there is no install-failure path to encode — the operator runs the + one printed command. (Update the header doc-comment `cli.ts:36-37` to note `schedule` is print-first and never + shells `launchctl`/`crontab`.) +- **Imports** `launchdPlist`, `crontabLine`, `installInstructions`, `uninstallInstructions`, `defaultLabel` from + `./schedule.js`; **adds `dirname` to the `node:path` import** (`cli.ts:41` imports only `join` today — folded — + plan-critic #5), `homedir` from `node:os`, `fileURLToPath` from `node:url` (`defaultStorePath` is already imported, + `cli.ts:45`). The DAG stays acyclic: `schedule.ts` is a **leaf** (imports nothing from the package); `cli.ts` + is the existing root. +- **Usage + header synopsis** (`cli.ts:5-14`, `:86-100`): add the `schedule …` line + a one-line header note that + `schedule` emits/installs a daily headless brief (print-first; deterministic — no AI capture; that is slice e). + +### Wiring (D-default — WIRE, mirrors R3a/R3b) + +- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): one line — the morning brief can now be **scheduled** + to regenerate autonomously (deterministic, from the store) via `schedule`; the agent's polling remains the + capture path (autonomous AI polling is a later slice). No batch-shape change. Domain-general (no vendor/sector + token). +- `scripts/trends/README.md` (EDIT): document the headless wrapper + the `schedule` subcommand (print-first, + launchd/cron, `--install`/`--uninstall`), the **deterministic-brief-only boundary (C1)** and the (e) AI-capture + seam, the `cron.log`, and the R3b per-day idempotency that makes a double-fire safe. +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (`:709`, currently 171) to the `tests N` line reported + after Steps 1–5, **append** `+ RE-R3c: scheduler +N` to the inline breakdown comment. Add **Section 16l** + ("Trends Scheduler / Headless Wiring", RE-R3c) **after Section 16k's closing block (`~:1305`), before Section 18 + (`:1307`)** (16k is the last 16x before the anti-erosion Section 18; preserve that order). Mirror 16k's shape: + **unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail). + Recommended **6 emitters** (all on tracked source — no `tsx`): (1) self-test; (2) `export function launchdPlist` + in `schedule.ts`; (3) `export function crontabLine` in `schedule.ts`; (4) `command === "schedule"` in `cli.ts` + (the verb); (5) `cli.ts" brief` in `run-daily.sh` (the wrapper invokes the deterministic brief — the sentinel + matches the literal `…cli.ts" brief`, folded — plan-critic #8); (6) the data-path twin in `run-daily.sh` + (`LINKEDIN_STUDIO_DATA:-`). **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 105 → exactly 111** + (`:1329`; "live recount" is the safety net; the expected value is the pinned 105 + 6). Insert the 16l clause into + the **header-enumeration prose chain at `:57`** (before "…the assertion-count anti-erosion floor (SC6) in Section + 18"), and **append the R3b (→105) + R3c-16l (→111) narration** to the Section-18 floor-history comment + (`~:1310-1324`, which still stops at "= 99" — folded — scope-guardian #7). + +## 4. Non-goals — what is OUT (deferred) + +- **AI capture in the nightly run** (`poll→score→capture` via a headless `claude -p` trend-spotter) — **slice (e)**, + behind the post-(d) re-evaluation gate. R3c builds the deterministic headless path + the documented (e) seam; it + adds **no** AI invocation, no `claude -p`, no API dependency in the scheduler context. +- **Running `launchctl` / `crontab` autonomously** — OUT (C2 print-first). `schedule` prints the activation + command; `--install` writes only the inert launchd plist FILE. The operator runs the one system-mutating command. +- **A `/linkedin:schedule` command wrapper** (plugin surface) — OUT for R3c (would change the command count). R3c + ships the CLI subcommand + README; a command front-door is a later ergonomic slice. Counts stay **29/19/27**. +- **Windows Task Scheduler** — OUT. launchd (macOS) + cron (Linux) cover the plugin's runtimes; a Windows emitter + is a later portability add. +- **A lock / mutex / run-marker** — unneeded. R3b's per-day-idempotent surfacing + the per-day brief filename make + a double-fire a safe no-op; B4's separate *delivery* window is not needed for a once-daily calendar job. +- **A config-file pillar source** — OUT. Pillars are `--pillars`, **baked into the schedule artifact** at + generation (the operator supplies them once at install). A config/profile-resolved pillar source is a later + nicety. +- **Brief history / day-over-day diff** ("what's new since yesterday" — hull 7) — **slice (d)**. +- **Re-scoring / time-decay recompute on a schedule** — OUT. Re-score is on **re-capture** (R3b); R3c does no + capture, so the nightly run re-ranks the *unchanged* scores against the *current* freshness window only. +- **Schema bumps** — none. R3c touches **no** store field and **no** brief frontmatter field + (`SCHEMA_VERSION` stays 4; `BRIEF_SCHEMA_VERSION` stays 1). It adds a new *module* + a *wrapper* + a *CLI verb* — + no data shape changes. +- **New agent / new command / new reference doc** — none. R3c adds **two source files** (`schedule.ts`, + `run-daily.sh`) + their tests, and EDITs `cli.ts` + one agent (prose) + README + gate. `store.ts`/`brief.ts`/ + `item.ts`/`score.ts`/`types.ts` are **untouched** (the nightly run reuses the existing deterministic `brief` + path). Counts stay 29/19/27. + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — subprocess + assertion-RED against the existing CLI (`schedule` unknown → exit 2; `run-daily.sh` absent → exit 127) on the + exit-0/stdout assertions. Phase B — `schedule.ts` exports are imported by the test; land non-throwing stubs + first (Node16 ESM throws a missing named import at module-load), then record value-assertion RED against them. + The plan does **not** claim a single "everything fails before any code" run. +- **`schedule.ts` is pure** (no clock, no fs, no env, no AI): every value the emitters use is injected via + `ScheduleSpec`. The CLI is the only edge that reads `process.execPath`/`import.meta.url`/`defaultStorePath`. + Mirrors `renderBrief`'s purity. +- **Determinism of the nightly run:** the wrapper invokes the **deterministic** `brief` (whose byte-determinism + R2b/R3a/R3b proved); given `(store, pillars, day, freshDays)` the written `.md` is byte-identical. The wrapper + adds only a timestamped log line + an exit code. +- **No autonomous system mutation (C2):** `schedule` (default) writes **nothing**; `--install` writes only an + inert launchd plist file (reversible `rm`); the tool **never** runs `launchctl` or `crontab`. The global guard is + an **execution** guard (`voyage` `pre-bash-executor.mjs`, pattern `\bcrontab\b|>\s*/etc/cron` — verified live), so + it inspects **bash commands**, not file content: the new files' printed strings (`crontab -`, `launchctl + bootstrap`) are written by `Write`/emitted by the CLI and are **fine**, and the 16l grep uses `crontabLine` + (no `\bcrontab\b` word-boundary match). **No code path — source or test — ever *executes* a command containing the + bare word `crontab` or `launchctl …`**; the install commands are printed STRINGS the operator runs, and tests + assert those strings on **stdout/the written file** without executing them (a test that *ran* `crontab` would trip + the guard and mutate the real system — explicitly forbidden). +- **One data-dir seam, four sanctioned runtime twins:** `store.ts:253` (TS store), `data-root.mjs:25` (hooks + `.mjs`), `analytics/src/utils/storage.ts:54` (TS analytics — the existing third, named in `data-root.mjs:44`), + `run-daily.sh` (shell — NEW fourth). The shell form is the canonical inline `${LINKEDIN_STUDIO_DATA:-$HOME/ + .claude/linkedin-studio}` expansion (`references/data-path-convention.md` rule 1), **not** a new seam; documented + as a twin (like `data-root.mjs`'s comment) and asserted behaviorally (SC8 newly binds `store.ts`'s + `defaultStorePath` into the consistency check — `dirname(defaultStorePath()) == getDataRoot('trends') == the + wrapper's `${…}/trends`). +- **Domain-general:** no hard-coded user/repo path in the committed **source** (`schedule.ts`/`run-daily.sh`/the + `cli.ts` edit) — every concrete path is resolved at generation/run on the operator's machine and lives only in + the **generated** artifact (outside the repo, in `~/Library/LaunchAgents` / the crontab). The launchd Label is + the plugin namespace; pillars are args. Section 17 de-niche stays green. +- **Bash 3.2-compatible wrapper** (operator's macOS): no `declare -A`/`mapfile`/`|&`; all expansions quoted; + ASCII-only (a multibyte char crashes under `set -u` on bash 3.2). +- **Minimal-env robustness:** the wrapper must run from launchd/cron's profile-less env — node resolved via baked + `NODE_BIN` (absolute `process.execPath`) with a `command -v` fallback; `WorkingDirectory`/`cd` set so tsx + resolves; log dir `mkdir -p`'d. +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only and never shells to tsx; R3c touches + neither the hook nor the frontmatter schema, so surfacing is unchanged. The hook suite must still pass untouched + (regression sanity; R3c adds no hook test). +- **No schema/SSOT change:** `references/trend-scoring-modes.md`, `types.ts`, `store.ts`, `brief.ts` untouched + (R3c changes no data shape and no scoring/render math). +- **Pathguard:** the two NEW files are under `scripts/trends/` — **write-allowed** (the global pre-write-pathguard + allowlists `~/repos/*`; `cli.ts`/`test-runner.sh` were added there with no friction — folded — scope-guardian #4: + the earlier ".mjs-under-hooks/scripts-only" phrasing was a fabricated mechanism; the *conclusion* holds). EDITs + are to existing files. *(Implementation risk, not a docs-step blocker: if any Write is nonetheless blocked, the + operator authorizes via the R2b `!cp` fallback — see plan Risk R4.)* +- **Counts** (refs/agents/commands 27/19/29) unchanged. **Recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (launchd plist emit)** — `schedule --pillars ai,gov --platform launchd --at 07:30 --print` → stdout is a + **key-complete + well-formed** plist (balanced-tag/parse asserted, not just substring greps — folded — + brief-reviewer #7) containing `Label` = `com.linkedin-studio.trends.daily`, `ProgramArguments` invoking + `run-daily.sh` with `--pillars ai,gov` (the wrapper supplies `brief`), `StartCalendarInterval` `Hour 7`/`Minute + 30`, `StandardOutPath`/`StandardErrorPath` = the resolved `cron.log` path, `NODE_BIN` + + `LINKEDIN_STUDIO_DATA` in `EnvironmentVariables`; exit 0. Two `--print` runs (same args) → byte-identical (the + emitter is pure). `plutil -lint` is the deps-present manual check (Step 7). +- **SC2 (crontab line emit — string only)** — `schedule --pillars ai,gov --platform cron --at 07:30 --print` → + stdout contains `30 7 * * * NODE_BIN=… /bin/bash …/run-daily.sh brief --pillars ai,gov >> <log> 2>&1 # + com.linkedin-studio.trends.daily` **+** the `(crontab -l 2>/dev/null; echo '<line>') | crontab -` install + instruction; exit 0. The test asserts the **emitted string** and **never executes `crontab`**. +- **SC3 (platform auto)** — `schedule --pillars ai --print` (no `--platform`) → launchd on darwin, cron elsewhere; + asserted against `process.platform` (the subprocess inherits the host platform; the assertion branches on it). +- **SC4 (print-first writes nothing)** — `schedule --pillars ai --platform launchd --print` with `HOME=<tmp>` → + exit 0, stdout has the plist, **and `<tmp>/Library/LaunchAgents` is absent/empty** (no fs write); crontab never + invoked. +- **SC5 (`--install` launchd: inert plist file, no launchctl)** — `schedule --pillars ai --platform launchd + --install` with `HOME=<tmp>` → `<tmp>/Library/LaunchAgents/com.linkedin-studio.trends.daily.plist` **exists** + with the SC1 plist content; stdout prints the `launchctl bootstrap` command; **`launchctl` is never run** (the + test asserts only the file + stdout; no system job is created); exit 0. +- **SC6 (`--install` cron: never self-installs)** — `schedule --pillars ai --platform cron --install` → stdout has + the line + the `crontab -` instruction; exit 0; **`crontab` is never invoked** (no system mutation; asserted by + stdout only). +- **SC7 (headless wrapper runs the deterministic brief + logs)** — `run-daily.sh --pillars ai --store <tmp>/ + s.json --out <tmp>/mb` (the wrapper supplies `brief`) with `LINKEDIN_STUDIO_DATA=<tmp>` on a seeded fresh store → + writes `<tmp>/mb/<today>.md` (the dated brief), appends **exactly one** `<ISO-ts> exit=0 {…compact-json…}` line + to `<tmp>/trends/cron.log` (the multi-line `brief --json` collapsed — folded — plan-critic #1), exit 0. A + **second** run the same day → the brief `.md` is byte-identical (idempotent re-render), the seen-log is **not** + double-counted (R3b per-day idempotency), the log gains a second line. **CWD-independence:** the same invocation + with `cwd=<tmp-unrelated>` (not the package dir) still resolves `tsx` and succeeds (the wrapper's `cd "$DIR"` — + folded — brief-reviewer #1). The test invokes via `bash run-daily.sh …` so the absent-file RED is exit 127 + (folded — plan-critic #7). **No AI** is invoked (C1). +- **SC8 (data-path twin consistency)** — the wrapper's `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ + trends` resolves to the **same** directory as `dirname(defaultStorePath())` (TS — the base the CLI's `logPath` + uses, **not** the `--store` override) and `getDataRoot('trends')` (hooks `.mjs`), for both the default root and + an overridden `LINKEDIN_STUDIO_DATA`. Asserted behaviorally (resolve all three for a temp override; assert + equal). Because `logPath` derives from `defaultStorePath()`, a custom `--store` never splits the plist + `StandardOutPath` from the wrapper's log (folded — all three reviewers). +- **SC9 (usage / validation)** — `schedule` with **no** `--pillars` → `usage` exit 2; `--at 25:00` / `--at 7:99` / + `--at noon` → `usage` exit 2; `--platform bogus` → `usage` exit 2. Each leaves the fs untouched. +- **SC10 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the bumped + `TRENDS_TESTS_FLOOR`; new **Section 16l** green (`launchdPlist`/`crontabLine` in `schedule.ts`, `command === + "schedule"` in `cli.ts`, the `cli.ts brief` + `LINKEDIN_STUDIO_DATA:-` sentinels in `run-daily.sh`, non-vacuity + self-test); `ASSERT_BASELINE_FLOOR` = **111** (105 + 6); Section 17 de-niche green; counts 29/19/27; the hook + suite still green untouched (`node --test hooks/scripts/__tests__/*.test.mjs`). + +## 7. Verification + +**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; Section 16l self-test + +greps pass; `ASSERT_BASELINE_FLOOR` = 111; Section 17 de-niche green; ref/agent/command counts unchanged. +**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3c touches no +hook; adds no hook test). +**Behavioural (manual):** +1. Seed a store: `echo '[{"source":"tavily","title":"A","url":"https://e/a","topics":["ai","gov"], + "publishedAt":"<~2d ago>"}]' | node --import tsx src/cli.ts capture --store /tmp/r3c.json`. +2. `node --import tsx src/cli.ts schedule --pillars ai,gov --platform launchd --at 07:00 --print` → inspect the + plist; `node --import tsx src/cli.ts schedule … --platform cron --print` → inspect the crontab line + install + instruction. +3. **Lint the plist (macOS):** pipe the `--print` plist to `plutil -lint -` → "OK" (a malformed plist won't load). +4. `LINKEDIN_STUDIO_DATA=/tmp/r3c-data ./run-daily.sh brief --pillars ai,gov --store /tmp/r3c.json --out + /tmp/r3c-data/trends/morning-brief` → confirm `/tmp/r3c-data/trends/morning-brief/<today>.md` written + a line + appended to `/tmp/r3c-data/trends/cron.log`; exit 0. +5. Re-run step 4 same day → `.md` byte-identical; `cron.log` gains a second line; `list --json` shows + `surfacedCount:1` (not 2 — per-day idempotent). +6. `schedule --pillars ai --platform launchd --install` with a throwaway `HOME` → confirm the plist file is + written under `<HOME>/Library/LaunchAgents/` and the `launchctl bootstrap` command is printed (do **not** run it + against the real system unless intentionally activating). + +## 8. Open questions for the go-gate + +Two architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-26): **C1** deterministic +brief-only (no AI capture — that is slice e); **C2** print-first installer (emit + the operator runs the system +mutation; `--install` writes only the inert launchd plist file). Residual decisions, all baked to the recommended +default — confirm or redirect with "Go": +- **D1 — schedule time default `07:00`, `--at HH:MM` overrides?** YES (rec). A morning brief wants a pre-workday + fire; the operator tunes it. Re-open only for a different default hour. +- **D2 — launchd Label = `com.linkedin-studio.trends.daily` (plugin namespace)?** YES (rec). Reverse-DNS, the + plugin's own namespace (domain-general; no user-domain token). Drop only for a different naming scheme. +- **D3 — `--install` writes the launchd plist FILE but never runs `launchctl`/`crontab`?** YES (rec). The strictest + honest print-first: the tool prepares the inert artifact, the operator activates it. Re-open only to make + `--install` a pure no-op (print-only, no file write). +- **D4 — `--platform auto` defaults via `process.platform`?** YES (rec). darwin→launchd, else→cron. Explicit + `--platform` overrides (e.g. to emit a crontab line on a Mac for a Linux box). Drop only to require `--platform`. +- **D5 — pillars baked into the artifact at generation (no config-file source)?** YES (rec). The operator supplies + `--pillars` once at install; the schedule carries them. A profile-resolved pillar source is a later nicety. +- **D6 — `cron.log` under `<data>/trends/cron.log`?** YES (rec). Colocated with the store + morning-brief under the + data-dir seam, survives reinstalls. Drop only for a different log location. +- **D7 — a single `.sh` wrapper invoked by BOTH launchd + cron (vs a CLI `run` subcommand)?** YES (rec). A shell + wrapper handles the launchd/cron minimal-env robustness (node resolution, `cd`, logging) the CLI cannot; the CLI + `brief` stays the deterministic core. One wrapper → one tested entry. Re-open only to push the robustness into a + CLI `run` verb (more TS, but then the plist must still bake node). +- **D8 — no `/linkedin:schedule` command (CLI + README only)?** YES (rec). Keeps the command count; a command + front-door is a later ergonomic slice. Re-open only if the scheduler should be operator-facing via a slash + command now. +- **D9 — commit split?** Docs commit first, then **one** code commit (rec) — the scheduler (module + wrapper + CLI + verb + wiring) is one coherent feature. Re-open only for a module-then-wiring split. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. **scope-guardian: ALIGNED** +(0 creep / 0 gaps; every SC1–SC10 traces to a step; no AI/capture, no schema bump, counts 29/19/27 + untouched- +files claim verified live; 1 MAJOR line-cite + 6 MINOR accuracy/precision). **brief-reviewer: PROCEED_WITH_RISKS** +(RED premises TRUE, the data-path trio genuinely consistent, C2 no-execution path confirmed; 2 MAJOR + 5 MEDIUM/LOW). +**plan-critic: REVISE → 73/C** (the two-phase RED, de-niche safety, bash-3.2 wrapper, path resolution, and the ++6→111 gate arithmetic all verified correct against live code; 4 MAJOR + 5 MINOR; the C grade is largely the +legacy-manifest-format penalty — these are hand-authored slice docs, not trekexecute manifests). **All findings +folded** (per-finding resolution in `plan-re-r3c.md §Plan-critic — folded`). Headlines: + +- **[MAJOR, folded — plan-critic #1] `brief --json` is pretty-printed** (`cli.ts:323` `JSON.stringify(…, null, 2)`), + so the wrapper's "one log line" / SC7 contract was false. → the wrapper **compacts** `OUT` (`tr '\n' ' ' | tr -s + ' '`) after capturing `CODE`; SC7 asserts exactly one line. +- **[MAJOR, folded — brief-reviewer #1] the wrapper never `cd`s to its package dir**, so the cron path (CWD `$HOME`) + would fail to resolve `tsx`. → the wrapper adds **`cd "$DIR"`**; SC7 gains a CWD-independence assertion. +- **[MAJOR/MINOR, folded — all three] the `logPath` expression was self-contradicting and used the `--store` + override base.** → pinned to **`join(dirname(defaultStorePath()), "cron.log")`** (the data-root anchor, matching + the wrapper); the `".."` and `storePath` variants removed. +- **[MAJOR, folded — brief-reviewer #4 / plan-critic #3] `ScheduleSpec.env` was stated three ways.** → `env: + Record<string,string>` is now **canonical** in §3, always carrying `NODE_BIN` + a resolved-absolute + `LINKEDIN_STUDIO_DATA`; the emitters render it only (purity holds), and the always-baked root also fixes the + `HOME`-unset `set -u` edge. +- **[MAJOR, folded — all three] `ASSERT_BASELINE_FLOOR` line-cite `:1259` → `:1329`** (verified live; value 105→111 + correct). `TRENDS_TESTS_FLOOR` `:705` → `:709`; header-enum insertion `:49-53` → `:57`; the Section-18 + floor-history comment (still "= 99") gets the R3b(→105)+R3c(→111) narration appended. +- **[MINOR, folded — plan-critic #5] `dirname` is not imported in `cli.ts`** (`:41` is `join` only). → the plan adds + `dirname` to the `node:path` import (+ `homedir`/`fileURLToPath`). +- **[MINOR, folded — plan-critic #8 / brief-reviewer #5] stray double `brief`** (`cli.ts brief brief …`). → the + baked `args` drops the leading `"brief"`; the wrapper owns the subcommand; the 16l sentinel matches `cli.ts" brief`. +- **[MINOR, folded — scope-guardian #4] the pathguard justification was a fabricated mechanism.** → restated: + `~/repos/*` is allowlisted; the conclusion (new files write-allowed) holds. +- **[LOW, folded — brief-reviewer #7] SC1 "lint-valid" was only grep-checked.** → SC1 asserts **well-formed** + (balanced-tag/parse) + key-complete; `plutil -lint` stays the deps-present manual check. +- **[LOW, folded — brief-reviewer #8] the twin census undercounted (3 → 4).** → `analytics/storage.ts` named as the + existing third; `run-daily.sh` is the fourth. diff --git a/docs/research-engine/brief-re-r3d.md b/docs/research-engine/brief-re-r3d.md new file mode 100644 index 0000000..47bf7f1 --- /dev/null +++ b/docs/research-engine/brief-re-r3d.md @@ -0,0 +1,401 @@ +# Brief — RE-R3d: temporal overlay — first-mover + saturation (R3 slice b) + +> **Slice:** RE-R3d (research-engine rung-2, R3 slice **(b)** in the operator's `(a)→(c)→(b)→(d)→(e)` +> sequence — the doc is numbered r3d by creation order, the concept is slice (b)). It closes the **rest of +> hull #3** (substrate §1): the store-schema fields a brief *ranks on* were `relevance` (✅ R3a) · `status` +> (✅ R3b) · **first-mover** · **saturation** · `angle`. `angle` is **already** a scored dimension +> (`score.dimensions.angle`, weight 15 %/20 % — `score.ts:24`/`:32`), folded into the R3a composite; so the +> rest of #3 is exactly **saturation + first-mover**, confirming §5's framing. +> **Predecessor:** RE-R3a (persisted relevance composite — the PRIMARY within-bucket rank key, `brief.ts:102`) +> + RE-R3b (the seen-log: per-day-idempotent `surfacedCount`/`lastSurfacedAt`, `types.ts:71-78`, whose comment +> names this slice — *"the temporal foundation slices (c)+(b) read"*) + RE-R3c (the autonomous trigger that +> makes `surfacedCount` **accumulate day-over-day without the operator running anything** — the dependency that +> makes saturation a real signal, the load-bearing reason (b) follows (c)). +> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull (3) (*"store-schema mangler felt en brief +> rangerer på (relevance/first-mover/saturation/angle/status)"*) + §B4 (*"freshness-vindu … + dedup-state +> (append-only seen-log → ikke re-overflate samme sak)"* — the seen-log IS the saturation substrate). R3d turns +> the seen-log + the publish/capture dates into a **live temporal overlay** the brief ranks on. +> **The core decision (operator-confirmed, AskUserQuestion 2026-06-26 — baked):** +> - **SB1 — derived at brief time, NOT a stored field.** first-mover/saturation are pure functions of fields the +> store ALREADY persists (`publishedAt`/`capturedAt` → age; `surfacedCount` → repeat-exposure) + the injected +> `today` — exactly like `ageDays`/`effectiveDate` are derived on `BriefEntry`, never stored. **No +> `SCHEMA_VERSION` bump (stays 4); no `BRIEF_SCHEMA_VERSION` bump (stays 1).** The signal recomputes every run, +> so it can never go stale (a stored field would, as `surfacedCount` grows). Respects the discipline that an +> *avledbar* value is not persisted (`ageDays` is derived; `score` is persisted only because it is a frozen +> model judgment — `types.ts:54-64`). +> - **SB2 — refine recency WITHIN the composite tier; do not override it.** The R3a relevance composite stays +> the PRIMARY sort key (`brief.ts:103`). The temporal signal enters `cmp` as a NEW key **after** pillar-overlap, +> **before** `effectiveDate` — a richer recency class than the raw date it sits in front of. A +> saturated-but-higher-composite trend still outranks a fresh-but-lower one (composite dominates across tiers); +> the overlay only re-orders *within* the same (composite, overlap) tier. Honors *"felt en brief rangerer på"* +> without shadowing the SSOT-pinned relevance score. +> **TDD-order (two-phase RED, light-Voyage discipline, inherited from R3c):** Phase A — `temporalSignal` is a NEW +> named export of the EXISTING `brief.ts`; under Node16 ESM a missing named import throws at module-load (every +> `brief.test.ts` test would error, not assert), so land a **non-throwing stub** (`temporalSignal → {tier:"fresh", +> …}`, `BriefEntry.temporal` populated by it) FIRST, then record value-assertion RED against it (the stub returns +> "fresh" always → the first-mover/saturated/ordering/token assertions fail on values, true assertion-RED). Phase +> B — the CLI flag tests are value-RED against the existing `brief` handler (the new flags are silently ignored by +> `parseFlags` today → tuned-threshold behaviour is unchanged → RED). See plan Step 1. + +## 1. Operator decision context (2026-06-26) + +The research engine is **Tier-1** (operator, 2026-06-23). R1→R3c built the deterministic spine, the trend's life +after capture, **and** the autonomy that runs it: item-schema + triage (R1) → capture bridge (R2a) → dated +morning brief + surfacing (R2b) → persisted relevance + composite ranking (R3a) → status lifecycle + seen-log + +re-score (R3b) → autonomous trigger + headless entry (R3c). The brief now **regenerates itself every morning** +(R3c), so `surfacedCount` accumulates day-over-day on its own — but the brief still treats a trend the operator +has seen surfaced for five straight days **identically** to one captured an hour ago, as long as their frozen +relevance composites tie. The temporal axis the seen-log records is **logged but unread by the ranking**. + +R3d closes the **rest of hull #3** — the **first-mover** and **saturation** signals — which the operator chose as +slice **(b)** of the full-R3 build-out (2026-06-24, *"ALLE gjenstående R3-slices … i rekkefølge (a) → (c) → (b) +→ (d) → (e)"*). It is sequenced **after** R3c for a load-bearing reason: saturation reads `surfacedCount`, and +`surfacedCount` only accumulates *autonomously* because R3c now fires the brief daily. (c) makes (b) meaningful; +without the daily trigger, the seen-log would only grow on the days the operator happened to ask for a brief. + +**The concrete value — keeping a frozen composite honest over time.** R3a *froze* the relevance composite at first +capture (`types.ts:54-59`: *"first-sight, never updated on re-capture"*). A trend captured fresh scores +`timing` 9 ("you'd be among first") → a high composite → "Immediate" → sorts top (`brief.ts:103`). Six days and +five surfacings later it is stale and over-exposed, yet the **frozen** composite still says "Immediate" and still +sorts top. R3d is the **live temporal overlay** that demotes it — *without mutating the stored composite* (which +stays the SSOT-pinned, re-score-on-re-capture value R3b owns). `surfacedToken` (`brief.ts:154`) is already a +*hint* of this — the `· sett Nx` marker explicitly labelled *"Not the saturation SCORING of slice (b)"*. R3d +promotes that placeholder hint into the real, ranked signal. + +## 2. The gap — grounded in code + +- **The seen-log is written but never ranked on (hull 3, saturation).** R3b records `surfacedCount`/ + `lastSurfacedAt` per trend (`store.ts:187-202`, `types.ts:71-78`) and renders a display hint (`brief.ts:154-157`, + the `· sett Nx` token at `>=2`), but `rankForBrief`'s comparator (`brief.ts:102-107`) **never reads it** — sort + order is `composite → overlap → effectiveDate → title → url`. A trend surfaced ten times sorts identically to + one surfaced zero times at the same composite+overlap. +- **Recency is read coarsely (hull 3, first-mover).** The comparator's only temporal key is `effectiveDate` + (`brief.ts:105`) — a raw date tiebreaker. There is no notion of *"this is genuinely fresh AND uncrowded — you'd + be first"* vs *"this is just the most recent of a stale set"*. The capture-time `timing` dimension + (`score.ts:23`, kortform 20 %) captures a first-mover *judgment*, but it is **frozen at capture** (R3a) — it + cannot reflect that the same trend is no longer fresh a week later. +- **`angle` is already covered (hull 3, no work needed).** Hull #3 lists `angle` among the rank fields, but + `angle` is one of the five scored dimensions (`score.ts:24` kortform 15 %, `:32` long-form 20 %), persisted in + `score.dimensions` (R3a) and already folded into the composite the brief ranks on. No separate field is needed; + §5's *"saturation + first-mover (resten av #3)"* is exact. +- **The brief's `ranking:` frontmatter would misrepresent itself.** The descriptor line (`brief.ts:187`) states + the exact sort; once the overlay enters `cmp`, that line must name the new key or the artifact lies about its + own ordering. + +## 3. Scope — what is IN (RE-R3d) + +**Zero new source/test files** (the two tracked slice docs aside). R3d is pure EDITs: the derived signal lives beside `ageDaysBetween` in `brief.ts` (the module +that already owns the derived-temporal ranking logic — surgical, no new module to wire into the gate), its unit +tests in `brief.test.ts`, its CLI flags in `cli.ts`/`cli.test.ts`, plus the wiring docs + gate. + +### S-signal — `scripts/trends/src/brief.ts` (EDIT) — the derived temporal overlay + +- **`export type TemporalTier = "first-mover" | "neutral" | "warming" | "saturated"`** — the four ordinal classes + of the temporal axis (best → worst opportunity). **`neutral`** (the draft called this "fresh" — renamed: a tier + named "fresh" collided with the `freshDays` bucketing concept AND mislabelled a 30-day-old-but-unsurfaced trend + as "fresh"; `neutral` is honestly "no exposure signal, no recency edge" — folded, all three reviewers). +- **`export interface TemporalSignal { tier: TemporalTier; firstMover: boolean; surfacings: number; rank: number }`** + — `firstMover` = recent AND unsurfaced; `surfacings` = prior-day surfacings (`surfacedCount ?? 0`, the + self-exposure level); `rank` = the descending sort integer (`first-mover` 3 > `neutral` 2 > `warming` 1 > + `saturated` 0). +- **`export function temporalSignal(ageDays: number, surfacedCount: number | undefined, opts: { firstMoverDays: + number; saturationAt: number }): TemporalSignal`** — pure (no clock/fs/env; takes the already-computed + `ageDays` + the persisted `surfacedCount` + the injected thresholds). Logic: + - `surfacings = surfacedCount ?? 0`; `const at = Math.max(1, opts.saturationAt)` — a **defensive clamp**: a + direct caller (the function is a public export the gate greps for) passing `saturationAt 0` would otherwise + make `surfacings >= 0` always true → every non-first-mover trend "saturated". The CLI also guards `< 1`, but + the pure function must not trust its caller (folded — plan-critic m3). + - `firstMover = ageDays >= 0 && ageDays <= firstMoverDays && surfacings === 0` — recent AND never surfaced on a + prior day. The **`ageDays >= 0`** guard keeps a *future*-dated trend (data glitch) OUT of the "act now, you're + early" headline (folded — plan-critic m5). The window maps to the SSOT "<24-72h, you would be among first" + band, evaluated on the LIVE date rather than frozen at capture. + - tier: `first-mover` if `firstMover`; else `saturated` if `surfacings >= at`; else `warming` if `surfacings >= + 1`; else `neutral`. (`firstMover` requires `surfacings === 0`; `saturated`/`warming` both require `surfacings + >= 1` — so first-mover can collide with neither; the four branches are disjoint and total.) + - `rank` derived from tier as above. +- **`RankOptions` gains two knobs** (`brief.ts:51-54`): `firstMoverDays?: number` (default **2**) + + `saturationAt?: number` (default **3** — the existing `· sett Nx` hint fires at `>=2`, so default 3 keeps + `surfacedCount 2` a "warming · sett 2x" FYI and escalates `>=3` to "saturated"). Defaults documented like + `freshDays`'s default 7. +- **`BriefEntry` gains `temporal: TemporalSignal`** (`brief.ts:26-36`) — populated in `rankForBrief` right after + `ageDays` is computed: `temporalSignal(ageDays, trend.surfacedCount, { firstMoverDays, saturationAt })`. +- **`rankForBrief` threads the two new opts** (`brief.ts:78`): `const firstMoverDays = opts.firstMoverDays ?? 2; + const saturationAt = opts.saturationAt ?? 3;`. The bucketing (`isFresh`/`freshDays`) is **unchanged** — + saturation does NOT move a trend between top/single/older buckets; it only re-orders *within* a bucket via the + comparator (a soft signal, not a filter — staleness still owns the older bucket via `freshDays`). **Note** the + intended in-bucket effect this exposes (folded — plan-critic m4): inside `olderMatched`, a `neutral` + (unsurfaced) trend ranks *above* a `warming`/`saturated` one — "you have not been shown this stale item yet" + ranks above "you have seen and skipped this stale item N times." That is the saturation signal working, not a + bug; the `neutral` (not "fresh") name keeps it honest. +- **`cmp` gains the temporal key (SB2)** (`brief.ts:102-107`): insert `b.temporal.rank - a.temporal.rank` **after** + `b.overlap - a.overlap` and **before** `b.effectiveDate.localeCompare(a.effectiveDate)`. Composite stays + PRIMARY; the chain stays a total order (`rank` is an integer; ties fall through to the unchanged + `effectiveDate → title → url` tail, whose `(title,url)` pair is the unique id). +- **`temporalToken` replaces `surfacedToken`** (`brief.ts:154-157`): the R3b placeholder hint is promoted to the + ranked signal's badge — `first-mover` → `· 🥇 først ute`; `saturated` → `· 🔁 mettet (Nx)`; `warming` → + `· sett Nx` **only when `surfacings >= 2`** (this **exactly preserves the R3b ≥2 badge contract**: the live + `surfacedToken` fires only at `c >= 2`, `brief.ts:156`, and `brief.test.ts:408` pins `!md.includes("sett 1x")`; + the warming *tier* still demotes a surfaced-once trend in `rank`, but its *badge* stays suppressed at 1 — folded, + all three reviewers: the draft's "preserves the hint" was inaccurate because warming covers `>= 1`; gating the + badge at `>= 2` makes it true); `neutral` → `""`. Used in both `renderTopEntry` (`brief.ts:162`) and + `renderBulletEntry` (`brief.ts:171`), replacing the `surfacedToken(e)` call. (The `scoreToken` is untouched.) +- **`briefSummary` carries the first-mover marker** (`brief.ts:131-141`): when the top entry is a first-mover, + append `· 🥇 først ute` inside the headline parens (`(${pillar}${band} · 🥇 først ute · ${top.ageDays}d)`) — + the one-line surfacing the SessionStart hook shows then says *"act now, you're early"*. The marker carries **no + double-quote and no newline**, so the hook's `^summary: *"?([^"\n]*)"?` regex still captures it whole + (`brief.ts:128-130` invariant preserved). +- **`renderBrief`'s `ranking:` descriptor names the new key** (`brief.ts:187`): *"composite desc, then + pillar-overlap desc, **then temporal (first-mover↑/saturated↓)**, then publishedAt desc (capturedAt fallback); + freshDays N; excludes acted/skipped"* — so the artifact self-documents its true sort. + +### S-cli — `scripts/trends/src/cli.ts` (EDIT) — two `brief` threshold flags + +- **`brief … [--first-mover-days N] [--saturation-at N]`** (mirror `--fresh-days`, `cli.ts:312-317`): + `--first-mover-days` parses a **non-negative** integer (default 2; bad → `usage` exit 2); `--saturation-at` + parses a **positive** integer (default 3; `< 1` → `usage` exit 2, since `saturationAt 0` would mark every + surfaced trend saturated). Both passed into `rankForBrief(store, pillars, day, { freshDays, firstMoverDays, + saturationAt })` (`cli.ts:323`). +- **Usage + header synopsis** (`cli.ts:14`, `:110`): add the two optional flags to the `brief` line + a one-line + header note that the brief applies a derived temporal overlay (first-mover↑/saturated↓) at rank time. +- **`schedule` is untouched** — the nightly run uses the **defaults** (2/3). Baking tunable thresholds into the + schedule artifact is OUT for R3d (keeps `schedule.ts`/`run-daily.sh` and the R3c tests untouched — no + regression surface). + +### Wiring (D-default — WIRE, mirrors R3a/R3b/R3c) + +- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): one line — the morning brief now applies a **live + temporal overlay** at rank time (first-mover ranked up, repeatedly-surfaced/saturated ranked down), **derived** + from the publish/capture dates + the seen-log — no new capture step; the agent's polling/capture path is + unchanged. Domain-general (no vendor/sector token). +- `references/trend-scoring-modes.md` (SSOT, EDIT — **one-line note only**): under "Consumers", note that the + morning brief applies a *brief-time* temporal overlay (first-mover/saturation, RE-R3d) as a + **within-composite-tier** ranking refinement, and that **this does not change the capture-time weights** above. + The five-dimension capture-scoring math is **untouched** (R3d changes no weight, no band, no formula) — the note + prevents the SSOT from being misread as the *whole* ranking story (verifiseringsplikt; honesty). +- `scripts/trends/README.md` (EDIT): add a `## Temporal overlay (RE-R3d)` section between the R3c scheduler + section (`README.md:135-154`) and `## Tests` (`:155`): the first-mover/saturation definitions, the + **derived-not-stored** boundary (no schema bump), the `--first-mover-days`/`--saturation-at` flags + defaults, + the `cmp` integration (composite stays primary), and the badge set. +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (**:713**, currently **192**) to the `tests N` line + reported after the suite runs, **append** `+ RE-R3d: brief +N, cli +N (temporal overlay)` to the inline + breakdown comment. Add **Section 16m** ("Trends Temporal Overlay", RE-R3d) **between Section 16l's closing + `echo ""` (`:1374`) and the Section 18 header (`:1376`)** (anti-erosion must stay last). Mirror 16l's shape: + **unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail). + **6 emitters** (all on tracked source, no `tsx`, all literals **ASCII** — test-runner.sh must stay ASCII-only, + so the badge sentinels grep the ASCII tier/flag literals, never the emoji): (1) self-test; (2) `export function + temporalSignal` in `brief.ts`; (3) the cmp key `b.temporal.rank` in `brief.ts`; (4) the tier literal + `"first-mover"` in `brief.ts`; (5) the flag key `first-mover-days` in `cli.ts`; (6) the flag key `saturation-at` + in `cli.ts`. **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 111 → exactly 117** (**:1403**; "live + recount" is the safety net; the expected value is the pinned 111 + 6). Insert the 16m clause into the + **header-enumeration prose chain (`:57-62`)** before "…the assertion-count anti-erosion floor (SC6) in Section + 18", and **append the R3d (→117) narration** to the Section-18 floor-history comment (`:1376-1402`, which ends + "= 111"). + +## 4. Non-goals — what is OUT (deferred) + +- **A stored `saturation`/`firstMover` field + schema bump** — OUT (SB1). The signals are **derived** each run; + persisting an *avledbar* value would go stale as `surfacedCount` grows and would violate the + `ageDays`-is-derived discipline. `SCHEMA_VERSION` stays **4**; `types.ts` is **untouched**. +- **Market/external saturation** (how crowded the topic is *across the web* — how many others have posted) — OUT, + needs external polling = **slice (e)** (the AI fan-out). R3d's saturation is **self-surfacing** only: a proxy + for *"you keep seeing this and not acting → the window is closing for you"*, derived from OUR seen-log. The + brief is honest about which it measures (the README + the badge wording say "seen N×", not "covered N× online"). +- **Mutating / re-scoring the relevance composite** — OUT. The R3a composite stays frozen + PRIMARY (SB2). R3d + adds a SEPARATE sort key; it never recomputes, decays, or overwrites `score.composite`. The displayed composite + is always the stored value. +- **Saturation as a filter (auto-dropping / hiding saturated trends)** — OUT. Saturation **demotes within a + bucket**; it never removes a trend. Dropping is owned by `status` (acted/skipped, R3b) and the `freshDays` + staleness window (the older bucket); R3d's overlay is a soft *re-ordering* signal, not a gate. +- **Tunable thresholds in the scheduled run** — OUT, and a **known limitation** (not merely a later nicety — + folded — plan-critic m6 / brief-reviewer). `brief` gets `--first-mover-days`/`--saturation-at` for an + interactive/tuned run; the **nightly `schedule` run is the primary saturation consumer** (it is what makes + `surfacedCount` accumulate, §1) yet is locked to the **defaults** (2/3) — the operator cannot retune the signal + *where it actually fires* without re-running `schedule`. Accepted for R3d to keep `schedule.ts`/`run-daily.sh` + + the R3c tests untouched (no regression surface); baking the two flags into the schedule artifact is a small, + clearly-scoped follow-up. +- **Brief history / day-over-day diff** ("what changed since yesterday" — hull 7) — **slice (d)**. +- **A new module / new test file** — none. The signal lives in `brief.ts`; its tests in `brief.test.ts`. (No + pathguard surface either — all EDITs.) +- **New agent / new command / new reference doc** — none. R3d EDITs `brief.ts` + `cli.ts` + their tests + one + agent (prose) + the SSOT (one note) + README + gate. Counts stay **29/19/27**. `BRIEF_SCHEMA_VERSION` stays + **1** (the frontmatter *fields* are unchanged — only the `ranking:` descriptor string + body badge tokens + change; R3a/R3b added tokens without bumping it either — `brief.ts:23`). + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — `temporalSignal` is a + new named export of the existing `brief.ts`; Node16 ESM throws a missing named import at module-load, so land a + non-throwing **stub** first (returns `{tier:"fresh",firstMover:false,surfacings:0,rank:2}`; `BriefEntry.temporal` + populated by it), THEN record value-assertion RED (the stub's constant "fresh" fails the first-mover/saturated/ + ordering/token assertions). Phase B — the CLI flag tests are value-RED against the existing handler (the new + flags are silently ignored today). The plan does **not** claim a single "everything fails before any code" run. +- **`temporalSignal` is pure** (no clock, no fs, no env, no AI): every input is injected (`ageDays` already + computed, `surfacedCount` from the record, thresholds from the CLI edge). Mirrors `ageDaysBetween`/`renderBrief` + purity. Same inputs → same output. +- **Determinism of the brief:** given `(store, pillars, today, freshDays, firstMoverDays, saturationAt)` the + rendered `.md` is **byte-identical** (R2b/R3a/R3b/R3c proved the byte-determinism of the rest; the overlay adds + only a pure, injected-threshold sort key + deterministic tokens). +- **Composite stays PRIMARY + frozen (SB2 + R3a):** `cmp`'s first key is unchanged (`b.trend.score?.composite ?? + -1`); the temporal key sits *after* overlap. A saturated higher-composite trend still outranks a fresh + lower-composite one — the overlay re-orders only WITHIN a (composite, overlap) tier. The stored + `score.composite` is never read for mutation, only for sorting. +- **No schema / SSOT-math change:** `types.ts` untouched (`SCHEMA_VERSION` 4); `brief.ts`'s + `BRIEF_SCHEMA_VERSION` stays 1; `score.ts`/`store.ts`/`item.ts`/`schedule.ts` untouched. The SSOT + (`trend-scoring-modes.md`) gets a **one-line consumer note** only — **no weight, band, or formula changes** (the + capture-scoring instrument is unchanged; the overlay is a separate brief-time layer). +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only (`session-start.mjs` per R3c brief + §2). The summary's new `· 🥇 først ute` marker carries no double-quote/newline, so the extractYaml regex still + captures it whole. R3d touches neither the hook nor the frontmatter field set; the hook suite must still pass + untouched (regression sanity; R3d adds no hook test). +- **ASCII-only gate literals:** `scripts/test-runner.sh` must stay ASCII (a multibyte char crashes bash 3.2 under + `set -u`). The Section-16m sentinels grep the **ASCII** tier/flag literals (`"first-mover"`, `first-mover-days`, + `saturation-at`, `b.temporal.rank`, `export function temporalSignal`) — **never** the emoji badges (which live + only in `brief.ts` source + rendered output, asserted by the TS tests, not by the shell gate). +- **Domain-general:** no hard-coded user/repo path, no vendor/sector token in any edit. The tier labels + + Norwegian badge wording (`først ute`, `mettet`, `sett Nx`) are domain-general UI copy (the brief's existing + language); pillars/topics remain config. Section 17 de-niche stays green. +- **Pathguard:** R3d adds **no new files** — every change is an EDIT of an existing file (write-allowed). (No + `.mjs`-under-`hooks/scripts/` surface, no new `scripts/` file.) +- **Counts** (refs/agents/commands 27/19/29) unchanged — **recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (first-mover detection)** — `temporalSignal(ageDays, surfacedCount, {firstMoverDays:2, saturationAt:3})`: + `(1, 0)`, `(1, undefined)`, `(2, 0)`, `(0, 0)` → `{tier:"first-mover", firstMover:true, rank:3}`; `(3, 0)` (past + the window) → NOT first-mover (`tier:"neutral", rank:2`); `(1, 1)` (recent but already surfaced) → NOT + first-mover (`tier:"warming", rank:1`); **`(-1, 0)`** (future publishedAt) → NOT first-mover (`tier:"neutral"` — + the `ageDays >= 0` guard). Pure: same inputs → same output. +- **SC2 (saturation grading + clamp)** — same thresholds: `(5, 3)` and `(5, 4)` → `{tier:"saturated", rank:0}`; + `(5, 2)` and `(5, 1)` → `{tier:"warming", rank:1}`; `(5, 0)` → `{tier:"neutral", rank:2}`. `surfacedCount === + saturationAt` is saturated (inclusive `>=`). **Defensive clamp:** `temporalSignal(5, 5, {…, saturationAt:0})` + classifies via the clamped `at=1` (NOT "every trend saturated") — the function does not trust an out-of-range + threshold. +- **SC3 (ranking — overlay re-orders within tier, composite dominates, temporal↔date DISAGREE)** — the fixture + **forces the temporal key and `effectiveDate` to disagree**, so the test is genuinely RED in Phase A and the new + key is what decides (folded — plan-critic M1: `surfacedCount` correlates with age, so a naive "first-mover vs + saturated" fixture would already be ordered correctly by the existing `effectiveDate`-desc key — a vacuous test). + Three entries, **same** overlap: **A** = `neutral` (surfaced 0, **older** date, e.g. 5d), composite 7.0; **B** = + `warming` (surfaced 2, **newer** date, e.g. 1d), composite 7.0; **Z** = `saturated` (surfaced 4), composite + **8.5**. Expected order **`[Z, A, B]`**: Z first (higher composite — PRIMARY, SB2); then A **above** B even + though B is newer — the temporal key (`neutral` rank 2 > `warming` rank 1) overrides the `effectiveDate`-desc + tiebreaker that would have put the newer B first. In Phase A (stub, constant rank) the order is `[Z, B, A]` + (effectiveDate decides A vs B) → RED. `cmp` remains a total order. +- **SC4 (render badges + the ≥2 badge boundary)** — `renderBrief`/the bullet path: a first-mover entry contains + `· 🥇 først ute`; a saturated entry (`surfacedCount 3`, default `saturationAt 3`) contains `· 🔁 mettet (3x)`; a + warming entry with `surfacedCount 2` contains `· sett 2x`; a warming entry with **`surfacedCount 1` contains NO + badge** (the preserved R3b ≥2 contract — `brief.test.ts:408` stays green, unchanged); a `neutral` entry contains + **none** of the three. The R3b `· sett 3x` assertion (`brief.test.ts:407`) is **updated** to `· 🔁 mettet (3x)` + (its surfacedCount-3 trend is now saturated). **Also updated** (folded — brief-reviewer MEDIUM-2): the two tests + pinning the `ranking:` descriptor verbatim (`brief.test.ts:325-331` and the regex `:410-416`) gain the new + `then temporal (first-mover↑/saturated↓), ` segment; and the `effectiveDate`-isolation test (`brief.test.ts: + 96-102`) is **re-based** so both entries share a temporal tier (both `neutral`) — else the new key, not + `effectiveDate`, would silently decide it (coverage erosion — folded — brief-reviewer MEDIUM-3). +- **SC5 (summary first-mover marker)** — when the top entry is a first-mover, `briefSummary` (and the frontmatter + `summary:` line) contains `· 🥇 først ute` inside the headline parens; when it is not, the marker is absent. The + summary contains **no** `"` and **no** `\n` (the hook-regex invariant). +- **SC6 (CLI flags)** — `brief --pillars ai --first-mover-days 1 --saturation-at 2 …` changes the tiers vs the + defaults (a 2-day-old trend is first-mover at default 2 but `fresh` at `--first-mover-days 1`; `surfacedCount 2` + is `warming` at default 3 but `saturated` at `--saturation-at 2`); absent flags use defaults 2/3; + `--first-mover-days -1` / `--first-mover-days x` / `--saturation-at 0` / `--saturation-at x` → `usage` exit 2. +- **SC7 (determinism)** — two `brief` runs with the same `(store, pillars, today, freshDays, firstMoverDays, + saturationAt)` → byte-identical `.md`. +- **SC8 (no schema / no score mutation)** — `SCHEMA_VERSION` 4; `BRIEF_SCHEMA_VERSION` 1; `types.ts` untouched; a + `brief` run does **not** change any record's `score.composite` (assert the store's scores are unchanged after a + brief, only `surfacedCount`/`lastSurfacedAt` move — the existing R3b behaviour). +- **SC9 (purity)** — `temporalSignal` reads no clock/fs/env; a property check over a grid of `(ageDays, + surfacedCount)` gives stable, threshold-consistent tiers (first-mover ⊆ recent∧unsurfaced; saturated ⇔ + `surfacings >= saturationAt`). +- **SC10 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the + bumped `TRENDS_TESTS_FLOOR`; new **Section 16m** green (the six ASCII sentinels + non-vacuity self-test); + `ASSERT_BASELINE_FLOOR` = **117** (111 + 6); Section 17 de-niche green; counts 29/19/27; the hook suite still + green untouched (`node --test hooks/scripts/__tests__/*.test.mjs`). + +## 7. Verification + +**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; Section 16m self-test + +greps pass; `ASSERT_BASELINE_FLOOR` = 117; Section 17 de-niche green; ref/agent/command counts unchanged. +**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3d touches no +hook). The R3c suite (`schedule.test.ts`/`run-daily.test.ts`) still green untouched (`schedule.ts`/`run-daily.sh` +not edited). +**Behavioural (manual):** +1. Seed a store with three trends sharing topics/pillars: one fresh+unsurfaced (`publishedAt` ~1d ago, + `surfacedCount` absent), one warming (`surfacedCount` 2), one saturated (`surfacedCount` 4) — same composite. +2. `node --import tsx src/cli.ts brief --pillars ai,gov --out /tmp/r3d-mb --store /tmp/r3d.json` → inspect the + `.md`: the fresh+unsurfaced entry sorts first with `· 🥇 først ute`; the saturated one sorts last with `· 🔁 + mettet (4x)`; the `ranking:` descriptor names the temporal key. +3. Re-run with `--first-mover-days 0 --saturation-at 2` → the first-mover badge disappears (0-day window) and the + `surfacedCount 2` entry escalates to `mettet (2x)`. +4. `--first-mover-days x` → exit 2 (`usage`); fs untouched. +5. Confirm the seeded records' `score.composite` values are unchanged after the brief (only `surfacedCount`/ + `lastSurfacedAt` advance) — the overlay never mutates the relevance score. + +## 8. Open questions for the go-gate + +Two architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-26): **SB1** derived-at-brief +(no schema bump); **SB2** refine recency within the composite tier (composite stays primary). Residual decisions, +all baked to the recommended default — confirm or redirect with "Go": +- **D1 — `firstMoverDays` default `2`?** YES (rec). The tight end of the SSOT "<24-72h, you would be among first" + band; `--first-mover-days N` tunes it. Re-open only for a different default (e.g. 3 = the full 72h "early" + band). +- **D2 — `saturationAt` default `3`?** YES (rec). The existing `· sett Nx` hint fires at `>=2`, so default 3 + keeps `surfacedCount 2` an FYI ("warming · sett 2x") and escalates `>=3` to "saturated". `--saturation-at N` + tunes it. Re-open only for a different default. +- **D3 — four tiers (`first-mover`/`fresh`/`warming`/`saturated`)?** YES (rec). A first-mover top, a fresh + baseline, a warming FYI (preserves the R3b `sett Nx` hint), a saturated demotion. Drop only to collapse + warming into fresh (a 3-tier model) or to add a fifth class. +- **D4 — temporal key sits AFTER overlap, BEFORE effectiveDate in `cmp`?** YES (rec, = SB2). Composite then + overlap stay primary; the overlay is the coarse recency class, `effectiveDate` the fine tiebreaker beneath it. + Re-open only to move the key (e.g. before overlap — stronger overlay). +- **D5 — saturation NEVER moves a trend between top/single/older buckets (soft re-order only)?** YES (rec). A + soft signal; bucketing stays `overlap`+`freshDays`. Re-open only to let a saturated trend drop a bucket. +- **D6 — badges `🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` (warming) / none (fresh)?** YES (rec). Promotes the + R3b `sett Nx` hint into a graded set. Re-open for different wording/emoji (the gate sentinels are ASCII, so + emoji changes are test-only). +- **D7 — summary line carries `· 🥇 først ute` when the top is a first-mover?** YES (rec). The one-line surfacing + then signals "act now, you're early". Drop only to keep the summary minimal (no marker). +- **D8 — add a one-line overlay note to the SSOT (`trend-scoring-modes.md`)?** YES (rec). Honest cross-reference + so the SSOT is not misread as the whole ranking story; **no** weight/formula change. Drop only to document the + overlay solely in `brief.ts` + README + the brief's `ranking:` descriptor. +- **D9 — `schedule` untouched (nightly run uses default thresholds)?** YES (rec). Keeps `schedule.ts`/ + `run-daily.sh` + the R3c tests untouched (no regression surface). Re-open only to bake `--first-mover-days`/ + `--saturation-at` into the schedule artifact now. +- **D10 — commit split?** Docs commit first, then **one** code commit (rec) — the overlay (signal + flags + + wiring) is one coherent feature. Re-open only for a signal-then-wiring split. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran on the drafts, each verifying claims against live code. They **converged on the same two +defects** (the strongest signal): **scope-guardian: MIXED** (0 hard creep, both confirmed decisions honored, +every SC1–SC10 traces to a step; 1 MAJOR + 1 line-cite + discretionary MINORs). **brief-reviewer: +PROCEED_WITH_RISKS** (all seven RED-premise/correctness claims HOLD; the risk is GREEN-completeness — the plan +listed 1 of 4 breaking test assertions; 1 MAJOR + 2 MEDIUM + 2 LOW). **plan-critic: APPROVE_WITH_NOTES, 78/B** (the +floor arithmetic, line-cites, grep sentinels, cmp total-order, and two-phase-RED structure all verified correct; +2 MAJOR + 4 MINOR). **All findings folded** (per-finding resolution in `plan-re-r3d.md §Plan-critic — folded`). +Headlines: + +- **[MAJOR, folded — all three] the warming badge fired at `surfacings >= 1`, but the live R3b `surfacedToken` + fires only at `>= 2`** (`brief.ts:156`), and `brief.test.ts:408` pins `!md.includes("sett 1x")`. The draft's + "preserves the R3b hint" was false (it broadened `>=2` to `>=1`). → `temporalToken`'s warming badge is gated at + **`surfacings >= 2`** (R3b contract preserved exactly; `:408` stays green); the warming *tier* still demotes + surfaced-once in `rank`. SC4 gains the `surfacedCount 1 → no badge` boundary. +- **[MAJOR, folded — plan-critic M1 / brief-reviewer MEDIUM-3] the ordering test was not genuinely RED + vacuous.** + `surfacedCount` correlates with age, so a "first-mover vs saturated" fixture is *already* ordered by the existing + `effectiveDate`-desc key — Phase A would be GREEN and GREEN proves nothing. → SC3's fixture now **forces + temporal↔date disagreement** (older-`neutral` A vs newer-`warming` B at equal composite; the temporal key, not + the date, must decide A>B). The coverage-eroded `effectiveDate`-isolation test (`:96-102`) is re-based to a + shared tier. +- **[MEDIUM, folded — brief-reviewer MEDIUM-2] the `ranking:` descriptor change breaks two more pinned tests** + (`brief.test.ts:325-331` + the regex `:410-416`). → the test inventory (SC4 + plan Step 1) now enumerates **all + four** touch points, not one. +- **[MINOR, folded — plan-critic m3] `temporalSignal` was undefensive against `saturationAt < 1`.** → a + `Math.max(1, saturationAt)` clamp inside the pure function (the CLI guard is not enough — the function is a + public, gate-grepped export). SC2 gains a clamp case. +- **[MINOR, folded — plan-critic m4] the "fresh" tier name was a misnomer** (a 30-day-old unsurfaced trend is not + "fresh"; collides with `freshDays`). → renamed **`neutral`** ("no exposure signal"); the in-bucket effect + (unsurfaced ranks above seen-and-skipped within `olderMatched`) is documented as intended. +- **[MINOR, folded — plan-critic m5] a future `publishedAt` (ageDays < 0) became a first-mover** "act now" + headline. → the `ageDays >= 0` guard excludes it (it falls to `neutral`). SC1 gains the `(-1, 0)` case. +- **[MINOR, folded — plan-critic m6 / brief-reviewer] the nightly run (the primary saturation consumer) is locked + to default thresholds.** → reframed in §4 as a **known limitation**, not a "later nicety." +- **[LOW, folded — all three] long-form `angle` weight cite `:34` → `:32`** (`:34` is `currency`; the substance — + angle is a scored dimension in both modes — holds). diff --git a/docs/research-engine/brief-re-r3e.md b/docs/research-engine/brief-re-r3e.md new file mode 100644 index 0000000..a115d69 --- /dev/null +++ b/docs/research-engine/brief-re-r3e.md @@ -0,0 +1,430 @@ +# Brief — RE-R3e: brief history + day-over-day diff (R3 slice d) + +> **Slice:** RE-R3e (research-engine rung-2, R3 slice **(d)** in the operator's `(a)→(c)→(b)→(d)→(e)` +> sequence — the doc is numbered r3e by creation order, the concept is slice (d)). It closes **hull #7** +> (substrate §1 hull list: *"ingen brief-historikk"*): the dated morning brief already writes one Markdown +> file per day (`morning-brief/YYYY-MM-DD.md`), but the file is **prose-only** — nothing records *which* +> trends a brief showed in a machine-readable form, and no run reads yesterday's brief, so the engine cannot +> answer the one question a daily motor exists to answer: **"what is new since I last looked?"** +> **Predecessor:** RE-R2b (the dated brief artifact + `surfacedIds(ranking)` — the exact set a brief shows: +> `brief.ts:305`) + RE-R3b (the per-day-idempotent seen-log `surfacedCount`/`lastSurfacedAt`, which already +> records *that* a trend was surfaced but not *with which cohort*) + RE-R3c (the autonomous trigger that makes +> the dated files **accumulate day-over-day on their own** — the dependency that makes a day-over-day diff a real +> signal, not a once-in-a-while comparison) + RE-R3d (the temporal overlay — the within-brief recency/saturation +> class the diff is orthogonal to). +> **Substrate:** `docs/research-engine-concepts.local.md` §1 hull **#7** (*"ingen brief-historikk"*) + §B3 +> (*"Dated-digest som flat plain-text-artefakt … diffbar, grep-bar, lenkbar … Senere sesjon laster «gårsdagens +> brief» trivielt"* — the dated file is **explicitly designed to be diffed**, R3e is the diff B3 anticipated) + +> §B4 (*"append-only seen-log → ikke re-overflate samme sak"* — R3e is the per-cohort complement: not "have I +> seen this ever" but "was this in the PRIOR brief"). +> **The core decisions (operator-confirmed, AskUserQuestion 2026-06-26 — baked):** +> - **SD1 — persist membership in the brief's own frontmatter, NOT a sidecar.** Each brief writes a single +> `surfaced: <id-csv>` line into its YAML frontmatter — the ids it actually showed (`surfacedIds(ranking)`). +> This keeps **one self-describing, grep-bar artifact** per day (B3), mirrors the existing `store: { … }` +> frontmatter idiom, and is **hook-safe** (the SessionStart `extractYaml` is `^summary:`-anchored and +> line-scoped — a new `surfaced:` line cannot perturb it). The diff reads the prior brief's `surfaced:` line +> via one pure regex. **No sidecar `.json`; no second artifact.** `BRIEF_SCHEMA_VERSION` bumps **1 → 2** (the +> frontmatter gained a field — the first bump since R2b; the store's `SCHEMA_VERSION` stays **4**). +> - **SD2 — `added` with titles + `dropped` as a count; `brief.ts` stays store-free.** The diff is the symmetric +> set difference of today's surfaced ids against the prior brief's: **added** (in today, not prior — the +> headline "what's new", rendered with titles resolved from the ranking the brief already holds), **carried** +> (in both), **dropped** (in prior, not today). `added` is the value; `dropped`/`carried` render as a one-line +> tally (counts). The dropped ids are **not** resolved against the store for an acted/skipped/aged *reason* — +> that would require injecting store records into the render and is the one explicit follow-up (§4). The render +> needs **only the ranking** it already has → `brief.ts` stays pure (no store, no fs). The framing is **honest**: +> "ikke vist i dag" (not shown today), never "resolved" (which the count cannot prove). +> **TDD-order (two-phase RED, light-Voyage discipline, inherited from R3c/R3d):** Phase A — `diffSurfaced`, +> `parseSurfacedFrontmatter`, `selectPriorBriefFile` (+ the `BriefDiff` type) are NEW named exports of the +> EXISTING `brief.ts`; under Node16 ESM a missing named import throws at module-load (every `brief.test.ts` test +> would error, not assert), so land **non-throwing stubs** FIRST (`diffSurfaced → {priorDate:null,added:[], +> carried:[],dropped:[]}`, `parseSurfacedFrontmatter → []`, `selectPriorBriefFile → null`; `renderBrief` gains an +> optional `diff` param it ignores in the stub), then record value-assertion RED against them (the stubs' constant +> returns fail the diff/parse/select/section/marker assertions — true assertion-RED). Phase B — the CLI two-day +> diff test is value-RED against the existing `brief` handler (today it writes no `surfaced:` line, reads no prior +> brief, and its `--json` carries no `diff` key → the day-2 diff assertions fail). See plan Step 1. + +## 1. Operator decision context (2026-06-26) + +The research engine is **Tier-1** (operator, 2026-06-23). R1→R3d built the deterministic spine, the trend's life +after capture, the autonomy that runs it, and the within-brief temporal overlay: item-schema + triage (R1) → +capture bridge (R2a) → dated morning brief + surfacing (R2b) → persisted relevance + composite ranking (R3a) → +status lifecycle + seen-log + re-score (R3b) → autonomous trigger + headless entry (R3c) → temporal overlay +(first-mover + saturation, R3d). The brief now **regenerates itself every morning** (R3c) and ranks each trend by +its frozen relevance composite, its pillar overlap, and a live first-mover/saturation class (R3d). But every +morning's brief is a **standalone snapshot**: it cannot say *"these three are new since yesterday; the two you +saw yesterday are gone."* The accumulated dated files are a pile of snapshots, not a **history with a diff**. + +R3e closes **hull #7** — the **brief history + day-over-day diff** — which the operator chose as slice **(d)** of +the full-R3 build-out (2026-06-24, *"ALLE gjenstående R3-slices … i rekkefølge (a) → (c) → (b) → (d) → (e)"*). It +is sequenced **after** R3c for a load-bearing reason: a day-over-day diff is only meaningful when a brief is +**produced every day on its own** — R3c's nightly trigger is what makes "yesterday's brief" reliably exist. (c) +makes (d) a real signal; without the daily trigger, "since last brief" could mean "since whenever the operator +last happened to ask." + +**The concrete value — turning a pile of snapshots into a feed.** A daily motor's job is to surface the *delta*: +the operator does not want to re-read the full ranked list every morning and diff it in their head — they want +the engine to say **"3 nye siden i går"** at the top of the brief (and on the one-line SessionStart surfacing, +for free). R3e makes the dated file a genuine **history rung**: each brief records what it showed +(`surfaced:` frontmatter), and the next brief reads the most recent prior one and renders **"Nytt siden sist."** +This is the smallest honest step from *"a brief is written daily"* (R3c) to *"the brief tells me what changed"* +(the point of a feed) — and it is exactly the diffable dated-digest §B3 said the artifact was designed to be. + +## 2. The gap — grounded in code + +- **The dated brief is prose-only; membership is not machine-readable.** `cli.ts:341-343` writes + `morning-brief/${day}.md` from `renderBrief(ranking)`; the body embeds each trend's `id` inside a rendered + bullet (`brief.ts:242`, `:251` — `` `${e.trend.id}` ``), but there is **no structured record** of *the set a + brief showed*. `surfacedIds(ranking)` (`brief.ts:305`) computes that set and feeds it to `markSurfaced` + (`cli.ts:348`), but it is **never persisted to the artifact** — so a later run that wants "what did yesterday's + brief show" would have to scrape prose. The seen-log (`surfacedCount`/`lastSurfacedAt`, R3b) records *that* and + *how many days* a trend was surfaced, but **not which cohort it appeared with** — it cannot reconstruct + "yesterday's brief contained {A, B, C}." +- **No run reads a prior brief.** The only consumer of the dated files is the SessionStart hook's + `latestMorningBrief` (`session-start.mjs:60-77`), which reads the **single newest** file's `date`+`summary` and + surfaces it verbatim. Nothing reads the **second-newest** to compare. There is no diff, anywhere. +- **"New since last" is not derivable from the store alone.** The seen-log gives "never surfaced ever" + (`surfacedCount` absent ⇒ a first-ever sighting) — but that is **not** "new since the last brief": a trend + surfaced once three days ago, absent from yesterday's brief, reappearing today is *new to yesterday's reader* + yet has `surfacedCount 1` (not 0). Only a **per-brief membership record** (the prior `surfaced:` set) answers + "was this in the immediately-prior brief," and only it can compute **dropped** (in the prior cohort, gone + today) — which the store cannot express at all. This is precisely the gap §B3's "diffbar … dated-digest" and + §1 hull #7 name. +- **The brief's own summary cannot signal a delta.** `briefSummary` (`brief.ts:204-216`) describes today's top + match in isolation; the SessionStart surfacing (`session-start.mjs:534-536`) shows that line verbatim. There is + no "N nye siden sist" the operator could see *without opening the file* — the one number a feed leads with. + +## 3. Scope — what is IN (RE-R3e) + +**Zero new source/test files** (the two tracked slice docs aside). R3e is pure EDITs: the diff lives beside +`surfacedIds` in `brief.ts` (the module that already owns the brief's pure read logic — surgical, no new module +to wire into the gate), its unit tests in `brief.test.ts`, its CLI wiring (prior-file discovery) in +`cli.ts`/`cli.test.ts`, plus the wiring docs + gate. + +### S-history — `scripts/trends/src/brief.ts` (EDIT) — the pure diff + the persisted membership + +- **`BRIEF_SCHEMA_VERSION` bumps 1 → 2** (`brief.ts:23`) — the frontmatter gained the `surfaced:` field. (The + store's `SCHEMA_VERSION` is untouched at **4** — R3e adds **no store field**; the membership lives in the + artifact, the diff is derived.) +- **`export interface BriefDiff { priorDate: string | null; added: string[]; carried: string[]; dropped: string[] }`** + — `priorDate` = the date of the brief diffed against (`null` ⇒ no prior brief, i.e. the first ever / a fresh + data dir); `added`/`carried`/`dropped` = the three partitions of the set difference, each **order-stable** + (added/carried preserve today's `surfacedIds` order; dropped preserves the prior set's order). +- **`export function diffSurfaced(currentIds: string[], priorIds: string[], priorDate: string | null): BriefDiff`** + — pure (no clock/fs/env; both id lists + the prior date are injected by the CLI edge). `added` = `currentIds` + not in `priorIds`; `carried` = `currentIds` in `priorIds`; `dropped` = `priorIds` not in `currentIds`. Uses a + `Set` for membership; preserves input order in the output arrays. When `priorIds` is empty (first brief), + `added === currentIds` and `dropped === []`. +- **`export function parseSurfacedFrontmatter(md: string): string[]`** — pure; extracts the `surfaced:` value + from a brief's full text via a single line-anchored regex (mirrors the hook's `extractYaml` idiom: + `/^surfaced: *([^\n]*)/m`), splits on `,`, trims, drops empties. Returns `[]` when the line is **absent, blank, + or malformed** (a pre-R3e brief, or a hand-edited file) — degrades to "empty prior," never throws. Real ids are + comma-free hex (`store.ts:69-72`), so the CSV is unambiguous. +- **`export function selectPriorBriefFile(filenames: string[], today: string): string | null`** — pure; from a + directory listing, returns the **lexicographically greatest** filename matching `^\d{4}-\d{2}-\d{2}\.md$` whose + date is **strictly less than** `today` (ISO dates sort lexicographically, so string compare = date compare), + else `null`. Mirrors the hook's `latestMorningBrief` filter+sort (`session-start.mjs:63-66`) but **excludes + today and any future-dated file** — so a same-day re-run diffs against the true previous day, not its own + just-written file (the byte-determinism guarantee, SC8). +- **`renderBrief` gains an optional `diff` param** (`brief.ts:259`): `renderBrief(ranking: BriefRanking, diff?: + BriefDiff)`. Two additive emissions: + - **Frontmatter `surfaced:` line** — `surfaced: ${surfacedIds(ranking).join(",")}` inserted **before** + `schemaVersion:` (always emitted, even for an empty store → `surfaced: ` blank; this is the record the *next* + day's diff reads, independent of whether *today* had a prior). `schemaVersion:` now renders **2**. + - **A `## 🆕 Nytt siden sist` section** (the header gains ` (<prior-date>)` when a prior brief exists, per SC9), + placed **after the intro line and before `## 🎯 Topp-treff`** (the + delta leads, then the full ranked list). Branches (all deterministic): + - **no diff arg / `priorDate === null` with added** → `_Første brief — alt nedenfor er nytt._` + - **`priorDate === null` with no added** (empty first brief) → `_Første brief._` + - **`priorDate !== null`, `added` non-empty** → one bullet per added id, its entry resolved from the ranking + (title + matched pillars + date + link + id, reusing the bullet idiom), then a tally line + `_${carried.length} båret over, ${dropped.length} ikke vist i dag._` + - **`priorDate !== null`, `added` empty** → `_Ingenting nytt siden ${priorDate}._` (+ the same tally line) + - When `diff` is omitted (a bare `renderBrief(ranking)` call, e.g. a unit test that does not exercise the diff), + it defaults to the empty diff (`{priorDate:null,added:[],carried:[],dropped:[]}`) → the **`_Første brief._`** + section branch (`priorDate===null`, `added` empty). The `surfaced:` frontmatter line is **independent of the + diff** — always `surfacedIds(ranking).join(",")` (blank **only** for an empty store), so a non-empty ranking + still emits its real surfaced ids. (This keeps existing single-arg call sites compiling and semantically valid.) +- **`briefSummary` gains an optional `diff` param** (`brief.ts:204`): `briefSummary(ranking, diff?)`. When `diff` + is present, `priorDate !== null`, and `added.length > 0`, it appends ` ${added.length} nye siden sist.` to the + one-line headline — so the **SessionStart hook surfaces the delta for free** (it already shows the `summary:` + line verbatim; no hook edit). The marker is suppressed on the first brief (`priorDate === null`) and when + nothing is new (no noise). It carries **no double-quote and no newline** (the `^summary: *"?([^"\n]*)"?` + hook-regex invariant, `brief.ts:200-203`). `renderBrief` passes its `diff` through to `briefSummary` so the + frontmatter `summary:` and the `--json summary` agree. + +### S-cli — `scripts/trends/src/cli.ts` (EDIT) — prior-brief discovery + the diff in `--json` + +- **The `brief` handler discovers the prior brief and computes the diff** (between the ranking at `cli.ts:339` + and the render at `:340`): `readdirSync(outDir)` (guarded by `existsSync` — a first run has no dir) → + `selectPriorBriefFile(files, day)` → if found, `readFileSync` it and `parseSurfacedFrontmatter` → build + `diffSurfaced(surfacedIds(ranking), priorIds, priorDate)`; on any fs error, degrade to the empty-prior diff + (`priorDate: null`). Pass the diff into `renderBrief(ranking, diff)`. Adds `readdirSync` to the existing + `node:fs` import (`cli.ts:51`). +- **`--json` gains a `diff` object** (`cli.ts:352`): `diff: { priorDate, added: added.length, carried: + carried.length, dropped: dropped.length }` — counts, not id lists (the headless `run-daily.sh` collapses + `--json` to one cron-log line). The non-JSON console line (`cli.ts:355`) appends `, N nye siden sist` when + `added > 0 && priorDate !== null`. +- **`--no-mark` is unchanged in meaning** — it still governs only the **store** seen-log write (`cli.ts:347-349`). + The artifact's `surfaced:` frontmatter records what the brief showed **regardless** of `--no-mark` (it is a + property of the rendered brief, not of the store mutation). No new flag. + +### Wiring (D-default — WIRE, mirrors R3a/R3b/R3c/R3d) + +- `agents/trend-spotter.md` (EDIT, **prose-only, minimal**): one line — the morning brief now records which trends + it showed (frontmatter `surfaced:`) and renders a **day-over-day diff** ("Nytt siden sist") against the most + recent prior brief — no new capture step; the agent's polling/capture path is unchanged. Domain-general (no + vendor/sector token). +- `scripts/trends/README.md` (EDIT): add a `## Brief history + diff (RE-R3e)` section between the R3d temporal- + overlay section and `## Tests`: the `surfaced:` frontmatter record, the `selectPriorBriefFile` prior-discovery + (strict `< today`, same-day re-run determinism), the `diffSurfaced` partitions, the section + the summary + marker, and the `BRIEF_SCHEMA_VERSION 1→2` boundary (artifact-only; store `SCHEMA_VERSION` stays 4). +- `scripts/test-runner.sh` (EDIT): bump `TRENDS_TESTS_FLOOR` (**live `:716`**, currently **216**) to the `tests N` + line reported after the suite runs — **recounted live**, **append** `+ RE-R3e: brief +N, cli +N (brief history + + diff)` to the inline breakdown comment. Add **Section 16n** ("Trends Brief History / Diff", RE-R3e) **between + Section 16m's closing `echo ""` and the Section 18 header** (anti-erosion must stay last). Mirror 16m's shape: + **unconditional**, deps-absent-safe (`grep -qF` + a non-vacuity self-test emitting **one** pass/fail). **6 + emitters** (all on tracked source, no `tsx`, all literals **ASCII** — the section header emoji `🆕` is **never** + grepped; the shell stays ASCII-clean for bash 3.2 `set -u`): (1) self-test; (2) `export function diffSurfaced` + in `brief.ts`; (3) `parseSurfacedFrontmatter` in `brief.ts`; (4) the section header literal `Nytt siden sist` + in `brief.ts`; (5) `selectPriorBriefFile` in `cli.ts` (the diff wiring); (6) the frontmatter emit `surfaced: ` + in `brief.ts`. **6 unconditional emitters → bump `ASSERT_BASELINE_FLOOR` 117 → exactly 123** (**live `:1473`**; + "live recount" is the safety net; the expected value is the pinned 117 + 6). Insert the 16n clause into the + **header-enumeration prose chain (`:53-64`)** before "…the assertion-count anti-erosion floor (SC6) in Section + 18," and **append the R3e (→123) narration** to the Section-18 floor-history comment (which ends "= 117"). + +## 4. Non-goals — what is OUT (deferred) + +- **A sidecar `.json` membership manifest** — OUT (SD1). Membership lives in the brief's own frontmatter + (`surfaced:`), keeping one self-describing artifact (B3). No second file per day. +- **`dropped` resolved to an acted/skipped/aged *reason*** — OUT (SD2), and the **one explicit follow-up**. + `dropped` renders as a **count** ("N ikke vist i dag"). Labelling *why* each dropped id left (acted/skipped via + `status`, or aged past `freshDays`) would require injecting the store records into the render — `brief.ts` would + no longer be store-free. Honest framing for R3e: "ikke vist i dag," never "resolved." A small, clearly-scoped + follow-up (CLI resolves dropped ids → `{title, status}` and passes them to a richer render) if the loop-closing + signal proves worth the coupling. +- **A browsable history INDEX file** (e.g. a rolling `history.md` of all past briefs) — OUT. The dated files + + the `surfaced:` frontmatter **are** the history (grep-bar, lenkbar — B3); an index is a presentation nicety, not + a capability gap. +- **A SessionStart hook change to render the diff** — OUT. The "N nye siden sist" marker rides the **existing** + `summary:` surfacing (`session-start.mjs:534-536`) — no hook edit, no hook test, no new frontmatter field the + hook must learn. (The hook still reads only `date`+`summary`.) +- **`schedule` / `run-daily.sh` changes** — OUT. The nightly run calls `brief` (`run-daily.sh:33`), which now + computes the diff internally → the scheduled brief gets "Nytt siden sist" **automatically**, with **no** + scheduler edit (no R3c regression surface). +- **A new store field / schema bump / store mutation for the diff** — OUT. `SCHEMA_VERSION` stays **4**; + `types.ts`/`store.ts` are **untouched**. The membership is an artifact property; the diff is derived at the CLI + edge. +- **"New" defined as first-ever-sighting (`surfacedCount === 0`)** — OUT (rejected as less correct). R3e's "new" + is **relative to the immediately-prior brief** (artifact diff), which also flags a trend *re-emerging* after a + gap — the honest meaning of "siden sist." (`surfacedCount` stays the R3d saturation input, a different + question.) +- **Diffing against an arbitrary historical brief (`--since <date>`)** — OUT. R3e diffs against the **most recent + prior** brief only (the "since last" a daily feed needs). An arbitrary baseline is a later nicety. +- **A new module / new test file** — none. The diff lives in `brief.ts`; its tests in `brief.test.ts`. (No + pathguard surface — all EDITs.) +- **New agent / new command / new reference doc** — none. R3e EDITs `brief.ts` + `cli.ts` + their tests + one + agent (prose) + README + gate. Counts stay **29/19/27**. (The SSOT `trend-scoring-modes.md` is **not** touched — + the diff is not a scoring concern; scope fence.) + +## 5. Boundaries / invariants (must hold) + +- **TDD iron law (two-phase RED):** failing tests land **BEFORE** implementation. Phase A — the new named exports + (`diffSurfaced`/`parseSurfacedFrontmatter`/`selectPriorBriefFile`/`BriefDiff`) need non-throwing **stubs** first + (Node16 ESM throws a missing named import at module-load), THEN value-assertion RED against the constant stubs. + Phase B — the CLI two-day diff test is value-RED against the existing handler (no `surfaced:` write, no prior + read, no `diff` in `--json` today). The plan does **not** claim a single "everything fails before any code" run. +- **`brief.ts` stays pure** (no clock, no fs, no env, no AI): `diffSurfaced`/`parseSurfacedFrontmatter`/ + `selectPriorBriefFile` all take strings/arrays and return values — the directory read + file read live in + `cli.ts` (the edge), exactly like `today`/`pillars` are injected. The module's "No fs, no clock, no AI, no + network" header claim is preserved. +- **Determinism of the brief:** given `(store, pillars, today, freshDays, firstMoverDays, saturationAt, diff)` the + rendered `.md` is **byte-identical** (the diff is now an injected input, like `today`). Critically, a **same-day + re-run is byte-identical**: `selectPriorBriefFile` excludes `${today}.md` (strict `<`), so the re-run diffs + against the same previous day's brief and re-writes the same `surfaced:` line (R3c SC7 preserved). +- **`surfaced:` records the shown set, `--no-mark`-independent:** the frontmatter line is `surfacedIds(ranking)` + joined — what the brief *showed* — regardless of whether the store seen-log was written (`--no-mark` governs the + store mutation only). The artifact is always self-consistent. +- **Frozen composite + temporal overlay untouched (R3a + R3d):** R3e adds **no** `cmp` key and changes **no** + ranking — `rankForBrief` is unchanged. The diff is a **post-ranking, render-time** layer over the same surfaced + set. `score.composite` is never read for mutation; the R3d `temporal` overlay is orthogonal (it orders within + the brief; the diff compares across briefs). +- **Schema boundary:** `BRIEF_SCHEMA_VERSION` bumps **1 → 2** (the artifact's frontmatter gained `surfaced:`); + the store's `SCHEMA_VERSION` stays **4**; `types.ts`/`store.ts`/`score.ts`/`item.ts`/`schedule.ts`/ + `run-daily.sh` are **untouched**. +- **Hook unaffected:** the SessionStart surfacing reads `date`+`summary` only (`session-start.mjs:60-77`). The new + `surfaced:` frontmatter line is `^surfaced:`-keyed (the `^summary:`-anchored, line-scoped `extractYaml` cannot + match it), and the `summary:` marker carries no `"`/`\n` — so the regex still captures the summary whole. R3e + touches neither the hook nor the field set the hook reads; the hook suite must still pass untouched (regression + sanity; R3e adds no hook test). +- **ASCII-only gate literals:** `scripts/test-runner.sh` must stay ASCII (a multibyte char crashes bash 3.2 under + `set -u`). The Section-16n sentinels grep the **ASCII** literals (`export function diffSurfaced`, + `parseSurfacedFrontmatter`, `Nytt siden sist`, `selectPriorBriefFile`, `surfaced: `) — **never** the `🆕` emoji + (which lives only in `brief.ts` source + rendered output, asserted by the TS tests, not by the shell gate). +- **Domain-general:** no hard-coded user/repo path, no vendor/sector token in any edit. The section header + + Norwegian copy (`Nytt siden sist`, `båret over`, `ikke vist i dag`, `nye siden sist`, `Første brief`) are + domain-general UI copy (the brief's existing language); pillars/topics remain config. Section 17 de-niche stays + green. +- **Pathguard:** R3e adds **no new files** — every change is an EDIT of an existing file (write-allowed). (No + `.mjs`-under-`hooks/scripts/` surface, no new `scripts/` file.) +- **Counts** (refs/agents/commands 27/19/29) unchanged — **recounted live at land**, never pinned/guessed. + +## 6. Success criteria (testable) + +- **SC1 (diffSurfaced — partitions + order + empty prior)** — `diffSurfaced(["a","b","c"], ["b","c","d"], "2026- + 06-25")` → `{priorDate:"2026-06-25", added:["a"], carried:["b","c"], dropped:["d"]}` (added/carried in current + order, dropped in prior order). `diffSurfaced(["a","b"], [], null)` → `{priorDate:null, added:["a","b"], + carried:[], dropped:[]}` (empty prior ⇒ everything added). Pure: same inputs → same output. The three + partitions are **mutually disjoint** — `Set` membership is binary (an id is in `priorIds` or not), so each id + lands in exactly one of added/carried and dropped is disjoint from both; within each list, order and any + duplicates **mirror the input** (`surfacedIds` yields **distinct** ids in production, so within-list dups never + arise — the cross-partition exclusivity is the real invariant, not within-list dedup). +- **SC2 (parseSurfacedFrontmatter — read + degrade)** — parses `surfaced: 1a2b,3c4d,5e6f` (in a full frontmatter + block) → `["1a2b","3c4d","5e6f"]`; a **blank** `surfaced: ` → `[]`; an **absent** `surfaced:` line (a pre-R3e + brief) → `[]`; whitespace around ids is trimmed; the `summary:`/`store:`/`date:` lines are **not** mismatched + (line-anchored). Never throws on malformed input. +- **SC3 (selectPriorBriefFile — strict-prior selection)** — from `["2026-06-24.md","2026-06-25.md","2026-06-26.md", + "README.md","2026-06-30.md"]` with `today="2026-06-26"` → `"2026-06-25.md"` (greatest `< today`; **excludes** + today `2026-06-26.md` and the future `2026-06-30.md`; ignores the non-dated `README.md`). Empty list, or no file + `< today`, → `null`. +- **SC4 (frontmatter `surfaced:` line + round-trip)** — `renderBrief(ranking, diff)` emits exactly one + `^surfaced: <csv>$` line, equal to `surfacedIds(ranking).join(",")`, positioned before `schemaVersion: 2`; an + empty-store brief emits `surfaced: ` (blank); `parseSurfacedFrontmatter(renderBrief(r, d))` round-trips to + `surfacedIds(r)`. `schemaVersion:` renders `2`. +- **SC5 (Nytt siden sist section — all four branches)** — the rendered body contains `## 🆕 Nytt siden sist`; + with `priorDate:null` + added → `Første brief — alt nedenfor er nytt`; with `priorDate:null` + no added (empty + store) → `Første brief.`; with a prior + `added` → one bullet per added entry (its **title** present, resolved + from the ranking) + `N båret over, M ikke vist i dag`; with a prior + no added → `Ingenting nytt siden <date>` + + the tally. The section precedes `## 🎯 Topp-treff`. +- **SC6 (summary delta marker)** — `briefSummary(ranking, diff)` with `priorDate !== null` and `added.length > 0` + ends with ` ${added.length} nye siden sist.`; with `priorDate:null` (first brief) or `added.length === 0`, the + marker is **absent** (and `briefSummary(ranking)` with no diff === `briefSummary(ranking, emptyDiff)` — no + marker, so the existing `frontmatter summary === briefSummary(r)` test stays green). The summary contains **no** + `"` and **no** `\n`. +- **SC7 (schema boundary)** — `BRIEF_SCHEMA_VERSION === 2`; `SCHEMA_VERSION === 4`; `types.ts` untouched; a + `brief` run does **not** change any record's `score.composite` (only `surfacedCount`/`lastSurfacedAt` move — the + existing R3b behaviour, since `rankForBrief`/`markSurfaced` are unchanged). +- **SC8 (determinism, incl. same-day re-run)** — two `brief` renders with the same `(store, pillars, today, + opts, diff)` → byte-identical `.md`. End-to-end via the CLI: running `brief` **twice on the same day** (the + second after the first wrote `${day}.md`) → byte-identical files, because `selectPriorBriefFile` excludes the + same-day file and picks the same prior day. +- **SC9 (CLI diff wiring — two-day sequence)** — write a day-1 brief (records `surfaced:` for its cohort), then a + day-2 brief over a store with one **new** trend: the day-2 `.md` `## 🆕 Nytt siden sist (<day-1>)` section lists + the new trend, the day-2 `--json` carries `diff: { priorDate:<day-1>, added:≥1, carried:…, dropped:… }`, and the + console line appends `N nye siden sist`. A **first** run (empty dir) → `diff.priorDate === null`. A **custom + `--out`** isolates discovery to that dir (the diff reads prior briefs only from `outDir`). +- **SC10 (gate + wiring + de-niche)** — `bash scripts/test-runner.sh` → `FAIL=0`: trends suite green at the + bumped `TRENDS_TESTS_FLOOR`; new **Section 16n** green (the six ASCII sentinels + non-vacuity self-test); + `ASSERT_BASELINE_FLOOR` = **123** (117 + 6); Section 17 de-niche green; counts 29/19/27; the hook suite still + green untouched (`node --test hooks/scripts/__tests__/*.test.mjs`). + +## 7. Verification + +**Deterministic:** `bash scripts/test-runner.sh` → `FAIL=0`; trends suite ≥ new floor; Section 16n self-test + +greps pass; `ASSERT_BASELINE_FLOOR` = 123; Section 17 de-niche green; ref/agent/command counts unchanged. +**Regression sanity:** `node --test hooks/scripts/__tests__/*.test.mjs` → still green untouched (R3e touches no +hook). The R3c suite (`schedule.test.ts`/`run-daily.test.ts`) still green untouched (`schedule.ts`/`run-daily.sh` +not edited). +**Behavioural (manual):** +1. `D=/tmp/r3e-mb-$$; S=/tmp/r3e-$$.json` (unique dir, no `rm`). Seed a store with two on-pillar trends and run a + day-1 brief: `node --import tsx src/cli.ts brief --pillars ai,gov --out "$D" --store "$S"` → inspect the `.md`: + the frontmatter carries `surfaced: <ids>` and `schemaVersion: 2`; the `## 🆕 Nytt siden sist` section says + `Første brief — alt nedenfor er nytt`. +2. `capture` a third on-pillar trend into the same store, then run a day-2 brief **with a later `today`** (seed via + a second dated file is not possible — use `--out "$D"` so day-1's `${day}.md` is the prior; on a real next-day + run the date advances): inspect the new `.md` → `## 🆕 Nytt siden sist (<prior-date>)` lists the new trend with + its title, then `N båret over, M ikke vist i dag`; the `--json` shows `diff.added ≥ 1`. +3. Re-run the **same-day** brief → the written `.md` is **byte-identical** (`diff` against the same prior file; + `surfaced:` re-written identically) — `cmp` the two files. +4. Confirm the seeded records' `score.composite` values are unchanged after the briefs (only `surfacedCount`/ + `lastSurfacedAt` advance) — the diff never mutates the store ranking. +5. Confirm a pre-R3e brief (no `surfaced:` line) as the prior → `parseSurfacedFrontmatter` returns `[]` → every + trend reads as `added` (graceful degrade, no crash). + +## 8. Open questions for the go-gate + +Two architectural decisions are **CONFIRMED** (operator, AskUserQuestion 2026-06-26): **SD1** persist membership +in the brief's frontmatter (no sidecar); **SD2** `added` with titles + `dropped` as a count (`brief.ts` +store-free). Residual decisions, all baked to the recommended default — confirm or redirect with "Go": +- **D1 — "new" = relative to the immediately-prior brief (artifact diff), not first-ever (`surfacedCount 0`)?** + YES (rec). It is the honest meaning of "siden sist" and catches re-emergence; it is also what unlocks `dropped`. + Re-open only to redefine "new" as first-ever. +- **D2 — diff against the most recent prior brief only (no `--since` baseline)?** YES (rec). The "since last" a + daily feed needs. Re-open only to add an arbitrary historical baseline. +- **D3 — `## 🆕 Nytt siden sist` placed before `## 🎯 Topp-treff` (delta leads)?** YES (rec). The one thing a feed + leads with. Re-open only to place it after the ranked list (appendix) or omit the header on a first brief. +- **D4 — summary marker ` N nye siden sist.` (suppressed on first brief / when nothing new)?** YES (rec). The + delta the SessionStart hook surfaces for free, with no hook edit. Drop only to keep the summary minimal. +- **D5 — `surfaced:` frontmatter always emitted (incl. `--no-mark`, incl. empty store → blank)?** YES (rec). It is + the record the *next* diff reads; gating it on `--no-mark` or non-empty would silently break tomorrow's diff. + Re-open only to gate it. +- **D6 — `dropped`/`carried` render as a one-line count (no titles); `dropped` framed "ikke vist i dag"?** YES + (rec, = SD2). Keeps `brief.ts` store-free; honest (a count cannot prove "resolved"). Re-open only to pull the + reason-labeled follow-up into R3e now. +- **D7 — `BRIEF_SCHEMA_VERSION` 1 → 2; store `SCHEMA_VERSION` stays 4?** YES (rec). The frontmatter gained a + field (the first artifact-schema change since R2b); no store field. Re-open only to add a store field instead. +- **D8 — README gets the R3e section; `trend-spotter.md` gets one prose line; the SSOT is NOT touched?** YES + (rec). The diff is not a scoring concern — touching `trend-scoring-modes.md` would be scope creep. Re-open only + to add an SSOT note. +- **D9 — commit split?** Docs commit first, then **one** code commit (rec) — the diff (helpers + render + CLI + wiring + gate) is one coherent feature. Re-open only for a helpers-then-wiring split. + +## 9. Light-Voyage review — folded + +Three Opus reviewers ran COLD on this brief + the plan against live `scripts/trends/` code (scope-guardian, +brief-reviewer, plan-critic — the R3c/R3d discipline). **Verdicts:** scope-guardian **MIXED** · brief-reviewer +**PROCEED_WITH_RISKS** · plan-critic **REWORK (0.88)**. They **converged on 2 MAJOR** (both re-verified against +live code before folding) + 4 MINOR. Every line-cite, the floors, the regex/lex/hook safety, and the §3 scope +fence were **confirmed correct** by all three and left untouched. + +**MAJOR-1 — a hard schema literal breaks at the 1→2 bump (the §6/Step-1 "no existing assertion breaks" scoping +missed it).** `tests/brief.test.ts:574` is `assert.equal(BRIEF_SCHEMA_VERSION, 1)` — a **hard literal**, not the +constant-tracking RegExp at `:163` (`new RegExp("\\nschemaVersion: " + BRIEF_SCHEMA_VERSION + "\\n")` auto-tracks). +It lives in the `rankForBrief — no schema/score mutation` block (`:568-577`), **outside** the frontmatter tests +§6/plan-Step-1 enumerated, so the "every frontmatter assertion auto-tracks" claim did not cover it. **Resolution:** +the GREEN schema bump (`BRIEF_SCHEMA_VERSION = 2`) must **also flip `:574` → `, 2)`** in the same step (plan +Step 3). `:575` (`assert.equal(SCHEMA_VERSION, 4)`, the store schema) is untouched. Folded into plan Step 3 + R1 + +Step 1's enumeration. (Swept live: `:574` is the *only* hard `BRIEF_SCHEMA_VERSION` literal in the suite; +`cli.test.ts:102` asserts the **store** `persisted.schemaVersion === SCHEMA_VERSION` (4) — unrelated, stays green.) + +**MAJOR-2 — the `--json summary` would diverge from the file frontmatter on day-2 (the "one source" invariant).** +The file's frontmatter `summary:` is built inside `renderBrief` (`brief.ts:265`), which Step 3 routes through +`briefSummary(ranking, diff)` → on day-2 it carries the ` N nye siden sist.` marker. But the CLI's `--json` +`summary` field reads a **separate** `const summary = briefSummary(ranking)` (`cli.ts:350`, comment `// SAME source +the frontmatter carries`) that Step 4 left unthreaded → no marker. `cli.test.ts:268` asserts +`fileFrontmatter.summary === json.summary` ("one source") → would **break** on day-2. **Resolution:** Step 4 +changes `cli.ts:350` to `briefSummary(ranking, diff)` (the `diff` is in scope — Step 4 computes it between the +ranking at `:339` and the render at `:340`). **Safe on day-1:** `priorDate === null` ⇒ the marker is suppressed ⇒ +byte-identical to today's string. Folded into plan Step 4 + the files-touched table. + +**MINOR (folded):** +- **M1 — `BriefDiff` is an interface (type-only export).** Anywhere it is referenced as a type (tests or `cli.ts`), + import it with **`import type { BriefDiff }`**, never a value import — under Node16 ESM + tsx a type-only export + is stripped from the emitted JS, so a value-import named binding fails to resolve at **module-load** (the same + Phase-A hazard as a missing named import). The plan's Step-4 code does **not** annotate `: BriefDiff` (it infers + from `diffSurfaced`'s return) and the SC tests pass object literals — so in practice no `BriefDiff` import is + needed; the rule is the guardrail if one is added. Folded into plan Step 2/4. +- **M2 — SC9's prior brief is a real renamed brief, not a hand-fixture (rename-real-write).** Replace the "pre-write + a `<prior-date>.md` fixture carrying a `surfaced:` line" mechanism (plan Step 4 note / Phase B / Step 7) with: + run `brief` once (writes `${today}.md` with a genuine `surfaced:` line), `mv ${today}.md → 2026-06-20.md` (a fixed + past date) **in the same `--out`**, then run `brief` again. This (a) closes the write→read loop **clock-free** (no + `today()` manipulation), and (b) **guarantees the prior `surfaced:` ids are real store ids** (they came from a + real run), so `carried` is non-trivial and `added` is *exactly* the newly-captured trend — a hand-fixture risks an + id mismatch that makes everything read as added/dropped (a weaker, possibly-vacuous test). Folded into plan Step 4 + note, Phase B, Step 7, SC9. +- **M3 — SC1 "repeated id" wording.** `diffSurfaced` uses `currentIds.filter(...)`/`priorIds.filter(...)`, which + **preserve** within-list duplicates — so "not double-counted" is wrong as within-list dedup. What the `Set` + membership actually guarantees is **cross-partition disjointness** (added/carried/dropped are mutually exclusive). + Reworded in §6 SC1 + plan Step 1/verification. (Production `surfacedIds` yields distinct ids, so within-list dups + never arise.) +- **M4 — §3 "empty `surfaced:`" self-contradiction.** The bare `renderBrief(ranking)` default-diff prose said it + yields "an empty `surfaced:` reflecting the ranking" — contradictory: `surfaced:` is always + `surfacedIds(ranking).join(",")` (**non-empty** for a non-empty ranking; blank only for an empty store), + independent of the diff; the default empty diff only drives the **`_Første brief._`** section branch. Reworded in + §3 (the `renderBrief` default-diff bullet). + +**Confirmed correct by all three — left untouched:** every line-cite (`brief.ts:23/204/265/305`, +`cli.ts:339/340/350/352`, `session-start.mjs:60-77/534-536`); the floors (`TRENDS_TESTS_FLOOR` 216 @ live `:716`, +`ASSERT_BASELINE_FLOOR` 117 @ live `:1473` → 123 = 117 + 6 unconditional 16n emitters); Section 16m is the last +trends section (16n sits between its `echo ""` and Section 18); the 6 ASCII sentinels are non-vacuous; the +`/^surfaced: *([^\n]*)/m` regex, the ISO-lex compare, the `^surfaced:` ≠ `^summary:` hook-safety, the `--json` +shape, and the same-day-determinism strict `<`. diff --git a/docs/research-engine/plan-re-r1.md b/docs/research-engine/plan-re-r1.md new file mode 100644 index 0000000..074f9f1 --- /dev/null +++ b/docs/research-engine/plan-re-r1.md @@ -0,0 +1,110 @@ +# Plan — RE-R1: item-schema (B1) + triage-scorer (B2) as tested code + +> **Brief:** `docs/research-engine/brief-re-r1.md`. **Slice:** RE-R1 (research-engine rung-2, slice 1). +> **TDD-order:** RED (item + score tests as logic-RED) → GREEN (item.ts, score.ts) → GREEN (CLI + cli tests) → wire trend-spotter prose → gate floors → behavioural → land. **Counts recounted live at land, never pinned/guessed.** +> **Light-Voyage hardened:** scope-guardian ALIGNED; brief-reviewer + plan-critic findings folded (see §Plan-critic — folded). + +## Goal + +Move the research engine's deterministic core — the canonical item envelope (B1) and the composite/band/threshold arithmetic (B2) — out of `agents/trend-spotter.md` prose into pure, tested TypeScript under `scripts/trends/`, behind a CLI seam the agent and a future orchestrator call. No store-schema change; the five judgment scores stay with the model; wiring `normalizeItem` into the store is R2. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/item.ts` | **NEW** — `TrendItem` ingress envelope + `normalizeItem` / `normalizeItems` (pure, validating; no `id`) | SC1 | +| `scripts/trends/src/score.ts` | **NEW** — `ScoreMode`, per-mode weight consts (mirror SSOT), `composite`, `band`, `triage` | SC2, SC3, SC4 | +| `scripts/trends/src/cli.ts` | **EDIT** — add `normalize` + `score` subcommands (stdin JSON in, JSON out, exit 2 on bad invocation) | SC5 | +| `scripts/trends/tests/item.test.ts` | **NEW** — normalize: required-field errors, whitespace/topic dedupe, publishedAt validate, batch partition | SC1 | +| `scripts/trends/tests/score.test.ts` | **NEW** — golden composite (both modes: all-tens=10.0 + `{10,8,6,4,2}`=7.0 + weights-sum-1.0 + pinned weights), range guard, band boundaries + pinned action strings, triage gate/rank | SC2, SC3, SC4 | +| `scripts/trends/tests/cli.test.ts` | **NEW** — subprocess: `normalize`/`score` happy path (stdin→JSON) + exit-2 bad invocation | SC5 | +| `agents/trend-spotter.md` | **EDIT** — replace L124-137 "compute composite/bands yourself" with a pointer naming `src/cli.ts score` as the deterministic-step owner; domain-general | SC7 | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` 24→recount (stays inside deps guard); NEW unconditional Section **16g** (before Section 18); `ASSERT_BASELINE_FLOOR` 84→recount; header enumeration | SC6, SC7 | +| `docs/research-engine/{brief,plan}-re-r1.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (floors, gate total). *Land bookkeeping, not slice scope; LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `scripts/trends/src/{store.ts,types.ts}` (no schema change, `SCHEMA_VERSION` stays 1) · `references/trend-scoring-modes.md` (SSOT unchanged) · `references/*` (no new ref doc) · `config/trends-sources.template.md` (source list — not wired in R1) · `agents/*` count (19) · `commands/*` (29) · `hooks/**` · `.gitignore` (trends lines already present). + +## Step 1 — (RED) failing tests for B1 + B2 + +Write `tests/item.test.ts` and `tests/score.test.ts` against not-yet-existing modules. Make them **logic-RED**, not import-RED, with a stub strategy split by assertion type: +- **arithmetic / "returns X" tests** → stub returns a *wrong constant* (so the equality assertion fails on value, not on a throw); +- **"should throw" validation tests** → stub returns a valid-looking value (so the `assert.throws` fails because nothing threw). + +`item.test.ts`: well-formed raw → canonical item (fields verbatim; topics normalized+deduped); missing/empty `source|title|url` → `{ok:false}` naming the field; whitespace collapse via the same normalization as `store.normalizeField`; `publishedAt` present-and-ISO → kept, absent → undefined, present-and-invalid → `{ok:false}`; `normalizeItems` partitions a batch into `{items, errors}`; the canonical item carries **no `id`**. + +`score.test.ts`: `composite` for both modes on (a) all-tens → exactly **10.0**, (b) the asymmetric vector `{10,8,6,4,2}` in dimension order → **7.0**; a dimension = 0 or 11 → throws; a **pinned-weights** assertion (each mode's five constants equal the SSOT values, Σ=1.0); `band` at 8.0 / 6.0 / 4.0 / 2.0 / below → correct priority + **the exact SSOT action string** (kortform + long-form); `triage` with threshold 4.0 → kept (≥4.0, sorted composite-desc, annotated composite+band) and dropped (<4.0). + +**RED proof (record in commit):** `(cd scripts/trends && npm test)` → new cases fail with assertion errors (not module-not-found). + +## Step 2 — (GREEN) implement `item.ts` + +Implement the `TrendItem` ingress envelope `{source,title,url,publishedAt?,topics[],summary?}` + `normalizeItem`/`normalizeItems` to pass Step 1's item cases. Reuse `normalizeField` (import from `./store.js`) for whitespace; topic normalize + dedupe. `publishedAt` validated with a strict ISO-date check; **carried, not persisted** (comment: the *source's* publish date, forward-compat for B4 freshness — distinct from the store's `capturedAt`). **Do not derive or carry `id`** — the store owns it via `addTrend`→`trendId`; the envelope has no id field. **Do not wire to the store** — the item→`TrendInput` bridge (`capturedAt` injection) is R2. + +## Step 3 — (GREEN) implement `score.ts` + +Encode the two weight-sets as `const` records mirroring `trend-scoring-modes.md` (header comment: the SSOT is the human source, "ordering is the signal, not a measured coefficient", + the SSOT path). Implement `composite` (validate each dimension ∈[1,10], weighted sum), `band` (the five-range map → `{priority, kortformAction, longformAction}` using the **exact SSOT action strings**), and `triage` (`kept`/`dropped` + composite-desc sort + per-item composite/band annotation). Make Step 1's score cases green (incl. the pinned-weights + pinned-action-string assertions). + +## Step 4 — (GREEN) CLI subcommands + `cli.test.ts` + +Add `normalize` and `score` to `cli.ts`'s `main` dispatch. Both **read the JSON payload from stdin** (not a flag — the existing `--json` is an *output* toggle and must keep that meaning) and **print JSON to stdout**. `normalize` → canonical items / `{ok:false}` error entries. `score` → `triage` with `--mode`/`--threshold`, prints `{kept, dropped}`. Exit **2** on a malformed invocation (unparseable stdin, missing required flag) via the existing `usage()` path; **0** otherwise. Write `tests/cli.test.ts` (subprocess: spawn `node --import tsx src/cli.ts <sub>` with a piped stdin payload) covering a happy path + an exit-2 bad-invocation for each new subcommand. + +## Step 5 — wire `trend-spotter.md` (prose pointer) + +Replace the L124-137 "score 5 dims, take the weighted composite, apply the bands yourself" instruction with: the agent supplies the five 1–10 judgment scores, then pipes them to **`scripts/trends/src/cli.ts score`** (the deterministic owner of composite + bands + threshold). The replacement prose **must contain the literal `src/cli.ts score`** (the exact string Step 6's `grep -qF` matches). Keep it domain-general — no vendor/sector tokens (Section 17). The agent still owns mode selection and the qualitative scoring. + +## Step 6 — gate: floors + new unconditional section + +In `scripts/test-runner.sh`: +- Bump `TRENDS_TESTS_FLOOR` 24 → **live recount** after Steps 1–4 (24 + new item/score/cli cases). It **stays inside** the `if [ -x scripts/trends/node_modules/.bin/tsx ]` deps guard (conditional — do not hoist it out; that would break fresh-clone safety). +- Add **Section 16g** (label it 16g; place it **after Section 17 / before Section 18**, since Section 18 anti-erosion must run last). Three **unconditional**, deps-absent-safe checks (pure `grep`, no `tsx`): (1) `score.ts` encodes both `kortform` and `long-form` weight-sets; (2) `grep -qF "src/cli.ts score" agents/trend-spotter.md`; (3) a non-vacuity self-test for those greps (house pattern, per Sections 16c–17). +- Bump `ASSERT_BASELINE_FLOOR` 84 → **live recount** (= 84 + the count of new unconditional `pass`/`fail` emitters in 16g; expected ~87 with the self-test, **recounted at land, not pinned**). Update the section-header enumeration comment. + +## Step 7 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then run (verified invocation form, not `npm run start`): +`echo '<3-item sample>' | node --import tsx src/cli.ts normalize` and +`echo '<scored sample>' | node --import tsx src/cli.ts score --mode kortform --threshold 4.0`; +confirm by hand that one ≥4.0 item is kept (correct band/action) and one <4.0 is dropped. Run full `bash scripts/test-runner.sh` → `FAIL=0`. + +## Step 8 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor, gate total). Commit order (house style: `feat … [skip-docs]` for code; plain for docs): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r1.md` (no suffix, tracked); **(2)** code commit `scripts/trends/*` + `agents/trend-spotter.md` + `scripts/test-runner.sh` with `[skip-docs]`. Check the push window (`date '+%u %H:%M'`); `origin` is the PUBLIC `open/` remote → **confirm with operator before push**. No version bump (additive; `v0.5.2` dev). + +## Verification (testable) + +| SC | Check | Command | Expected | +|---|---|---|---| +| — | RED proof | `(cd scripts/trends && npm test)` after Step 1 | new cases fail on assertion (logic-RED), not module-not-found | +| SC1 | normalize | `npm test` (item.test.ts) | required-field `{ok:false}` + dedupe + publishedAt + batch-partition cases green; no `id` on item | +| SC2 | composite | `npm test` (score.test.ts) | all-tens=10.0 both modes; `{10,8,6,4,2}`=7.0 both modes; pinned weights; range guard throws | +| SC3 | bands | `npm test` (score.test.ts) | 8.0/6.0/4.0/2.0 boundaries → correct priority + exact SSOT action string | +| SC4 | triage | `npm test` (score.test.ts) | kept ≥ threshold ranked desc + annotated; dropped below | +| SC5 | CLI | `npm test` (cli.test.ts) + manual stdin run | JSON out; exit 2 malformed invocation / 0 well-formed | +| SC6 | gate | `bash scripts/test-runner.sh` | `FAIL=0`; trends ≥ new floor; ASSERT floor = live recount | +| SC7 | wiring + de-niche | Section 16g greps + Section 17 | `src/cli.ts score` present in trend-spotter.md; no vendor/sector tokens; counts 19/29/27 | + +## Risks + +- **R1 — SSOT/code drift.** Weights, band thresholds, AND the ten action strings now live in both `trend-scoring-modes.md` and `score.ts`. *Mitigated:* `score.test.ts` pins all three (weights + Σ=1.0 + thresholds + exact action strings) against the SSOT values, with an SSOT-path comment naming the markdown as the human source. (A markdown-table-parsing cross-check is deferred — out of scope.) +- **R2 — `publishedAt` carried but not persisted; `capturedAt` not on the item.** Could read as a dangling field. *Mitigated:* explicit comment (source publish-date, forward-compat for B4) + the brief's non-goal; the store bridge (capturedAt injection) is explicitly R2; the scorer does not depend on either. +- **R3 — editing `trend-spotter.md` could trip the de-niche guard.** *Mitigated:* Section 17 runs in the gate; replacement prose is pillar-driven and vendor/sector-free. +- **R4 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* the three new checks are pure `grep`/self-test on tracked source files (no `tsx`), so they are unconditional and safe; the arithmetic proof stays inside the deps-gated suite; `TRENDS_TESTS_FLOOR` stays inside the deps guard. +- **R5 — default threshold (4.0) may not match operator intent.** *Mitigated:* single config param; brief open question #1 settles it at the go-gate. +- **R6 — CLI `--json` semantic collision.** *Mitigated:* new subcommands take payload via **stdin**, leaving `--json` as the existing output toggle; cli.test.ts encodes the stdin contract. + +## Plan-critic — folded + +plan-critic returned **REVISE** (1 blocker, 6 majors, 4 minors); brief-reviewer **PROCEED_WITH_RISKS**; scope-guardian **ALIGNED**. Resolution, each verified against live code: + +- **[BLOCKER] `TrendInput` shape mismatch** (`store.ts:26-33` requires `capturedAt`, no `publishedAt`). ✅ Step 2 no longer claims a direct map; the item→store bridge is deferred to R2; envelope carries no `id`. +- **[MAJOR] gate-section placement / Section-18-last** ✅ Step 6 pins **16g, before Section 18**. +- **[MAJOR] `ASSERT_BASELINE_FLOOR` hard-pinned 86** ✅ Step 6 now **live recount** (~+3 with the house self-test), not pinned. +- **[MAJOR] `TRENDS_TESTS_FLOOR` could be hoisted out of the deps guard** ✅ Step 6 states it stays conditional. +- **[MAJOR] CLI `--json` input/output overload** (`cli.ts:79` output toggle) ✅ Step 4 reads payload from **stdin**. +- **[MAJOR] band action-string drift unguarded** ✅ Step 1/3 + R1 pin the thresholds + action strings. +- **[MAJOR] Step 5/6 grep literal unpinned** ✅ pinned to `src/cli.ts score` in both steps. +- **[MINOR] RED-stub strategy** ✅ Step 1 splits stub by assertion type. **[MINOR] `npm run start` unverified** ✅ Step 7 uses `node --import tsx src/cli.ts`. **[MINOR] commit grouping** ✅ Step 8 fixes order. **[MINOR] empty folded sections** ✅ filled. +- **[scope-guardian MINOR] STATE.md + `config/trends-sources.template.md`** ✅ STATE.md added as a land-bookkeeping row; template added to the scope fence. + +**scope-guardian — ALIGNED:** every SC1–SC7 traces to a step; zero scope creep; every NON-goal respected. diff --git a/docs/research-engine/plan-re-r2a.md b/docs/research-engine/plan-re-r2a.md new file mode 100644 index 0000000..4143288 --- /dev/null +++ b/docs/research-engine/plan-re-r2a.md @@ -0,0 +1,192 @@ +# Plan — RE-R2a: capture bridge (item→store) + publishedAt persistence + +> **Brief:** `docs/research-engine/brief-re-r2a.md`. **Slice:** RE-R2a (research-engine rung-2, slice 2 — R2 data layer). +> **TDD-order:** RED (migration + bridge + capture tests as logic-RED) → GREEN (`types.ts`/`store.ts` migration + persist) → +> GREEN (`item.ts` bridge) → GREEN (`cli.ts capture` + tests) → wire `trend-spotter.md` → gate floors → behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Light-Voyage hardened:** scope-guardian / brief-reviewer / plan-critic findings folded (see §Plan-critic — folded). + +## Goal + +Build the item→store bridge RE-R1 deferred: a pure `itemToInput` mapping the validated `TrendItem` envelope to a +store input (injecting `capturedAt`, carrying `publishedAt`), a `publishedAt` schema bump (v1→v2) with a lossless +migrate-on-load, and a `capture` CLI that closes the poll→normalize→store loop. No scoring change; no hook touch; the +dated brief + surfacing are R2b. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/types.ts` | **EDIT** — `SCHEMA_VERSION 1→2`; `publishedAt?: string` on `TrendRecord` (after `capturedAt`, with the capturedAt-distinction comment) | SC3 | +| `scripts/trends/src/store.ts` | **EDIT** — `TrendInput` +`publishedAt?`; `addTrend` persists it (conditional-spread, first-sight kept on merge); `loadStore` forward migrate-on-load (`max(onDisk, SCHEMA_VERSION)`) | SC2, SC3 | +| `scripts/trends/src/item.ts` | **EDIT** — pure `itemToInput(item, capturedAt): TrendInput` (injects capturedAt; no id; no re-validate) | SC1 | +| `scripts/trends/src/cli.ts` | **EDIT** — `capture` subcommand only (stdin→normalize→bridge→addTrend→saveStore→summary; exit 2 bad invocation; `--json`). *(`add --published-at` deferred at light-Voyage — see brief §4.)* | SC4 | +| `scripts/trends/tests/store.test.ts` | **EDIT** — migration cases (v1→v2 lossless, idempotent, round-trip) + `publishedAt` persist/first-sight-merge cases | SC2, SC3 | +| `scripts/trends/tests/item.test.ts` | **EDIT** — `itemToInput` mapping cases (capturedAt inject, carry-through, no id, absent publishedAt omitted) | SC1 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — `capture` happy path (stdin→store), duplicate/merge, content-invalid in errors[], exit-2 bad invocation, `--json` summary | SC4 | +| `scripts/trends/README.md` | **EDIT** — add `publishedAt?` to the record-shape block + a `capture` example (honest schema/CLI doc) | — | +| `agents/trend-spotter.md` | **EDIT (Open Q#1, default)** — Step 4.5 `add`→`capture`; carries literal `src/cli.ts capture`; domain-general | SC6 | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` 62→recount (stays inside deps guard); NEW unconditional **Section 16h** (before Section 18); `ASSERT_BASELINE_FLOOR` 87→recount; anti-erosion header enumeration | SC5, SC6 | +| `docs/research-engine/{brief,plan}-re-r2a.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor, gate total). *Land bookkeeping, not slice scope; LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `scripts/trends/src/score.ts` (no scoring change) · `references/trend-scoring-modes.md` + `references/*` (SSOT unchanged, no new ref) · `hooks/**` (no surfacing — R2b) · `config/trends-sources.template.md` · no new `.ts` source file (bridge in `item.ts`) · `agents/*` count (19) · `commands/*` (29) · `.gitignore` (trends lines present). + +## Step 1 — (RED) failing tests for migration + bridge + capture + +Extend the three existing test files against the not-yet-changed code. Logic-RED (not import-RED), stub-by-assertion-type. +**Critical RED-vs-GREEN-guard split** (plan-critic blocker): against old code (`SCHEMA_VERSION=1`, `loadStore` returns +`parsed.schemaVersion ?? SCHEMA_VERSION`), only some assertions actually fail: +- **Genuinely RED** (old code fails): v1-fixture load → `schemaVersion===2` (old returns 1); v1 round-trip + `loadStore`→`saveStore` writes `schemaVersion:2` (old writes 1); `addTrend({…, publishedAt})` persists it (old + drops it); `itemToInput` carries/injects correctly (add a thin **throwing** stub for the not-yet-existing export so + the case fails on assertion, not on `undefined is not a function`). +- **GREEN-only regression guards** (pass against old code — NOT labelled RED): a v2 fixture load → `schemaVersion===2` + (old already returns 2 via `?? `); **missing** `schemaVersion` → 2 (old `??` already yields current); **non-numeric** + `schemaVersion` → 2; empty/absent store → `{schemaVersion:2,trends:[]}`. These are written in Step 1 but documented + as regression guards, so the RED proof is not falsely claimed for them. + +`store.test.ts`: the RED migration cases above + the regression-guard cases; `addTrend` without `publishedAt` omits the +key; re-capture (same title+url) leaves existing `publishedAt` unchanged and only unions topics; **absent→present** +re-capture (first sighting lacked `publishedAt`, re-capture carries one) does **NOT** add it (no back-fill, Open Q#2), +`merged` reflects topic change alone. +`item.test.ts`: `itemToInput(item,"2026-06-24")` → `capturedAt` injected (`==="2026-06-24"`), all fields carried verbatim, +**no `id`**, item without `publishedAt` → input without the key; **field-confusion guard:** an item whose `publishedAt` +differs from the injected date → `result.capturedAt !== result.publishedAt`. +`cli.test.ts`: `capture` happy (one valid item piped → store gains it, `added:1`); a batch with one invalid item → +`errors[]` carries it, valid one added, exit 0; the summary tally **sums to the input size**; the captured record's +`capturedAt` matches `/^\d{4}-\d{2}-\d{2}$/` and `!==` the item's `publishedAt`; empty/unparseable stdin → exit 2; +`--json` emits the summary object. + +**RED proof (record in commit):** `(cd scripts/trends && npm test)` → the **genuinely-RED** cases fail on assertion +(logic-RED), not module-not-found; the regression-guard cases may pass pre-change (documented, not claimed RED). + +## Step 2 — (GREEN) schema migration: `types.ts` + `store.ts` + +`types.ts`: `SCHEMA_VERSION = 2`; add `publishedAt?: string` to `TrendRecord` after `capturedAt` with the +distinction comment (source publish-date; forward-compat for B4; distinct from capturedAt). +`store.ts`: `TrendInput` gains `publishedAt?`; `addTrend` adds `...(input.publishedAt !== undefined ? { publishedAt: input.publishedAt } : {})` to the new-record literal (after `capturedAt`); the merge branch is **unchanged** (topics union only — first-sight `publishedAt` kept, no back-fill). `loadStore` returns `schemaVersion: Math.max(onDisk, SCHEMA_VERSION)` where `onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION` (forward-only stamp handling string/`NaN`/absent → current; never crashes); **the existing `Array.isArray(parsed.trends) ? … : []` coercion (`store.ts:79`) is preserved verbatim — a corrupt `trends` field stays out of the losslessness claim**. Make Step 1's RED migration + persist cases (and the regression guards) green. + +## Step 3 — (GREEN) bridge: `itemToInput` in `item.ts` + +Add `import type { TrendInput } from "./store.js";` (item.ts already imports the `normalizeField` *value* from there; +this adds the *type* — dependency direction `item.ts → store.ts` stays acyclic). Then **replace the Step-1 throwing +stub** with `export function itemToInput(item: TrendItem, capturedAt: string): TrendInput` returning +`{ source, title, url, topics: [...item.topics], capturedAt, ...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}), ...(item.summary !== undefined ? { summary: item.summary } : {}) }`. No `id`; no re-validation (the envelope is already validated). Confirm no throwing stub survives into GREEN. Make Step 1's bridge cases green. + +## Step 4 — (GREEN) CLI `capture` + `cli.test.ts` + +Add `capture` to `cli.ts`'s `main` dispatch: read stdin via the existing `readStdinJson()` (its empty/unparseable path +already does `usage()`→exit 2); `Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload)`; for each +valid item, `itemToInput(item, today())` → `addTrend(store, res.store…)`; `saveStore` once. **Tally derived from +`AddResult {added, merged}` (no `duplicates` field, `store.ts:35-41`):** `added += res.added ? 1 : 0`; +`merged += (!res.added && res.merged) ? 1 : 0`; `duplicates += (!res.added && !res.merged) ? 1 : 0`. Human summary by +default; `--json` prints `{added, duplicates, merged, errors}`. **`add --published-at` is NOT added (deferred).** Write +the `cli.test.ts` cases (subprocess: `node --import tsx src/cli.ts capture` with piped stdin + a `--store` temp path), +including an explicit `added + merged + duplicates + errors.length === payload.length` assertion and a +`capturedAt` shape (`/^\d{4}-\d{2}-\d{2}$/`) + `!== publishedAt` check. **`capturedAt` *exact-value* assertions live in +`item.test.ts` (injected fixed date), never in `cli.test.ts` (which reads the wall clock — would be flaky).** + +## Step 5 — wire `trend-spotter.md` (Open Q#1, default) + README + +Replace Step 4.5's N× `add` block (`trend-spotter.md:282-301`) with: build a raw-item JSON batch (the same items +already scored), pipe it to **`scripts/trends/src/cli.ts capture`** in one call — it normalizes + folds + persists +`publishedAt`. Replacement prose **must contain the literal `src/cli.ts capture`** (Section 16h `grep -qF`). Keep the +"skip silently if no deps" escape hatch + domain-general phrasing (no vendor/sector tokens — Section 17). Update +`scripts/trends/README.md`: add `publishedAt?` to the record-shape block, add a `capture` CLI example, **and correct +the `add` framing** — `README.md:47-64` currently calls `add` "the capture path"; after the re-point `add` is the +**manual single-trend** path and `capture` is the **normalizing batch** path. Fix the framing, don't just append (else +the README contradicts the new agent wiring). + +## Step 6 — gate: floors + new unconditional Section 16h + +In `scripts/test-runner.sh`: +- Bump `TRENDS_TESTS_FLOOR` 62 → **live recount** after Steps 1–4. Stays **inside** the `if [ -x …/tsx ]` deps guard + (conditional — do not hoist). **Also update the inline breakdown comment** at `test-runner.sh:695` + (`# store 24 + RE-R1: item 18 + score 16 + cli 4`) — extend it with the R2a additions (e.g. + `+ RE-R2a: store +N + item +M + cli +K`), so the comment doesn't drift from the number (the exact erosion the + anti-erosion section guards against). +- Add **Section 16h** ("Trends Capture Wiring"), mirroring Section 16g's three-check shape, **after Section 17 / + before Section 18**. Three **unconditional**, deps-absent-safe checks (pure `grep`/self-test, no `tsx`): + (1) a non-vacuity self-test (accept a probe carrying `src/cli.ts capture`, reject one without it); + (2) `grep -qF 'command === "capture"' scripts/trends/src/cli.ts` — deps-absent proof the handler exists (`grep -qF` + exact, mirroring 16g lines 1058/1064 — not `grep -E`); + (3) `grep -qF "src/cli.ts capture" agents/trend-spotter.md`. +- Bump `ASSERT_BASELINE_FLOOR` 87 → **live recount** (= 87 + the new unconditional emitters in 16h; expected ~90, + **recounted at land, not pinned**). Update the Section-18 anti-erosion header enumeration comment. + +## Step 7 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then: +`echo '[{"source":"tavily","title":"X","url":"https://e/x","topics":["a"],"publishedAt":"2026-06-20"},{"title":"bad"}]' | node --import tsx src/cli.ts capture --store /tmp/r2a-trends.json --json` → +confirm `added:1`, the invalid entry in `errors[]`, and `node … list --store /tmp/r2a-trends.json --json` shows the +persisted `publishedAt`. Author a v1 fixture (`{"schemaVersion":1,"trends":[{…no publishedAt}]}`), run `status --json` +→ confirm count intact (migration lossless). Run full `bash scripts/test-runner.sh` → `FAIL=0`. + +## Step 8 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor, gate total). Commit +order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r2a.md` (no suffix, tracked); **(2)** +code commit `scripts/trends/*` + `agents/trend-spotter.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` +with `[skip-docs]`. Push freely (window restriction lifted 2026-06-24; gitleaks runs at commit; `origin` = PUBLIC +`open/` — STATE/`*.local.*` never pushed). No version bump (additive; `v0.5.2` dev). + +## Verification (testable) + +| SC | Check | Command | Expected | +|---|---|---|---| +| — | RED proof | `(cd scripts/trends && npm test)` after Step 1 | new cases fail on assertion (logic-RED), not module-not-found | +| SC1 | bridge | `npm test` (item.test.ts) | capturedAt injected (`==="2026-06-24"`); fields carried; no `id`; absent publishedAt omitted; `capturedAt !== publishedAt` (field-confusion guard) | +| SC2 | persist | `npm test` (store.test.ts) | publishedAt persisted when present / omitted when absent; first-sight kept on merge; absent→present re-capture does NOT back-fill | +| SC3 | migrate | `npm test` (store.test.ts) | v1→v2 lossless (records intact, no publishedAt invented); missing/non-numeric schemaVersion → 2; empty store → v2/[]; idempotent; round-trip writes v2 | +| SC4 | capture | `npm test` (cli.test.ts) + manual | normalize+fold; errors[] for invalid; tally sums to input size; capturedAt today-shaped & ≠ publishedAt; exit 2 bad stdin / 0 well-formed; `--json` summary | +| SC5 | gate | `bash scripts/test-runner.sh` | `FAIL=0`; trends ≥ new floor; Section 16h green; ASSERT floor = live recount | +| SC6 | wiring + de-niche | Section 16h greps + Section 17 | `src/cli.ts capture` in trend-spotter.md; no vendor/sector tokens; counts 19/29/27 | + +## Risks + +- **R1 — migration eats or rewrites records.** *Mitigated:* migrate-on-load is a version *stamp* only (`max(onDisk, + current)`); records pass through untouched; SC3 pins lossless + idempotent + round-trip on a real v1 fixture. +- **R2 — `publishedAt` first-sight vs back-fill ambiguity.** *Mitigated:* brief Open Q#2 settles it at first-sight + (no back-fill); SC2 pins "existing publishedAt unchanged on re-capture"; back-fill explicitly deferred. +- **R3 — `capture` exit-code semantics drift** (content-invalid item leaking into a non-zero exit). *Mitigated:* SC4 + pins exit 2 = malformed invocation only; content-invalid → `errors[]` at exit 0; mirrors `normalize`/`score`. +- **R4 — editing `trend-spotter.md` trips the de-niche guard.** *Mitigated:* Section 17 runs in the gate; replacement + prose is pillar/source-driven and vendor/sector-free; only the store-fold mechanism changes (`add`→`capture`). +- **R5 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16h is pure `grep`/self-test on + tracked source (no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps guard. +- **R6 — future schema downgrade (on-disk > current) silently drops unknown fields on save.** *Mitigated/deferred:* + cannot happen pre-v3; `max()` already refuses to downgrade the stamp; field-preservation-on-save is an R-future + concern, noted not handled (no impossible-scenario code). + +## Plan-critic — folded + +plan-critic returned **REVISE** (1 blocker, 5 majors, 5 minors); brief-reviewer **PROCEED_WITH_RISKS**; +scope-guardian **ALIGNED**. Resolution, each verified against live code: + +- **[BLOCKER] "v2 load idempotent" is not RED** (`store.ts:78` `?? ` already returns 2 for a v2 fixture under old + `SCHEMA_VERSION=1`). ✅ Step 1 now splits **genuinely-RED** (v1→2 load + round-trip + addTrend-persist + itemToInput) + from **GREEN-only regression guards** (v2-idempotence, missing/non-numeric/empty); the RED proof is claimed only for + the former. +- **[MAJOR] capture tally mis-maps onto `AddResult`** (no `duplicates` field, `store.ts:35-41`). ✅ Step 4 pins the + exact derivation (`added`/`merged`/`duplicates` from `res.added`+`res.merged`) + a `sum === payload.length` test. +- **[MAJOR] `TRENDS_TESTS_FLOOR` breakdown comment (`:695`) left stale.** ✅ Step 6 now extends the inline breakdown + comment alongside the number. +- **[MAJOR] `import isValidIso` impossible** (`item.ts:51` private). ✅ `add --published-at` **deferred entirely** + (brief §4) — no export-vs-inline decision, `item.ts` edit stays `itemToInput`-only. +- **[MAJOR] README `add`-as-capture framing would contradict the re-point.** ✅ Step 5 corrects the framing + (`add` = manual single-trend; `capture` = normalizing batch), not just appends. +- **[MAJOR] empty "folded" placeholders.** ✅ this section + brief §9 filled. +- **[MINOR] stub cleanup unstated** ✅ Step 3 states the throwing stub is **replaced**, none survives into GREEN. + **[MINOR] `today()` capturedAt untestable in cli.test** ✅ Step 4 routes exact-value assertions to `item.test.ts`, + cli.test asserts shape + `≠ publishedAt`. **[MINOR] 16h grep flag** ✅ Step 6 specifies `grep -qF`. + **[MINOR] non-numeric schemaVersion untested** ✅ added to Step 1 regression guards + SC3. **[MINOR] README under + `[skip-docs]`** ✅ kept in the code commit (it documents the shipped code, like R1's `trend-spotter.md`); noted. +- **[brief-reviewer MAJOR] capturedAt injection unverified at the new ingress** ✅ SC1 + SC4 field-confusion guards. + **[brief-reviewer MAJOR] "lossless" over-claim** ✅ §5/§3 scope it to well-formed stores; malformed-`trends` + coercion unchanged + out of scope. **[brief-reviewer MINOR] absent→present back-fill** ✅ pinned by an SC2 test. +- **[plan-critic headless-readiness 60]** N/A — R2a is executed **in-session, operator-driven** (driftsmodell), not + as a headless autonomous run, so per-step revert/halt clauses aren't needed (R1's plan had none either). + +**scope-guardian — ALIGNED:** every SC1–SC6 traces to a step; zero creep (README is in-change documentation); every +§4 non-goal held (no hook touch, no brief artifact, no relevance/saturation/status, no back-fill, no scoring change). diff --git a/docs/research-engine/plan-re-r2b.md b/docs/research-engine/plan-re-r2b.md new file mode 100644 index 0000000..948d673 --- /dev/null +++ b/docs/research-engine/plan-re-r2b.md @@ -0,0 +1,282 @@ +# Plan — RE-R2b: dated morning-brief artifact + session-start surfacing + +> **Brief:** `docs/research-engine/brief-re-r2b.md`. **Slice:** RE-R2b (research-engine rung-2 — R2 visible layer). +> **TDD-order:** RED (`brief.ts` rank/render/path + `cli.ts brief` tests as logic-RED) → GREEN (`brief.ts` pure +> functions) → GREEN (`cli.ts brief` subcommand) → GREEN (`session-start.mjs` surfacing + hook test) → wire +> `trend-spotter.md` + README → gate floors + Section 16i → behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Light-Voyage hardened:** scope-guardian / brief-reviewer / plan-critic findings folded (see §Plan-critic — folded). + +## Goal + +Make the accumulated, publish-dated store **visible**: a pure `rankForBrief` (pillar-overlap → recency over the +store, with a `publishedAt ?? capturedAt` freshness window) + a pure `renderBrief` (a dated Markdown artifact +with a hook-surfaceable `summary` frontmatter), a `brief` CLI that writes the dated file, and a zero-tsx +session-start surfacing of the latest brief. No store schema change; no scoring change; no scheduler — the +autonomous trigger + freshness-as-seen-log + relevance scoring stay R3. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/brief.ts` | **NEW** — pure `rankForBrief` + `renderBrief` + `briefSummary` (single summary source) + `defaultBriefDir` (derived from `defaultStorePath`) + `BriefRanking`/`BriefEntry` types + `BRIEF_SCHEMA_VERSION` | SC1, SC2, SC3 | +| `scripts/trends/src/cli.ts` | **EDIT** — `brief` subcommand (flag-driven: store→rank→render→write `<outDir>/<today>.md`; `--pillars`/`--fresh-days`/`--out`/`--store`/`--json`; exit 2 bad invocation) + `usage()`/header doc line + import from `brief.js` | SC4 | +| `scripts/trends/tests/brief.test.ts` | **NEW** — `rankForBrief` grouping/freshness/sort + `renderBrief` frontmatter/`summary`/empty-case/determinism + `defaultBriefDir` | SC1, SC2, SC3 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — `brief` happy path (writes dated file, prints path), `--json` summary, `--fresh-days` bad → exit 2, empty `--pillars` → exit 0 no-match brief, `--out` override | SC4 | +| `hooks/scripts/session-start.mjs` | **EDIT** — module-private `latestMorningBrief(briefDir)` (zero-tsx readdir+read+`extractYaml`) + an unconditional `## Morning Brief` block after the brain nudge (`:500-504`), brief dir = `join(getDataRoot('trends'),'morning-brief')` | SC5 | +| `hooks/scripts/__tests__/session-start-morning-brief.test.mjs` | **NEW** — subprocess + `LINKEDIN_STUDIO_DATA` fixture (mirrors `session-start-trends-staleness.test.mjs`): brief present → block surfaces; absent → no block/no crash; zero-tsx | SC5 | +| `agents/trend-spotter.md` | **EDIT (Open Q#1, default)** — after Step 4.5 `capture`, run `brief --pillars <scanned pillars>`; carries literal `src/cli.ts brief`; domain-general | SC6 | +| `scripts/trends/README.md` | **EDIT** — document `brief` subcommand + `trends/morning-brief/YYYY-MM-DD.md` artifact + frontmatter shape | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` 79→recount (inside deps guard) + inline breakdown comment (`:697`); NEW unconditional **Section 16i** (after 16h / before 18); `ASSERT_BASELINE_FLOOR` 90→recount; header-enumeration comment (`:33-46`) | SC6 | +| `docs/research-engine/{brief,plan}-re-r2b.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor, hook-suite, gate total). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `scripts/trends/src/{types,store,item,score}.ts` (no schema change, no scoring +change, `queryByTopic` NOT refactored) · `references/*` (SSOT unchanged, no new ref) · `config/*` · no new +`.mjs` under `hooks/scripts/` (surfacing edits existing `session-start.mjs`) · `agents/*` count (19) · +`commands/*` (29) · `references/*` (27) · `.gitignore` (trends lines present). + +## Step 1 — (RED) failing tests for brief ranking, rendering, and the `brief` CLI + +Author `brief.test.ts` (new) + extend `cli.test.ts`. To make the brief cases fail **on assertion** (not on +module-absent — the R2a discipline, `plan-re-r2a.md:38-47`), Step 1 creates `brief.ts` with **wrong-but-non- +throwing stubs**: `rankForBrief` → `{ today, freshDays: opts?.freshDays ?? 7, totals:{trends:0,matched:0, +fresh:0}, topMatches:[], singleMatches:[], olderMatched:[] }`; `renderBrief` → `""`; `briefSummary` → `""`; +`defaultBriefDir` → `""`. Cases then fail on value assertions (empty buckets ≠ expected, `""` lacks +frontmatter), not `module-not-found`. The stubs are **replaced** in Step 2 — none survives into GREEN. + +`brief.test.ts`: +- **rank/group (SC1):** a fixture store with trends at known `overlap` (0/1/2+) × freshness → `topMatches` only + `overlap≥2 & fresh`, `singleMatches` only `overlap===1 & fresh`, `olderMatched` only `overlap≥1 & stale`, + `overlap===0` absent everywhere; within-group order `overlap desc → effectiveDate desc → title asc → url asc`; + `matchedPillars` = actual matched names (case-insensitive match, pillar original case kept); `totals` correct. + **Total-order fixture:** two records with the **same title + effectiveDate + overlap but different url** → + assert a fixed order by `url asc` (proves order is independent of store insertion / V8 sort stability). +- **freshness (SC2):** `effectiveDate = publishedAt ?? capturedAt`; fresh-by-publishedAt-despite-old-capturedAt + and fresh-by-capturedAt-fallback-when-publishedAt-absent; boundary `ageDays === freshDays` is fresh; + `freshDays: 14` re-buckets a 10-day item from older→fresh. +- **render + summary (SC3):** `renderBrief` output starts with frontmatter carrying `date` / a column-0 + single-line `summary` (assert **no `"` and no `\n`** in the summary line) / `store: { trends, matched, fresh }` + / `schemaVersion: 1`; the frontmatter `summary` **equals `briefSummary(ranking)`** (one source); body has the + three section headings + the pinned entry-line shape; the empty ranking → a valid brief whose `summary` is + `briefSummary(emptyRanking)` = the "no fresh" line; **determinism:** two `renderBrief` calls on the same + ranking are byte-identical. +- **path:** `defaultBriefDir()` ends with `trends/morning-brief` and honors `LINKEDIN_STUDIO_DATA` (set the env + in-test, assert prefix == that root; restore) — proving it rides on `defaultStorePath`'s root. + +`cli.test.ts` (subprocess `node --import tsx src/cli.ts brief` with a `--store` temp + `--out` temp): +- happy: `--pillars a,b --store <tmp-with-matches> --out <tmp>` writes `<out>/<today>.md` (file exists; + today-shaped name) and prints the path; `--json` → object with `path/date/totals/summary`, `summary` equal to + what the file's frontmatter carries. +- `--fresh-days xyz` → exit 2; empty `--pillars` (omit the flag) → exit 0 + a file written (no-match brief); + **bare `--out` (no value)** → does NOT write to `./true/` (falls back to `defaultBriefDir()` — the + `flags.out !== "true"` guard). + +**RED proof (record in commit):** `(cd scripts/trends && npm test)` → `brief.test.ts` cases fail on **assertion** +(wrong-value stubs), and `cli.test.ts` brief cases fail **logic-RED** against the existing dispatch (an unknown +`brief` command hits `usage()` → exit 2, no file written) — neither is module-not-found. + +## Step 2 — (GREEN) pure brief layer: `brief.ts` + +Create `scripts/trends/src/brief.ts`: +- `import type { TrendStore, TrendRecord } from "./types.js";` +- Types: `BriefEntry { trend: TrendRecord; overlap: number; matchedPillars: string[]; effectiveDate: string; + ageDays: number }`; `BriefRanking { today: string; freshDays: number; totals: { trends: number; matched: + number; fresh: number }; topMatches: BriefEntry[]; singleMatches: BriefEntry[]; olderMatched: BriefEntry[] }`. +- `export const BRIEF_SCHEMA_VERSION = 1;` +- `import { defaultStorePath } from "./store.js";` + `import { join, dirname } from "node:path";` (NO `homedir` + — the root comes via `defaultStorePath`, not re-resolved). +- `rankForBrief(store, pillars, today, opts)`: `freshDays = opts?.freshDays ?? 7`; lowercase the pillar set + once; for each trend compute `overlap`/`matchedPillars` (case-insensitive membership, original-case pillar + preserved — same idiom as `store.ts:151-152`, recomputed here, `queryByTopic` untouched), `effectiveDate = + trend.publishedAt ?? trend.capturedAt`, `ageDays = Math.floor((Date.parse(today) - + Date.parse(effectiveDate)) / 86400000)` (a **local** day-delta — NOT imported from `cli.ts`'s `daysBetween` + `cli.ts:107-109`; importing it would invert the dependency direction, as `cli.ts` imports `brief.ts`). + Bucket; sort each bucket `overlap desc → effectiveDate desc → title asc → url asc` (total order); `totals`. +- `briefSummary(ranking)`: the single summary source — fresh>0 ? `<fresh> ferske tema-signaler matcher + pillarene dine. Topp: «<top title>» (<top pillar> · <age>d).` : `Ingen ferske tema-signaler på pillarene dine + (av <trends> i lager).` One line, no `"`, no `\n`. +- `renderBrief(ranking)`: build the YAML frontmatter with `summary: ${briefSummary(ranking)}` (the **shared** + source, never re-derived), `date`/`store`/`ranking`/`schemaVersion`, then the three sections with the pinned + entry-line shape (titles in «»). Column-0 `summary`. +- `defaultBriefDir()`: `join(dirname(defaultStorePath()), "morning-brief")` — `defaultStorePath()` = + `<root>/trends/trends.json`, so `dirname` = `<root>/trends`, + `morning-brief` = `<root>/trends/morning-brief`. + **One** root resolution (reuses `defaultStorePath`); no duplicate `LINKEDIN_STUDIO_DATA ?? join(homedir()…)`. +- **Replace the Step-1 wrong-value stubs.** Make Step 1's `brief.test.ts` cases green. + +## Step 3 — (GREEN) CLI `brief` + `cli.test.ts` + +In `cli.ts`: add `import { rankForBrief, renderBrief, briefSummary, defaultBriefDir } from "./brief.js";`, add +`mkdirSync`/`writeFileSync` to the `node:fs` import (currently only `readFileSync` `cli.ts:30`), and add +`import { join } from "node:path";`. Add the `brief` branch to `main`'s dispatch (after `capture`): `const +pillars = splitTopics(flags.pillars)`; parse `--fresh-days` (default 7; `Number.isNaN` → `usage("--fresh-days +must be a number")`); `const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir()` (the +`!== "true"` guard is required — `parseFlags` `cli.ts:52-53` yields `"true"` for a bare `--out`); `const ranking += rankForBrief(loadStore(storePath), pillars, today(), { freshDays })`; `const md = renderBrief(ranking)`; +`const path = join(outDir, today() + ".md")`; `mkdirSync(outDir, { recursive: true }); writeFileSync(path, md, +"utf8")`; human → `Wrote brief: <path> (M matched, K fresh)`; `--json` → `{ path, date: ranking.today, totals: +ranking.totals, summary: briefSummary(ranking) }` — `summary` is `briefSummary(ranking)`, the **same** source +`renderBrief` puts in the frontmatter (no re-derivation). Add the `brief …` line to `usage()` (`cli.ts:71-84`) +and the header doc-comment (`cli.ts:1-28`). Make Step 1's `cli.test.ts` brief cases green. **`today()` +exact-value assertions are NOT in `cli.test.ts`** (it reads the wall clock) — the date-shape +(`/^\d{4}-\d{2}-\d{2}$/`) is asserted on the written filename; exact-date logic is covered in `brief.test.ts` +via injected `today`. + +## Step 4 — (GREEN) session-start surfacing + hook test + +`session-start.mjs`: add `latestMorningBrief(briefDir)` near `trendsNewestCapture` (`:38`): `existsSync` guard → +`readdirSync(briefDir).filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse()[0]` → read it → +`{ date: extractYaml(c,'date'), summary: extractYaml(c,'summary'), file: join(briefDir, name) }`; any +throw/absence → `null`. `extractYaml` (`:19-23`) captures `[^"\n]*` + `.trim()`, so `date`/`summary` are +**newline-free** → the surfaced block needs **no** `.replace(/\n/g,'\\n')` (contrast the multi-line state +sections `:320`); only the static `\\n` literals are used. Inject **after the brain-missing nudge +(`:500-504`)**, unconditional: +``` +const latestBrief = latestMorningBrief(join(getDataRoot('trends'), 'morning-brief')); +if (latestBrief && latestBrief.summary) { + context += `\\n## Morning Brief (${latestBrief.date})\\n${latestBrief.summary}\\n→ Full brief: ${latestBrief.file}\\n`; +} +``` +(`getDataRoot` `:11` + `join` `:6` are already imported; `readdirSync` already imported `:5`.) **No tsx.** Then +`session-start-morning-brief.test.mjs` (new): **read `session-start-trends-staleness.test.mjs` first** and copy +its exact structure (the env-var name it sets, how it spawns `session-start.mjs`, how it parses the JSON +`hookSpecificOutput.additionalContext`, how it writes the fixture under the temp root). Cases: (a) with a +fixture `trends/morning-brief/<date>.md` under `env.LINKEDIN_STUDIO_DATA` → `additionalContext` includes `## +Morning Brief`, the `summary`, the file path, and **no raw `\n` inside that block** (the `\\n` idiom held); +(b) no brief dir → no `Morning Brief` block + `continue: true` (no crash); (c) **path cross-check** — write the +fixture at the path `defaultBriefDir()` resolves to under that same `LINKEDIN_STUDIO_DATA` and confirm the hook +finds it (CLI-write path == hook-read path). The temp root has **no** `node_modules/tsx` (zero-dep proof). +**This test runs under `node --test hooks/scripts/__tests__/`, the separate hook-suite gate — NOT +`test-runner.sh`.** + +## Step 5 — wire `trend-spotter.md` (Open Q#1, default) + README + +In `agents/trend-spotter.md`, after the Step 4.5 `capture` block, add a step: build the pillar list the agent +already scans and run **`node --import tsx scripts/trends/src/cli.ts brief --pillars <pillars>`** to write +today's dated brief. Replacement prose **must contain the literal `src/cli.ts brief`** (Section 16i `grep -qF`). +Keep the "skip silently if no deps" escape hatch + domain-general phrasing (no vendor/sector tokens — Section +17). Update `scripts/trends/README.md`: a `brief` CLI example + the `trends/morning-brief/YYYY-MM-DD.md` +artifact + its frontmatter (`date`/`summary`/`store`/`schemaVersion`), framed as "the dated, surfaced read over +the store" (distinct from `query`/`list` interactive dumps). + +## Step 6 — gate: floors + new unconditional Section 16i + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (`:697`, currently 79) to the **`tests N` line** reported by `(cd scripts/trends && + npm test)` after Steps 1–3 — recounted live, NOT an additive guess. Stays **inside** the `if [ -x …/tsx ]` + deps guard. **Append** `+ RE-R2b: brief +N + cli +M (morning-brief)` to the inline per-slice breakdown comment + on `:697` so the comment can't drift from the number. +- Add **Section 16i** ("Trends Brief Wiring", RE-R2b), mirroring Section 16h's shape (`:1074-1116`). **Placement + (verified):** file order is 17→16g→16h→18 (`:943/:1010/:1074/:1118`), so **16h is the last section before + Section 18** — insert 16i **after 16h's closing `fi`/`echo ""` (~`:1116`), before the Section 18 block + (`:1118`)** (anti-erosion Section 18 must stay last so it counts every prior check). Four **unconditional**, + deps-absent-safe checks (pure `grep -qF`/self-test, no `tsx`): + (1) a non-vacuity self-test (probe carrying `src/cli.ts brief` accepted, one without rejected); + (2) `grep -qF 'command === "brief"' scripts/trends/src/cli.ts`; + (3) `grep -qF 'src/cli.ts brief' agents/trend-spotter.md`; + (4) `grep -qF 'latestMorningBrief' hooks/scripts/session-start.mjs` (surfacing is wired, not just doc'd). +- Bump `ASSERT_BASELINE_FLOOR` (`:1136`, currently 90) → **live recount** (= 90 + the new unconditional 16i + emitters; expected ~94, **recounted at land**). Update the **header-enumeration prose chain** (`:33-46`) by + inserting the 16i clause **between** the 16h clause (`:43-45`) and the Section-18 clause (`:46`), preserving + sentence flow (it's prose, not an append). +- **NOT touched here:** the hook suite has no floor in `test-runner.sh` (no `HOOK_TESTS_FLOOR`, no root + `package.json`); the new hook test is gated by `node --test hooks/scripts/__tests__/` (Step 4), separately. + +## Step 7 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then run brief §7's three behavioural steps (seed via +`capture`, generate via `brief --json`, surface via `session-start.mjs` with `LINKEDIN_STUDIO_DATA`). Confirm +the top-match grouping, the `summary`, and the surfaced block (tsx absent for the surfacing). Run full +`bash scripts/test-runner.sh` → `FAIL=0`. + +## Step 8 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor, hook-suite, gate +total). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r2b.md` (no +suffix, tracked); **(2)** code commit `scripts/trends/src/brief.ts` + `cli.ts` + tests + +`hooks/scripts/session-start.mjs` + the new hook test + `agents/trend-spotter.md` + `scripts/trends/README.md` ++ `scripts/test-runner.sh` with `[skip-docs]`. Push freely (window lifted; gitleaks at commit; `origin` = +PUBLIC `open/` — STATE/`*.local.*` never pushed). No version bump (additive; `v0.5.2` dev). + +## Verification (testable) + +| SC | Check | Command | Expected | +|---|---|---|---| +| — | RED proof | `(cd scripts/trends && npm test)` after Step 1 | brief cases fail on assertion (logic-RED), not module-not-found | +| SC1 | rank/group | `npm test` (brief.test.ts) | groups by overlap×freshness; overlap-0 excluded; sort overlap→effectiveDate→title; matchedPillars + totals correct | +| SC2 | freshness | `npm test` (brief.test.ts) | effectiveDate = publishedAt ?? capturedAt; boundary `=freshDays` fresh; freshDays configurable | +| SC3 | render | `npm test` (brief.test.ts) | frontmatter date/summary(no `"`)/store/schemaVersion:1; 3 sections; empty→valid no-match brief; deterministic bytes | +| SC4 | CLI brief | `npm test` (cli.test.ts) + manual | writes `<out>/<today>.md`; `--json` {path,date,totals,summary}; `--fresh-days` bad → exit 2; empty `--pillars` → exit 0 no-match | +| SC5 | surfacing | `node --test hooks/scripts/__tests__/` + manual | brief present → `## Morning Brief` + summary + path, no raw `\n`; absent → no block/no crash; zero-tsx; CLI-write path == hook-read path | +| SC6(a) | gate + wiring + de-niche | `bash scripts/test-runner.sh` | `FAIL=0`; trends ≥ floor; Section 16i green; ASSERT floor recount; Section 17; counts 27/19/29 | +| SC6(b) | hook gate | `node --test hooks/scripts/__tests__/` | new `session-start-morning-brief.test.mjs` green (hook suite is NOT part of test-runner.sh) | + +## Risks + +- **R1 — non-determinism leaks into the brief** (clock/env inside the pure functions → flaky bytes). *Mitigated:* + `today`/`pillars`/`freshDays` injected; SC3 asserts byte-identical output; `ageDays` uses the injected `today`. +- **R2 — `extractYaml` mis-reads the `summary`** (a `"` or newline in the value truncates it). *Mitigated:* + `renderBrief` emits a single-line `summary` with no embedded `"` (titles in «»); SC3 asserts it; the hook + guards `latestBrief.summary` truthy before surfacing. +- **R3 — the hook accidentally needs tsx** (e.g. someone "reuses" the TS ranker in the hook). *Mitigated:* + surfacing is `readdir`+`readFile`+`extractYaml` only; SC5 runs with tsx absent; the B-S3 precedent + (`:32-37`) is the explicit pattern. +- **R4 — editing `trend-spotter.md` trips the de-niche guard.** *Mitigated:* Section 17 runs in the gate; + the added step is pillar-driven (user config), vendor/sector-free; only a `brief` call is added. +- **R5 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16i is pure + `grep`/self-test on tracked source (no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps guard. +- **R6 — brief dir vs store path drift** (hook `join(getDataRoot('trends'),'morning-brief')` vs the CLI's + `defaultBriefDir()`). *Mitigated:* `defaultBriefDir()` now **derives from `defaultStorePath()`** (one root + resolution, not a re-implementation) — so `brief.ts` and `store.ts` cannot disagree on the root. The remaining + hook-vs-CLI pair (`getDataRoot('trends')` vs `defaultStorePath`'s root) is the **same store-path twin already + in production** (the B-S3 staleness nudge reads `join(getDataRoot('trends'),'trends.json')` `:376` and must + already equal the CLI's `defaultStorePath()` for that nudge to work) and is behaviorally guarded by + `__tests__/data-root.test.mjs`. SC5(c) adds a direct write-then-read cross-check; `brief.test` pins + `defaultBriefDir` against `LINKEDIN_STUDIO_DATA`. +- **R7 — `brief.ts` is a new source file** (R2a forbade one for the bridge). *Mitigated:* justified by cohesion + (a ranking+rendering module with its own types/version, not a single-use function); §5 states it; counts + unaffected (no new ref/agent/command). + +## Plan-critic — folded + +plan-critic returned **REVISE** (2 blockers, 5 majors, 4 minors); brief-reviewer **PROCEED_WITH_RISKS** (1 +major + minors); scope-guardian **ALIGNED** (0 findings; counts 27/19/29 verified live). Resolution, each +verified against live code: + +- **[BLOCKER] `--json` summary source left as "re-derive OR expose"** — RED can't target an undecided API. + ✅ committed to `export function briefSummary(ranking): string` (Step 2); `renderBrief` frontmatter + CLI + `--json` both call it (Step 3); Step 1 asserts `renderBrief`'s frontmatter `summary === briefSummary(ranking)`. +- **[BLOCKER] Section 16i placement ambiguous** ("after 16h / before 18" vs brief's wording). ✅ verified file + order 17→16g→16h→18 (`:943/:1010/:1074/:1118`); Step 6 pins 16i after 16h's closing `echo ""` (~`:1116`), + before Section 18 (`:1118`); states 16h is the last section before 18. +- **[MAJOR] throwing-stub RED contradicts "fail on assertion, not module-not-found"** (R2a's blocker class). + ✅ Step 1 now uses **wrong-but-non-throwing** stubs (empty buckets / `""`) so `brief.test` fails on assertion; + `cli.test` brief cases are logic-RED against the existing dispatch (unknown command → `usage()` exit 2). +- **[MAJOR] brief §3 `flags.out ?? defaultBriefDir()` writes `./true/`** for a bare `--out` (`parseFlags` + `cli.ts:52-53`). ✅ brief §3 + Step 3 use the `flags.out !== "true"` guard; Step 1 adds a bare-`--out` + cli.test case. +- **[MAJOR] `TRENDS_TESTS_FLOOR` recount ambiguous** (full `tests N` vs additive). ✅ Step 6 pins it to the + `tests N` line after Steps 1–3, comment appends `+ RE-R2b: brief +N + cli +M`. +- **[MAJOR] `defaultBriefDir` triple-twin root re-resolution** (drift risk). ✅ it now derives from + `defaultStorePath()` (one root resolution, Step 2); R6 + SC5(c) cite the existing `data-root.test.mjs` twin + guard and add a write-then-read cross-check. +- **[MAJOR] surfacing newline-escape unproven.** ✅ Step 4 + SC5 state `extractYaml`'s `[^"\n]*` + `.trim()` + makes `date`/`summary` newline-free → no `.replace` needed; SC5 asserts no raw `\n` in the block. +- **[brief-reviewer MAJOR] SC6 "hook-suite at recount" attributed to `test-runner.sh`** (which neither runs nor + counts it — no `HOOK_TESTS_FLOOR`, no root `package.json`). ✅ SC6 + the verification table split into two + gate commands: `test-runner.sh` (trends/16i/ASSERT/de-niche/counts) and `node --test hooks/scripts/__tests__/` + (the new hook test). +- **[MINOR] non-total sort order** (same title, different url ties fully). ✅ added `url asc` final tie-break + (Step 2/SC1); Step 1 adds a same-title/diff-url fixture. **[MINOR] local `ageDays` duplication unstated** ✅ + Step 2 states the deliberate non-import (dependency direction `cli.ts → brief.ts`). **[MINOR] `.md$` anchor** + ✅ confirmed in the filter regex (Step 4). **[MINOR] header-enumeration prose flow** ✅ Step 6 inserts the 16i + clause in-prose between the 16h and Section-18 clauses. **[brief-reviewer MINOR] body entry-line age format** + ✅ pinned in brief §3 + SC3. **[brief-reviewer MINOR] Open Q#2/#4 are load-bearing** ✅ reframed as + confirmations (brief §8). +- **[plan-critic headless-readiness] N/A** — R2b executes **in-session, operator-driven** (driftsmodell), not + as a headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a had none either). + +**scope-guardian — ALIGNED:** every SC1–SC6 traces to a step; zero creep; all nine §4 non-goals held (no +scheduler, no scoring change, no store-schema change, no relevance/saturation/status fields, no pillar-from-state +resolution, no delivery channel, no brief-diffing, no new agent/command); counts 27/19/29 verified live; +`brief.ts` the only new source file. diff --git a/docs/research-engine/plan-re-r3a.md b/docs/research-engine/plan-re-r3a.md new file mode 100644 index 0000000..7a393ae --- /dev/null +++ b/docs/research-engine/plan-re-r3a.md @@ -0,0 +1,321 @@ +# Plan — RE-R3a: persist the relevance score + rank the morning brief on it + +> **Brief:** `docs/research-engine/brief-re-r3a.md`. **Slice:** RE-R3a (research-engine rung-2 — R3 slice 1, +> research-deepening: the relevance half of hull 5 + the hull-3 schema remainder). +> **TDD-order (two-phase RED — light-Voyage BLOCKER fold):** Step 1 records RED in two phases — +> **(A)** true logic-RED for `store`/`brief`/`cli` against the pre-edit code (inline fixtures, no new import); +> **(B)** for `score`/`item`, land non-throwing stubs for the new `score.ts` exports first (Node16 ESM throws a +> missing named import at module-load, not on assertion), then record the value-assertion RED against the stubs. +> Then GREEN: S-score envelope → S-types + S-store (first-sight persist + v2→v3 migrate) → S-item (validate + +> bridge) → S-brief (composite-sort + render band+mode) → S-cli (doc-only) → wire `trend-spotter.md` + README → +> gate floors + Section 16j → behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Go-gate decisions (confirmed "Go" 2026-06-24):** D1 4-field `TrendScore` · D2 composite primary within +> bucket · D3 first-sight · D4 one slice (data-then-visible commit order) · D6 mode shown in per-entry render. +> **Light-Voyage hardened:** scope-guardian ALIGNED (0) / brief-reviewer PROCEED_WITH_RISKS (6 MINOR) / +> plan-critic REVISE (1 BLOCKER, 4 MAJOR, 4 MINOR) — all folded (see §Plan-critic — folded). + +## Goal + +Stop discarding the relevance judgment the `trend-spotter` agent already computes. Persist a 4-field +`TrendScore { mode, dimensions, composite, priority }` on the store record (schema v2→v3, additive lossless +migrate — the R2a pattern), computed deterministically from the agent's five judgment scores by the already-built +`score.ts` (`composite`+`band`, one owner). Then make `rankForBrief` order each bucket on composite first, and +`renderBrief` surface the band + mode. No re-score-on-recapture, no saturation/status, no scheduler, no new +source file — those stay later R3 slices. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/score.ts` | **EDIT** — `TrendScore` interface + `requiredDimensions(mode)` (ordered) + `scoreEnvelope(mode, dimensions)` (composes the existing `composite`+`band`, no new arithmetic; throws on bad dim by contract) | SC1 | +| `scripts/trends/src/types.ts` | **EDIT** — `import type { TrendScore }`; `TrendRecord.score?: TrendScore`; `SCHEMA_VERSION` 2→3; doc-comment | SC4 | +| `scripts/trends/src/store.ts` | **EDIT** — `TrendInput.score?: TrendScore`; `addTrend` persists `score` first-sight on add (conditional spread), duplicate does NOT update; migrate comment v1→v2→v3 (logic unchanged) | SC3, SC4 | +| `scripts/trends/src/item.ts` | **EDIT** — `TrendItem.score?: {mode,dimensions}`; `normalizeItem` validates (non-array `score`/`dimensions`; mode; the mode's five dims in [1,10]) → structured error, carries validated dims; `itemToInput` carries `scoreEnvelope(...)` (throws by contract on direct bad dims) | SC2 | +| `scripts/trends/src/brief.ts` | **EDIT** — `rankForBrief` comparator: composite primary (`?? -1`), buckets unchanged; `renderBrief`/`renderBulletEntry` surface `· <priority> (<mode>)` (full pinned shapes); `briefSummary` band only; exact `ranking:` descriptor | SC5, SC6 | +| `scripts/trends/src/cli.ts` | **EDIT (doc-only behavior)** — capture persists score automatically via `itemToInput` (no logic change); header doc-comment note | SC7 | +| `scripts/trends/tests/score.test.ts` | **EDIT** — `requiredDimensions` (both modes, ordered + order pinned) + `scoreEnvelope` (composite/priority = existing funcs; bad dim throws) | SC1 | +| `scripts/trends/tests/item.test.ts` | **EDIT** — score validation (valid carried/validated dims; bad mode/missing/out-of-range/non-array/array-dims → structured error, no throw) + `itemToInput` envelope + `itemToInput` direct bad-dim throws | SC2 | +| `scripts/trends/tests/store.test.ts` | **EDIT** — first-sight persist (new persists; duplicate keeps first score, topics union; score-free add) + v2→v3 migration (lossless/idempotent + score-survives-round-trip, mirrors `:403-476` with `2`→`3`) | SC3, SC4 | +| `scripts/trends/tests/brief.test.ts` | **EDIT** — composite-primary within bucket; unscored last (`-1`); total order; full render lines (`· <priority> (<mode>)` scored / unchanged unscored); summary band; unscored single-match-top summary; quote-safe summary; exact `ranking:` string; determinism | SC5, SC6 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — capture batch with score → record carries computed composite/priority (read back via `list --json`); bad score → `errors[]`, valid added, exit 0 | SC7 | +| `agents/trend-spotter.md` | **EDIT** — Step 4.5 capture batch carries per-item `"score":{"mode":"kortform","dimensions":{…}}`; prose ("carry the Step-2 scores"); domain-general; contains literal `"dimensions"` (currently absent → grep non-vacuous) | SC8 | +| `scripts/trends/README.md` | **EDIT** — item `score` field (judgment in) + persisted `TrendScore` (out) + brief ranks on composite | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` 104→recount + breakdown comment (`:701`); NEW unconditional **Section 16j** (after 16i `~:1171` / before 18 `:1173`); `ASSERT_BASELINE_FLOOR` 94→**99**; header-enumeration chain (16i clause `:46-49`, Section-18 clause `:49`) | SC8 | +| `docs/research-engine/{brief,plan}-re-r3a.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 99, schema v3, gate total). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `references/trend-scoring-modes.md` (SSOT weights/bands unchanged) · the +SessionStart hook + its tests (R3a touches no hook; no frontmatter-schema change; no new hook test) · +`queryByTopic`/`history`/`newestCaptureDate` (store query unchanged) · the `score` CLI digest path + the `add` +manual path (`cli.ts`) · `config/*` · no new `.ts`/`.mjs` file · `agents/*` count (19) · `commands/*` (29) · +`references/*` (27) · `.gitignore` (trends lines present). + +## Step 1 — (RED, two phases) failing tests across score/item/store/brief/cli + +RED discipline (R2a/R2b) + the light-Voyage BLOCKER fold: a missing **named** import throws at module-load under +Node16 ESM (`package.json:8` `node --import tsx --test`; `tsconfig.json` `module: Node16`), so `score`/`item` +(which reference new `score.ts` exports) cannot be assertion-RED before those exports exist. Split the RED proof: + +**Phase A — true logic-RED against the pre-edit code** (`store`/`brief`/`cli` build fixtures inline; `TrendStore`/ +`TrendRecord` are `import type`, erased by tsx; they import no new runtime symbol): +- `store.test.ts`: `addTrend(emptyStore, inputWithScore)` → record has `score`; a second `addTrend` (same + title+url, **different** score) → stored score **unchanged**, `added:false`, topics unioned; score-free add → + score-free record. **Migration:** a `schemaVersion:2` store with score-less records → `loadStore` gives + `schemaVersion:3`, records intact, `"score" in record === false` (not invented); round-trip writes + `schemaVersion:3`; a v3 store with `score` is idempotent; **a v3 store's `score` survives load+resave** + (read back, re-save, re-read — `score` byte-identical). *(Mirror the RE-R2a block `store.test.ts:403-476`, + retitled `(RE-R3a / score v2→v3)`, every `schemaVersion` assertion literal flipped `2`→`3`.)* +- `brief.test.ts`: two **same-overlap, same-freshness** records, composites 9 vs 6 → the 9 sorts first in its + bucket; a **scored** vs **unscored** same-bucket pair → scored first (the `-1` sentinel); a same-title/diff-url + **both-unscored** pair → fixed by `url asc` (total order intact); `renderBrief` for a scored top entry contains + the **full line** `… (<age>d) · <priority> (<mode>) · Pillarer: …`, for an unscored one the **unchanged** line + (no token) — assert full lines, not substrings; a scored bullet contains `… (<age>d) · <priority> (<mode>) · + 🔗 …`; `briefSummary` names the band on a scored top, omits it on an unscored top, and is one line with no `"` + **even when the top title contains a guillemet/quote**; a store whose only fresh match is a **single-pillar + unscored** record → summary with no `· <priority>` token; the `ranking:` line equals the pinned string + verbatim; two `renderBrief` calls byte-identical. +- `cli.test.ts`: a `capture` batch (subprocess, `--store` temp) with a valid per-item `score` → a following + `list --store <tmp> --json` shows the record's `score` with the computed composite/priority; a batch with one + bad score (`timing:99`) → JSON `errors[]` non-empty, the valid items added, **exit 0**. + +**Phase B — stub-first, then value-assertion RED** (`score`/`item` reference new exports): +- Land **non-throwing stubs** in `score.ts` so the imports resolve: `requiredDimensions → []`; `scoreEnvelope → + { mode, dimensions, composite: 0, priority: "Skip" }`. (These are the wrong-value stubs Step 2 replaces.) +- `score.test.ts`: `requiredDimensions("kortform")`/`("long-form")` deep-equal the two **ordered** five-key + lists (and a guard that the order matches `Object.keys(KORTFORM_WEIGHTS)` so a SSOT reorder fails); + `scoreEnvelope(mode, dims)` returns `{mode, dimensions, composite, priority}` with composite/priority **equal + to `composite(dims,mode)` / `band(...).priority`** (assert against the existing functions — not hard-coded — + so they share one owner); a bad dim makes `scoreEnvelope` throw (it calls `composite`). Fails on assertion + against the stubs (`[] ≠ expected`, `composite 0 ≠ real`). +- `item.test.ts`: a valid `score` → `normalizeItem` `ok:true` carrying the **validated** dims; bad `mode` / a + missing dimension / a dim `0` or `11` / a non-object `score` / an **array** `dimensions` → `ok:false` with an + `invalid score` error (**does not throw**); absent `score` → key omitted. `itemToInput(validItemWithScore, + "2026-06-24")` → `score` equals `scoreEnvelope(mode,dims)`; without score → no `score`; **`itemToInput` with an + out-of-range dim throws** (the defense-in-depth contract). Fails on assertion against the stubs. + +**RED proof (record in commit, two phases):** Phase A — `(cd scripts/trends && npm test)` before any src edit → +the `store`/`brief`/`cli` new cases fail on **assertion** (logic-RED), not module-not-found. Phase B — after the +non-throwing stubs land, the `score`/`item` cases fail on **value assertion** against the stubs. The plan does +**not** claim a single "all five fail before any code" run. + +## Step 2 — (GREEN) `score.ts` envelope + +In `scripts/trends/src/score.ts`: +- `export interface TrendScore { mode: ScoreMode; dimensions: DimensionScores; composite: number; priority: + Priority }`. +- `export function requiredDimensions(mode: ScoreMode): string[] { return Object.keys(WEIGHTS[mode]); }` — + **ordered** (insertion order of the SSOT weight literal); `score.test` pins the order. +- `export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore { const c = + composite(dimensions, mode); return { mode, dimensions, composite: c, priority: band(c).priority }; }` — + composes the existing pure functions; **no new arithmetic**; throws via `composite` on a bad dim (its + contract). Replace the Phase-B stubs. Make `score.test` green. + +## Step 3 — (GREEN) `types.ts` + `store.ts` (schema v3 + first-sight persist) + +- `types.ts`: `import type { TrendScore } from "./score.js";`; add `score?: TrendScore;` to `TrendRecord` + (doc-comment marks it as the realized `:21-23` field); `SCHEMA_VERSION = 3`. +- `store.ts`: add `score?: TrendScore;` to `TrendInput` (`import type { TrendScore } from "./score.js"`); + in `addTrend`'s new-record branch add `...(input.score !== undefined ? { score: input.score } : {})` (after the + `summary` spread `:136`); the **duplicate** branch is unchanged (topics union only — score is first-sight, + D3). Extend the `loadStore` migrate comment to *"v1→v2→v3 all additive-optional … the migration is the version + stamp alone"* — **no logic change** (`Math.max(onDisk, SCHEMA_VERSION)` `:87` already handles v3; `saveStore` + `JSON.stringify` `:95` preserves `score`). Make `store.test` green (first-sight + v2→v3 + survives-round-trip). + +## Step 4 — (GREEN) `item.ts` (validate + bridge) + +- `TrendItem` gains `score?: { mode: ScoreMode; dimensions: DimensionScores };` (`import type { ScoreMode, + DimensionScores } from "./score.js"` + `import { requiredDimensions } from "./score.js"`). +- `normalizeItem`: after the `publishedAt` validation (`:99-106`), if `r.score !== undefined && r.score !== + null`, validate: `score` is a **non-array** object; `mode` ∈ `{kortform, long-form}`; `dimensions` is a + **non-array** object; **for each key in `requiredDimensions(mode)`**, the value is a number in [1,10]. On any + failure `errors.push("invalid score: <reason>")`; on success build `score = { mode, dimensions }` from the + **validated** values (not raw `r.score.dimensions`). Carried into the returned `TrendItem` via conditional + spread (key omitted when absent/invalid). **Never throws** — structured errors only (the `publishedAt` + discipline). +- `itemToInput`: add `...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, + item.score.dimensions) } : {})` (`import { scoreEnvelope } from "./score.js"`). On the **capture path** the + dims are pre-validated by `normalizeItem`, so `scoreEnvelope`→`composite` cannot throw there; called + **directly** with bad dims it throws by contract (defense-in-depth — SC2 asserts it). Make `item.test` green. + +## Step 5 — (GREEN) `brief.ts` (composite sort + render band+mode) + +- `rankForBrief` comparator (`:94-98`): prepend `(b.trend.score?.composite ?? -1) - (a.trend.score?.composite + ?? -1) ||` before the existing `b.overlap - a.overlap || …`. **Sentinel `-1`** (composite ≥ 1.0 always; `-1` + sorts unscored last and subtracts cleanly). Buckets (`:100-104`) and totals unchanged. +- `renderTopEntry` (`:132-141`): when `e.trend.score` is present, insert `· ${e.trend.score.priority} + (${e.trend.score.mode})` into the meta line **between `(${e.ageDays}d)` and `· Pillarer`** (unscored: + unchanged). `renderBulletEntry` (`:143-145`): when scored, insert `· ${score.priority} (${score.mode})` + **before `· 🔗`** (unscored: unchanged). Both shapes asserted as **full lines** in `brief.test`. +- `briefSummary` (`:122-130`): in the fresh>0 branch, when `top.trend.score` is present include `· + ${top.trend.score.priority}` in the top mention (**band only — no mode** to keep the one-line headline clean): + `… Topp: «<title>» (<pillar> · <priority> · <age>d).`; when the top is unscored omit the token: + `… Topp: «<title>» (<pillar> · <age>d).` Keep it one line, no `"`/`\n`. +- `renderBrief`'s `ranking:` line (`:160`) → the **exact pinned** string + `composite desc, then pillar-overlap desc, then publishedAt desc (capturedAt fallback); freshDays + ${ranking.freshDays}` (asserted byte-for-byte). `BRIEF_SCHEMA_VERSION` stays 1. Make `brief.test` green. + +## Step 6 — (GREEN) `cli.ts` (doc-only) + `cli.test` + +`capture` (`:243-269`) already folds through `itemToInput` (`:254`), which now carries `score` → capture +persists it with **no logic change**. Update only the header doc-comment (`:15-21`): note `capture` persists an +optional relevance score computed from the item's judgment scores. Make `cli.test`'s capture-persists-score + +bad-score-in-errors cases green. *(No `today()` exact-value assertions in `cli.test` — the wall clock is read at +the edge; composite/priority are deterministic and asserted on the read-back record.)* + +## Step 7 — wire `trend-spotter.md` (Open Q#1 default = WIRE) + README + +In `agents/trend-spotter.md` Step 4.5 (`:291-298`), extend each capture-batch item with **`"score": {"mode": +"kortform", "dimensions": {"pillar": N, "audience": N, "timing": N, "angle": N, "authority": N}}`** — the same +five judgment scores computed in Step 2 (`:134`). Add prose: don't discard the Step-2 scores; fold them into the +capture batch so the store persists the relevance assessment and the morning brief ranks on it. Mode defaults +`kortform`; `long-form` when invoked from `/linkedin:newsletter` (the long-form dims are +`pillar/depth/angle/authority/currency`, `trend-scoring-modes.md:59-65`). The replacement prose **must contain +the literal `"dimensions"`** (Section 16j `grep -qF`; verified absent today → non-vacuous). Keep the "skip +silently if no deps" escape hatch + domain-general phrasing (Section 17). Update `scripts/trends/README.md`: the +item `score` field (judgment in), the persisted `TrendScore` (composite/priority out), and that the brief now +ranks on composite. + +## Step 8 — gate: floors + new unconditional Section 16j + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (`:701`, currently 104) to the **`tests N` line** reported by `(cd scripts/trends && + npm test)` after Steps 1–6 — recounted live, NOT additive-guessed. Stays **inside** the `if [ -x …/tsx ]` + deps guard. **Append** `+ RE-R3a: score +N` to the inline breakdown comment (`:701`) so it can't drift. +- Add **Section 16j** ("Trends Score Wiring", RE-R3a), mirroring Section 16i's shape (`:1122-1171`). **Placement + (verified):** file order is 17→16g→16h→16i→18 (`:947/:1014/:1078/:1122/:1173`), so **16i is the last section + before Section 18** — insert 16j **after 16i's closing `fi`/`echo ""` (~`:1171`), before the Section 18 block + (`:1173`)** (anti-erosion Section 18 must stay last so it counts every prior check). Five **unconditional**, + deps-absent-safe checks (pure `grep -qF`/self-test, no `tsx`), the self-test emitting **one** pass/fail like + 16i: + (1) a non-vacuity self-test (a probe carrying `score?.composite` accepted, one without rejected); + (2) `grep -qF 'export interface TrendScore' scripts/trends/src/score.ts`; + (3) `grep -qF 'score?: TrendScore' scripts/trends/src/types.ts`; + (4) `grep -qF '"dimensions"' agents/trend-spotter.md` (the capture batch carries the judgment); + (5) `grep -qF 'score?.composite' scripts/trends/src/brief.ts` (the brief ranks on it — payoff wired, not + merely doc'd). +- Bump `ASSERT_BASELINE_FLOOR` (`:1193`, currently 94) → **exactly 99** (94 + the 5 new unconditional 16j + emitters; the self-test emits one pass/fail like 16i, so 99 is deterministic — "live recount" is the safety + net, not a guess). Update the **header-enumeration prose chain** by inserting the 16j clause **between** the + 16i clause (`:46-49`) and the Section-18 clause (`:49`), preserving sentence flow (it's prose, not an append). +- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3a adds no hook test). It + must still pass untouched (`node --test hooks/scripts/__tests__/`) as a regression sanity at land. + +## Step 9 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then run brief §7's four behavioural steps (capture A>B with +scores, `list --json` to confirm persisted composite/priority, `brief --json` to confirm A precedes B + the band +in the summary + `· <priority> (kortform)` in the entry line, a bad-score item lands in `errors[]` with exit 0). +Run full `bash scripts/test-runner.sh` → `FAIL=0` (`ASSERT_BASELINE_FLOOR` 99); run `node --test +hooks/scripts/__tests__/` → still green (untouched regression). + +## Step 10 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 99, schema v3, +gate total). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3a.md` (no +suffix, tracked); **(2)** code commit — the six `src/*.ts` + five test files + `agents/trend-spotter.md` + +`scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]`. (Per D4, the code commit MAY be split +into a data commit [score/types/store/item/cli + their tests] and a visible commit [brief + its tests + agent +wire] if the R2a/R2b two-commit rhythm is preferred.) Push freely (window lifted; gitleaks at commit; `origin` = +PUBLIC `open/` — STATE/`*.local.*` never pushed). No version bump (additive; `v0.5.2` dev). + +## Verification (testable) + +| SC | Check | Command | Expected | +|---|---|---|---| +| — | RED Phase A | `(cd scripts/trends && npm test)` before src edits | store/brief/cli new cases fail on assertion (logic-RED), not module-not-found | +| — | RED Phase B | `npm test` after non-throwing stubs | score/item cases fail on value assertion against stubs (`[] ≠ expected`, `composite 0 ≠ real`) | +| SC1 | score envelope | `npm test` (score.test) | `requiredDimensions` both modes ordered (order pinned); `scoreEnvelope` composite/priority = existing funcs; bad dim throws | +| SC2 | item validate + bridge + contract | `npm test` (item.test) | valid score carried (validated dims); bad mode/missing/out-of-range/non-array/array-dims → structured error (no throw); `itemToInput` envelope; direct bad-dim throws | +| SC3 | first-sight persist | `npm test` (store.test) | new persists score; duplicate keeps first score (topics union); score-free add works | +| SC4 | migration v2→v3 | `npm test` (store.test) | v2 loads as v3, records intact, no score invented; round-trip writes v3; v3 idempotent; score survives load+resave | +| SC5 | brief ranks on composite | `npm test` (brief.test) + manual | composite primary within bucket; unscored last (`-1`); total order; deterministic | +| SC6 | render band+mode | `npm test` (brief.test) | full lines `· <priority> (<mode>)` scored / unchanged unscored; summary band (no mode); unscored single-match summary; quote-safe; exact `ranking:` string | +| SC7 | CLI persists score | `npm test` (cli.test) + manual | capture batch w/ score → record carries composite/priority; bad score → `errors[]`, valid added, exit 0 | +| SC8 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | `FAIL=0`; trends ≥ floor; Section 16j green; `ASSERT_BASELINE_FLOOR`=99; Section 17; counts 27/19/29 | + +## Risks + +- **R1 — composite/band drift from the SSOT** (someone re-implements the math in `scoreEnvelope`). *Mitigated:* + `scoreEnvelope` *composes* `composite()`+`band()` (one owner); SC1 asserts equality against those functions; + `score.test.ts:12-30` (weights) + the band-string assertions already pin them to the SSOT. +- **R2 — a bad score crashes the capture loop.** *Mitigated:* `normalizeItem` fully validates the score (mode + + the mode's five dims in [1,10], non-array objects) → structured error into `errors[]`; on the capture path + `itemToInput`→`composite` is unreachable for bad dims; SC2 + SC7 assert no-throw + `errors[]` routing. + `itemToInput` called **directly** with bad dims throws by contract (SC2) — defense-in-depth, not a leak. +- **R3 — comparator NaN from the unscored sentinel** (`-Infinity - -Infinity`). *Mitigated:* sentinel is `-1` + (composite ≥ 1.0 = min 1×Σweights); subtracts cleanly; SC5 asserts the both-unscored total order holds. +- **R4 — losing the brief's determinism** (composite ties not fully broken). *Mitigated:* the new term is a + leading tie-break; the existing `overlap → effectiveDate → title → url` chain still gives a total order (the + `(title,url)` pair is the unique dedupe id, `store.ts:66-68`); SC5 asserts byte-identical output + the + same-title/diff-url case. +- **R5 — `extractYaml` mis-reads the `summary`** if the band token introduces a `"`/newline. *Mitigated:* the + band strings (`Immediate`/`High`/…) are bare words; the summary stays one line, no `"` — asserted in SC6 even + with a guillemet/quote in the top title; the surfacing hook is untouched. +- **R6 — migration not actually lossless** (a v2 record mutated on load, or a new field stripped on resave). + *Mitigated:* the migration is the version stamp alone (logic unchanged); `saveStore` `JSON.stringify` `:95` + strips nothing; SC4 mirrors the proven R2a `store.test:403-476` and adds a score-survives-round-trip case. +- **R7 — editing `trend-spotter.md` trips the de-niche guard.** *Mitigated:* Section 17 runs in the gate; the + added `score`/`dimensions` are the rubric's generic dimension names + the user's pillars, vendor/sector-free. +- **R8 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16j is pure + `grep`/self-test on tracked source (no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps guard. +- **R9 — import cycle.** *Mitigated:* `score.ts` imports nothing internal today (`:1-17`, verified leaf). R3a + adds three new **one-way** inbound edges to it — `score.ts ← types.ts` (`TrendScore` type), `score.ts ← + store.ts` (`TrendScore` type on `TrendInput`), `score.ts ← item.ts` (`ScoreMode`/`DimensionScores` types + + `requiredDimensions`/`scoreEnvelope` values). The DAG stays acyclic: `score.ts (leaf) ← {types, store, item, + brief, cli}`, since `score.ts` imports none of them back. +- **R10 — mode-mixing makes the ranking apples-to-oranges** (kortform vs long-form composites ranked together). + *Accepted for R3a, mitigated visibly:* almost all records are `kortform` (the default); the body entry line + **shows the mode** (`<priority> (<mode>)`, D6/SC6) so the operator can see when adjacent entries used different + instruments; a mode-segmented brief is a documented R3-later non-goal (brief §4). + +## Plan-critic — folded + +Three Opus reviewers ran, each verifying claims against live code: **scope-guardian ALIGNED** (0 findings; counts +27/19/29 + "no new source file" verified live); **brief-reviewer PROCEED_WITH_RISKS** (all four load-bearing +claims verified TRUE; 6 MINOR); **plan-critic REVISE** (1 BLOCKER, 4 MAJOR, 4 MINOR; score 72/C). Resolution, +each verified against live code: + +- **[BLOCKER — plan-critic] Step 1 RED-proof self-contradictory for score/item under ESM** (a missing named + import throws at module-load, not on assertion; the stub-first fix inverted the stated ordering). ✅ Step 1 is + now **explicitly two-phase**: Phase A true logic-RED for `store`/`brief`/`cli` against pre-edit code; Phase B + stub-first then value-assertion RED for `score`/`item`. The header blockquote + brief §5 + §TDD-order state it; + the "all five fail before any code" claim is removed. +- **[MAJOR — plan-critic] no-throw guarantee overstated** (`itemToInput` is public; direct bad-dim calls throw). + ✅ Step 4 + R2 + brief §5 reword it **path-specific** (no throw on the capture path because `normalizeItem` + gates it; direct calls throw by contract); SC2 adds a direct-throw assertion + a carries-validated-dims + assertion. +- **[MAJOR — plan-critic] mode-mixing waved away + "mode shown per entry" contradicted the render spec.** ✅ D6: + the render now shows `· <priority> (<mode>)` per body entry (Step 5); brief §4 adds a mode-blind-ranking + non-goal with the rationale (mode visible, mostly kortform); SC6 asserts the full line incl. mode; R10 added. +- **[MAJOR — plan-critic] `requiredDimensions` order contract ambiguous** (SC1 hard-coded arrays vs membership + use). ✅ pinned **ordered** (Step 2 + SC1 deep-equal the SSOT-order array; `score.test` pins the order; + `normalizeItem` consumes as a set). +- **[MAJOR — plan-critic] `ASSERT_BASELINE_FLOOR` "~99" not pinned.** ✅ pinned **99** (94 + 5 unconditional 16j + emitters; the self-test emits one pass/fail like 16i) — Step 8 + SC8 + brief §3. +- **[MINOR — brief-reviewer] SC4 ref `:403-471` stale + v2 assertions** → ✅ `:403-476` + "flip every + `schemaVersion` literal 2→3" note (SC4, Step 1). +- **[MINOR — brief-reviewer] R1 SSOT-pin cite was the doc-comment** (`score.ts:9-13`) → ✅ now `score.test.ts:12-30` + (R1, brief §2/§5). +- **[MINOR — brief-reviewer] bullet `· <priority>` placement substring-only** → ✅ full pinned line shape + (priority+mode before `🔗`), asserted as a full line (Step 5, SC6). +- **[MINOR — brief-reviewer] three diverging `ranking:` descriptor strings** → ✅ one verbatim target string, + asserted byte-for-byte (Step 5, SC6). +- **[MINOR — brief-reviewer] unscored single-match-top summary path untested** → ✅ added as a Phase-A + brief.test case + SC6. +- **[MINOR — brief-reviewer] `normalizeItem` non-array object case understated in the brief** → ✅ "non-array" + added to both `score` and `dimensions` object checks (Step 4, brief §3, SC2 array-dims case). +- **[MINOR — plan-critic] header-chain line-ref `:33-49` loose** → ✅ tightened to the 16i clause `:46-49` / + Section-18 clause `:49` (Step 8). +- **[MINOR — plan-critic] R9 DAG omitted the new `score.ts ←` edges** → ✅ R9 now lists all three one-way edges. +- **[MINOR — plan-critic] SC6 quote-safety regression with the new token** → ✅ SC6 asserts the summary stays + one-line/no-`"` with a scored top title containing a guillemet/quote. +- **[MINOR — plan-critic] SC4 forward-compat / score-survives-round-trip untested** → ✅ added to SC4 + Step 1 + store.test. +- **[plan-critic headless-readiness] N/A** — R3a executes **in-session, operator-driven** (driftsmodell), not as + a headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a/R2b had none either). + +**scope-guardian — ALIGNED:** every SC1–SC8 traces to a step; zero creep; all §4 non-goals held (no +re-score-on-recapture, no saturation/status/first-mover field, no scheduler, no seen-log, no brief-diff, no +A1–A4, no mode-filter, no `score` in the `add` path, no new source file/agent/command); counts 27/19/29 verified +live; exactly 6 `src/*.ts` + 5 `tests/*.test.ts`, all edited, none added. diff --git a/docs/research-engine/plan-re-r3b.md b/docs/research-engine/plan-re-r3b.md new file mode 100644 index 0000000..219c11b --- /dev/null +++ b/docs/research-engine/plan-re-r3b.md @@ -0,0 +1,384 @@ +# Plan — RE-R3b: trend lifecycle — re-score on re-capture · status (acted/skipped) · seen-log + +> **Brief:** `docs/research-engine/brief-re-r3b.md`. **Slice:** RE-R3b (research-engine rung-2 — R3 slice 2, the +> **lifecycle** slice: re-score-on-recapture + status `new`/`acted`/`skipped` + the `surfacedCount`/`lastSurfacedAt` +> seen-log). +> **TDD-order (two-phase RED — light-Voyage BLOCKER fold, inherited from R3a):** Step 1 records RED in two phases — +> **(A)** true logic-RED for the re-score + migration parts of `store.test` (existing `addTrend`/`loadStore`, +> inline fixtures), all of `brief.test` (behaviour change to existing `rankForBrief`/`renderBrief`), and `cli.test` +> (subprocess — `act`/`skip` are unknown commands today → assertion-RED); **(B)** for `setStatus`/`markSurfaced`/ +> `effectiveStatus` (new `store.ts` exports), land non-throwing stubs first (Node16 ESM throws a missing named +> import at module-load, not on assertion), then record value-assertion RED against the stubs. Then GREEN: +> S-store stubs→real (effectiveStatus + setStatus + markSurfaced + re-score) → S-types (3 fields + v3→v4) **+ the +> R3a-migration-block reconcile** → S-brief (status filter + id/marker render + surfacedIds + descriptor) → S-cli +> (act/skip/reset + brief-marks-surfaced + --no-mark) → wire `trend-spotter.md` (prose) + README → gate floors + +> Section 16k → behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-25):** A1 on-record seen-log, brief records +> surfacing (rankForBrief pure, `--no-mark`) · A2 re-score last-wins (score the one mutable field; status not +> reset) · A3 acted/skipped EXCLUDED from the brief. Go-gate D1–D9 baked to recommended defaults (brief §8). +> **Light-Voyage hardened:** scope-guardian ALIGNED (0 creep / 0 gaps; 2 MINOR line-cites) · brief-reviewer +> PROCEED_WITH_RISKS (all 9 load-bearing claims TRUE; 1 MEDIUM + 3 LOW) · plan-critic PROCEED_WITH_RISKS (78/B; +> 1 MAJOR + 5 MINOR) — all folded (see §Plan-critic — folded). The MAJOR (the `brief` CLI `store` binding) is +> fixed by the Step-5 hoist. + +## Goal + +Give a trend a **life after first capture**. (1) **Re-score on re-capture** — `addTrend`'s duplicate branch now +refreshes `score` (last-wins; timing decays), reusing the already-built capture path (no new arithmetic, `score.ts` +untouched). (2) **Status lifecycle** — `new`/`acted`/`skipped` on the record, set by new `act`/`skip`/`reset` CLI +verbs; the brief **excludes** handled trends. (3) **Seen-log** — `surfacedCount`/`lastSurfacedAt` accumulated +(per-day-idempotent) by the `brief` CLI after the pure ranking, the temporal foundation slices (c)+(b) build on. +Schema v3→v4 (additive lossless migrate — the R3a pattern, **plus** reconciling the R3a migration block's +hard-coded `3` literals that the bump would otherwise regress). No saturation scoring, no scheduler, no brief-diff, +no new source file — those stay later R3 slices. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/types.ts` | **EDIT** — `export type TrendStatus`; `TrendRecord` gains `status?`/`surfacedCount?`/`lastSurfacedAt?` (all optional); `SCHEMA_VERSION` 3→4; doc-comment | SC1, SC4 | +| `scripts/trends/src/store.ts` | **EDIT** — `effectiveStatus(t)` (absent⇒new); `setStatus(store,id,status)`; `markSurfaced(store,ids,today)` (per-day idempotent); `addTrend` duplicate branch re-scores (last-wins, `merged` broadened, no false-merge); migrate comment v1→…→v4 (logic unchanged) | SC1, SC2, SC3, SC4 | +| `scripts/trends/src/brief.ts` | **EDIT** — `rankForBrief` excludes `effectiveStatus !== "new"`; `renderTopEntry`/`renderBulletEntry` append `· \`<id>\`` + `surfacedToken` (`· sett Nx` when count≥2); `surfacedIds(ranking)`; `ranking:` descriptor gains `; excludes acted/skipped` | SC5, SC6 | +| `scripts/trends/src/cli.ts` | **EDIT** — `act`/`skip`/`reset --id` (setStatus, not-found→exit 2); `brief` **hoists `const store = loadStore(storePath)`** + records surfacing via `markSurfaced`+`saveStore` unless `--no-mark`, `--json` gains `marked`; exit-code doc-comment (`:33`) + capture tally comment (`:251-252`) broadened; usage + header doc | SC7, SC8 | +| `scripts/trends/tests/store.test.ts` | **EDIT** — effectiveStatus; setStatus (found/absent/reset); markSurfaced (increment/idempotent/skip); re-score (last-wins/no-false-merge/status+provenance untouched/no-score-noop); **NEW `(RE-R3b / lifecycle v3→v4)` migration block** + **reconcile the existing `(RE-R3a / score v2→v3)` block's `3` literals** | SC1–SC4 | +| `scripts/trends/tests/brief.test.ts` | **EDIT** — exclude acted/skipped (all buckets; totals.trends full; only-handled→empty); render full lines (`· \`<id>\``; `· sett Nx` at count≥2 / none below); `surfacedIds`; exact `ranking:` string; determinism | SC5, SC6 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — act/skip/reset (read back via `list --json`; unknown id→exit 2; missing --id→exit 2); brief marks surfaced (`surfacedCount:1`+`lastSurfacedAt`, `marked` in json); second-same-day idempotent (`marked:0`); `--no-mark` (no write); brief md omits acted/skipped | SC7, SC8 | +| `scripts/trends/src/item.ts` | **UNTOUCHED** — re-score reuses the existing `itemToInput`→`scoreEnvelope` bridge (R3a). Listed to assert it is *not* in scope. | — | +| `scripts/trends/src/score.ts` | **UNTOUCHED** — no scoring-math change. Listed to assert it is *not* in scope. | — | +| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: re-capture refreshes the score (timing decays); operator marks `acted`/`skipped` via the CLI so the brief stops re-surfacing handled work. No batch-shape change (score already emitted, R3a). Domain-general. | — | +| `scripts/trends/README.md` | **EDIT** — status lifecycle + verbs, seen-log (per-day idempotent, brief-recorded), re-score last-wins, brief exclude-handled + `--no-mark` | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` 146→recount + breakdown comment (`:705`); NEW unconditional **Section 16k** (after 16j's block `~:1235` / before Section 18 header `:1237`); `ASSERT_BASELINE_FLOOR` 99→**105**; header-enumeration chain | SC9 | +| `docs/research-engine/{brief,plan}-re-r3b.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 105, schema v4, gate total). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `references/trend-scoring-modes.md` (no scoring-math change) · `score.ts` + `item.ts` +(re-score reuses the R3a capture path) · the SessionStart hook + its tests (no hook change; no frontmatter-schema +change; `BRIEF_SCHEMA_VERSION` stays 1; no new hook test) · `queryByTopic`/`history`/`newestCaptureDate` (store +query unchanged) · the CLI `status` staleness reader (unrelated to the new `TrendStatus` type — the name collision +is pre-existing, not reconciled here) · `config/*` · no new `.ts`/`.mjs` file · `agents/*` count (19) · +`commands/*` (29) · `references/*` (27) · `.gitignore` (trends lines present) · the two inert `schemaVersion: 2` +fixture literals — `brief.test.ts:53` (`rankForBrief` ignores `schemaVersion`) AND `cli.test.ts:247` (the seed +store is re-stamped to current on capture/brief load; no test reads its on-disk version — `cli.test.ts:102` uses +the `SCHEMA_VERSION` constant) — both **left as-is** (folded — brief-reviewer #3: enumerated so "miss none" is +literally true). + +## Step 1 — (RED, two phases) failing tests across store/brief/cli + +Same RED discipline as R3a (two-phase, light-Voyage BLOCKER fold): a missing **named** import throws at +module-load under Node16 ESM, so the `store.test` cases that reference the new `setStatus`/`markSurfaced`/ +`effectiveStatus` exports cannot be assertion-RED before stubs exist. Split: + +**Phase A — true logic-RED against the pre-edit code** (uses existing `addTrend`/`loadStore`/`saveStore`/ +`rankForBrief`/`renderBrief`; `TrendStore`/`TrendRecord` are `import type`, erased by tsx; subprocess for cli): +- `store.test.ts` — **re-score** (uses existing `addTrend`): `addTrend(store, dupSameTitleUrlDifferentScore)` → + stored `score` **replaced**, `merged:true`, `added:false`, topics unioned, `source`/`capturedAt`/`publishedAt` + unchanged; a **byte-identical** re-score (no new topics) → `merged:false`; a duplicate with **no** `score` → + stored score unchanged; re-capture of a record with `status:"acted"` (constructed inline) → score updated, + `status` still `"acted"`, `surfacedCount` untouched. *(These fail against pre-edit `addTrend`, which never + touches `score` on a duplicate.)* +- `store.test.ts` — **NEW migration block `(RE-R3b / lifecycle v3→v4)`** (uses existing `loadStore`/`saveStore`, + **hard-coded `4`** so it is RED while `SCHEMA_VERSION` is still 3 — the genuine-RED device; the target+idempotent + assertions are switched to `SCHEMA_VERSION` at GREEN, Step 3, per brief-reviewer #4): a + `schemaVersion:3` store with score-bearing, lifecycle-field-less records → `loadStore` gives `schemaVersion:4`, + records intact, `"status" in record === false` (+ `surfacedCount`/`lastSurfacedAt` absent — none invented); + round-trip writes `schemaVersion:4`; a `schemaVersion:4` store with lifecycle fields loads **idempotent** + (stays 4, fields deep-equal); the three new fields **survive load→save→load**. +- `brief.test.ts` — **exclude** (uses existing `rankForBrief`): a store with `new` + `acted` + `skipped` matches + all fresh+on-pillar → only the `new` records appear in any bucket; `totals.trends` equals the **full** count + (incl. handled); a store whose only matches are `acted`/`skipped` → all buckets empty + the "no fresh signals" + `briefSummary`. **render** (uses existing `renderBrief`): a top entry's meta line ends `· \`<id>\`` (full line); + a bullet ends `· \`<id>\`` (full line); a record with `surfacedCount:3` shows `· sett 3x` (full line), one with + `surfacedCount:1`/absent shows **no** surfaced token (full line); `surfacedIds(ranking)` (new export — see + Phase B note) … **[moved to Phase B]**; the `ranking:` line equals the pinned string ending `; excludes + acted/skipped`; two `renderBrief` calls byte-identical. +- `cli.test.ts` — **act/skip/reset** (subprocess, `--store` temp): `act --id <id>` then `list --json` → record + `status:"acted"`; `skip` → `"skipped"`; `reset` → `"new"`; an **unknown** id → **exit 2** + store unchanged; a + missing `--id` → exit 2. **brief-marks-surfaced**: `brief --store <tmp>` on fresh matches → `list --json` shows + the surfaced trends `surfacedCount:1` + today's `lastSurfacedAt`, the `brief --json` carries `marked:<n>`; a + **second** same-day `brief` → `marked:0`, counts unchanged; `brief --no-mark` → `marked:0`, no `surfacedCount` + written; the brief `.md` **omits** an `acted` record. **re-score tally** (folded — plan-critic #4): `capture` a + scored item, then `capture` the same title+url with a **changed** score → the second `capture --json` reports + `merged:1` and `list --json` shows the **updated** composite. *(Fail today: `act`/`skip`/`reset` are unknown + commands → `usage` exit 2 but no status set; `brief` does not write `surfacedCount` / emit `marked`; a re-capture + with a changed score is a plain `duplicate` (score discarded), not `merged`.)* + +**Phase B — stub-first, then value-assertion RED** (`store.test`/`brief.test` reference new exports): +- Land **non-throwing stubs** so the imports resolve: in `store.ts` — `effectiveStatus → "new"` (constant), + `setStatus → { store, found:false }`, `markSurfaced → { store, marked:0 }`; in `brief.ts` — `surfacedIds → []`. + (Wrong-value stubs Step 2/4 replace.) +- `store.test.ts`: `effectiveStatus({…status:"acted"})` is `"acted"` (stub returns `"new"` → RED); + `setStatus(store, presentId, "skipped")` → `{ found:true }` and the record's status set (stub `found:false` → + RED); `markSurfaced(store,[idA],today)` increments + sets `lastSurfacedAt`, `marked:1`, and is per-day + idempotent on a re-call (stub `marked:0`, no mutation → RED). +- `brief.test.ts`: `surfacedIds(ranking)` deep-equals the ids of `topMatches ∪ singleMatches ∪ + olderMatched.slice(0,5)` (stub `[]` → RED). + +**RED proof (record in commit, two phases):** Phase A — `(cd scripts/trends && npm test)` before any src edit → +the re-score/migration/brief/cli new cases fail on **assertion** (logic-RED), not module-not-found. Phase B — +after the non-throwing stubs land, the `effectiveStatus`/`setStatus`/`markSurfaced`/`surfacedIds` cases fail on +**value assertion** against the stubs. The plan does **not** claim a single "everything fails before any code" run. + +## Step 2 — (GREEN) `types.ts` lifecycle type+fields, then `store.ts` functions + re-score + +**(Reordered — folded plan-critic #5: the type must exist before `store.ts` uses it.)** First, in +`scripts/trends/src/types.ts` — add **`export type TrendStatus = "new" | "acted" | "skipped";`** and the three +optional `TrendRecord` fields (`status?: TrendStatus`, `surfacedCount?: number`, `lastSurfacedAt?: string`) with +doc-comments marking them the realized lifecycle fields the `:22` note anticipated. **Do NOT bump `SCHEMA_VERSION` +here** — the bump lands in Step 3, atomic with the R3a-block reconcile (so the suite is never bumped-but-unreconciled). + +Then in `scripts/trends/src/store.ts` (replace the Phase-B stubs): +- `import type { TrendStatus } from "./types.js";` (type-only — no cycle; the DAG stays `score ← types ← store`, + acyclic). +- `export function effectiveStatus(t: TrendRecord): TrendStatus { return t.status ?? "new"; }`. +- `export function setStatus(store, id, status): { store: TrendStore; found: boolean }` — `const t = + store.trends.find((x) => x.id === id); if (!t) return { store, found: false }; t.status = status; return { + store, found: true };`. Sets `"new"` explicitly on a `reset`. +- `export function markSurfaced(store, ids: string[], today: string): { store: TrendStore; marked: number }` — + `const wanted = new Set(ids); let marked = 0; for (const t of store.trends) { if (!wanted.has(t.id)) continue; + if (t.lastSurfacedAt === today) continue; t.surfacedCount = (t.surfacedCount ?? 0) + 1; t.lastSurfacedAt = + today; marked++; } return { store, marked };`. Per-day idempotent. +- **Re-score in `addTrend`'s duplicate branch** (`:127-131`): after `existing.topics = topics;` compute + `let changed = topicsChanged;` (rename the `unionTopics` result) and add: + `if (input.score !== undefined && JSON.stringify(existing.score) !== JSON.stringify(input.score)) { + existing.score = input.score; changed = true; }`; `return { store, added: false, merged: changed };`. Broaden + the `AddResult.merged` doc-comment to *"the existing record was mutated — topics unioned and/or score + refreshed."* The new-record branch is unchanged. Make the Phase-A re-score + Phase-B lifecycle `store.test` + cases green. + +## Step 3 — (GREEN) `SCHEMA_VERSION` 3→4 bump — ATOMIC with the R3a-block reconcile + +The bump + every test it touches land in **one** step (verified-correct by plan-critic #2): the suite is never +bumped-but-unreconciled. *(At RED, Step 1, only the new R3b block was failing — hard-coded `4` while SCHEMA_VERSION +was still 3; the existing R3a block was green at `3===3`. This step flips SCHEMA_VERSION to 4, which would regress +the R3a block's `3` literals UNLESS reconciled here — hence atomic.)* + +- `types.ts`: **`SCHEMA_VERSION = 4`**. Extend the `store.ts` `loadStore` migrate comment to *"v1→v2→v3→v4 all + additive-optional … the migration is the version stamp alone"* (no logic change — `Math.max(onDisk, + SCHEMA_VERSION)` already stamps v4; `saveStore` preserves the new fields). +- **Reconcile the existing `(RE-R3a / score v2→v3)` block** (`store.test.ts:558-650`) — the bump to 4 regresses + its hard-coded `3` literals (a v3 store now migrates to **4**, so "v3 idempotent" is no longer true). The + brief-reviewer's full enumeration confirmed **exactly three** breaking assertions (`:588`/`:602`/`:628`); apply + the minimal, intent-preserving fix (align to the R2a block's `SCHEMA_VERSION` discipline, `:493`): + - `:588` `assert.equal(s.schemaVersion, 3, "v2 store must migrate to v3")` → `SCHEMA_VERSION` + message "must + migrate to the current version"; **flip the test title at `:571`** ("…loads stamped as **v3**" → "…to the + current version") and the **stale `:570` comment** ("…≠ 3"). + - `:602` `assert.equal(onDisk.schemaVersion, 3)` → `SCHEMA_VERSION`; **flip the test title at `:598`** ("…writes + **schemaVersion:3** to disk" → "…writes the current schemaVersion", mirroring the reconciled R2a twin `:502`). + - `:606` test **"a v3 store … loads idempotent"** → **retitle** "a v3 store migrates to the current version, + score preserved" and change `:628` `assert.equal(s.schemaVersion, 3)` → `SCHEMA_VERSION` (the + `assert.deepEqual(score, …)` stays — score survives the migration). *(The v4-**idempotent** guarantee now + lives in the new R3b block.)* + - `:638` test "a v3 store's score survives load→save→load" → **verified** it asserts only the `score` field + (`:663`, no version literal) — **no change**. +- **The new `(RE-R3b / lifecycle v3→v4)` block** (landed RED in Step 1 with hard-coded `4`): switch its **target + + idempotent** version assertions from hard-`4` to **`SCHEMA_VERSION`** (folded — brief-reviewer #4: now == 4 and + **future-proof**, so R3c's v4→v5 bump won't have to reconcile this block — breaking the cycle R3b pays for R3a). + The v3 **input** fixtures stay literal `3` (they are old-version inputs). *(The hard-`4` was only the genuine-RED + device for the Step-1 run; record that RED proof in the commit log.)* +- Make the new `(RE-R3b / lifecycle v3→v4)` block + the reconciled R3a block both green; the full suite green after + the bump (SC4). + +## Step 4 — (GREEN) `brief.ts` (exclude handled + id/marker render + surfacedIds) + +- `import { effectiveStatus } from "./store.js";` (brief.ts already imports `defaultStorePath` from there — one-way). +- `rankForBrief` entry loop (`:82-92`): add `if (effectiveStatus(trend) !== "new") continue;` immediately before + the `if (overlap === 0) continue;` (`:89`). `totals.trends` stays `store.trends.length` (`:116`). Buckets + + the composite total order (R3a) unchanged. +- `surfacedToken(e)` helper (after `scoreToken`, `:141-145`): `const c = e.trend.surfacedCount; return c && + c >= 2 ? ` · sett ${c}x` : "";`. **Prior-day semantic (folded — plan-critic #3):** the CLI records today's + surfacing **after** `renderBrief`, so `surfacedCount` here is the **prior-day** count — `· sett Nx` = "shown on + N prior distinct days" (today's appearance is recorded but not yet in this render). Document it in the README; + `brief.test` asserts the token by setting `surfacedCount` directly, the cross-day behaviour by §8. +- `renderTopEntry` (`:147-156`) meta line (`:150`): append `${surfacedToken(e)}` after `${scoreToken(e)}` and + ` · \`${e.trend.id}\`` at the very end (after `Pillarer: …`). +- `renderBulletEntry` (`:158-160`): append `${surfacedToken(e)}` after `${scoreToken(e)}` and ` · \`${e.trend.id}\`` + at the very end (after `🔗 ${e.trend.url}`). +- `export function surfacedIds(ranking: BriefRanking): string[]` — `return [...ranking.topMatches, + ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id);` (mirrors the render's + `:199` `.slice(0, 5)`). Replace the Phase-B stub. +- `renderBrief`'s `ranking:` line (`:175`) → append `; excludes acted/skipped` to the pinned string. `briefSummary` + + `BRIEF_SCHEMA_VERSION` unchanged. Make `brief.test` green. + +## Step 5 — (GREEN) `cli.ts` (act/skip/reset + brief marks surfaced + --no-mark) + +- Import `setStatus`, `markSurfaced` from `./store.js`; `surfacedIds` from `./brief.js`. +- A shared `setStatusCmd(status: TrendStatus)` inline helper (or three branches): read `flags.id`; if missing/`"true"` + → `usage('<cmd> needs --id <id>')`; `const store = loadStore(storePath); const res = setStatus(store, flags.id, + status); if (!res.found) { console.error(`error: no trend with id: ${flags.id}`); process.exit(2); } + saveStore(storePath, store); console.log(`Marked ${flags.id} ${status}`);`. Wire `command === "act"` → + `"acted"`, `"skip"` → `"skipped"`, `"reset"` → `"new"`. +- **Broaden the exit-code doc-comment** (folded — plan-critic #2): `cli.ts:33` *"2 on usage error (incl. + unparseable stdin / bad flag)"* → *"2 on usage error or a not-found id (act/skip/reset)"*. A wrong `--id` value + is an argument-class error (exit 2), distinct from `capture`'s data items (`cli.ts:31` — `errors[]`, never the + exit code). No third code introduced. +- `brief` branch (`:274-298`): **hoist the load** (folded — plan-critic #1 / brief-reviewer #1): `cli.ts:286` is + `const ranking = rankForBrief(loadStore(storePath), …)` — there is **no `store` variable** (verified). Replace + with `const store = loadStore(storePath); const ranking = rankForBrief(store, pillars, day, { freshDays });`. + Then after `writeFileSync(path, md, "utf8")` (`:290`), add: `const mark = flags["no-mark"] !== "true"; const + marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0; if (mark) saveStore(storePath, + store);` — the **hoisted `store`** holds the full inventory, so acted/skipped records (filtered from the + ranking, still in the store) are preserved on resave; the `.md` is rendered from the pure `ranking` **before** + the mutation. Add `marked` to the `--json` object (`:293`) and the human summary line. +- **Update the `capture` tally comment** (folded — plan-critic #4): `cli.ts:251-252` *"`merged` (existing gained + topics)"* → *"`merged` (existing gained topics and/or a refreshed score)"* (no tally-logic change). +- `usage()` (`:82-91`) + header synopsis (`:5-13`): add `act`/`skip`/`reset --id <id>` and `[--no-mark]`; a + one-line header note (lifecycle verbs set status; the brief excludes handled trends + records surfacing; + re-capture refreshes the score). Make `cli.test` green (incl. the re-captured-changed-score → `merged:1` tally + assertion, plan-critic #4). + +## Step 6 — wire `trend-spotter.md` (prose) + README + +In `agents/trend-spotter.md`: add **one prose line** (no batch-shape change — the per-item `score` is already +emitted, R3a): re-capturing a known trend now **refreshes** its relevance score (timing decays), and the operator +marks trends `acted`/`skipped` via the CLI (`act`/`skip --id`) so the morning brief stops re-surfacing handled +work. Domain-general (Section 17). Update `scripts/trends/README.md`: the status lifecycle (`new`/`acted`/`skipped` ++ `act`/`skip`/`reset`), the seen-log (`surfacedCount`/`lastSurfacedAt`, per-day idempotent, recorded by `brief`), +re-score-on-recapture (last-wins), and the brief's exclude-handled + `--no-mark` behaviour. + +## Step 7 — gate: floors + new unconditional Section 16k + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (`:705`, currently 146) to the **`tests N` line** reported by `(cd scripts/trends && + npm test)` after Steps 1–6 — recounted live, NOT additive-guessed. Stays **inside** the deps guard. **Append** + `+ RE-R3b: lifecycle +N` to the inline breakdown comment. +- Add **Section 16k** ("Trends Lifecycle Wiring", RE-R3b), mirroring Section 16j (header `:1177`, block runs + through `~:1235`). **Placement (verified live — scope-guardian / brief-reviewer):** 16j is the last section + before Section 18 (anti-erosion, header `:1237`) — insert 16k **after 16j's block (`~:1235`), before Section 18 + (`:1237`)** (anti-erosion must stay last so it counts every prior check). Six + **unconditional**, deps-absent-safe checks (pure `grep -qF`/self-test, no `tsx`), the self-test emitting **one** + pass/fail like 16j: + (1) a non-vacuity self-test (a probe carrying `effectiveStatus` accepted, one without rejected); + (2) `grep -qF 'export type TrendStatus' scripts/trends/src/types.ts`; + (3) `grep -qF 'surfacedCount' scripts/trends/src/types.ts` (the seen-log field); + (4) `grep -qF 'export function markSurfaced' scripts/trends/src/store.ts` (the seen-log writer); + (5) `grep -qF 'effectiveStatus' scripts/trends/src/brief.ts` (the brief excludes handled); + (6) `grep -qF 'command === "act"' scripts/trends/src/cli.ts` (the lifecycle verb). +- Bump `ASSERT_BASELINE_FLOOR` (`:1259`, currently 99) → **exactly 105** (99 + the 6 new unconditional 16k + emitters; the self-test emits one pass/fail like 16j, so 105 is deterministic — "live recount" is the safety + net, not a guess). Update the **header-enumeration prose chain** (`:49-53`) by inserting the 16k clause + **between** the 16j clause and the Section-18 clause, preserving sentence flow. +- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3b adds no hook test). It must + still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land. + +## Step 8 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then run brief §7's seven behavioural steps (capture A; re-capture +A with a lower timing → `list --json` shows the composite dropped + `merged:1`; `brief --json` → `marked:1`, +`list` shows `surfacedCount:1`+`lastSurfacedAt`, entry line shows `· \`<id>\``; re-run brief → `marked:0`; +`act --id <A>` → A absent from the brief md; `reset --id <A>` → A reappears; `--no-mark` → no surfacedCount +written). Run full `bash scripts/test-runner.sh` → `FAIL=0` (`ASSERT_BASELINE_FLOOR` 105); run `node --test +hooks/scripts/__tests__/*.test.mjs` → still green (untouched regression). + +## Step 9 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 105, schema v4, +gate total). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3b.md` (no +suffix, tracked); **(2)** code commit — the three `src/*.ts` (`types`/`store`/`brief`/`cli` — four) + three test +files + `agents/trend-spotter.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]` +(D9: single code commit — the lifecycle is tightly coupled). Push freely (window lifted; gitleaks at commit; +`origin` = PUBLIC `open/` — STATE/`*.local.*` never pushed). No version bump (additive; `v0.5.2` dev). + +## Verification (testable) + +| SC | Check | Command | Expected | +|---|---|---|---| +| — | RED Phase A | `(cd scripts/trends && npm test)` before src edits | re-score/migration/brief/cli new cases fail on assertion (logic-RED), not module-not-found | +| — | RED Phase B | `npm test` after non-throwing stubs | effectiveStatus/setStatus/markSurfaced/surfacedIds fail on value assertion against stubs | +| SC1 | status + effectiveStatus + setStatus | `npm test` (store.test) | effectiveStatus absent⇒new; setStatus found/absent; reset⇒new | +| SC2 | re-score last-wins | `npm test` (store.test) | dup w/ diff score → replaced, merged:true, provenance+status+surfaced untouched; identical→merged:false; no-score→unchanged; acted stays acted | +| SC3 | markSurfaced + idempotency | `npm test` (store.test) | increments + sets lastSurfacedAt; same-day re-call marked:0; later day increments; unknown id skipped | +| SC4 | migration v3→v4 | `npm test` (store.test) | v3 loads as v4, intact, no field invented; round-trip writes v4; v4 idempotent; new fields survive; **R3a block reconciled (no regression)** | +| SC5 | brief excludes handled | `npm test` (brief.test) | only `new` in buckets; totals.trends full; only-handled→empty + no-fresh summary; surviving order = R3a total order | +| SC6 | render id + marker + descriptor | `npm test` (brief.test) | full lines `· \`<id>\``; `· sett Nx` at count≥2 / none below; surfacedIds set; exact `ranking:` ending `; excludes acted/skipped`; deterministic | +| SC7 | CLI act/skip/reset | `npm test` (cli.test) + manual | act→acted, skip→skipped, reset→new (via list --json); unknown id→exit 2; missing --id→exit 2 | +| SC8 | CLI brief marks + --no-mark + exclude | `npm test` (cli.test) + manual | surfacedCount:1 + lastSurfacedAt + marked in json; second same-day marked:0; --no-mark no write; md omits acted/skipped | +| SC9 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16k green; ASSERT_BASELINE_FLOOR=105; Section 17; counts 27/19/29; hook suite green | + +## Risks + +- **R1 — the v3→v4 bump silently regresses the R3a migration block** (its hard-coded `3` literals; "v3 + idempotent" is false after the bump). *Mitigated:* Step 3 **explicitly reconciles** the `(RE-R3a / score v2→v3)` + block (flip `3`→`SCHEMA_VERSION`, retitle the idempotent test to a forward-migration test); SC4 asserts the + full suite green after the bump. This is the load-bearing migration subtlety — caught by premise verification + before drafting, not after. +- **R2 — re-score corrupts provenance / status / the seen-log** (over-broad mutation in `addTrend`). *Mitigated:* + the duplicate branch touches **only** `topics` + `score`; `source`/`capturedAt`/`publishedAt`/`status`/ + `surfacedCount`/`lastSurfacedAt` are untouched; SC2 asserts each is preserved (incl. an acted-trend re-capture + keeping `status:"acted"`). +- **R3 — false-merge inflation** (a re-capture with an identical score reported as `merged`). *Mitigated:* the + `JSON.stringify(existing.score) !== JSON.stringify(input.score)` guard (the envelope is built in a fixed key + order by `scoreEnvelope`, so the compare is stable); SC2 asserts an identical re-score → `merged:false`. +- **R4 — non-idempotent surfacing** (re-running today's brief double-counts; the autonomous loop (c) would inflate + `surfacedCount`). *Mitigated:* `markSurfaced` skips records whose `lastSurfacedAt === today`; SC3 + SC8 assert a + same-day re-call → `marked:0`, counts unchanged. +- **R5 — the brief CLI's new store write loses data** (acted/skipped records dropped on resave, or the brief + written from a mutated ranking). *Mitigated:* `markSurfaced` mutates only the `surfacedCount`/`lastSurfacedAt` + of the **surfaced** ids on the **already-loaded full store**; `saveStore` writes the whole store (handled + records preserved); the `.md` is rendered from the pure `ranking` **before** the mutation; SC8 asserts the md + omits acted/skipped AND the store still contains them with surfacing recorded. +- **R6 — `rankForBrief` loses purity** (the status filter or surfacing leaking fs/mutation into the pure render). + *Mitigated:* the filter is a pure read of `effectiveStatus`; the seen-log **write** is only in the `brief` CLI + edge, guarded by `--no-mark`; SC5/SC6 assert deterministic, byte-identical render; `markSurfaced`/`setStatus`/ + `surfacedIds`/`effectiveStatus` are all pure (no fs). +- **R7 — losing the brief's determinism** (the id/marker tokens or the status filter perturbing the total order). + *Mitigated:* the id + `surfacedToken` are deterministic reads; the filter only removes records, preserving the + R3a composite total order on the survivors; SC5/SC6 assert byte-identical output. +- **R8 — editing `trend-spotter.md` trips the de-niche guard.** *Mitigated:* Section 17 runs in the gate; the + added prose is generic lifecycle wording (`acted`/`skipped`/"refresh the score"), vendor/sector-free. +- **R9 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16k is pure + `grep`/self-test on tracked source (no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps guard. +- **R10 — import cycle.** *Mitigated:* the new edges are one-way: `store.ts ← types.ts` (`TrendStatus` type into + `store`, via `types`), `brief.ts → store.ts` (`effectiveStatus` value — brief already imports store), `cli.ts → + {store,brief}` (already). `score.ts`/`item.ts` are untouched. The DAG stays acyclic: `score (leaf) ← types ← + store ← brief ← cli`. +- **R11 — the `status` name collision** (the CLI `status` staleness subcommand vs the new `TrendStatus` lifecycle). + *Accepted:* the subcommand reads store staleness (newest capture), the type is the per-record lifecycle — no + shared code; the collision is pre-existing and cosmetic; not reconciled here (documented non-goal). + +## Plan-critic — folded + +Three Opus reviewers ran, each verifying claims against live code: **scope-guardian ALIGNED** (0 creep, 0 gaps; +counts 27/19/29 + `score.ts`/`item.ts`-untouched + A1/A2/A3-consistency verified live; 2 MINOR plan line-cites); +**brief-reviewer PROCEED_WITH_RISKS** (all 9 load-bearing claims TRUE; the v3→v4 reconcile complete for every +breaking literal, enumerated; 1 MEDIUM + 3 LOW); **plan-critic PROCEED_WITH_RISKS (78/B)** (the two-phase RED, +the atomic bump+reconcile, the `merged` broadening's non-regression, the `surfacedIds` formula, and the gate +arithmetic all verified correct; 1 MAJOR + 5 MINOR). Resolution, each verified against live code: + +- **[MAJOR — plan-critic #1 / MEDIUM — brief-reviewer #1] the `brief` CLI's `store` binding does not exist.** + `cli.ts:286` inlines `rankForBrief(loadStore(storePath), …)` — no `const store`, so the `markSurfaced(store, + …)` / `saveStore(storePath, store)` edit referenced an undefined identifier (would not compile). ✅ Step 5 now + **hoists** `const store = loadStore(storePath); const ranking = rankForBrief(store, …);`; the brief §3 S-cli + + A1 wording + R5 corrected to the hoisted binding. +- **[MINOR — plan-critic #2] not-found id → exit 2 contradicted the documented exit-code contract** (`cli.ts:33` + "2 on usage error"; `cli.ts:31` data-conditions never via exit code). ✅ Step 5 **broadens the `:33` + doc-comment** to "2 on usage error or a not-found id (act/skip/reset)" (a wrong `--id` is an argument-class + error); no third code introduced. +- **[MINOR — plan-critic #3] `· sett Nx` off-by-one** (render precedes the surfacing mutation → the token reflects + the prior-day count). ✅ the **prior-day semantic** is now explicit (Step 4 + brief §3 + README): `· sett Nx` = + "shown on N prior distinct days"; the `brief.test` sets `surfacedCount` directly, §8 exercises the cross-day path. +- **[MINOR — plan-critic #4] `capture` tally comment stale + the re-score CLI tally untested.** ✅ Step 5 updates + the `cli.ts:251-252` comment (`merged` = topics ∪ score-refresh); Step 1 + SC2 add a subprocess assertion that a + re-captured changed-score item reports `merged:1` with the updated composite. +- **[MINOR — plan-critic #5] Step 2 used `TrendStatus` before Step 3 defined it.** ✅ **reordered**: Step 2 adds + the `TrendStatus` type + the three fields to `types.ts` first (then the `store.ts` functions); Step 3 isolates + the atomic `SCHEMA_VERSION` bump + the R3a reconcile. +- **[MINOR — plan-critic #6 / brief-reviewer LOW] stale R3a test titles + comment** (`store.test.ts:571`/`:598` + titles, `:570` comment still say "v3" after the bump). ✅ Step 3 flips both titles to "the current version" + + refreshes the comment, alongside the `:588`/`:602`/`:628` assertion flips. +- **[LOW — brief-reviewer #4] forward-debt: the new R3b migration block hard-coded `4`** (perpetuating the + reconcile-cycle). ✅ Step 3 commits the new block's **target + idempotent** assertions against `SCHEMA_VERSION` + (future-proof; the hard-`4` is the Step-1 RED device only); the v3 **input** fixtures stay literal `3`. +- **[LOW — brief-reviewer #3] `cli.test.ts:247` inert fixture not enumerated.** ✅ added to the scope-fence + enumeration alongside `brief.test.ts:53` ("miss none" now literally true). +- **[MINOR — scope-guardian] two plan line-cites** for the gate placement (`~:1191`/`:1262`). ✅ corrected to + `~:1235` (after 16j's block) / `:1237` (Section 18 header) in Step 7 + the Files-touched table. + +**scope-guardian — ALIGNED:** every SC1–SC9 traces to a step; zero creep, zero gaps; all §4 non-goals held (no +saturation scoring, no scheduler, no brief-diff, no A1–A4, no status/surfaced input on capture, no act/skip-by-title, +no auto-act-on-publish, no new source/agent/command, `BRIEF_SCHEMA_VERSION` unchanged); counts 27/19/29 verified +live; `score.ts`/`item.ts` untouched verified (`itemToInput` already builds the envelope on every capture incl. +re-capture); the R3a-block reconcile is a necessary prerequisite the bump forces, not creep. + +**[plan-critic headless-readiness] N/A** — R3b executes **in-session, operator-driven** (driftsmodell), not as a +headless autonomous run, so per-step revert/halt clauses aren't needed (R1/R2a/R2b/R3a had none either). diff --git a/docs/research-engine/plan-re-r3c.md b/docs/research-engine/plan-re-r3c.md new file mode 100644 index 0000000..1434b4b --- /dev/null +++ b/docs/research-engine/plan-re-r3c.md @@ -0,0 +1,376 @@ +# Plan — RE-R3c: autonomous trigger — scheduler + headless entry point + +> **Brief:** `docs/research-engine/brief-re-r3c.md`. **Slice:** RE-R3c (research-engine rung-2 — R3 slice 3, the +> **autonomy** slice: the scheduler trigger + the headless entry that runs the deterministic morning brief with no +> interactive session). Closes hulls (1) no autonomous trigger + (6) no headless entry point. +> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)** +> subprocess assertion-RED against the **existing** CLI (`schedule` is an unknown command → `usage` exit 2; the +> `run-daily.sh` wrapper file is absent → exit 127) on the exit-0/stdout assertions; **(B)** for the NEW +> `schedule.ts` exports the tests import, land **non-throwing stubs** first (Node16 ESM throws a missing named +> import at module-load, not on assertion), then record value-assertion RED against them. Then GREEN: `schedule.ts` +> stubs→real → `run-daily.sh` wrapper → `cli.ts` `schedule` verb → wire `trend-spotter.md` (prose) + README → +> gate floors + Section 16l → behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-26):** C1 deterministic brief-only (no AI capture — +> that is slice e) · C2 print-first installer (emit + the operator runs the system mutation; `--install` writes only +> the inert launchd plist file; the tool never runs `launchctl`/`crontab`). Go-gate D1–D9 baked to recommended +> defaults (brief §8). +> **Light-Voyage:** scope-guardian / brief-reviewer / plan-critic to run on these drafts; findings folded in +> §Plan-critic — folded before the code commit. + +## Goal + +Make the daily research loop **closed** and **headless**. (1) **Autonomous trigger** — a `schedule` CLI verb that +emits (print-first) a launchd plist (macOS) / crontab line (Linux) firing a daily brief, and `--install` that +writes only the inert launchd plist file (the operator runs the one activation command). (2) **Headless entry +point** — `run-daily.sh`, a bash-3.2 wrapper that runs the **deterministic** `brief` from a scheduler's +profile-less env (resolves node + the data-dir log seam + working dir), invoked identically by launchd and cron. +**No AI capture** (C1 — that is slice e, which plugs into the documented pre-brief seam); **no schema change** +(`SCHEMA_VERSION` 4 / `BRIEF_SCHEMA_VERSION` 1 untouched); **no new agent/command/reference**. Two new source +files under `scripts/trends/` (`schedule.ts` pure module + `run-daily.sh` wrapper) + their tests; EDIT `cli.ts` + +`trend-spotter.md` (prose) + README + the gate. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/schedule.ts` | **NEW (pure module)** — `ScheduleSpec` (incl. `env: Record<string,string>`); `launchdPlist`/`crontabLine`/`installInstructions`/`uninstallInstructions`/`defaultLabel`. No clock/fs/env/AI (renders `spec.env`; reads no env). | SC1, SC2 | +| `scripts/trends/run-daily.sh` | **NEW (wrapper)** — bash 3.2; resolves own dir + **`cd "$DIR"`** + node (`NODE_BIN`/`command -v`) + log seam (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log`); runs `cli.ts" brief "$@" --json`, **compacts** the pretty-printed json to one line; logs `<ts> exit=<code> <compact-json>`; (e)-seam comment. | SC7, SC8 | +| `scripts/trends/src/cli.ts` | **EDIT** — `schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform auto\|launchd\|cron] [--install] [--uninstall] [--store <p>]`; adds `dirname` (node:path) + `homedir` (node:os) + `fileURLToPath` (node:url) imports; resolves paths from runtime (`process.execPath`/`import.meta.url`/`defaultStorePath`); `logPath = join(dirname(defaultStorePath()), "cron.log")`; `env` always `{NODE_BIN, LINKEDIN_STUDIO_DATA(resolved)}`; `args` without leading `"brief"`; print-first dispatch; `--install` writes the inert plist only (never `launchctl`/`crontab`); usage + header doc; **no new exit code** | SC1–SC6, SC9 | +| `scripts/trends/tests/schedule.test.ts` | **NEW** — pure-emitter assertions: plist keys (Label/ProgramArguments/StartCalendarInterval/Std*Path/EnvironmentVariables), crontab line shape, install/uninstall instruction strings, `defaultLabel`, determinism | SC1, SC2 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — subprocess: `schedule --print` (launchd + cron) emits the artifact + instruction, exit 0; `--platform auto` branches on `process.platform`; `--print` writes nothing (temp HOME LaunchAgents empty); `--install` launchd writes the plist file + prints `launchctl` (never runs it); `--install` cron prints only; no `--pillars`/bad `--at`/bad `--platform` → exit 2 | SC3–SC6, SC9 | +| `scripts/trends/tests/run-daily.test.ts` | **NEW** — subprocess: wrapper on a seeded store writes the dated brief + appends to `cron.log` + exit 0; second same-day run → byte-identical `.md`, `surfacedCount` not double-counted, second log line; data-path twin consistency (wrapper log dir == `defaultStorePath` dir == `getDataRoot('trends')`) | SC7, SC8 | +| `scripts/trends/src/brief.ts` | **UNTOUCHED** — the nightly run reuses the existing deterministic `brief`/`rankForBrief`/`renderBrief`. Listed to assert it is *not* in scope. | — | +| `scripts/trends/src/store.ts` · `types.ts` · `item.ts` · `score.ts` | **UNTOUCHED** — no data-shape / scoring / render change. Listed to assert they are *not* in scope. | — | +| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: the brief can now be scheduled to regenerate autonomously (deterministic, from the store) via `schedule`; polling stays the capture path (autonomous AI polling is a later slice). Domain-general. | — | +| `scripts/trends/README.md` | **EDIT** — the headless wrapper + `schedule` (print-first, launchd/cron, `--install`/`--uninstall`), the deterministic-brief-only boundary (C1) + the (e) seam, `cron.log`, per-day idempotency | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` 171→recount + breakdown comment (`:709`); NEW unconditional **Section 16l** (after 16k's block `~:1305`, before Section 18 header `:1307`); `ASSERT_BASELINE_FLOOR` 105→**111** (`:1329`); header-enumeration chain (`:57`) + Section-18 floor-history narration (`~:1310-1324`) | SC10 | +| `docs/research-engine/{brief,plan}-re-r3c.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 111, gate total; schema unchanged v4). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `brief.ts`/`store.ts`/`types.ts`/`item.ts`/`score.ts` (the nightly run reuses the +existing brief path; no data/scoring/render change) · `references/trend-scoring-modes.md` + `algorithm-signals- +reference.md` (no scoring change) · the SessionStart hook + its tests (R3c generates the brief the hook already +surfaces; no hook change, no frontmatter-schema change, no new hook test) · `config/*` · `commands/*` (29 — no +new command) · `agents/*` count (19 — `trend-spotter.md` is a prose EDIT) · `references/*` (27) · `.gitignore` +(`scripts/trends/{node_modules,build}` already covered; `cron.log` lives under the external data dir, never in the +repo) · `BRIEF_SCHEMA_VERSION` (1) · `SCHEMA_VERSION` (4). + +## Step 1 — (RED, two phases) failing tests across schedule/cli/wrapper + +**Phase A — subprocess assertion-RED against the pre-edit code** (no new import needed; the CLI is invoked as a +subprocess and the wrapper file is simply absent): +- `cli.test.ts` — **`schedule`**: `schedule --pillars ai,gov --platform launchd --print` today → `usage` (unknown + command) **exit 2**, no plist on stdout → RED against the assertion (expects exit 0 + a plist with `Label + com.linkedin-studio.trends.daily`); `--platform cron --print` → RED (expects the crontab line + the `crontab -` + instruction string); `--print` with a temp `HOME` → RED (expects `<HOME>/Library/LaunchAgents` empty *and* exit + 0); no `--pillars` / `--at 25:00` / `--platform bogus` → these already exit 2 today (unknown command), so assert + the **post-implementation** behaviour (still exit 2, but for the validation reason) — recorded as RED only where + the message/route differs (kept minimal; the load-bearing RED is the `--print` emit). *(Fail today: `schedule` + is an unknown command.)* +- `run-daily.test.ts` — **wrapper**: invoke **via `bash scripts/trends/run-daily.sh --pillars ai --store <tmp> + --out <tmp>/mb`** (through `bash`, NOT a direct executable spawn — folded — plan-critic #7: a direct exec of a + missing file throws ENOENT/`status:null`, a module-not-found-class failure; `bash <missing>` exits **127**, a + clean assertion-RED) with `LINKEDIN_STUDIO_DATA=<tmp>` → the file is **absent** → exit **127** → RED against the + assertion (expects the dated `.md` written + exactly one `cron.log` line + exit 0). *(Fail today: the wrapper does + not exist.)* + +**Phase B — stub-first, then value-assertion RED** (`schedule.test` imports the new `schedule.ts` exports): +- Land **non-throwing stubs** so the imports resolve: in `schedule.ts` — `launchdPlist → ""`, `crontabLine → ""`, + `installInstructions → ""`, `uninstallInstructions → ""`, `defaultLabel → ""` (+ the `ScheduleSpec` interface, + erased by tsx). (Wrong-value stubs Step 2 replaces.) +- `schedule.test.ts`: `launchdPlist(spec)` contains `<key>Label</key>` + `spec.label` + `StartCalendarInterval` + + `spec.hour`/`spec.minute` + the `cron.log` path (stub `""` → RED); `crontabLine(spec)` matches the + `<min> <hour> * * * … run-daily.sh … >> <log> 2>&1 # <label>` shape (stub `""` → RED); `installInstructions` / + `uninstallInstructions` carry the `launchctl bootstrap` / `crontab -` recipes (stub `""` → RED); `defaultLabel()` + is `com.linkedin-studio.trends.daily` (stub `""` → RED); two `launchdPlist` calls byte-identical. + +**RED proof (record in commit, two phases):** Phase A — `(cd scripts/trends && npm test)` before any src edit → +the `schedule` cli cases fail on the exit-0/stdout assertion (logic-RED) and the wrapper case fails on exit 127, +not on a missing module. Phase B — after the non-throwing `schedule.ts` stubs land, the `schedule.test` cases fail +on **value assertion** against the `""` stubs. The plan does **not** claim a single "everything fails before any +code" run. + +## Step 2 — (GREEN) `scripts/trends/src/schedule.ts` — the pure emitters + +Replace the Phase-B stubs with the real, pure implementations (no clock/fs/env/AI; every value via `ScheduleSpec` — +the emitters **render** `spec.env`, they never read `process.env`): +- `export interface ScheduleSpec { platform: "launchd" | "cron"; label: string; nodeBin: string; wrapperPath: + string; args: string[]; hour: number; minute: number; logPath: string; workingDir: string; env: + Record<string, string>; }`. **`env` is canonical** (folded — brief-reviewer #4 / plan-critic #3: declared once, + here + brief §3 + the Files table + the Step-1 fixtures — not mutated mid-step). The CLI always builds it as + `{ NODE_BIN, LINKEDIN_STUDIO_DATA }`; the emitters only stringify it. +- `launchdPlist(spec)` — a pinned, well-formed `<?xml … !DOCTYPE plist …>` template: `Label`=`spec.label`; + `ProgramArguments` = `["/bin/bash", spec.wrapperPath, ...spec.args]` (each as a `<string>`); + `StartCalendarInterval` `<dict>` with `<key>Hour</key><integer>${spec.hour}</integer>` + `Minute`; + `EnvironmentVariables` `<dict>` rendered from **`spec.env`** (each `K`→`<key>K</key><string>V</string>`); + `WorkingDirectory`=`spec.workingDir`; `StandardOutPath`/`StandardErrorPath`=`spec.logPath`; `RunAtLoad`=`<false/>`. + XML-escape any value that could contain `&`/`<`/`>` (paths are safe, but escape defensively for well-formedness). +- `crontabLine(spec)` — `${spec.minute} ${spec.hour} * * * ${envPrefix} /bin/bash ${spec.wrapperPath} + ${spec.args.join(" ")} >> ${spec.logPath} 2>&1 # ${spec.label}` where `envPrefix` = the `spec.env` map as + `K=V K=V` (cron's inline env form). **Returns a string; never executes `crontab`.** +- `installInstructions(spec, plistTargetPath?)` — launchd: `"Wrote ${plistTargetPath}. Activate:\n launchctl + bootstrap gui/$(id -u) ${plistTargetPath}"`; cron: `"Add the line above:\n (crontab -l 2>/dev/null; echo + '${crontabLine(spec)}') | crontab -"`. +- `uninstallInstructions(spec, plistTargetPath?)` — launchd: `"launchctl bootout gui/$(id -u)/${spec.label} && rm + ${plistTargetPath}"`; cron: `"crontab -l | grep -vF '# ${spec.label}' | crontab -"`. +- `defaultLabel()` — `"com.linkedin-studio.trends.daily"`. +Make the Phase-B `schedule.test` cases green. + +## Step 3 — (GREEN) `scripts/trends/run-daily.sh` — the headless wrapper + +Create the bash-3.2 wrapper (Write a new file under `scripts/trends/` — allowed; if Write is blocked, the operator +authorizes via the R2b `!cp` fallback, Risk R4). Shape: +```sh +#!/usr/bin/env bash +# RE-R3c headless entry: runs the DETERMINISTIC morning brief from a scheduler's +# profile-less env. No AI. The (e) slice will insert a pre-brief capture step here. +# Data-path: the FOURTH sanctioned twin of store.ts:defaultStorePath / data-root.mjs:getDataRoot +# / analytics/storage.ts:getDataRoot (shell form of the references/data-path-convention.md +# inline seam). Keep in sync. +set -eu +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" # so `--import tsx` resolves node_modules even under cron's $HOME CWD +NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || true)}" +if [ -z "$NODE_BIN" ]; then for c in /usr/local/bin/node /opt/homebrew/bin/node /usr/bin/node; do + [ -x "$c" ] && NODE_BIN="$c" && break; done; fi +LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log" +mkdir -p "$(dirname "$LOG")" +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +if [ -z "$NODE_BIN" ]; then printf '%s exit=127 node not found\n' "$TS" >> "$LOG"; exit 127; fi +set +e +OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)"; CODE=$? +set -e +OUT="$(printf '%s' "$OUT" | tr '\n' ' ' | tr -s ' ')" # brief --json is pretty-printed -> one line +printf '%s exit=%s %s\n' "$TS" "$CODE" "$OUT" >> "$LOG" +exit "$CODE" +``` +- `chmod +x` (or the CLI's `--install` does it). All expansions quoted; ASCII-only; no bash-4 features. `cd "$DIR"` + makes the one wrapper scheduler-agnostic (folded — brief-reviewer #1). `CODE=$?` is captured **before** the + compaction pipe (so it is node's code, not `tr`'s — folded — plan-critic #1). +- The scheduler bakes `NODE_BIN=<process.execPath>` + a resolved-absolute `LINKEDIN_STUDIO_DATA` (so a scheduled + run never evaluates `$HOME` under `set -u` — folded — plan-critic #9) + `--pillars … --fresh-days N` into `"$@"`; + the wrapper hard-codes the `brief` subcommand (the baked `args` carry **no** leading `brief` — folded — + plan-critic #8) and appends `--json`. +Make `run-daily.test.ts` green (SC7, SC8): the seeded-store run writes the dated `.md` + one compact log line + +exit 0; a `cwd=<unrelated>` run still resolves `tsx` (CWD-independence); a same-day re-run is byte-identical with +`surfacedCount` not double-counted. + +## Step 4 — (GREEN) `cli.ts` — the `schedule` subcommand + +- Import `{ launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel }` from + `./schedule.js`; **add `dirname` to the `node:path` import** (`cli.ts:41` is `import { join } from "node:path"` + — `dirname` is NOT there today, folded — plan-critic #5; `join` stays); import `homedir` from `node:os` (the + LaunchAgents path) + `fileURLToPath` from `node:url`. `defaultStorePath` is already imported (`cli.ts:45`). +- A `schedule` branch (after the `brief` branch, before the trailing `usage`): + - `const pillars = splitTopics(flags.pillars); if (pillars.length === 0) usage("schedule needs --pillars <a,b>");` + - parse `--at` (default `"07:00"`): split on `:`, `Number.parseInt` both; validate `0≤H≤23`, `0≤M≤59` → else + `usage("--at must be HH:MM (00:00–23:59)")`. + - parse `--fresh-days` (default 7, reuse the `brief` validation idiom). + - `const platform = flags.platform && flags.platform !== "true" ? flags.platform : (process.platform === + "darwin" ? "launchd" : "cron");` validate `platform ∈ {launchd, cron, auto}` (auto already resolved) → else + `usage`. + - resolve runtime paths (no hard-coding): `const here = dirname(fileURLToPath(import.meta.url)); // …/src`; + `const wrapperPath = join(here, "..", "run-daily.sh"); const workingDir = join(here, ".."); const nodeBin = + process.execPath;`. **logPath (pinned — folded, all three reviewers):** `const logPath = join(dirname + (defaultStorePath()), "cron.log");` — derived from `defaultStorePath()` (= `<root>/trends/trends.json` → + `dirname` = `<root>/trends` → `<root>/trends/cron.log`), the data-root anchor the wrapper also uses; **NOT** + `dirname(storePath)` (the `--store` override) and **NOT** with a spurious `".."`. Matches `defaultBriefDir`'s + `dirname(defaultStorePath())` idiom (`brief.ts:236`), so `cron.log` is a sibling of `morning-brief/`. + - `const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); + const env: Record<string,string> = { NODE_BIN: nodeBin, LINKEDIN_STUDIO_DATA: root };` — **always** bake the + resolved-absolute root (pins the scheduled run to the install-time data dir **and** removes the wrapper's + `$HOME`-unset `set -u` edge — folded — plan-critic #9 / brief-reviewer #9). + - `const args = ["--pillars", pillars.join(","), "--fresh-days", String(freshDays)];` **(no leading `"brief"`** — + the wrapper owns the subcommand; folded — plan-critic #8) and append `["--store", storePath]` **iff** the + operator passed an explicit `--store` (so the scheduled run targets the same store; otherwise the wrapper's + default resolves it). + - `const label = defaultLabel(); const spec: ScheduleSpec = { platform, label, nodeBin, wrapperPath, args, hour, + minute, logPath, workingDir, env };` + - dispatch: + - **`--uninstall`** → print `uninstallInstructions(spec, plistTarget)`; if the launchd plist file exists, + `rmSync` it (reversible); exit 0. + - **`--install`** → launchd: `const plistTarget = join(homedir(), "Library", "LaunchAgents", `${label}.plist`); + mkdirSync(dirname, {recursive:true}); writeFileSync(plistTarget, launchdPlist(spec))` + `console.log` the + plist path + `installInstructions(spec, plistTarget)` — **never run `launchctl`**. cron: `console.log(crontabLine + (spec)); console.log(installInstructions(spec))` — **never run `crontab`**. exit 0. + - **default / `--print`** → `console.log(platform === "launchd" ? launchdPlist(spec) : crontabLine(spec)); + console.log(installInstructions(spec, platform === "launchd" ? join(homedir(),"Library","LaunchAgents", + `${label}.plist`) : undefined));` — **no fs**. exit 0. +- **Header doc-comment** (`cli.ts:5-14`, `:36-37`): add the `schedule …` synopsis line + a note that `schedule` is + **print-first** (emits the plist/crontab + the install command; `--install` writes only the inert launchd plist + file; the tool never runs `launchctl`/`crontab`) and runs the **deterministic** brief (no AI capture — slice e). + **No new exit code** (0 success / 2 usage): an autonomy install never *runs* the system mutation, so there is no + install-failure path to encode. +- `usage()` (`:86-100`) — add the `schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] [--platform + auto|launchd|cron] [--install|--uninstall] [--store <path>]` line. +Make the Phase-A `cli.test` `schedule` cases green (print emit, auto-platform, print-writes-nothing, +install-launchd-writes-plist, install-cron-prints-only, validation exits). + +## Step 5 — wire `trend-spotter.md` (prose) + README + +In `agents/trend-spotter.md`: add **one prose line** (no batch-shape change): the morning brief can now be +**scheduled** to regenerate autonomously (deterministic, from the store) via the `schedule` CLI verb; the agent's +polling stays the capture path (autonomous AI polling is a later slice). Domain-general (Section 17). Update +`scripts/trends/README.md`: the headless wrapper (`run-daily.sh`) + the `schedule` subcommand (print-first, +launchd/cron, `--install`/`--uninstall`, `--at`/`--platform`), the **deterministic-brief-only boundary (C1)** + the +documented (e) AI-capture seam, the `cron.log` location, and the R3b per-day idempotency that makes a double-fire +safe. + +## Step 6 — gate: floors + new unconditional Section 16l + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (`:709`, currently 171) to the **`tests N` line** reported by `(cd scripts/trends && + npm test)` after Steps 1–5 — recounted live, NOT additive-guessed. Stays **inside** the deps guard. **Append** + `+ RE-R3c: scheduler +N` to the inline breakdown comment. +- Add **Section 16l** ("Trends Scheduler / Headless Wiring", RE-R3c), mirroring Section 16k (unconditional, + deps-absent-safe, pure `grep -qF`/self-test, no `tsx`). **Placement (verified live):** Section 16k ends `~:1305`, + Section 18 begins `:1307` — insert 16l **after 16k's block (`~:1305`), before Section 18 (`:1307`)** (anti-erosion + must stay last so it counts every prior check). Six **unconditional** checks, the self-test emitting **one** + pass/fail like 16k: + (1) a non-vacuity self-test (a probe carrying `launchdPlist` accepted, one without rejected); + (2) `grep -qF 'export function launchdPlist' scripts/trends/src/schedule.ts`; + (3) `grep -qF 'export function crontabLine' scripts/trends/src/schedule.ts`; + (4) `grep -qF 'command === "schedule"' scripts/trends/src/cli.ts` (the verb); + (5) `grep -qF 'cli.ts" brief' scripts/trends/run-daily.sh` (the wrapper runs the deterministic brief — the + sentinel matches the literal `…cli.ts" brief`, folded — plan-critic #8); + (6) `grep -qF 'LINKEDIN_STUDIO_DATA:-' scripts/trends/run-daily.sh` (the data-path twin seam). +- Bump `ASSERT_BASELINE_FLOOR` (**`:1329`**, currently 105 — folded, all three reviewers: the earlier `:1259` cite + was wrong, that line is `LIFECYCLE_FILTER_LIT`) → **exactly 111** (105 + the 6 new unconditional 16l emitters; + the self-test emits one pass/fail like 16k, so 111 is deterministic — "live recount" is the safety net, not a + guess). Insert the 16l clause into the **header-enumeration prose chain at `:57`** (before "…the assertion-count + anti-erosion floor (SC6) in Section 18"), preserving sentence flow. **Also append** the RE-R3b (→105) + RE-R3c + 16l (→111) narration to the **Section-18 floor-history comment** (`~:1310-1324`, which still stops at "= 99" + because R3b's +6 was never narrated — folded — scope-guardian #7), so the comment matches the live floor. +- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3c adds no hook test). It must + still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land. + +## Step 7 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then run brief §7's six behavioural steps: seed a store; `schedule +… --platform launchd --print` + `… --platform cron --print` (inspect); `plutil -lint -` the plist → OK; +`LINKEDIN_STUDIO_DATA=/tmp/r3c-data ./run-daily.sh brief --pillars ai,gov --store /tmp/r3c.json --out +/tmp/r3c-data/trends/morning-brief` → dated `.md` written + `cron.log` line + exit 0; re-run same day → `.md` +byte-identical, `surfacedCount:1` (per-day idempotent), second log line; `schedule … --install` with a throwaway +`HOME` → the plist file written under `<HOME>/Library/LaunchAgents/` + the `launchctl bootstrap` command printed +(do **not** activate against the real system unless intentional). Run full `bash scripts/test-runner.sh` → `FAIL=0` +(`ASSERT_BASELINE_FLOOR` 111); run `node --test hooks/scripts/__tests__/*.test.mjs` → still green (untouched +regression). + +## Step 8 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 111, gate total; +schema unchanged v4). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3c.md` +(no suffix, tracked); **(2)** code commit — `schedule.ts` + `run-daily.sh` + `cli.ts` + three test files +(`schedule.test`/`cli.test`/`run-daily.test`) + `agents/trend-spotter.md` + `scripts/trends/README.md` + +`scripts/test-runner.sh` with `[skip-docs]` (D9: single code commit — the scheduler is one coherent feature). +Push freely (window lifted; gitleaks at commit; `origin` = PUBLIC `open/` — STATE/`*.local.*` never pushed). No +version bump (additive; `v0.5.2` dev). + +## Verification (testable) + +| SC | Check | Command | Expected | +|---|---|---|---| +| — | RED Phase A | `(cd scripts/trends && npm test)` before src edits | `schedule` cli cases fail on the exit-0/stdout assertion (logic-RED); wrapper case fails on exit 127, not module-not-found | +| — | RED Phase B | `npm test` after non-throwing `schedule.ts` stubs | launchdPlist/crontabLine/install/uninstall/defaultLabel fail on value assertion against the `""` stubs | +| SC1 | launchd plist emit | `npm test` (schedule.test, cli.test) | plist has Label/ProgramArguments/StartCalendarInterval(H/M)/Std*Path/EnvironmentVariables; deterministic | +| SC2 | crontab line emit (string only) | `npm test` (schedule.test, cli.test) | `<m> <h> * * * … run-daily.sh brief --pillars … >> <log> 2>&1 # <label>` + `crontab -` instruction; never executes crontab | +| SC3 | platform auto | `npm test` (cli.test) | darwin→launchd, else→cron; branches on `process.platform` | +| SC4 | print-first writes nothing | `npm test` (cli.test) | `--print` → exit 0, stdout plist, temp-HOME LaunchAgents empty | +| SC5 | `--install` launchd inert plist | `npm test` (cli.test) | plist FILE written under temp HOME LaunchAgents; `launchctl bootstrap` printed; launchctl never run; exit 0 | +| SC6 | `--install` cron prints only | `npm test` (cli.test) | line + `crontab -` instruction printed; crontab never run; exit 0 | +| SC7 | headless wrapper runs brief + logs | `npm test` (run-daily.test, via `bash`) + manual | dated `.md` written; **one** compact `cron.log` line; exit 0; second same-day run byte-identical + surfacedCount not double-counted; CWD-independent (`cd "$DIR"`); no AI | +| SC8 | data-path twin consistency | `npm test` (run-daily.test) | wrapper log dir == `dirname(defaultStorePath())` (not `--store`) == `getDataRoot('trends')`, default + override | +| SC9 | usage / validation | `npm test` (cli.test) | no `--pillars`/bad `--at`/bad `--platform` → exit 2; fs untouched | +| SC10 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16l green; ASSERT_BASELINE_FLOOR=111; Section 17; counts 29/19/27; hook suite green | + +## Risks + +- **R1 — the wrapper's data-path twin drifts from `store.ts`/`data-root.mjs`.** *Mitigated:* SC8 behavioral + twin-consistency test (resolve all three for a temp `LINKEDIN_STUDIO_DATA` override; assert equal); the wrapper + uses the exact `references/data-path-convention.md` rule-1 inline form; documented as the third twin (mirroring + `data-root.mjs`'s comment). +- **R2 — a test runs `crontab`/`launchctl` and trips the global guard or mutates the real system.** *Mitigated:* + every test asserts the **emitted string / written file on stdout/temp-HOME** — none executes `crontab` or + `launchctl`; `--install` tests use a throwaway `HOME` and assert only the inert plist file + printed command. + (A test that ran `crontab` would be a BLOCKER — invariant §5.) +- **R3 — malformed launchd plist (won't load).** *Mitigated:* SC1 asserts every required key; the plist is a + pinned `<!DOCTYPE plist …>` template; behavioural Step 7 runs `plutil -lint -` (macOS, deps-present manual check + — not a gate, since a deps-absent fresh clone has no `plutil`). +- **R4 — the two NEW files (`schedule.ts`, `run-daily.sh`) blocked at Write.** *Mitigated:* they are under + `scripts/trends/` (allowed — `cli.ts`/`test-runner.sh` were added there; the pathguard blocks NEW `.mjs` under + `hooks/scripts/` only). If Write is nonetheless blocked, the operator authorizes the R2b `!cp` fallback + (write to scratch, `cp` in). This is an implementation-step risk, not a docs-step blocker. +- **R5 — the nightly deterministic brief is a near-no-op without new captures (thin visible value).** + *Accepted/honest (C1):* R3c is the **mechanism**; freshness-aging + per-day surfacing accumulation are real (feed + slice b), but the visible autonomous-research payoff lands with slice (e), which plugs AI capture into the + documented pre-brief seam. Stated plainly in the README + the brief §1 value framing — no salesmanship. +- **R6 — `--at HH:MM` parse / validation gaps.** *Mitigated:* explicit `0≤H≤23`/`0≤M≤59` validation → `usage` + exit 2; SC9 asserts `25:00`/`7:99`/`noon` all exit 2. +- **R7 — bash 3.2 incompatibility / `set -u` crash in the wrapper.** *Mitigated:* ASCII-only, all expansions + quoted, no `declare -A`/`mapfile`/`|&`; the `set +e`/`set -e` fence around the node call captures the exit code + cleanly; the operator's macOS (bash 3.2) is the test bed; `run-daily.test` runs it as a subprocess in CI-shape. +- **R8 — node unresolvable in launchd/cron's profile-less env.** *Mitigated:* the scheduler bakes + `NODE_BIN=<process.execPath>` (absolute) into the artifact env; the wrapper falls back to `command -v node` then + common locations, logging `exit=127 node not found` if truly absent; `WorkingDirectory` is set so tsx resolves. +- **R9 — editing `trend-spotter.md` / the new source trips the de-niche guard.** *Mitigated:* Section 17 runs in + the gate; the added prose + the source carry only generic scheduling wording; the launchd Label is the plugin + namespace; pillars are args (no vendor/sector token). +- **R10 — new gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16l is pure + `grep`/self-test on tracked source (`schedule.ts` + `cli.ts` + `run-daily.sh`; no `tsx`) → unconditional; + `TRENDS_TESTS_FLOOR` stays inside the deps guard. +- **R11 — import cycle.** *Mitigated:* `schedule.ts` is a **leaf** (imports nothing from the package); `cli.ts` + (root) imports it. The DAG stays acyclic: `score (leaf) ← types ← store ← brief ← cli`, with `schedule (leaf) ← + cli` added. `brief.ts`/`store.ts`/`item.ts`/`score.ts`/`types.ts` untouched. + +## Plan-critic — folded + +Three Opus reviewers ran, each verifying claims against live code: **scope-guardian ALIGNED** (0 creep, 0 gaps; +every SC1–SC10 traces to a step; no AI/capture, no schema bump, counts 29/19/27 + the untouched-files claim +verified live; 1 MAJOR line-cite + 6 MINOR); **brief-reviewer PROCEED_WITH_RISKS** (RED premises TRUE, the +data-path trio genuinely consistent, the C2 no-execution path confirmed; 2 MAJOR + 5 MEDIUM/LOW); **plan-critic +REVISE 73/C** (the two-phase RED, de-niche safety, the bash-3.2 `set -eu` fence, the `import.meta.url`/`process. +execPath` path resolution, and the **+6→111 gate arithmetic** all verified correct; the C grade is largely the +legacy-manifest-format penalty — hand-authored slice docs, not trekexecute manifests). Resolution, each verified +against live code: + +- **[MAJOR — plan-critic #1] `brief --json` is pretty-printed** (`cli.ts:323` `JSON.stringify(…, null, 2)`), so the + wrapper's "one log line" / SC7 contract was false. ✅ Step 3 captures `CODE=$?` first, then **compacts** `OUT` + (`tr '\n' ' ' | tr -s ' '`); SC7 asserts exactly one line. +- **[MAJOR — brief-reviewer #1] the wrapper never `cd`s to its package dir** → cron (CWD `$HOME`) would fail to + resolve `tsx` (the plist sets `WorkingDirectory`, cron does not). ✅ Step 3 adds **`cd "$DIR"`**; SC7 gains a + CWD-independence case. +- **[MAJOR/MAJOR/MINOR — all three] the `logPath` expression** self-contradicted (`".."` escaping `trends/`) and + used the `--store` override base. ✅ Step 4 pins **`join(dirname(defaultStorePath()), "cron.log")`** (data-root + anchor, matches the wrapper); the `".."` and `storePath` variants removed; SC8 retargeted. +- **[MAJOR — brief-reviewer #4 / plan-critic #3] `ScheduleSpec.env` stated three ways.** ✅ `env: + Record<string,string>` is **canonical** (Step 2 interface + brief §3 + Files table + Step-1 fixtures), always + `{ NODE_BIN, LINKEDIN_STUDIO_DATA(resolved) }`; the emitters render it only (purity holds). +- **[MAJOR — all three] `ASSERT_BASELINE_FLOOR` line-cite `:1259` → `:1329`** (verified live; `:1259` is + `LIFECYCLE_FILTER_LIT`; value 105→111 correct). ✅ Step 6 + Files table corrected; `TRENDS_TESTS_FLOOR` `:705` → + `:709`; header-enum insertion `:49-53` → `:57`; the stale Section-18 "= 99" narration gets the R3b(→105)+R3c(→111) + append. +- **[MINOR — plan-critic #5] `dirname` not imported in `cli.ts`** (`:41` = `join` only). ✅ Step 4 adds `dirname` + to the `node:path` import (+ `homedir`/`fileURLToPath`). +- **[MINOR — plan-critic #8 / brief-reviewer #5] stray double `brief`** (`cli.ts brief brief …`). ✅ the baked + `args` drop the leading `"brief"` (Step 4); the wrapper owns the subcommand; the 16l sentinel matches `cli.ts" + brief` (Step 6 (5)). +- **[MINOR — plan-critic #7] wrapper-absent RED is invocation-dependent.** ✅ Step 1 pins the test through + **`bash run-daily.sh`** so the absent file is exit 127 (clean assertion-RED), not a thrown ENOENT. +- **[MINOR — plan-critic #9 / brief-reviewer #9] `$HOME`-unset `set -u` edge.** ✅ the CLI **always** bakes a + resolved-absolute `LINKEDIN_STUDIO_DATA` (Step 4 env), so a scheduled run never evaluates `$HOME`. +- **[MINOR — scope-guardian #4] pathguard justification fabricated.** ✅ restated (brief §5): `~/repos/*` is + allowlisted; the conclusion (new files write-allowed) holds. +- **[LOW — brief-reviewer #7] SC1 "lint-valid" only grep-checked.** ✅ SC1 asserts **well-formed** (balanced-tag/ + parse) + key-complete; `plutil -lint` stays the deps-present manual check (Step 7). +- **[LOW — brief-reviewer #8] twin census undercounted (3 → 4).** ✅ `analytics/storage.ts` named as the existing + third; `run-daily.sh` the fourth (brief §5 + the wrapper comment). +- **[MINOR — scope-guardian/plan-critic] de-niche safety (Section 17) confirmed:** `NICHE_TOKENS=Microsoft|Azure| + Copilot|public sector|offentlig sektor`, scoped to an allowlist (`trend-spotter.md`/`content-planner.md`/ + `content-framework.md`); `schedule.ts`/`run-daily.sh` are not scanned, the agent prose carries no token, the label + `com.linkedin-studio.trends.daily` is clean. R9 holds (no change needed). + +**[plan-critic headless-readiness]** — R3c **builds** a headless entry, but the slice itself is authored and landed +**in-session, operator-driven** (driftsmodell), not as a headless autonomous run, so per-step revert/halt clauses +aren't needed (R1/R2a/R2b/R3a/R3b had none either). The *artifact* it ships (`run-daily.sh`) is the headless entry; +the *development* is in-session. diff --git a/docs/research-engine/plan-re-r3d.md b/docs/research-engine/plan-re-r3d.md new file mode 100644 index 0000000..a6a3181 --- /dev/null +++ b/docs/research-engine/plan-re-r3d.md @@ -0,0 +1,335 @@ +# Plan — RE-R3d: temporal overlay — first-mover + saturation (R3 slice b) + +> **Brief:** `docs/research-engine/brief-re-r3d.md`. **Slice:** RE-R3d (research-engine rung-2 — R3 slice **(b)**: +> the live temporal overlay). Closes the rest of hull #3 — **first-mover** + **saturation** (relevance ✅ R3a; +> status ✅ R3b; `angle` already a scored dimension). **Zero new files** — pure EDITs. +> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)** +> `temporalSignal` is a NEW named export of the EXISTING `brief.ts`; Node16 ESM throws a missing named import at +> module-load (every `brief.test.ts` test would error, not assert), so land a **non-throwing stub** first +> (`temporalSignal → {tier:"fresh",…}` + the type exports + `BriefEntry.temporal` populated by the stub in +> `rankForBrief`), then record value-assertion RED against it (the constant "fresh" stub fails the +> first-mover/saturated/ordering/token assertions); **(B)** the CLI flag tests are value-RED against the existing +> `brief` handler (the new flags are silently ignored by `parseFlags` today → tuned-threshold behaviour unchanged). +> Then GREEN: real `temporalSignal` + `RankOptions` knobs + `cmp` key → render tokens + summary marker + descriptor +> → `cli.ts` flags → wire `trend-spotter.md` (prose) + SSOT note + README → gate floors + Section 16m → +> behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-26):** SB1 derived-at-brief (no schema bump) · SB2 +> refine recency within the composite tier (composite stays PRIMARY). Go-gate D1–D10 baked to recommended defaults +> (brief §8). +> **Light-Voyage:** scope-guardian / brief-reviewer / plan-critic to run on these drafts; findings folded in +> §Plan-critic — folded before the code commit. + +## Goal + +Make the morning brief read the **temporal axis** R3b logs but the ranking ignores. Two derived signals, computed +**at brief time** from already-persisted fields (no new store field, no schema bump): **first-mover** (recent AND +never surfaced on a prior day — "you'd be early") ranks a trend up; **saturation** (surfaced on `>= saturationAt` +prior days — "you keep seeing this and not acting") ranks it down. The R3a relevance composite stays the PRIMARY +sort key (SB2); the overlay is a new `cmp` key **after** pillar-overlap, **before** `effectiveDate`, re-ordering +only WITHIN a (composite, overlap) tier. It also promotes the R3b `· sett Nx` display hint into a graded badge set +and surfaces a first-mover marker on the one-line summary. **No schema change** (`SCHEMA_VERSION` 4 / +`BRIEF_SCHEMA_VERSION` 1 untouched); **no new agent/command/reference/module/file**; `score.ts`/`store.ts`/ +`types.ts`/`item.ts`/`schedule.ts`/`run-daily.sh` untouched. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/brief.ts` | **EDIT** — add `TemporalTier`/`TemporalSignal` exports + pure `temporalSignal(ageDays, surfacedCount, {firstMoverDays, saturationAt})`; `RankOptions` gains `firstMoverDays?`(def 2)/`saturationAt?`(def 3); `BriefEntry` gains `temporal`; `rankForBrief` threads the opts + populates `temporal`; `cmp` gains `b.temporal.rank - a.temporal.rank` after overlap, before effectiveDate; `surfacedToken`→`temporalToken` (badges); `briefSummary` first-mover marker; `ranking:` descriptor names the new key | SC1–SC5, SC7, SC9 | +| `scripts/trends/src/cli.ts` | **EDIT** — `brief … [--first-mover-days N] [--saturation-at N]` (mirror `--fresh-days` at `:312-317`, into `rankForBrief` opts at `:323`); usage line (`:110`) + header synopsis (`:14`) note the overlay; **`schedule` untouched** (nightly uses defaults) | SC6 | +| `scripts/trends/tests/brief.test.ts` | **EDIT** — `temporalSignal` unit incl. clamp + future-date + neutral cases (SC1/SC2/SC9); **disagreement** ordering fixture (SC3); render badges (SC4) + **4 touched assertions**: `:407` `sett 3x`→`mettet (3x)`, `:408` unchanged (≥2 gate), `:325-331`+`:410-416` descriptor segment, `:96-102` re-based to a shared tier; summary marker (SC5); determinism (SC7); no-score-mutation (SC8) | SC1–SC5, SC7–SC9 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — subprocess: `brief --first-mover-days N --saturation-at N` changes tiers vs defaults; absent → defaults 2/3; `--first-mover-days -1`/`x`, `--saturation-at 0`/`x` → exit 2 | SC6 | +| `scripts/trends/src/types.ts` · `store.ts` · `score.ts` · `item.ts` · `schedule.ts` · `run-daily.sh` | **UNTOUCHED** — no data-shape/scoring/store/scheduler change. Listed to assert they are *not* in scope (`SCHEMA_VERSION` 4 held in `types.ts`; the R3c scheduler suite stays green untouched). | — | +| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: the brief now applies a live temporal overlay (first-mover↑/saturated↓) at rank time, derived from dates + the seen-log; capture path unchanged. Domain-general. | — | +| `references/trend-scoring-modes.md` | **EDIT (one-line note, "Consumers")** — the brief applies a within-composite-tier temporal overlay (RE-R3d); **no** weight/band/formula change. | — | +| `scripts/trends/README.md` | **EDIT** — new `## Temporal overlay (RE-R3d)` between the R3c scheduler section (`:135-154`) and `## Tests` (`:155`): first-mover/saturation, derived-not-stored, the flags + defaults, the `cmp` integration, the badges | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` (`:713`, 192)→recount + breakdown comment; NEW unconditional **Section 16m** between 16l's `echo ""` (`:1374`) and Section 18 (`:1376`); `ASSERT_BASELINE_FLOOR` (`:1403`) 111→**117**; header-enum chain (`:57-62`) + Section-18 floor-history narration (`:1376-1402`, ends "= 111") | SC10 | +| `docs/research-engine/{brief,plan}-re-r3d.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 117, gate total; schema unchanged v4; **correct the stale :709/:1329 line-cites to live :713/:1403**). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `types.ts`/`store.ts`/`score.ts`/`item.ts` (no data-shape/scoring change; +`SCHEMA_VERSION` 4) · `schedule.ts`/`run-daily.sh` + their tests (R3c untouched — the nightly run uses default +thresholds; no regression surface) · the SessionStart hook + its tests (R3d changes no frontmatter *field*; the +summary's new marker is regex-safe; no hook change/test) · `config/*` · `commands/*` (29) · `agents/*` count (19 +— `trend-spotter.md` is a prose EDIT) · `references/*` count (27 — `trend-scoring-modes.md` is an EDIT) · +`algorithm-signals-reference.md` (cited for grounding, not edited) · `.gitignore` (no new artifact) · +`BRIEF_SCHEMA_VERSION` (1) · `SCHEMA_VERSION` (4). + +## Step 1 — (RED, two phases) failing tests across brief/cli + +**Phase A — stub-first, then value-assertion RED** (`brief.test.ts` imports the new `brief.ts` exports): +- Land **non-throwing stubs** so the static imports resolve (Node16 ESM links named imports before any test runs): + in `brief.ts` — add `export type TemporalTier`, `export interface TemporalSignal`, and `export function + temporalSignal(): TemporalSignal { return { tier: "neutral", firstMover: false, surfacings: 0, rank: 2 }; }` (a + constant stub ignoring its args); add `temporal: TemporalSignal` to `BriefEntry` and populate it in `rankForBrief` + via the stub. The `cmp` key + `temporalToken` + the summary marker are **NOT** added yet (Steps 2–3). +- `brief.test.ts` (value-RED against the stub): + - **unit (SC1/SC2/SC9)** — `temporalSignal(1,0,{firstMoverDays:2,saturationAt:3})` expects + `{tier:"first-mover",rank:3}` (stub returns "neutral" → RED); `(5,3,…)`→`saturated`; `(5,2,…)`→`warming`; + `(5,0,…)`→`neutral`; `(3,0,…)`→`neutral` (past window); `(-1,0,…)`→`neutral` (future-date `>=0` guard); the + clamp case `(5,5,{…,saturationAt:0})`→`saturated` via `at=1`. + - **ordering (SC3) — DISAGREEMENT fixture** (folded — plan-critic M1: `surfacedCount` correlates with age, so a + naive first-mover-vs-saturated fixture is *already* ordered by the existing `effectiveDate`-desc key → GREEN in + Phase A, vacuous in GREEN). Three same-overlap entries: **A** `neutral` (surfaced 0, **older** date ≈5d, + composite 7.0); **B** `warming` (surfaced 2, **newer** date ≈1d, composite 7.0); **Z** `saturated` (surfaced 4, + composite **8.5**). Expect `topMatches.map(e=>e.trend.title) === [Z, A, B]`. Phase A (stub: constant rank, no + temporal key) yields `[Z, B, A]` (`effectiveDate`-desc decides A vs B) → RED; GREEN (Step 2 inserts + `temporal.rank`) flips A above the newer B, proving the key. + - **render (SC4)** — `renderBrief(...)` expects `· 🥇 først ute` on the first-mover entry, `· 🔁 mettet (3x)` on a + `surfacedCount 3` entry, `· sett 2x` on `surfacedCount 2`, **no badge** on `surfacedCount 1`, none on a + `neutral` entry (stub render still emits the old `surfacedToken` → RED). **Four existing assertions touched** + (folded — brief-reviewer MEDIUM-2/3): (1) `:407` `· sett 3x` → `· 🔁 mettet (3x)` (its surfacedCount-3 trend is + now saturated); (2) `:408` `!md.includes("sett 1x")` **stays unchanged** (the ≥2 badge gate keeps + surfacedCount-1 markerless); (3) `:325-331` + (4) the regex `:410-416` (the `ranking:` descriptor) gain `then + temporal (first-mover↑/saturated↓), `; and the `effectiveDate`-isolation test `:96-102` is **re-based** so both + entries share a `neutral` tier (else the new key, not `effectiveDate`, silently decides it — coverage erosion). + - **summary (SC5)** — `briefSummary(...)` with a first-mover top expects `· 🥇 først ute` in the headline (stub → + no marker → RED); assert the summary has no `"` and no `\n`. + - **no-score-mutation (SC8)** — capture a store's `score.composite` values, run `rankForBrief` (pure — no + mutation) and assert unchanged; assert `BRIEF_SCHEMA_VERSION === 1`, `SCHEMA_VERSION === 4`. + +**Phase B — subprocess value-RED against the existing CLI handler** (no new import; the flags are parsed-but-unused +today): +- `cli.test.ts` — `brief --pillars ai --first-mover-days 1 --saturation-at 2 --store <seeded> --out <tmp>`: + today `parseFlags` stores the flags but the `brief` handler ignores them → the rendered tiers match the defaults + → RED. **The tier badges live in the written `.md` body, not in `--json`** (which returns only + `{path,date,totals,summary,marked}`, `cli.ts:336`), so the assertion **`readFileSync(path)`** and checks the + badge (a `surfacedCount 2` trend renders `· 🔁 mettet (2x)` at `--saturation-at 2`, but `· sett 2x` at the + default) — folded — brief-reviewer LOW-5; the first-mover marker is also observable via the `--json` `summary`. + `--first-mover-days x` / `--saturation-at 0` today are ignored (exit 0) → RED against the expected `usage` exit 2. + +**RED proof (record in commit, two phases):** Phase A — after the non-throwing `temporalSignal` stub lands, +`(cd scripts/trends && npm test)` fails the unit/ordering/render/summary cases on **value** assertions against the +constant-"fresh" stub (not module-not-found). Phase B — the `cli.test` flag cases fail on value/exit assertions +against the flag-ignoring handler. The plan does **not** claim a single "everything fails before any code" run. + +## Step 2 — (GREEN) `brief.ts` — the real signal + ranking integration + +Replace the Phase-A stub with the real, pure implementation: +- `export type TemporalTier = "first-mover" | "fresh" | "warming" | "saturated";` +- `export interface TemporalSignal { tier: TemporalTier; firstMover: boolean; surfacings: number; rank: number; }` +- `export function temporalSignal(ageDays, surfacedCount, opts): TemporalSignal`: + ```ts + const surfacings = surfacedCount ?? 0; + const at = Math.max(1, opts.saturationAt); // defensive clamp (plan-critic m3) + const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0; // >=0 guard (m5) + const tier: TemporalTier = firstMover ? "first-mover" + : surfacings >= at ? "saturated" + : surfacings >= 1 ? "warming" : "neutral"; + const rank = tier === "first-mover" ? 3 : tier === "neutral" ? 2 : tier === "warming" ? 1 : 0; + return { tier, firstMover, surfacings, rank }; + ``` + Pure (no clock/fs/env). Branches disjoint + total (`firstMover` requires `surfacings === 0`; `saturated`/ + `warming` both require `surfacings >= 1`). +- `RankOptions` (`:51-54`) gains `firstMoverDays?: number` + `saturationAt?: number`. `rankForBrief` (`:78`) reads + `const firstMoverDays = opts.firstMoverDays ?? 2; const saturationAt = opts.saturationAt ?? 3;` and populates + `BriefEntry.temporal = temporalSignal(ageDays, trend.surfacedCount, { firstMoverDays, saturationAt })` at the + push site (`:93`). +- `cmp` (`:102-107`): insert `b.temporal.rank - a.temporal.rank ||` **after** `b.overlap - a.overlap ||` and + **before** `b.effectiveDate.localeCompare(a.effectiveDate)`. Composite stays the first key (SB2). The chain stays + a total order. The bucketing (`isFresh`/`freshDays`, `:109-113`) is **unchanged**. +Make the Phase-A unit + ordering cases green. + +## Step 3 — (GREEN) `brief.ts` — render badges + summary marker + descriptor + +- Replace `surfacedToken` (`:154-157`) with `temporalToken(e: BriefEntry): string`: + ```ts + const t = e.temporal; + if (t.tier === "first-mover") return " · 🥇 først ute"; + if (t.tier === "saturated") return ` · 🔁 mettet (${t.surfacings}x)`; + if (t.tier === "warming" && t.surfacings >= 2) return ` · sett ${t.surfacings}x`; // preserves the R3b ≥2 badge contract + return ""; // neutral, or warming with surfacings 1 + ``` + Replace the `surfacedToken(e)` call in `renderTopEntry` (`:162`) and `renderBulletEntry` (`:171`) with + `temporalToken(e)`. (`scoreToken` untouched.) +- `briefSummary` (`:131-141`): compute `const fm = top.temporal.firstMover ? " · 🥇 først ute" : "";` and emit + `(${pillar}${band}${fm} · ${top.ageDays}d)`. The marker carries no `"`/`\n` (hook-regex invariant, `:128-130`). +- `renderBrief`'s `ranking:` descriptor (`:187`): insert `then temporal (first-mover↑/saturated↓), ` between + `pillar-overlap desc, ` and `then publishedAt desc`. +Make the Phase-A render + summary cases green. + +## Step 4 — (GREEN) `cli.ts` — the two `brief` threshold flags + +After the `--fresh-days` block (`:313-317`), add (mirroring its idiom): +```ts +let firstMoverDays = 2; +if (flags["first-mover-days"] && flags["first-mover-days"] !== "true") { + const n = Number.parseInt(flags["first-mover-days"], 10); + if (Number.isNaN(n) || n < 0) usage("--first-mover-days must be a non-negative integer"); + firstMoverDays = n; +} +let saturationAt = 3; +if (flags["saturation-at"] && flags["saturation-at"] !== "true") { + const n = Number.parseInt(flags["saturation-at"], 10); + if (Number.isNaN(n) || n < 1) usage("--saturation-at must be a positive integer"); + saturationAt = n; +} +``` +Pass into the rank call (`:323`): `rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt })`. +Update the `brief` usage line (`:110`) + header synopsis (`:14`) to list the two flags + a one-line note that the +brief applies a derived temporal overlay (first-mover↑/saturated↓) at rank time. **No new exit code** (0/2). The +`schedule` branch (`:343-417`) is **untouched** (nightly run uses defaults). Make the Phase-B `cli.test` cases green. + +## Step 5 — wire `trend-spotter.md` (prose) + SSOT note + README + +- `agents/trend-spotter.md` — one prose line (no batch-shape change): the morning brief now applies a **live + temporal overlay** at rank time (first-mover ranked up, repeatedly-surfaced/saturated ranked down), **derived** + from the publish/capture dates + the seen-log — no new capture step; the polling/capture path is unchanged. + Domain-general (Section 17). +- `references/trend-scoring-modes.md` — under "Consumers", add ONE line: the morning brief applies a brief-time + temporal overlay (first-mover/saturation, RE-R3d) as a within-composite-tier ranking refinement; this does **not** + change the capture-time weights above. (No weight/band/formula edit.) +- `scripts/trends/README.md` — add `## Temporal overlay (RE-R3d)` between `:154` and `## Tests` (`:155`): the + first-mover/saturation definitions (self-surfacing, not market-coverage), the **derived-not-stored** boundary + (no schema bump), the `--first-mover-days`(2)/`--saturation-at`(3) flags, the `cmp` integration (composite stays + primary), the badge set (`🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` / none). + +## Step 6 — gate: floors + new unconditional Section 16m + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (`:713`, currently **192**) to the **`tests N`** line reported by `(cd scripts/trends && + npm test)` after Steps 1–5 — recounted live, NOT additive-guessed. Stays inside the deps guard. **Append** + `+ RE-R3d: brief +N, cli +N (temporal overlay)` to the inline breakdown comment. +- Add **Section 16m** ("Trends Temporal Overlay", RE-R3d), mirroring Section 16l (unconditional, deps-absent-safe, + pure `grep -qF`/self-test, no `tsx`, **all literals ASCII** — the badge emoji are NEVER grepped; the shell must + stay ASCII-clean for bash 3.2 `set -u`). **Placement (verified live):** between Section 16l's trailing `echo ""` + (`:1374`) and the Section 18 header (`:1376`) — anti-erosion stays last. Six **unconditional** checks, the + self-test emitting **one** pass/fail like 16l: + (1) a non-vacuity self-test (a probe carrying `temporalSignal` accepted, one without rejected); + (2) `grep -qF 'export function temporalSignal' scripts/trends/src/brief.ts`; + (3) `grep -qF 'b.temporal.rank' scripts/trends/src/brief.ts` (the cmp integration); + (4) `grep -qF '"first-mover"' scripts/trends/src/brief.ts` (the tier literal); + (5) `grep -qF 'first-mover-days' scripts/trends/src/cli.ts` (the flag); + (6) `grep -qF 'saturation-at' scripts/trends/src/cli.ts` (the flag). +- Bump `ASSERT_BASELINE_FLOOR` (**`:1403`**, currently **111**) → **exactly 117** (111 + the 6 new unconditional + 16m emitters; the self-test emits one pass/fail like 16l, so 117 is deterministic — "live recount" is the safety + net, not a guess). Insert the 16m clause into the **header-enumeration prose chain (`:57-62`)** before "…the + assertion-count anti-erosion floor (SC6) in Section 18", preserving sentence flow. **Append** the RE-R3d (→117) + narration to the **Section-18 floor-history comment** (`:1376-1402`, which ends "= 111"). +- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3d adds no hook test). It must + still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land. + +## Step 7 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then run brief §7's five behavioural steps: seed a store with a +fresh+unsurfaced, a warming (`surfacedCount` 2), and a saturated (`surfacedCount` 4) trend at the **same** +composite; `brief --pillars … --out /tmp/r3d-mb --store /tmp/r3d.json` → the fresh+unsurfaced sorts first with +`· 🥇 først ute`, the saturated last with `· 🔁 mettet (4x)`, the `ranking:` descriptor names the temporal key; +re-run with `--first-mover-days 0 --saturation-at 2` → first-mover badge gone, `surfacedCount 2` escalates to +`mettet (2x)`; `--first-mover-days x` → exit 2; confirm the seeded `score.composite` values are unchanged after the +brief (only `surfacedCount`/`lastSurfacedAt` advance). Run full `bash scripts/test-runner.sh` → `FAIL=0` +(`ASSERT_BASELINE_FLOOR` 117, trends ≥ new floor, Section 16m green, Section 17 de-niche green, counts 29/19/27); +run `node --test hooks/scripts/__tests__/*.test.mjs` → still green (untouched regression); confirm `schedule.test`/ +`run-daily.test` still green (R3c untouched). + +## Step 8 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 117, gate total; +schema unchanged v4; **correct the stale :709/:1329 cites to live :713/:1403**). Commit order (house style): +**(1)** docs commit `docs/research-engine/{brief,plan}-re-r3d.md` (no suffix, tracked); **(2)** code commit — +`brief.ts` + `cli.ts` + `brief.test.ts` + `cli.test.ts` + `agents/trend-spotter.md` + +`references/trend-scoring-modes.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]` +(D10: single code commit — the overlay is one coherent feature). Push freely (window lifted; gitleaks at commit; +`origin` = PUBLIC `open/` — STATE/`*.local.*` never pushed). No version bump (additive; `v0.5.2` dev). + +## Verification (testable) + +| SC | Check | Command | Expected | +|---|---|---|---| +| — | RED Phase A | `(cd scripts/trends && npm test)` after the `temporalSignal` stub | unit/ordering/render/summary cases fail on **value** assertions vs the constant-"fresh" stub (not module-not-found) | +| — | RED Phase B | `npm test` (cli.test) before the flag impl | `--first-mover-days`/`--saturation-at` cases fail on value/exit vs the flag-ignoring handler | +| SC1 | first-mover detection | `npm test` (brief.test) | `(≤firstMoverDays, surfaced 0)` → first-mover; `(>window,0)`/`(≤window,≥1)` → not | +| SC2 | saturation grading | `npm test` (brief.test) | `surfacings ≥ saturationAt` → saturated; `1..at-1` → warming; `0` → fresh (inclusive `>=`) | +| SC3 | ranking — within-tier re-order, composite dominates (DISAGREE fixture) | `npm test` (brief.test) | `[Z(8.5,sat), A(7.0,neutral,older), B(7.0,warming,newer)]`; temporal key flips A above newer B; Phase A `[Z,B,A]`→RED; total order | +| SC4 | render badges + ≥2 boundary | `npm test` (brief.test) | `🥇 først ute` / `🔁 mettet (Nx)` / `sett Nx` (warming≥2) / none (warming 1, neutral); `:407` updated, `:408` stays green | +| SC5 | summary first-mover marker | `npm test` (brief.test) | first-mover top → `· 🥇 først ute` in headline; else absent; no `"`/`\n` | +| SC6 | CLI flags | `npm test` (cli.test) | flags change tiers vs defaults; defaults 2/3 when absent; bad values → exit 2 | +| SC7 | determinism | `npm test` (brief.test) | same `(store,pillars,today,freshDays,firstMoverDays,saturationAt)` → byte-identical `.md` | +| SC8 | no schema / no score mutation | `npm test` (brief.test) | `SCHEMA_VERSION` 4; `BRIEF_SCHEMA_VERSION` 1; `score.composite` unchanged after a brief | +| SC9 | purity | `npm test` (brief.test) | `temporalSignal` stable over a grid; first-mover ⊆ recent∧unsurfaced; saturated ⇔ `surfacings≥saturationAt` | +| SC10 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16m green; `ASSERT_BASELINE_FLOOR`=117; Section 17; counts 29/19/27; hook suite green | + +## Risks + +- **R1 — changing `cmp` re-orders existing brief output → silently breaks downstream expectations.** *Mitigated:* + composite stays the PRIMARY key (SB2) — the overlay only re-orders WITHIN a (composite, overlap) tier; the + bucketing is unchanged; SC3 pins the exact order; SC7 pins byte-determinism; the hook reads only date+summary + (unaffected). +- **R2 — the `surfacedToken`→`temporalToken` promotion breaks pinned R3b/descriptor assertions.** + *Mitigated/expected (folded — all three reviewers):* the warming badge is gated at `surfacings >= 2`, so + `brief.test.ts:408` (`!md.includes("sett 1x")`) stays green and the R3b ≥2 contract is preserved exactly; Step 1 + enumerates the **four** touched assertions (`:407` `sett 3x`→`mettet (3x)`; `:325-331`+`:410-416` descriptor) and + re-bases the coverage-eroded `:96-102`. `surfacedCount 2` still renders `· sett 2x`. +- **R3 — emoji in the gate crashes bash 3.2 `set -u`.** *Mitigated:* Section 16m greps ONLY ASCII literals + (`temporalSignal`, `b.temporal.rank`, `"first-mover"`, `first-mover-days`, `saturation-at`); the emoji live only + in `brief.ts` source + rendered output, asserted by the TS tests, never by the shell gate. +- **R4 — the summary marker breaks the SessionStart extractYaml regex.** *Mitigated:* `🥇 først ute` carries no + `"` and no `\n`; SC5 asserts the invariant; the hook suite is a land-time regression check. +- **R5 — saturation framing overclaims (reads as market-coverage).** *Accepted/honest:* R3d's saturation is + **self-surfacing** (our seen-log), a proxy for a closing/ignored window — NOT external coverage (that is slice + e, AI polling). The README + badge wording say "seen N×", not "covered online"; the brief §4 non-goal states the + boundary. No salesmanship. +- **R6 — first-mover default (2) too tight / saturationAt default (3) arbitrary.** *Mitigated:* both are + CLI-tunable (`--first-mover-days`/`--saturation-at`), documented as deliberate defaults (like `freshDays` 7), + grounded in the SSOT timing band (`<24-72h`) + the existing `sett Nx` `>=2` hint; D1/D2 are operator go-gate + knobs. +- **R7 — float/`-1` composite sentinel interaction with the new integer key.** *Mitigated:* the temporal key is a + separate `||` term (a small integer diff); it never touches the `score?.composite ?? -1` term; the comparator + stays a sum-free short-circuit chain (no NaN risk). +- **R8 — editing `trend-spotter.md` / the SSOT trips the de-niche guard (Section 17).** *Mitigated:* the added + prose + the SSOT note carry only generic overlay wording; pillars/topics stay config; Section 17 runs in the + gate. +- **R9 — gate checks must survive a deps-absent fresh clone.** *Mitigated:* Section 16m is pure `grep`/self-test on + tracked source (`brief.ts` + `cli.ts`; no `tsx`) → unconditional; `TRENDS_TESTS_FLOOR` stays inside the deps + guard. +- **R10 — STATE's pinned floor line-cites (`:709`/`:1329`) are stale (live `:713`/`:1403`).** *Mitigated:* caught + at brief time (the line numbers drifted when R3c added Section 16l + the floor-history narration); the plan cites + live values; Step 8 corrects STATE. +- **R11 — a temporal ordering test that passes WITHOUT the feature (false RED / vacuous GREEN).** *Mitigated + (folded — plan-critic M1):* `surfacedCount` correlates with age, so a naive first-mover-vs-saturated fixture is + already ordered by `effectiveDate`-desc. The SC3 fixture is built to force the temporal key and `effectiveDate` + to **disagree** (older-`neutral` A vs newer-`warming` B at equal composite), so it is RED in Phase A and the new + key is provably what decides in GREEN; the coverage-eroded `:96-102` is re-based to a shared tier. + +## Plan-critic — folded + +Three Opus reviewers ran on the brief + this plan, each verifying against live code; they **converged on the same +two defects**. Verdicts: **scope-guardian MIXED** (0 hard creep; both confirmed decisions honored; every SC traces +to a step; the floor/line cites verified live); **brief-reviewer PROCEED_WITH_RISKS** (all seven RED-premise/ +correctness claims HOLD; the gap was GREEN-completeness — 1 of 4 breaking assertions listed); **plan-critic +APPROVE_WITH_NOTES 78/B** (floor arithmetic, line-cites, grep sentinels, cmp total-order, two-phase-RED structure +all verified correct). Per-finding resolution (full headline list in `brief-re-r3d.md §9`): + +- **[MAJOR — all three] warming badge fired at `>=1`, but live `surfacedToken` fires at `>=2` (`brief.ts:156`) + + `brief.test.ts:408` pins `!sett 1x`.** ✅ Step 3 gates the warming badge at `surfacings >= 2` (R3b ≥2 contract + preserved exactly; `:408` unchanged); the warming *tier* still demotes in `rank`. SC4 gains the surfacedCount-1 + no-badge boundary. The "preserves the hint" wording is corrected. +- **[MAJOR — plan-critic M1 / brief-reviewer MEDIUM-3] the ordering test was not RED + vacuous** (`surfacedCount` + correlates with age → `effectiveDate`-desc already orders first-mover-vs-saturated). ✅ Step 1 SC3 fixture forces + temporal↔date **disagreement** (older-`neutral` A vs newer-`warming` B, equal composite; expect `[Z,A,B]`, Phase + A `[Z,B,A]`→RED); `:96-102` re-based to a shared tier (R11). +- **[MEDIUM — brief-reviewer MEDIUM-2] the `ranking:` descriptor change breaks `:325-331` + `:410-416`.** ✅ Step 1 + + the Files table enumerate all four touched assertions, not one. +- **[MINOR — plan-critic m3] `temporalSignal` undefensive vs `saturationAt < 1`.** ✅ Step 2 clamps `const at = + Math.max(1, opts.saturationAt)` inside the pure function (the CLI guard alone is insufficient — the function is a + public, gate-grepped export). SC2 clamp case added. +- **[MINOR — plan-critic m4] the "fresh" tier was a misnomer** (collides with `freshDays`; a 30-day unsurfaced + trend is not "fresh"). ✅ renamed **`neutral`** throughout (Steps 1–3, SCs); the gate sentinel greps `"first-mover"` + (unaffected). The in-bucket effect (unsurfaced ranks above seen-and-skipped within `olderMatched`) documented in + brief §3 as intended. +- **[MINOR — plan-critic m5] future `publishedAt` (ageDays < 0) became a first-mover "act now" headline.** ✅ Step 2 + adds the `ageDays >= 0` guard (future → `neutral`). SC1 gains the `(-1,0)` case. +- **[MINOR — plan-critic m6 / brief-reviewer] nightly run locked to default thresholds** (the primary saturation + consumer). ✅ reframed in brief §4 as a known limitation, not a "later nicety." +- **[LOW — brief-reviewer LOW-5] SC6 tier assertions can't read tiers from `--json`** (it omits the body badges). + ✅ Step 1 Phase B reads the written `.md` via `readFileSync(path)`; the first-mover marker is also in the `--json` + `summary`. +- **[LOW — all three] long-form `angle` cite `:34` → `:32`** (`:34` = `currency`; substance holds). ✅ brief §0/§2 + corrected. + +**Verified correct (no change needed):** the floor arithmetic (16m = 1 self-test + 5 greps = 6 → `ASSERT_BASELINE_ +FLOOR` 111→117), all live cites (`:713`/`:1403`/`:1374`/`:1376`/`:57-62`), the six ASCII grep sentinels match the +literals Steps 2–4 write, the `cmp` insertion preserves a total order with no NaN risk, and the two-phase RED +structurally avoids a module-load ERROR. **This slice is authored + landed in-session (driftsmodell), not as a +headless autonomous run, so per-step revert/halt clauses are not needed** (R1/R2/R3a/R3b had none either). diff --git a/docs/research-engine/plan-re-r3e.md b/docs/research-engine/plan-re-r3e.md new file mode 100644 index 0000000..00880b5 --- /dev/null +++ b/docs/research-engine/plan-re-r3e.md @@ -0,0 +1,401 @@ +# Plan — RE-R3e: brief history + day-over-day diff (R3 slice d) + +> **Brief:** `docs/research-engine/brief-re-r3e.md`. **Slice:** RE-R3e (research-engine rung-2 — R3 slice **(d)**: +> brief history + day-over-day diff). Closes hull **#7** (*"ingen brief-historikk"*) — each brief records the +> trends it showed (`surfaced:` frontmatter) and renders **"Nytt siden sist"** against the most recent prior +> brief. **Zero new files** — pure EDITs (the two tracked slice docs aside). +> **TDD-order (two-phase RED — light-Voyage discipline, inherited):** Step 1 records RED in two phases — **(A)** +> `diffSurfaced`/`parseSurfacedFrontmatter`/`selectPriorBriefFile` (+ `BriefDiff`) are NEW named exports of the +> EXISTING `brief.ts`; Node16 ESM throws a missing named import at module-load (every `brief.test.ts` test would +> error, not assert), so land **non-throwing stubs** first (constant returns; `renderBrief`/`briefSummary` gain an +> optional `diff` param ignored by the stub render), then record value-assertion RED against them (the constant +> stubs fail the diff/parse/select/section/marker assertions); **(B)** the CLI two-day diff test is value-RED +> against the existing `brief` handler (no `surfaced:` write, no prior read, no `diff` in `--json` today). +> Then GREEN: real `diffSurfaced`/`parseSurfacedFrontmatter`/`selectPriorBriefFile` → `surfaced:` frontmatter + +> `BRIEF_SCHEMA_VERSION` 1→2 → `## 🆕 Nytt siden sist` section → summary marker → `cli.ts` prior-discovery + +> `--json diff` → wire `trend-spotter.md` (prose) + README → gate floors + Section 16n → behavioural → land. +> **Counts recounted live at land, never pinned/guessed.** +> **Architectural decisions (CONFIRMED, AskUserQuestion 2026-06-26):** SD1 frontmatter `surfaced:` (no sidecar) · +> SD2 `added` w/ titles + `dropped` as a count (`brief.ts` store-free). Go-gate D1–D9 baked to recommended +> defaults (brief §8). +> **Light-Voyage:** scope-guardian / brief-reviewer / plan-critic to run on these drafts; findings folded in +> §Plan-critic — folded before the code commit. + +## Goal + +Turn the dated morning brief from a **standalone daily snapshot** into a **history rung with a day-over-day diff**. +Each brief persists the set of trend ids it showed into its YAML frontmatter (`surfaced: <id-csv>`), bumping +`BRIEF_SCHEMA_VERSION` 1→2 (the store's `SCHEMA_VERSION` stays 4 — **no store field**). The next brief discovers +the most recent **prior** dated file (strictly `< today`), parses its `surfaced:` line, and renders a +**`## 🆕 Nytt siden sist`** section — `added` (in today, not prior — the headline, with titles resolved from the +ranking), `carried`/`dropped` as a one-line count — plus a ` N nye siden sist.` marker on the one-line summary the +SessionStart hook already surfaces. The diff is a **pure, render-time** layer: `rankForBrief` and the R3a +composite / R3d temporal overlay are **unchanged**; `brief.ts` stays **store-free and fs-free** (the directory + +file reads live at the `cli.ts` edge, injected like `today`/`pillars`). **No new agent/command/reference/module/ +file**; `types.ts`/`store.ts`/`score.ts`/`item.ts`/`schedule.ts`/`run-daily.sh` + the hook untouched. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|---|---|---| +| `scripts/trends/src/brief.ts` | **EDIT** — `BRIEF_SCHEMA_VERSION` 1→2; add `BriefDiff` interface + pure `diffSurfaced(currentIds, priorIds, priorDate)` + `parseSurfacedFrontmatter(md)` + `selectPriorBriefFile(filenames, today)`; `renderBrief` gains optional `diff?` → emits `surfaced:` frontmatter line (before `schemaVersion:`) + the `## 🆕 Nytt siden sist` section (before Topp-treff); `briefSummary` gains optional `diff?` → ` N nye siden sist.` marker; `renderBrief` passes `diff` through to `briefSummary` | SC1–SC8 | +| `scripts/trends/src/cli.ts` | **EDIT** — `brief` handler: `readdirSync(outDir)` (existsSync-guarded) → `selectPriorBriefFile` → `readFileSync` + `parseSurfacedFrontmatter` → `diffSurfaced(surfacedIds(ranking), priorIds, priorDate)` → `renderBrief(ranking, diff)`; thread `diff` into the shared summary at `:350` (`briefSummary(ranking, diff)` — one-source, MAJOR-2); `--json` gains `diff:{priorDate,added,carried,dropped}` (counts); console line appends `, N nye siden sist`; add `readdirSync` to the `node:fs` import | SC9 | +| `scripts/trends/tests/brief.test.ts` | **EDIT** — `diffSurfaced` unit (SC1), `parseSurfacedFrontmatter` unit (SC2), `selectPriorBriefFile` unit (SC3), `surfaced:` frontmatter + round-trip (SC4), `## 🆕 Nytt siden sist` four branches (SC5), summary marker + the `briefSummary(r)===briefSummary(r,empty)` invariant (SC6), `BRIEF_SCHEMA_VERSION===2` (SC7), determinism-with-diff (SC8). **Stubs imported in Phase A.** No existing assertion breaks (verified §Step 1) | SC1–SC8 | +| `scripts/trends/tests/cli.test.ts` | **EDIT** — subprocess two-day sequence: day-1 brief writes `surfaced:`; day-2 over a +1-trend store → `Nytt siden sist (<day1>)` lists the new trend, `--json diff.added≥1`, console `N nye siden sist`; first run → `diff.priorDate===null`; custom `--out` isolates discovery | SC9 | +| `scripts/trends/src/types.ts` · `store.ts` · `score.ts` · `item.ts` · `schedule.ts` · `run-daily.sh` | **UNTOUCHED** — no data-shape/scoring/store/scheduler change (`SCHEMA_VERSION` 4 held in `types.ts`; the R3c scheduler suite + R3d ranking stay green untouched). Listed to assert they are *not* in scope. | — | +| `hooks/scripts/session-start.mjs` + its tests | **UNTOUCHED** — the `surfaced:` frontmatter line is `^surfaced:`-keyed (the `^summary:`-anchored `extractYaml` cannot match it); the summary marker is `"`/`\n`-free. No hook edit/test; the hook suite is a land-time regression check. | — | +| `agents/trend-spotter.md` | **EDIT (prose-only, minimal)** — one line: the brief now records its shown set + renders a day-over-day diff ("Nytt siden sist"); capture path unchanged. Domain-general. | — | +| `scripts/trends/README.md` | **EDIT** — new `## Brief history + diff (RE-R3e)` between the R3d temporal-overlay section and `## Tests`: `surfaced:` record, `selectPriorBriefFile` strict-prior + same-day determinism, `diffSurfaced` partitions, section + summary marker, `BRIEF_SCHEMA_VERSION 1→2` (artifact-only) | — | +| `scripts/test-runner.sh` | **EDIT** — `TRENDS_TESTS_FLOOR` (live `:716`, 216)→recount + breakdown comment; NEW unconditional **Section 16n** between 16m's `echo ""` and Section 18; `ASSERT_BASELINE_FLOOR` (live `:1473`) 117→**123**; header-enum chain (`:53-64`) + Section-18 floor-history narration (ends "= 117") | SC10 | +| `docs/research-engine/{brief,plan}-re-r3e.md` | **NEW** — slice docs (TRACKED, like `docs/second-brain/*`) | — | +| `STATE.md` | **EDIT at land** — Telling-block reconcile (trends floor, ASSERT floor 123, gate total; `BRIEF_SCHEMA_VERSION` 1→2; store schema unchanged v4; correct stale line-cites to live `:716`/`:1473`). *Land bookkeeping, LOCAL-ONLY.* | — | + +**Not touched (scope fence):** `types.ts`/`store.ts`/`score.ts`/`item.ts` (no data-shape/scoring change; +`SCHEMA_VERSION` 4) · `schedule.ts`/`run-daily.sh` + their tests (R3c untouched — the nightly run gets the diff +internally; no scheduler edit) · the SessionStart hook + its tests (R3e adds no field the hook reads; `surfaced:` ++ marker are regex-safe) · `references/trend-scoring-modes.md` (the diff is not a scoring concern) · `config/*` · +`commands/*` (29) · `agents/*` count (19 — `trend-spotter.md` is a prose EDIT) · `references/*` count (27) · +`.gitignore` (no new artifact — the brief files already live under the gitignored data dir). `SCHEMA_VERSION` (4). + +## Step 1 — (RED, two phases) failing tests across brief/cli + +**Phase A — stub-first, then value-assertion RED** (`brief.test.ts` imports the new `brief.ts` exports): +- Land **non-throwing stubs** so the static imports resolve (Node16 ESM links named imports before any test runs): + in `brief.ts` — add `export interface BriefDiff`, and + - `export function diffSurfaced(): BriefDiff { return { priorDate: null, added: [], carried: [], dropped: [] }; }` + - `export function parseSurfacedFrontmatter(): string[] { return []; }` + - `export function selectPriorBriefFile(): string | null { return null; }` + (each a constant stub ignoring its args). Add an **optional** `diff?: BriefDiff` param to `renderBrief` **and** + `briefSummary`, **wired but inert** in the stub: `renderBrief` does NOT yet emit the `surfaced:` line or the + section; `briefSummary` does NOT yet emit the marker. (Keeps the static signatures stable for the RED tests + while the *behaviour* is still absent → value-RED, not type-RED.) `BRIEF_SCHEMA_VERSION` is still **1** in Phase + A (so the SC7 `=== 2` assertion is RED). +- `brief.test.ts` (value-RED against the stubs): + - **diffSurfaced (SC1)** — `diffSurfaced(["a","b","c"],["b","c","d"],"2026-06-25")` expects + `{priorDate:"2026-06-25",added:["a"],carried:["b","c"],dropped:["d"]}` (stub returns all-empty/null → RED); + `diffSurfaced(["a","b"],[],null)` expects `{priorDate:null,added:["a","b"],carried:[],dropped:[]}`; a repeated + id not double-counted. + - **parseSurfacedFrontmatter (SC2)** — a full frontmatter string with `surfaced: 1a2b,3c4d,5e6f` → `["1a2b", + "3c4d","5e6f"]` (stub `[]` → RED); blank `surfaced: ` → `[]`; absent line → `[]`; trims whitespace; does not + match `summary:`/`store:`. + - **selectPriorBriefFile (SC3)** — `(["2026-06-24.md","2026-06-25.md","2026-06-26.md","README.md","2026-06-30.md"], + "2026-06-26")` → `"2026-06-25.md"` (stub `null` → RED); empty list / none `< today` → `null`. + - **frontmatter `surfaced:` + round-trip (SC4)** — `renderBrief(r, diff)` includes `\nsurfaced: ` + the + `surfacedIds(r).join(",")` value, before `\nschemaVersion: 2\n`; `parseSurfacedFrontmatter(renderBrief(r,d)) + === surfacedIds(r)`; empty store → `surfaced: ` blank. Stub render emits neither the line nor `schemaVersion: + 2` → RED. + - **section (SC5)** — `renderBrief(r, diff)` contains `## 🆕 Nytt siden sist`; the four branches (first-brief+ + added → `Første brief — alt nedenfor er nytt`; empty first → `Første brief.`; prior+added → the added title + + `båret over` + `ikke vist i dag`; prior+no-added → `Ingenting nytt siden <date>`). Stub render omits the + section → RED. **Assert the section precedes `## 🎯 Topp-treff`** (index check). + - **summary marker (SC6)** — `briefSummary(r, {priorDate:"2026-06-25",added:["x"],carried:[],dropped:[]})` ends + with ` 1 nye siden sist.` (stub omits → RED); `briefSummary(r, {priorDate:null,…})` and `briefSummary(r, + {…,added:[]})` have no marker; **`briefSummary(r) === briefSummary(r, emptyDiff)`** (the invariant that keeps + the existing `:166-171` test green); no `"`/`\n`. + - **schema (SC7)** — `BRIEF_SCHEMA_VERSION === 2` (stub still 1 → RED); `SCHEMA_VERSION === 4`. + - **determinism (SC8)** — `renderBrief(r, d) === renderBrief(r, d)`; with a fixed `d`, stable bytes. +- **Existing assertions — one hard literal flips with the bump; the rest auto-track (verified live, MAJOR-1):** + the frontmatter tests are `:158-160` (`startsWith "---\n"` — unaffected), `:161-164` (`schemaVersion: ` built + from the **imported** `BRIEF_SCHEMA_VERSION` constant via RegExp `:163` — auto-tracks 1→2; `date:`/`store:` + unaffected), `:166-173` (`summary: === briefSummary(r)` — preserved by the SC6 invariant: with no prior the + marker is suppressed, so `briefSummary(r)===briefSummary(r,emptyDiff)`), the determinism pair `:182-184` (both + sides default to the empty diff → still equal), and the `ranking:` descriptor tests (**descriptor unchanged by + R3e** → unaffected). **The one break:** `:574` `assert.equal(BRIEF_SCHEMA_VERSION, 1)` — a **hard literal** in + the `rankForBrief — no schema/score mutation` block (`:568-577`), **outside** the frontmatter set, which the + GREEN bump must flip to `, 2)` in **Step 3** (`:575` `assert.equal(SCHEMA_VERSION, 4)` stays — store schema + untouched). Swept: `:574` is the *only* hard `BRIEF_SCHEMA_VERSION` literal in the suite. The new section is + **additive**, asserted only by new tests; **no test pins the intro→Topp-treff adjacency** (verified — all body + assertions are substring/`match`). + +**Phase B — subprocess value-RED against the existing CLI handler** (no new import; the handler ignores prior +briefs today): +- `cli.test.ts` — a **two-day** sequence sharing one `--out <tmp>` dir: (1) seed a store, run `brief --pillars + ai,gov --out <tmp> --store <s> --json` → today the written `.md` has **no `surfaced:` line** and the `--json` + has **no `diff` key** → RED against the day-1 assertions (`surfaced:` present, `--json.diff.priorDate === null`). + (2) `capture` one new on-pillar trend, then **rename** the day-1 `.md` to a fixed past date (`mv` it to + `2026-06-20.md` in the same `<tmp>` — the **rename-real-write** mechanism, M2) and run a second `brief` → against + the *existing* handler it reads no prior, renders no `Nytt siden sist (<date>)` section, `--json` has no `diff` → + RED. **Diff content lives in the written `.md` body**, so the assertion `readFileSync(path)` checks `## 🆕 Nytt + siden sist`; the `diff` counts are read from `--json`. + +**RED proof (record in commit, two phases):** Phase A — after the non-throwing stubs land, `(cd scripts/trends && +npm test)` fails the diff/parse/select/frontmatter/section/marker/schema cases on **value** assertions against the +constant stubs (not module-not-found). Phase B — the `cli.test` two-day cases fail on the missing `surfaced:`/ +`diff`/section. The plan does **not** claim a single "everything fails before any code" run. + +## Step 2 — (GREEN) `brief.ts` — the three pure helpers + +Replace the Phase-A stubs with the real, pure implementations (all no clock/fs/env): +- `export interface BriefDiff { priorDate: string | null; added: string[]; carried: string[]; dropped: string[]; }` +- `diffSurfaced(currentIds, priorIds, priorDate)`: + ```ts + const prior = new Set(priorIds); + const cur = new Set(currentIds); + return { + priorDate, + added: currentIds.filter((id) => !prior.has(id)), + carried: currentIds.filter((id) => prior.has(id)), + dropped: priorIds.filter((id) => !cur.has(id)), + }; + ``` + Order-stable (filters preserve input order); empty `priorIds` ⇒ `added===currentIds`, `dropped===[]`. +- `parseSurfacedFrontmatter(md)`: + ```ts + const m = md.match(/^surfaced: *([^\n]*)/m); + if (!m) return []; + return m[1].split(",").map((s) => s.trim()).filter((s) => s.length > 0); + ``` + Absent/blank/malformed → `[]` (mirrors the hook's `extractYaml` line-anchoring; never throws). +- `selectPriorBriefFile(filenames, today)`: + ```ts + const todayFile = `${today}.md`; + return ( + filenames + .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f) && f < todayFile) + .sort() + .pop() ?? null + ); + ``` + ISO dates sort lexicographically, so `f < todayFile` = date `< today` (strict — excludes today + future); + greatest remaining = the most recent prior. Mirrors `session-start.mjs:63-66`, minus today. +Make the Phase-A diff/parse/select cases green. + +## Step 3 — (GREEN) `brief.ts` — frontmatter `surfaced:` + schema bump + the section + the marker + +- **`BRIEF_SCHEMA_VERSION = 2`** (`brief.ts:23`) — **and flip the one hard test literal in the same step** + (MAJOR-1): `tests/brief.test.ts:574` `assert.equal(BRIEF_SCHEMA_VERSION, 1)` → `assert.equal(BRIEF_SCHEMA_VERSION, + 2)`. (The RegExp at `:163` and the new SC7 already track the constant; `:575` `assert.equal(SCHEMA_VERSION, 4)` + is untouched.) +- **`renderBrief(ranking, diff: BriefDiff = { priorDate: null, added: [], carried: [], dropped: [] })`** — the + default empty diff keeps single-arg call sites valid. Two additive emissions: + - In the frontmatter block (`brief.ts:263-269`), insert **before** the `schemaVersion:` line: + `lines.push(\`surfaced: ${surfacedIds(ranking).join(",")}\`);` (empty store ⇒ `surfaced: ` blank). The + `schemaVersion:` line now renders `2` via the bumped constant. + - After the intro line (`brief.ts:273-276`) and **before** `## 🎯 Topp-treff` (`:278`), emit the section: + ```ts + lines.push(diff.priorDate !== null ? `## 🆕 Nytt siden sist (${diff.priorDate})` : "## 🆕 Nytt siden sist"); + if (diff.priorDate === null) { + lines.push(diff.added.length > 0 ? "_Første brief — alt nedenfor er nytt._" : "_Første brief._", ""); + } else if (diff.added.length === 0) { + lines.push(`_Ingenting nytt siden ${diff.priorDate}._`, + `_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, ""); + } else { + const byId = new Map( + [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched].map((e) => [e.trend.id, e]), + ); + for (const id of diff.added) { + const e = byId.get(id); + if (e) lines.push(renderBulletEntry(e)); + } + lines.push(`_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, ""); + } + ``` + (`renderBulletEntry` is the existing bullet renderer — reused, no new format.) An added id always resolves + (added ⊆ surfacedIds ⊆ ranking entries); the `if (e)` guard keeps it total. + - Pass the diff through: `briefSummary(ranking, diff)` at the frontmatter `summary:` line (`brief.ts:265`). +- **`briefSummary(ranking, diff?: BriefDiff)`** (`brief.ts:204`): after building the headline, append the marker: + ```ts + const delta = diff && diff.priorDate !== null && diff.added.length > 0 + ? ` ${diff.added.length} nye siden sist.` : ""; + return `${...existing headline...}${delta}`; + ``` + Suppressed on the first brief / when nothing new; carries no `"`/`\n` (the existing summary already guarantees + this — the marker adds only digits + ASCII words + a period). The no-diff call (`briefSummary(ranking)`) yields + exactly the pre-R3e string (the SC6 invariant). +Make the Phase-A frontmatter/section/marker/schema cases green. + +## Step 4 — (GREEN) `cli.ts` — prior-brief discovery + the diff in `--json` + +- Add `readdirSync` to the `node:fs` import (`cli.ts:51`). +- In the `brief` handler, **between** the ranking (`cli.ts:339`) and the render (`:340`): + ```ts + const todayIds = surfacedIds(ranking); + let priorIds: string[] = []; + let priorDate: string | null = null; + try { + if (existsSync(outDir)) { + const priorFile = selectPriorBriefFile(readdirSync(outDir), day); + if (priorFile) { + priorIds = parseSurfacedFrontmatter(readFileSync(join(outDir, priorFile), "utf8")); + priorDate = priorFile.slice(0, 10); + } + } + } catch { priorIds = []; priorDate = null; } // unreadable prior ⇒ first-brief path + const diff = diffSurfaced(todayIds, priorIds, priorDate); + const md = renderBrief(ranking, diff); + ``` +- Import `diffSurfaced`, `parseSurfacedFrontmatter`, `selectPriorBriefFile` from `./brief.js` (`cli.ts:71`). (If + any code annotates `: BriefDiff`, import it via **`import type`** — it is an interface, stripped from emitted JS, + so a value-import fails at module-load (M1); the code above infers the type from `diffSurfaced`'s return, so no + `BriefDiff` import is actually needed.) +- **Thread the diff into the shared summary** (`cli.ts:350`, MAJOR-2): change `const summary = briefSummary(ranking)` + → `const summary = briefSummary(ranking, diff)`. The frontmatter `summary:` (built inside `renderBrief` → + `briefSummary(ranking, diff)`) and the `--json` `summary` (read from this var) must stay **one source** + (`cli.test.ts:268`); without this, day-2's file carries the ` N nye siden sist.` marker but `--json.summary` + would not. **Safe on day-1:** `priorDate === null` ⇒ marker suppressed ⇒ byte-identical to the pre-R3e string. +- `--json` (`cli.ts:352`): add `diff: { priorDate: diff.priorDate, added: diff.added.length, carried: + diff.carried.length, dropped: diff.dropped.length }`. +- The non-JSON console line (`cli.ts:355`): append `${diff.added.length > 0 && diff.priorDate !== null ? \`, ${diff.added.length} nye siden sist\` : ""}`. +- **No new flag, no new exit code.** **Note (Phase-B test mechanism — rename-real-write, M2):** `today()` is + wall-clock, so a same-process two-day sequence cannot advance the date. Rather than hand-author a `<prior>.md` + fixture (which risks an id mismatch — its `surfaced:` ids would not be real store ids, so every trend reads as + added/dropped, a weak/vacuous test), the cli.test **runs `brief` for real** (writing `${today}.md` with a genuine + `surfaced:` line = `surfacedIds(ranking)`), **renames** it to a fixed past date (`mv ${out}/${today}.md + ${out}/2026-06-20.md`), then runs `brief` again in the same `--out`. The second run discovers `2026-06-20.md` as + the strict-prior, parses its **real** ids, and diffs against today's cohort — proving discovery + parse + diff + clock-free, with `carried`/`added` that are *exactly* right (id-matched). Capture one new on-pillar trend between + the runs → that trend is the sole `added`. +Make the Phase-B cli cases green. + +## Step 5 — wire `trend-spotter.md` (prose) + README + +- `agents/trend-spotter.md` — one prose line (no batch-shape change): the morning brief now **records the trends + it showed** (frontmatter `surfaced:`) and renders a **day-over-day diff** ("Nytt siden sist") against the most + recent prior brief — no new capture step; the polling/capture path is unchanged. Domain-general (Section 17). +- `scripts/trends/README.md` — add `## Brief history + diff (RE-R3e)` between the R3d temporal-overlay section and + `## Tests`: the `surfaced:` frontmatter record (one self-describing artifact, `BRIEF_SCHEMA_VERSION 1→2`, + store `SCHEMA_VERSION` stays 4), `selectPriorBriefFile` strict-`< today` discovery (same-day re-run + determinism), the `diffSurfaced` partitions (added/carried/dropped), the `## 🆕 Nytt siden sist` section + (added with titles, carried/dropped as a count, "ikke vist i dag" framing) + the ` N nye siden sist.` summary + marker the SessionStart hook surfaces for free. + +## Step 6 — gate: floors + new unconditional Section 16n + +In `scripts/test-runner.sh`: +- Set `TRENDS_TESTS_FLOOR` (live **`:716`**, currently **216**) to the **`tests N`** line reported by `(cd + scripts/trends && npm test)` after Steps 1–5 — recounted live, NOT additive-guessed. Stays inside the deps + guard. **Append** `+ RE-R3e: brief +N, cli +N (brief history + diff)` to the inline breakdown comment. +- Add **Section 16n** ("Trends Brief History / Diff", RE-R3e), mirroring Section 16m (unconditional, + deps-absent-safe, pure `grep -qF`/self-test, no `tsx`, **all literals ASCII** — the `🆕` emoji is NEVER grepped; + the shell stays ASCII-clean for bash 3.2 `set -u`). **Placement (verify live):** between Section 16m's trailing + `echo ""` and the Section 18 header — anti-erosion stays last. Six **unconditional** checks, the self-test + emitting **one** pass/fail like 16m: + (1) a non-vacuity self-test (a probe carrying `diffSurfaced` accepted, one without rejected); + (2) `grep -qF 'export function diffSurfaced' scripts/trends/src/brief.ts`; + (3) `grep -qF 'parseSurfacedFrontmatter' scripts/trends/src/brief.ts`; + (4) `grep -qF 'Nytt siden sist' scripts/trends/src/brief.ts` (the section header literal — ASCII portion only); + (5) `grep -qF 'selectPriorBriefFile' scripts/trends/src/cli.ts` (the diff wiring in the CLI); + (6) `grep -qF 'surfaced: ' scripts/trends/src/brief.ts` (the frontmatter emit). +- Bump `ASSERT_BASELINE_FLOOR` (live **`:1473`**, currently **117**) → **exactly 123** (117 + the 6 new + unconditional 16n emitters; the self-test emits one pass/fail like 16m, so 123 is deterministic — "live recount" + is the safety net, not a guess). Insert the 16n clause into the **header-enumeration prose chain (`:53-64`)** + before "…the assertion-count anti-erosion floor (SC6) in Section 18," preserving sentence flow. **Append** the + RE-R3e (→123) narration to the **Section-18 floor-history comment** (which ends "= 117"). +- **NOT touched here:** the hook suite (no `HOOK_TESTS_FLOOR` in `test-runner.sh`; R3e adds no hook test). It must + still pass untouched (`node --test hooks/scripts/__tests__/*.test.mjs`) as a regression sanity at land. + +## Step 7 — behavioural verification + +`(cd scripts/trends && npm install)` if needed, then run brief §7's five behavioural steps with a **unique tmp dir +(no `rm`)**: `D=/tmp/r3e-mb-$$; S=/tmp/r3e-$$.json`. Seed an on-pillar store; `brief --pillars … --out "$D" +--store "$S"` → frontmatter carries `surfaced: <ids>` + `schemaVersion: 2`, the section says `Første brief — alt +nedenfor er nytt`; **rename that real brief to a fixed past date** (`mv "$D/$(ls "$D")" "$D/2026-06-20.md"`), +`capture` a new on-pillar trend, and re-run `brief` → `## 🆕 Nytt siden sist (2026-06-20)` lists the added trend + +`N båret over, M ikke vist i dag`, `--json diff.added ≥ 1` (the **rename-real-write** path, M2 — the prior's ids +are real, so `carried`/`added` are id-exact); same-day re-run → `cmp` the two `${day}.md` byte-identical; confirm +`score.composite` unchanged after the briefs (only `surfacedCount`/`lastSurfacedAt` advance); strip the `surfaced:` +line from the renamed prior (a pre-R3e brief) and re-run → every trend reads as added (graceful degrade). Run +full `bash scripts/test-runner.sh` → `FAIL=0` (`ASSERT_BASELINE_FLOOR` 123, trends ≥ new floor, Section 16n green, +Section 17 de-niche green, counts 29/19/27); run `node --test hooks/scripts/__tests__/*.test.mjs` → still green +(untouched regression); confirm `schedule.test`/`run-daily.test` still green (R3c untouched). + +## Step 8 — land + +Recount all touched floors live; reconcile STATE.md "Telling" block (trends N/N, ASSERT floor 123, gate total; +`BRIEF_SCHEMA_VERSION` 1→2; store schema unchanged v4; **correct the stale `:713`/`:1403` cites to live +`:716`/`:1473`**). Commit order (house style): **(1)** docs commit `docs/research-engine/{brief,plan}-re-r3e.md` +(no suffix, tracked); **(2)** code commit — `brief.ts` + `cli.ts` + `brief.test.ts` + `cli.test.ts` + +`agents/trend-spotter.md` + `scripts/trends/README.md` + `scripts/test-runner.sh` with `[skip-docs]` (D9: single +code commit — the diff is one coherent feature). Push freely (window lifted; gitleaks at commit; `origin` = +PUBLIC `open/` — STATE/`*.local.*` never pushed). No version bump (additive; `v0.5.2` dev) — note +`BRIEF_SCHEMA_VERSION` 1→2 is the **artifact** schema, not the plugin version. + +## Verification (testable) + +| SC | Check | Command | Expected | +|---|---|---|---| +| — | RED Phase A | `(cd scripts/trends && npm test)` after the stubs | diff/parse/select/frontmatter/section/marker/schema cases fail on **value** assertions vs the constant stubs (not module-not-found) | +| — | RED Phase B | `npm test` (cli.test) before the wiring | the two-day cases fail on the missing `surfaced:`/`diff`/section | +| SC1 | diffSurfaced partitions | `npm test` (brief.test) | added/carried/dropped order-stable; empty prior ⇒ all added; repeated id once | +| SC2 | parseSurfacedFrontmatter | `npm test` (brief.test) | csv → ids; blank/absent/malformed → `[]`; line-anchored (no `summary:` mismatch) | +| SC3 | selectPriorBriefFile | `npm test` (brief.test) | greatest `< today`; excludes today + future; ignores non-dated; none → `null` | +| SC4 | `surfaced:` + round-trip | `npm test` (brief.test) | one `surfaced: <csv>` line = `surfacedIds(r).join(",")`, before `schemaVersion: 2`; round-trips; empty store → blank | +| SC5 | Nytt siden sist (4 branches) | `npm test` (brief.test) | `## 🆕 Nytt siden sist`; first-brief/empty/added/no-added branches; section precedes Topp-treff | +| SC6 | summary delta marker | `npm test` (brief.test) | ` N nye siden sist.` when prior+added; absent on first/no-added; `briefSummary(r)===briefSummary(r,empty)`; no `"`/`\n` | +| SC7 | schema boundary | `npm test` (brief.test) | `BRIEF_SCHEMA_VERSION` 2; `SCHEMA_VERSION` 4; no `score.composite` mutation after a brief | +| SC8 | determinism + same-day | `npm test` (brief.test) | same `(store,pillars,today,opts,diff)` → byte-identical; same-day re-run picks the same prior (strict `<`) | +| SC9 | CLI diff wiring | `npm test` (cli.test) | two-day (**rename-real-write**: real day-1 brief renamed to `2026-06-20.md`): `Nytt siden sist (<day1>)` lists the new trend; `--json diff` counts; first run `priorDate null`; `--out` isolates | +| SC10 | gate + wiring + de-niche | `bash scripts/test-runner.sh` | FAIL=0; trends ≥ floor; Section 16n green; `ASSERT_BASELINE_FLOOR`=123; Section 17; counts 29/19/27; hook suite green | + +## Risks + +- **R1 — adding `surfaced:` / bumping the schema breaks pinned tests.** *Mitigated (verified live):* the + frontmatter `schemaVersion:` test uses the **imported** `BRIEF_SCHEMA_VERSION` constant (auto-tracks 1→2); the + `summary:`-equality test is preserved by the SC6 invariant (`briefSummary(r)===briefSummary(r,empty)`); + `startsWith "---\n"` and the `date:`/`ranking:` tests are unaffected. **The one hard break (MAJOR-1):** `:574` + `assert.equal(BRIEF_SCHEMA_VERSION, 1)` — a literal **outside** the frontmatter set — which Step 3 flips to + `, 2)` with the bump (`:575` `SCHEMA_VERSION === 4` stays). Step 1 enumerates every surviving assertion + this + one break. +- **R2 — inserting a section before Topp-treff breaks an ordering assertion.** *Mitigated (verified):* no existing + test pins the intro→Topp-treff adjacency (all body assertions are substring/`match`); the new section is + asserted only by new tests. SC5 pins the section-before-Topp index in the new suite. +- **R3 — the `surfaced:` CSV could collide with a comma in an id.** *Mitigated:* real ids are 12-hex + (`store.ts:69-72`) — comma-free; the join/split is unambiguous in production. The brief.test fixtures use + comma-free ids for the round-trip. (Test `mkTrend` ids are `title|url`; the round-trip unit uses clean ids.) +- **R4 — same-day re-run picks its own just-written file → self-diff (empty) → non-deterministic vs the first + run.** *Mitigated:* `selectPriorBriefFile` filters `f < ${today}.md` (strict), so the same-day file is excluded + and the re-run picks the same true-prior → byte-identical (SC8). This is the exact R3c SC7 guarantee, preserved. +- **R5 — a malformed / hand-edited / pre-R3e prior brief crashes the diff.** *Mitigated:* `parseSurfacedFrontmatter` + returns `[]` on absent/blank/malformed (never throws); the cli `try/catch` degrades any fs error to the + empty-prior (first-brief) path. SC2 + behavioural step 5 cover it. +- **R6 — the summary marker breaks the SessionStart `extractYaml` regex.** *Mitigated:* ` N nye siden sist.` is + digits + ASCII words + a period — no `"`, no `\n`; SC6 asserts the invariant; the hook suite is a land-time + regression check. The hook reads `date`+`summary` only; `surfaced:` is `^surfaced:`-keyed (the `^summary:` + regex cannot match it). +- **R7 — the `🆕` emoji in the gate crashes bash 3.2 `set -u`.** *Mitigated:* Section 16n greps ONLY ASCII + literals (`export function diffSurfaced`, `parseSurfacedFrontmatter`, `Nytt siden sist`, `selectPriorBriefFile`, + `surfaced: `); the emoji lives only in `brief.ts` source + rendered output, asserted by the TS tests. +- **R8 — `BRIEF_SCHEMA_VERSION` bump misread as a store-schema/plugin-version change.** *Mitigated:* it is the + **artifact** frontmatter version (`brief.ts:23`, distinct from the store's `SCHEMA_VERSION` — the comment says + so); store `SCHEMA_VERSION` stays 4; no plugin version bump (additive). README + §4 state the boundary. +- **R9 — the diff couples `brief.ts` to fs (directory read) → breaks the purity claim.** *Mitigated (SD2):* the + three new helpers are pure (string/array in, value out); the `readdirSync`/`readFileSync` live in `cli.ts` (the + edge), injected exactly like `today`/`pillars`. `brief.ts`'s "No fs" header claim holds. +- **R10 — STATE's pinned floor line-cites (`:713`/`:1403`) are stale (live `:716`/`:1473`).** *Mitigated:* caught + at brief time (the lines drifted when R3d added Section 16m + the floor-history narration); the plan cites live + values; Step 8 corrects STATE. +- **R11 — a diff test that passes WITHOUT the feature (vacuous GREEN).** *Mitigated:* SC1/SC2/SC3 are unit tests + of pure functions whose stubs return constants (true value-RED in Phase A); SC9's day-2 asserts a **specific** + added trend appears in `Nytt siden sist (<day1>)` AND the `--json diff.added` count — both absent in the + flag-ignoring handler (Phase B RED). No fixture is ordered-by-accident. +- **R12 — `--no-mark` desync: the artifact records `surfaced:` but the store seen-log is not written.** *Accepted/ + intended:* `surfaced:` is a property of the rendered brief (what it showed), `--no-mark` governs only the store + mutation. The next diff reads the **artifact**, so it is correct regardless of `--no-mark`. SC4 (frontmatter) + and the existing `--no-mark` test (`cli.test:392-398`) both hold. + +## Plan-critic — folded + +Three Opus reviewers (scope-guardian, brief-reviewer, plan-critic) ran COLD on the brief + this plan against live +`scripts/trends/`. **Verdicts:** scope-guardian **MIXED** · brief-reviewer **PROCEED_WITH_RISKS** · plan-critic +**REWORK (0.88)** — **converged on 2 MAJOR + 4 MINOR** (all re-verified against live code before folding; full +rationale in `brief-re-r3e.md §9`). + +- **MAJOR-1** — `tests/brief.test.ts:574` `assert.equal(BRIEF_SCHEMA_VERSION, 1)` is a **hard literal** (outside + the frontmatter set §Step-1 enumerated; the `:163` RegExp auto-tracks). **Folded:** Step 3 flips it to `, 2)` + with the constant bump; Step 1's enumeration + R1 now name it; `:575` (`SCHEMA_VERSION === 4`) stays. +- **MAJOR-2** — `cli.ts:350` `const summary = briefSummary(ranking)` was left unthreaded → day-2 `--json.summary` + would lose the marker the file's frontmatter carries, breaking the `cli.test.ts:268` "one source" invariant. + **Folded:** Step 4 changes it to `briefSummary(ranking, diff)` (safe day-1; `priorDate===null` suppresses the + marker). +- **M1 (MINOR)** — `BriefDiff` is type-only → `import type` if referenced (Step 2/4); the Step-4 code infers it, so + no import is actually needed. +- **M2 (MINOR)** — SC9 uses **rename-real-write** (run `brief`, `mv ${day}.md → 2026-06-20.md`, re-run), not a + hand-fixture → clock-free + id-exact prior. **Folded:** Step 4 note, Phase B, Step 7, SC9 row. +- **M3 (MINOR)** — SC1 "repeated id not double-counted" reworded → **cross-partition disjointness** (filters + preserve within-list dups; production ids are distinct). Brief §6 SC1 + Step 1. +- **M4 (MINOR)** — brief §3 "empty `surfaced:`" contradiction reworded (the `surfaced:` line is diff-independent = + `surfacedIds(ranking)`; the default empty diff only drives the `_Første brief._` section). Brief §3. + +**Confirmed correct (untouched):** all line-cites, the floors (216 @ `:716`; 117 → 123 @ `:1473`), Section 16m as +the last trends section, the 6 ASCII sentinels' non-vacuity, the regex/ISO-lex/hook-safety/`--json`-shape/same-day +strict-`<` — verified by all three. diff --git a/docs/second-brain/architecture.md b/docs/second-brain/architecture.md new file mode 100644 index 0000000..67adb0a --- /dev/null +++ b/docs/second-brain/architecture.md @@ -0,0 +1,102 @@ +# Second Brain — Architecture Design + +> **Status:** architecture **approved by operator 2026-06-23**. **SB-S0 (Foundation) + SB-S1 (Ingest + gold signal) + SB-S2 (Evolution loop) landed 2026-06-23** (`scripts/brain/`, 82 tests, gate-wired; ingest CLI + published-only invariant + operator-gated consolidation loop + session-start nudge); S3–S4 remain design-phase. +> **Boundary (confirmed 2026-06-23):** the **engine** (store schema · evolution loop · ingest seam) → **the plugin** (domain-general, shareable); the **user's data** (posts · articles · newsletters · plans · ideas) → the **per-user data dir** (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/`, survives reinstall); the **personal cockpit** (the operator's day-to-day operations centre) → **Maskinrommet** (a thin layer that reads/writes *through* the plugin's store, never a fork of the engine). +> **Research inputs (three parallel threads, 2026-06-23):** `research/connector-egress.md` · `research/secondbrain-sota.md` · `research/silo-inventory.md`. + +## The problem + +A "second brain" stores everything about one creator — posts, articles, newsletters, data, plans, ideas — and compounds it into an ever-improving, user-aligned profile. It is **memory AND an operations centre**. + +The three research threads reframed the task in a decisive way: + +1. **There is nothing to migrate.** M0 already routed all **12 existing per-user silos** through one tested seam (`hooks/scripts/data-root.mjs` `getDataRoot()` + its TS twin `scripts/analytics/src/utils/storage.ts`), default `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/`. They all survive reinstall today. The task is **unify**, not move. +2. **Not a vector/graph DB.** For *our* case the portable, auditable, drift-resistant 2026 standard is **plain-text + JSON + git** — because **Claude *is* the semantic-retrieval engine** (it reads files in-context). A heavyweight store adds infra, breaks portability, and invites lock-in for no gain. +3. **Auto-ingest exists — for an EU/EEA member.** The DMA Member Data Portability API is self-serve in EU/EEA + Switzerland and gives automated pull of **content** (post text + articles), but **not** received-analytics. Analytics stays the existing manual CSV path. Scraping is a ToS breach with active enforcement → never baked in. So ingest is **manual-first as the contract**; any connector is an additive tributary. + +The real gap (from the silo inventory): the 12 silos are **per-user but siloed and heterogeneous** — three storage idioms, two roots, no cross-references, provenance reinvented under different field names in each. A published post lands in three non-referencing places with no shared id. The question *"which raw material actually performs?"* (specific → post → measured analytics) is **unanswerable today** — and it is exactly what a second brain should answer. + +## The shape: a thin Markdown hub over typed tributaries + +``` +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ + brain/ + index.md # MOC / hub — one-screen pointer to every tributary + freshness flags + # (the "memory AND ops centre" front page) + profile.md # SEMANTIC, two-layer: §static (stable) + §dynamic (evolving). + # Each fact: value · first_seen · last_seen · evidence_count · + # provenance(human|published|ai-draft) · status(active|superseded) + operations.md # PLANS / IDEAS + the "who I am now" anchor (frozen-past-self guard) + journal/ # EPISODIC, append-only: YYYY-MM-session.md — raw, never edited; + # the source the consolidation loop distils FROM + ingest/ + inbox/ # NEW drop-zone: manual import OR (EU/EEA) connector output + published/ # processed, provenance=published — the GOLD signal + voice-samples/ # tributary (style) — stays its own store + specifics-bank/ # tributary (raw material) — stays its own store + trends/ # tributary (external signal) — stays its own store + analytics/ # tributary (performance) — stays its own store +``` + +### Tributaries vs hub — the explicit call + +- **Stay separate (tributaries), feed the hub via distilled pointer-summaries:** voice-samples, specifics-bank, trends, analytics, ingest. Each has a *different dedupe key + lifecycle* (trends: title+url, fast decay · specifics-bank: human-only, near-permanent · analytics: numeric, immutable). Forcing one schema would destroy those distinctions — the silo inventory warns of this explicitly. The hub holds a distilled summary of each + a pointer. +- **Fold INTO the hub:** the flat `profile/user-profile.md` → two-layer `brain/profile.md`. Scattered `audience-insights/` / `examples/` become dynamic-layer sections or tributary-summaries referenced from `index.md`. The **operations/plans centre is genuinely new**. + +### Alternatives considered (and rejected) + +- **Vector/graph DB (Pinecone/Neo4j/Zep-style):** rejected — adds infra, breaks plain-text portability + git audit/rollback, invites lock-in, and duplicates retrieval that Claude already does in-context. The four SOTA ideas that port cleanly to files (episodic/semantic split, provenance-weighting, evidence-threshold promotion, temporal-validity frontmatter) give most of the value; the vector/graph machinery does not port and is not needed. +- **One unified schema across all silos:** rejected — destroys each silo's distinct dedupe key + lifecycle. Keep tributaries; add a thin hub + a cross-silo id. + +## Invariants (the spine) + +- **Provenance-weighted learning (the critical guard):** profile/voice learn from `provenance=published` (human-edited) **only**, never `ai-draft`. This is the model-collapse guard for our exact risk — a content engine that learns voice from what it itself drafted collapses toward its own priors. Generalises `voice-scrubber`'s existing rule ("gold standard = approved editions, not the English post corpus") to a system-wide invariant. +- **One canonical entity id + one provenance shape**, threaded through the silos → makes the post ↔ specific ↔ trend ↔ analytics graph assemblable. Fixes the inventory's hardest problem. +- **Episodic / semantic physically separated** (raw events in `journal/`, distilled facts in `profile.md`). +- **Evidence-threshold promotion (anti-overfit):** the dynamic layer decays; a fact enters the **static** layer only on explicit confirmation OR ≥N independent observations. Single weird sessions never reshape identity. +- **Temporal-validity + freshness nudge:** every fact carries `last_seen`; retrieval weights recent over old; the session-start nudge flags facts not refreshed in T days (extend the existing trend-staleness nudge brain-wide). +- **Anti-sycophancy as a built-in default, not a toggle:** consolidation + content agents treat the profile as evidence to *test*, not flatter. (Personalization context measurably *increases* sycophancy, so counter-pressure it every time the profile is used.) +- **Frozen-past-self guard:** `operations.md` holds a periodic user-authored "where I'm headed now" anchor that deprecates older inferences. +- **Zero required curation (graveyard guard):** the loop compounds value with ~zero mandatory upkeep; curation is available but never required. git = free rollback. + +## The evolution loop (the compounding mechanism) + +Run on a cadence (reuse the existing session-start staleness-nudge surface) as a **sleep-time consolidation pass**, not inline cost: + +1. **Capture (episodic, auto):** real-signal sessions append to `brain/journal/`. The ingest seam (`ingest/inbox/`) takes the user's actual published posts — manual import first; published output tagged `provenance=published`. +2. **Consolidate (reflection):** an Opus agent reads new journal + published + tributary deltas, proposes profile updates as a **diff**, never a silent overwrite. Each candidate carries `evidence_count`, `provenance`, timestamps. +3. **Promote with thresholds:** static-layer entry needs confirmation or ≥N observations; one-offs stay in the decaying dynamic layer. +4. **Reconcile contradictions:** classify temporal-update (supersede, keep old `status: superseded` + date) · contradictory (keep both, timestamped) · condition-dependent (scope) · distractor (drop). Bias toward "keep both + surface the conflict" over silent overwrite. +5. **Decay + freshness:** weight recent over old; flag stale facts at session-start. +6. **Curate (optional, never required):** the diff is presentable; confirm/edit/delete/roll-back via git. + +## Build sequence (full ambition, incremental — one slice per session) + +| Slice | Content | Why this order | +|-------|---------|----------------| +| **SB-S0 — Foundation** ✅ *landed 2026-06-23* | `brain/` scaffold + two-layer `profile.md` (fold in `user-profile.md`) + `index.md` MOC + `operations.md` + `journal/`; entity-id + provenance shape as a small typed, tested module; `ingest/` dirs + manual-import contract. **No loop yet.** Shipped as `scripts/brain/` (TS, 34 tests, gate-wired BRAIN floor); fold = P1 labeled-scalars + P2 expertise (checkbox-prefs deferred, §8 of `plan-sb-s0.md`). | Smallest thing that stands up and is testable; locks the id/provenance spine everything hangs on | +| **SB-S1 — Ingest + gold signal** ✅ *landed 2026-06-23* | Manual import → `ingest/published/` with `provenance=published` (CLI `brain ingest`); voice-trainer wired to learn from published-only, never `ai-draft`, gate-enforced. Shipped as `scripts/brain/src/ingest.ts` + `docs/second-brain/ingest-manual-import.md` (v0.5.1). **No profile.md mutation** (SB-S2). | The gold signal before the loop that consumes it | +| **SB-S2 — Evolution loop** ✅ *landed 2026-06-23* | Operator-invoked, operator-gated consolidation: `brain consolidate` (`--gather`/`--propose`/`--apply --confirm`) → profile **diff** w/ evidence_count/provenance/timestamps; threshold-promotion (N=3); contradiction → keep-both with distinct ids (no supersede); decay-flag (90d); `consolidation-state.json` sidecar; zero-dep session-start consolidation-due nudge + scaffold-ensure. Shipped as `scripts/brain/src/consolidate.ts` + `docs/second-brain/consolidation-loop.md` (v0.5.2). **Operator decisions:** journal deferred · no new agent (session extracts) · motor-only (no reader until S3). | The compounding mechanism | +| **SB-S3 — Cross-silo graph + ops centre** | Thread the id through tributaries (post↔specific↔trend↔analytics assemblable); flesh out `operations.md`; retire the dead `content-history.md` + triple-post reconciliation. **S3a ✅ first reader · S3b ✅ supersede · S3c ✅ cross-silo id-threading** (hub-side: the published record carries the `specifics`/`trends` ids it was built from + a pure analytics resolver — `scripts/brain/src/assemble.ts`, `brain assemble`; tributaries untouched). **S3d ✅ ops centre** (`operations.md` is a read tributary; `strategy-advisor` honours the dated "who I am now" anchor that *deprecates older inferences* — advisory/reader-side). **S3e ✅ hygiene + reconciliation** (dead `content-history.md` retired across its 8 plumbing surfaces; `brain reconcile` reconciles silo 1 `## Recent Posts` ↔ the silo 2↔3 graph read-side, surfacing the coverage gap — created posts never `brain ingest`-ed; auto-capture of specifics/trends = a flagged follow-up). **The second-brain arc is complete** (S4 EØS DMA-connector optional). | Finally answers "which raw material performs?" | +| *(later / optional)* **SB-S4** | EU/EEA DMA portability API as an auto-tributary into `ingest/inbox/` | Additive; never a dependency | + +## What's genuinely hard (honest flags) + +1. **Voice fidelity to a private individual is limited even at SOTA** — the real win is grounded *content* (specifics-bank) over mimicked *style*. Manage expectations. +2. **Detecting that a high-relevance fact went stale is UNSOLVED field-wide** (STALE benchmark: all tested models/frameworks fail). We timestamp + nudge; we cannot auto-detect "changed jobs." Keep a human in that loop. +3. **Contradiction classification** (real change vs context-scoped vs noise) is emerging + error-prone → bias to keep-both-timestamped. +4. **The connector** — no clean LinkedIn self-serve content API outside EU/EEA. Build the manual ingest seam as the contract; any connector is a tributary, never a dependency. Newsletter-edition coverage by the portability `ARTICLES` domain is **not fully verified** — check against a real export. +5. **Sycophancy is structural** — app-level mitigations reduce, not eliminate. +6. **Avoiding the graveyard is a product problem** — the loop must compound value with ~zero required curation or it dies in 6 months like 82% of second brains. + +## Verification (SB-S0, when we build it) + +- Set `LINKEDIN_STUDIO_DATA` to a temp dir, run init → assert `brain/{index,profile,operations}.md` + `journal/` + `ingest/{inbox,published}` exist via the `getDataRoot` seam. +- `profile.md` parses two-layer (§static / §dynamic); `user-profile.md` fields fold in without loss (diff check). +- entity-id module: deterministic id mint + provenance shape, unit-tested. +- **No regression:** gate `scripts/test-runner.sh` 89/0/0 green; trends 24/24; specifics 28/28; contract 33/33. +- **Key assumption to test early:** "Claude-as-retrieval-engine over plain files is sufficient (no vector DB)" — testable with a retrieval scenario once the brain holds content. Marked as assumption until proven. + +## Bottom line + +A thin two-layer Markdown hub (`brain/`: semantic `profile.md` + episodic `journal/` + ops `operations.md` + `index.md` MOC) over the existing typed tributaries, fed by a provenance-tagged ingest seam, maintained by a sleep-time consolidation loop with evidence-threshold promotion, temporal-validity reconciliation, and a built-in anti-sycophancy / anti-collapse stance. Every silo stays a tributary except the flat `user-profile.md`, which folds in. diff --git a/docs/second-brain/brief-sb-s1.md b/docs/second-brain/brief-sb-s1.md new file mode 100644 index 0000000..3a00b19 --- /dev/null +++ b/docs/second-brain/brief-sb-s1.md @@ -0,0 +1,113 @@ +# Task Brief — SB-S1 (Ingest + gold signal) + +> **Status:** DRAFT for adversarial brief-review (light-Voyage step 2). Not yet operator-approved. +> **Arc:** `architecture.md` (approved 2026-06-23). **Predecessor:** SB-S0 landed (`8c92719`) — `scripts/brain/` (types/id/profile/scaffold/cli, 34 tests, gate-wired). **This brief:** the second slice in detail; S2–S4 stay one-line in the arc. +> **Inputs:** `architecture.md` §"evolution loop" steps 1–2, §"invariants" (provenance-weighted learning); SB-S0 code under `scripts/brain/`; the silo inventory (`docs/second-brain/research/silo-inventory.md`). +> **Review delta (2026-06-23):** folded the brief-review (PROCEED_WITH_RISKS). Resolver standardised on `dataRoot` (M-resolver); D3 voice-samples↔ingest relationship pinned (M-D3); SC5 hardened to a gate-checkable lint (M-SC5); test-runner floor edits named as deliverables (m-floor); input path + D4 net-new note + Q3 sharpened (minors). + +--- + +## 1. Objective + +Capture the **gold signal** — the user's actual *published* posts — into `ingest/published/` tagged `provenance=published`, and **wire the voice/profile-learning surface to learn from that published signal only**, never from AI-drafted content. This is the one invariant the whole second-brain stands or falls on (the model-collapse guard): a content engine that learns its own voice from what it itself drafted collapses toward its own priors. SB-S1 lays the gold signal **before** the consolidation loop (SB-S2) that will consume it. + +## 2. Background (why this slice, why now) + +- **The invariant already has a single-agent precedent.** `voice-scrubber` enforces *"gold standard = approved Norwegian editions, never the English post corpus."* SB-S1 generalises that one agent's rule into a system-wide, data-backed invariant: learning consumes `provenance=published` only. +- **The signal is genuinely new, not a re-count of existing silos.** A published post lands today in three non-referencing places — state `## Recent Posts` (hook + topic only), the dead `analytics/content-history.md`, and `analytics/posts/*.json` (metrics, from CSV). **None holds the full post text.** Voice learning needs the verbatim text; `ingest/published/` holding it is additive, not redundant. (Cross-silo *threading* of a shared id is SB-S3, explicitly out of scope here.) +- **The provenance shape is already in place.** SB-S0 pinned `Provenance = human | published | ai-draft` and put a `provenance` field on every `ProfileFact`. SB-S1 is the first slice to actually *produce* `published` records and *gate* a learning consumer on them — it spends the spine S0 built. +- **Manual-first is the contract.** Auto-ingest (EU/EEA DMA portability) is a later additive tributary (SB-S4); SB-S1's ingest is the manual path: the user supplies the published post text, the CLI lands it as a gold record. + +## 3. Confirmed decisions (inherited from the approved arc — not for re-litigation) + +1. **Boundary:** engine → plugin · user data → data dir · cockpit → Maskinrommet. +2. **Plain-text/JSON + git, no vector/graph DB.** Claude is the retrieval engine. +3. **Provenance-weighted learning** (`published` only, never `ai-draft`) is an invariant, not a toggle. +4. **Tributaries keep their own schema/lifecycle** — SB-S1 does NOT reshape voice-samples, specifics, trends, or analytics stores. +5. **No new seam function** — ingest paths resolve through the brain package's own inlined `dataRoot(sub)` resolver (`scripts/brain/src/dataRoot.ts`, the repo idiom — NOT the hooks-side `getDataRoot()` seam, a different function in a different package). So the twin-sync surface (`data-root.mjs ⇄ storage.ts`) stays untouched. + +Review SHOULD challenge: the S1/S2 boundary (§5/§7), the store format (§5.1), feasibility, hidden coupling, testability, scope leak, missing risks. Review SHOULD NOT re-open the five decisions above unless internally contradictory or technically impossible. + +## 4. Scope — where SB-S1 sits in the arc + +| Slice | One-line scope | This brief | +|-------|----------------|------------| +| SB-S0 — Foundation | scaffold + profile fold + id/provenance spine | ✅ landed | +| **SB-S1 — Ingest + gold signal** | manual import → `ingest/published/` `provenance=published`; wire the voice/profile-learning surface to published-only | **← this** | +| SB-S2 — Evolution loop | sleep-time consolidation: journal+published+tributary deltas → profile **diff** (evidence/provenance/timestamps); threshold promotion; contradiction reconciliation; freshness nudge. **Owns session-start wiring of init.** | later | +| SB-S3 — Cross-silo graph + ops centre | thread the id through tributaries (post↔specific↔trend↔analytics); flesh out `operations.md`; retire dead `content-history.md` | later | +| SB-S4 *(optional)* | EU/EEA DMA portability as auto-tributary into `ingest/inbox/` | later | + +## 5. SB-S1 in detail (the build target) + +### 5.1 Deliverables + +1. **A published-record data layer (TS, `scripts/brain/`).** A `PublishedRecord` shape and pure parse/serialize functions for a published post file. Reuse the SB-S0 idioms: `mintEntityId` for the canonical id, the constrained-header line-grammar idea (NO YAML dep), `normalizeProvenance` for the provenance token. Fields (proposed, open to the plan): `id` (sha256[:12] of normalized post text) · `provenance` (always `published`) · `published_date` (YYYY-MM-DD) · `captured_at` (YYYY-MM-DD) · `source` (`manual` | future connector) · the **verbatim post body**. Pure functions, deterministic, unit-tested. + +2. **A `brain ingest` CLI** extending `scripts/brain/src/cli.ts` (same dispatch idiom). Proposed subcommands (final shape open to the plan): + - `brain ingest <file>` — read a file containing one published post, mint id, write `ingest/published/<id>.md` with `provenance=published`. **Idempotent:** same text → same id → no duplicate (compare-then-skip, matching `initBrain`). + - `brain ingest --scan-inbox` — process every file in `ingest/inbox/` into `ingest/published/`, then mark/clear the inbox source (drop-zone → processed, per the architecture's `inbox`→`published` model). + - `brain published list` — inspect the gold corpus (id · `provenance` · date · first line). Surfacing `provenance` per record makes the invariant *observable* — the operator can eyeball that nothing `ai-draft` leaked into `published/`. + +3. **Wire the voice/profile-learning surface to published-only (the invariant made enforceable).** Edit `agents/voice-trainer.md`'s "Gather" step. The pinned relationship to the existing corpus (resolves brief-review M-D3): + - **ADD** `ingest/published/` (`provenance=published`) as the **primary gold source**, ranked above the legacy `voice-samples/` corpus. + - **KEEP** `voice-samples/` as a human-curated tributary — **not reshaped, not redirected, not abandoned** (§7 honoured). By its existing contract its files are the user's own real writing (human-supplied via `/setup`/`/onboarding`); they are not `ai-draft`. voice-trainer keeps reading it but treats `ingest/published/` as the higher-trust signal. + - **FORBID** learning voice/profile patterns from any content marked `provenance=ai-draft` — the explicit negative rule that generalises `voice-scrubber`'s *"gold standard = approved editions, never the draft corpus"* precedent to the system invariant. + - **FENCE the auto-append trap:** the `voice-samples/` template hints at a (not-yet-built) Stop-hook that auto-appends posts after content sessions. S1 states explicitly that any such future auto-capture MUST tag provenance and admit `published` only — never blind-append generated drafts into a learning corpus. (Forward-looking guard, no S1 code; names the exact model-collapse trap so a later slice cannot reintroduce it.) + - **voice-trainer stays Sonnet** (no model/frontmatter change → no structure-lint model-consistency break). + +4. **Manual-import contract doc** — `docs/second-brain/ingest-manual-import.md`: the published-record file format, the CLI usage, and the published-only learning rule. (Design doc → `docs/`, NOT `references/`, so the reference-doc count guard does not fire.) **Net-new (verified):** SB-S0 shipped the `ingest/{inbox,published}` *dirs* only; no contract doc exists yet — the architecture's "SB-S0 … manual-import contract" wording referred to the dir shape, not a doc. + +5. **Tests (TDD, failing-first)** — published-record parse/serialize round-trip (incl. a body with `]`/`|`/quotes/newlines), id determinism + dedup, CLI idempotency on re-ingest and `--scan-inbox`, source-absent / empty-inbox no-op. **Plus the SC5 published-only structure-lint** (a new gate check; see SC5). Gate stays green. + +6. **Gate floor edits (named — Edit, not Write, on existing files → pathguard-safe):** bump `BRAIN_TESTS_FLOOR` in `scripts/test-runner.sh` to the new brain-suite count, and bump the assertion-count floor (`ASSERT_BASELINE_FLOOR` / the `TOTAL_CHECKS` anti-erosion floor) by the number of new `pass()` lines added (the BRAIN suite line is unchanged-count; the new SC5 structure-lint check adds one). Exact line numbers confirmed in the plan against the live `scripts/test-runner.sh`. + +7. **Docs** — flip the `architecture.md` SB-S1 row to "landed"; this brief + the SB-S1 plan persisted under `docs/second-brain/`. + +### 5.2 The S1/S2 boundary (the decision the review must pin) + +- **SB-S1 ships:** the gold-signal **data layer** (records + CLI), and wires the **existing on-demand learning surface** (`voice-trainer`, invoked when the user runs a voice command) to the published-only rule. This is a data module + an agent-doc edit — no scheduler, no automatic profile mutation. +- **SB-S2 owns:** the **automated** sleep-time consolidation loop (session-start cadence) that reads `published` + journal + tributary deltas and proposes `profile.md` **diffs** with evidence/provenance/timestamps + threshold promotion. **No profile.md is auto-mutated in S1.** + +This boundary keeps S1 a clean, testable data+invariant slice and leaves all *automatic profile evolution* to S2. It is the #1 thing for the operator to confirm. + +## 6. Success criteria (SB-S1 — testable) + +- **SC1 (ingest, runtime):** with `LINKEDIN_STUDIO_DATA` set to a temp dir, `brain ingest <file>` creates `ingest/published/<id>.md` with `provenance=published` and the verbatim body, at the runtime data-path via the brain package's `dataRoot`. **`ingest` creates `ingest/published/` on demand** (mkdir-recursive) — it does NOT hard-depend on a prior `brain init`, so ingest-before-init works. Verified by a test asserting file existence + content with no prior init. +- **SC2 (record round-trip):** a `PublishedRecord` serializes and parses back to an identical record, including a body containing `]`, `|`, quotes, and embedded newlines. Verified by a parse/serialize identity unit test. +- **SC3 (id determinism + dedup):** the same post text mints the same id (and re-ingesting it is a no-op — no duplicate file); two different posts mint different ids. Verified by id + idempotency unit tests. +- **SC4 (scan-inbox):** files dropped in `ingest/inbox/` are processed into `ingest/published/` by `--scan-inbox`; an empty inbox is a clean no-op; re-running does not re-create already-published records. Verified by a temp-dir test. +- **SC5 (published-only invariant, gate-checkable):** a **new structure-lint check** in `scripts/test-runner.sh` (matching the Section 8–17 honesty-lint idiom: positive + negative assertion + a non-vacuity self-test) asserts that `agents/voice-trainer.md` (a) names `ingest/published/` in its gather/source instructions AND (b) carries the explicit negative string forbidding learning from `provenance=ai-draft`. The self-test proves the check is non-vacuous (it would FAIL on a fixture missing either string). The honest runtime limit (an agent instruction can still be ignored at runtime) is stated in the doc — but the *presence* of the wiring is gate-enforced, not checklist-trusted. (Resolves brief-review M-SC5: no "OR checklist" escape hatch.) +- **SC6 (no regression / no new seam):** `scripts/test-runner.sh` green; floors hold (trends ≥24, specifics ≥28, contract ≥33, **brain ≥ its new floor**); hook tests pass; SB-S1 adds **no new seam function** so `data-root.test.mjs` twin-sync is untouched. + +## 7. Non-goals / scope fence (SB-S1 does NOT) + +- **No consolidation loop / no automatic profile mutation** (SB-S2). `brain ingest` writes only under `ingest/`, never to `brain/profile.md`. +- **No session-start wiring** of init *or* ingest (SB-S2 owns scaffold wiring; ingest stays a manual CLI in S1). +- **No cross-silo id threading** — ingest mints its own record id but does NOT weave it into trends/specifics/analytics/state (SB-S3). +- **No reconciliation of the triple-recorded post** and **no retirement of `content-history.md`** (SB-S3). +- **No connector / no LinkedIn API** (SB-S4). `source` allows a future connector value but no connector code ships. +- **No tributary-schema edits** — voice-samples, specifics, trends, analytics stores keep their current shape. (`voice-trainer.md` is edited; the voice-samples *store format* is not.) +- **No new seam function**, no state-file / two-roots reconciliation, no GUI/cockpit. + +## 8. Constraints / risks + +- **TDD iron law:** no production code without a failing test first. +- **Code placement:** all new code is TS under `scripts/brain/` — **no new `.mjs` under `hooks/scripts/`**, so the `pre-write-pathguard.mjs` Category-6 guard does not fire (it matches `…/hooks/scripts/*.mjs` on Write only; SB-S1 writes none). `agents/voice-trainer.md` is an **existing** file → Edit, not Write → unaffected regardless. +- **Public repo (`open/` Forgejo):** confirm before push; push only inside the window; STATE.md stays gitignored. +- **Structure lint:** SB-S1 adds no `agents/`, `commands/`, `references/`, or `skills/` file (ingest is a CLI subcommand in an existing package; voice-trainer is edited; the contract doc lives in `docs/`). So no count guard fires absent a version bump. **Version bump is an open question** (§9): the agent behavioural change may warrant 0.5.0 → 0.5.1. +- **Node v25 test quirk:** `node --test <dir>` fails — use `node --import tsx --test tests/*.test.ts`; run TS tools from their own dir; `npm install` in `scripts/brain` before the gate or the suite warn-skips. +- **Risk — store-format ambiguity:** the published-record file format is the one data-shaping decision in S1 (mirrors SB-S0's fold risk). Mitigate by pinning the grammar + a round-trip test (SC2) before the CLI wires it. +- **Risk — invariant enforceability:** "wire voice-trainer to published-only" is an agent-doc instruction, not executable code; an instruction can be ignored at runtime. Mitigate by making SC5 a *gate-checkable* lint where feasible (grep for the rule's presence), and by stating the limit honestly in the doc. +- **Risk — S1/S2 scope creep:** the temptation is to start consuming `published/` into `profile.md` here. Fence held by §7 + SC (no test asserts profile mutation in S1). + +## 9. Open questions for the operator (settle at "go") + +1. **S1/S2 boundary (§5.2):** confirm S1 = data layer + voice-trainer invariant wiring, S2 = the automatic profile-diff loop. (Recommended.) +2. **Profile vs voice in S1:** confirm that *profile* learning is invariant-only in S1 (provenance already on facts; no profile mutation) and that the only *active* consumer wired is `voice-trainer`. (Recommended.) +3. **Store format (~decided, confirm):** published records as **file-per-post constrained-header markdown** (the `brain/profile.md` line-grammar idiom, no YAML) — matching the architecture's `inbox`→`published` per-file drop-zone. This deliberately does NOT reuse the specifics-bank single-JSON-index idiom (that store is a topic-tagged bank, not a per-file drop-zone). Confirm. +4. **Version bump:** take 0.5.0 → 0.5.1, or no bump? SB-S0 set a no-bump precedent for count-neutral slices, but SB-S1 differs: it changes a *shipped agent's learning behaviour* (`voice-trainer`), a user-visible capability change — a stronger case for 0.5.1 than SB-S0 had. (Lean 0.5.1.) + +## 10. Definition of done (SB-S1) + +SC1–SC6 pass; deliverables 1–7 exist; non-goals untouched; the published-only invariant is wired into `voice-trainer` + documented + **gate-enforced (SC5 structure-lint)**; the BRAIN + assertion floors raised; committed (push per window, confirm first); `architecture.md` SB-S1 row → "landed"; version/CLAUDE.md/README touched only if a bump is taken. diff --git a/docs/second-brain/brief-sb-s2.md b/docs/second-brain/brief-sb-s2.md new file mode 100644 index 0000000..32ec337 --- /dev/null +++ b/docs/second-brain/brief-sb-s2.md @@ -0,0 +1,129 @@ +# Task Brief — SB-S2 (Evolution loop) + +> **Status:** DRAFT for adversarial brief-review (light-Voyage step 2). Not yet operator-approved. +> **Arc:** `architecture.md` (approved 2026-06-23). **Predecessors:** SB-S0 (scaffold + profile spine) + SB-S1 (ingest + published gold signal), both landed + released (v0.5.1). +> **Operator scope decisions (2026-06-23, settled before this brief):** (1) **journal-capture DEFERRED** — S2 consumes `published` + tributary deltas only; (2) **deterministic CLI engine + the invoking session does the fuzzy extraction** (count-neutral, NO new agent); (3) **motor-only** — S2 evolves `brain/profile.md` but wiring a *reader* is S3 (accepted, flagged). +> **Inputs:** `architecture.md` §"evolution loop" + §"invariants"; SB-S0/S1 code (`scripts/brain/`); the session-start surface (`hooks/scripts/session-start.mjs`). +> **Review delta (2026-06-23):** folded the brief-review (REVISE). B1+B2 (the state-file write idiom is false AND the brain CLI can't reach the HOME-scoped state file) → resolved by storing consolidation state in a **brain data-root sidecar** (`brain/consolidation-state.json`), reachable by both `--apply` (dataRoot) and the session-start nudge (getDataRoot) — no state-file involvement. M3 `--propose` emits TWO artifacts (md + json). M4 **supersede dropped from S2** (keep-both only; supersede→S3). M5 SC1 split per-rule. Minors: SC3 split, candidate-file schema + gate-validation, freshness nudge = new-published-count only (no profile.md parse at session-start), consolidation matches `kind:'observed'` only (folded `profile-field` static seeds immutable in S2). + +--- + +## 1. Objective + +Build the **compounding mechanism**: an operator-invoked, sleep-time **consolidation pass** that reads the new published gold signal + tributary deltas, proposes a **diff** to the two-layer `brain/profile.md` (never a silent overwrite) — with evidence-threshold promotion, contradiction reconciliation (bias: keep-both-timestamped), provenance-gated learning (`published`/`human` only, never `ai-draft`), and temporal decay flagging (in the diff, not at session-start) — plus a **"consolidation-due" nudge** on the session-start surface (new-published count + days-since-last-run; per-fact stale nudging deferred), and the **session-start wiring of the scaffold init**. This is the loop that turns the gold signal (SB-S1) into an ever-improving, drift-resistant profile. + +## 2. Background (why this shape, given the surfaces) + +- **The loop cannot run AI at session-start.** `session-start.mjs` is zero-dep, must NOT spawn `tsx`, 10s timeout (the B-S3 rule). So the consolidation *pass* (which needs fuzzy fact-extraction) is **operator-invoked** ("sleep-time" = when the operator runs it), and session-start only emits a deterministic **nudge** ("consolidation due / N new published posts"). This is faithful to the architecture's "sleep-time consolidation, not inline cost." +- **journal/ has no producer.** SB-S0 created the empty dir; nothing writes episodes. Per the operator decision, S2 consumes `published` (SB-S1, structured, provenance-tagged) + tributary deltas (specifics/trends/analytics — all have real timestamps); **journal-capture is its own later sub-slice.** +- **The fuzzy extraction lives in the command/session layer, not a new agent** (the specifics-bank precedent: the deterministic store + CLI ship, the elicitation interview lives upstream). So S2 ships a deterministic **engine + CLI**; the invoking session reads the gathered deltas and produces candidate facts. Count-neutral (no `agents/` file → no reload, no count-lint bump). +- **profile.md has no reader yet.** S2 evolves it into a (currently) unconsumed artifact; wiring content consumers is S3. Accepted: the value is that the profile compounds NOW so S3's reader inherits rich data. Flagged honestly (§9). +- **The diff is operator-gated** — matches the longform `SendUserFile` + `[OPERATØR]` idiom (editorial-review): proposed, presentable, never silently applied; confirm/edit/roll-back via git. Anti-sycophancy: the profile is evidence to *test*, presented neutrally. + +## 3. Confirmed decisions (inherited arc + operator scope — not for re-litigation) + +1. **Boundary:** engine → plugin · user data → data dir · cockpit → Maskinrommet. +2. **Invariants (architecture §"Invariants"):** provenance-weighted learning (`published`/`human` only, never `ai-draft`); episodic/semantic split; evidence-threshold promotion; contradiction → keep-both-timestamped; temporal-validity + freshness nudge; anti-sycophancy default; zero required curation. +3. **Operator scope (§Status):** journal deferred · deterministic CLI + session extraction (no new agent) · motor-only (no reader until S3). +4. **No new seam function** — brain code uses the package's own `dataRoot`; the session-start edit uses the existing `getDataRoot` (`data-root.mjs`). +5. **No AI at session-start** (zero-dep nudge only); the consolidation pass is operator-invoked; **no auto-apply** of any diff. + +Review SHOULD challenge: feasibility, the engine's classification rules (§5.1), the session-start edit's zero-dep safety, the diff/apply data shapes, hidden coupling, testability, scope leak, missing risks. Review SHOULD NOT re-open the §3 decisions unless internally contradictory or technically impossible. + +## 4. Scope — where SB-S2 sits in the arc + +| Slice | One-line scope | This brief | +|-------|----------------|------------| +| SB-S0 — Foundation | scaffold + profile fold + id/provenance spine | ✅ landed | +| SB-S1 — Ingest + gold signal | manual import → `ingest/published/`; voice-trainer published-only | ✅ landed (v0.5.1) | +| **SB-S2 — Evolution loop** | consolidation engine → profile **diff** (threshold/contradiction/decay, provenance-gated) + session-start freshness/consolidation-due nudge + scaffold-ensure wiring | **← this** | +| SB-S3 — Cross-silo graph + ops centre | thread the id through tributaries; wire profile.md READERS; flesh out `operations.md`; retire dead `content-history.md` | later | +| SB-S4 *(optional)* | EU/EEA DMA portability auto-tributary | later | + +## 5. SB-S2 in detail (the build target) + +### 5.1 Deliverables + +1. **Deterministic consolidation engine** (`scripts/brain/src/consolidate.ts`) — PURE functions, no IO, heavily tested. Core: `proposeDiff({ current: ProfileDoc, candidates: Candidate[], today, opts }) → ProfileDiff`. A `Candidate` = `{ key, value, provenance, source, observed_date }`. **Matching:** every candidate is keyed `mintEntityId({kind:'observed', key})` and matched ONLY against existing `observed`-kind facts. The SB-S0 **folded `profile-field` static seeds are immutable in S2** — a candidate that semantically overlaps a folded field becomes its own `observed` fact (the keep-both philosophy; reconciling observed-vs-folded is operator-gated / S3). This avoids the `profile-field`↔`observed` id mismatch (fixes brief-review NICE). Classification rules: + - **Reject** any `provenance: 'ai-draft'` candidate outright (model-collapse guard) — it never enters or bumps a fact. + - **Add** (no matching `observed` fact, provenance ∈ {published, human}) → a new **dynamic** fact, `evidence_count: 1`, dates = `observed_date`/`today`. + - **Evidence-bump** (matching fact, SAME value) → `evidence_count++`, `last_seen = today` (no other field touched). + - **Promote** (a dynamic fact whose `evidence_count` reaches threshold `N`, default 3) → move dynamic→**static**. + - **Conflict** (matching key, DIFFERENT value) → **keep-both-timestamped**: add the new value as its own fact, keep the old fact untouched (no evidence-bump of the old), and record the pair in `conflicts[]` — the architecture's explicit bias against silent overwrite. **No supersede in S2** (flipping `status:superseded` is deferred to S3; S2 never demotes/supersedes a fact). + - **Decay-flag** — dynamic facts whose `last_seen` is older than `DECAY_DAYS` (default 90) are listed in `staleFlags[]` (information only; S2 never auto-demotes). + `ProfileDiff` = `{ additions[], evidenceBumps[], promotions[], conflicts[], staleFlags[] }` (typed; NO `supersessions[]` — that arm is S3). The engine NEVER mutates `profile.md` — it returns proposals over a deep-frozen-safe input. `applyDiff(current, diff) → ProfileDoc` is a separate pure function (used only by the gated apply path); its output round-trips through `parseProfile`/`serializeProfile`. + +2. **`brain consolidate` CLI** (extend `scripts/brain/src/cli.ts`) — the operator-invoked loop: + - `brain consolidate --gather [--json]` — read-only: gather the deltas since the last run (new `published` records + each tributary's newest-timestamp summary + the current profile) and print them, for the **invoking session** to read and turn into candidate facts. (The fuzzy extraction lives here, in the session — no new agent.) + - `brain consolidate --propose --candidates <file.json>` — **validate** the candidate file against the `Candidate` shape (malformed/missing field → non-zero exit, no profile touch — the only enforceable session↔engine contract), run `proposeDiff` over the current profile + the candidates, and write **TWO artifacts** to `brain/`: `pending-diff.md` (operator-readable, for the `SendUserFile` gate) AND `pending-diff.json` (the typed `ProfileDiff`, machine-applyable). Print both paths. Does NOT touch `profile.md`. + - `brain consolidate --apply --diff brain/pending-diff.json --confirm` — apply the confirmed JSON diff to `profile.md` (the only path that writes the profile), then update the consolidation sidecar (D3). **Operator-gated** (requires `--confirm`); never auto-runs. + +3. **Last-consolidation state — a brain data-root sidecar** `brain/consolidation-state.json` (`{ "last_run": "YYYY-MM-DD" }`), written by `--apply` and read by the session-start nudge. **This resolves brief-review B1+B2:** both the brain CLI (`dataRoot`) and the session-start hook (`getDataRoot`) resolve the data root, so the sidecar is reachable by both — whereas the plugin state file lives in HOME (`~/.claude/linkedin-studio.local.md`), a SIBLING of the data root the brain CLI cannot reach through its own resolver, and `state-updater.mjs` has no `last_import_date` writer to mimic (that scalar is written by the import *command* via LLM, not a function). Keeping consolidation state in the brain's own data-root territory is also conceptually right (brain state, not plugin state) and zero-dep JSON for the hook to read. + +4. **Session-start wiring** (Edit `hooks/scripts/session-start.mjs` — existing file, zero-dep, pathguard-safe; all reads via the existing `getDataRoot`, the trend-nudge twin idiom): + - **Scaffold-ensure (zero-dep):** mkdir the brain/ + ingest/ dirs if absent (idempotent, no tsx); if `brain/profile.md` is absent, emit a nudge to run `brain init` (seeding needs the tsx fold — kept manual, not inlined). + - **Brain consolidation-due nudge (new-published-count only):** read `brain/consolidation-state.json` `last_run` (zero-dep JSON parse) + count `ingest/published/*.md` records (a `readdir` count — **no `profile.md` parse**, to respect the 10s budget + avoid a fragile zero-dep fact parser). Trend-nudge discipline: `daysSince(last_run)` + threshold + null-safe (a never-consolidated brain with no published records never nags). Emit e.g. "N published posts captured, last consolidation M days ago → run `brain consolidate`." + - **Out of scope (honest):** **stale-FACT** nudging (parsing `profile.md` last_seen at session-start) is NOT shipped in S2 — it needs a zero-dep fact parser + cost, and bites harder once a reader exists. S2's brain-wide nudge = the new-published/consolidation-due signal only; per-fact decay is computed inside `proposeDiff` (`staleFlags[]`), surfaced in the diff, not at session-start. + +5. **Operator-gated diff presentation** — the `--propose` artifact is a readable markdown diff (additions/bumps/promotions/conflicts/stale, sorted, neutral/anti-sycophantic framing); the invoking session `SendUserFile`s it and presents the `[OPERATØR]` gate before any `--apply` (the editorial-review idiom). + +6. **Docs** — `docs/second-brain/consolidation-loop.md` (the loop contract: CLI usage, the engine rules, the operator-gate, the honest limits); this brief + the SB-S2 plan; flip the `architecture.md` SB-S2 row to landed. + +7. **Tests + gate** — engine unit tests (every classification rule + idempotency + provenance-gating + threshold + keep-both + decay + `applyDiff` round-trip), CLI tests (gather/propose/apply, no-auto-apply, last-run recording), and a **session-start nudge test** in the hooks test suite (consolidation-due nudge fires/null-safe). Bump `BRAIN_TESTS_FLOOR` + the assertion floor (per the SB-S1-established empirical method) + the hook-test count if applicable. + +### 5.2 What "owns the session-start wiring of the scaffold init" means here + +S2 makes the brain **present + nudged** at session-start without running tsx: it zero-dep-ensures the dirs and nudges `brain init` (profile seeding stays the manual tsx CLI) + nudges `brain consolidate` when due. It does NOT run the consolidation pass automatically (that needs AI → operator-invoked). + +## 6. Success criteria (SB-S2 — testable) + +Engine rules each get their OWN criterion (fixes brief-review M5; N=3, DECAY_DAYS=90 pinned as the test constants — confirm §9 Q1 at "go" first): +- **SC1a (add):** a candidate with no matching `observed` fact + provenance ∈ {published,human} populates `additions[]` as a dynamic fact, `evidence_count:1`. +- **SC1b (reject ai-draft):** an `ai-draft` candidate yields an EMPTY diff — no add, no bump, no promote (the model-collapse guard, in code). +- **SC1c (evidence-bump):** a candidate matching an existing fact with the SAME value populates `evidenceBumps[]` (`evidence_count++`, `last_seen=today`), no other field changed. +- **SC1d (promote):** a dynamic fact reaching `evidence_count ≥ 3` populates `promotions[]` (dynamic→static). +- **SC1e (conflict→keep-both):** a candidate matching a key with a DIFFERENT value populates `conflicts[]`, ADDS the new value as its own fact, and leaves the old fact untouched (no evidence-bump, no supersede) — both retained. +- **SC1f (decay-flag):** a dynamic fact with `last_seen` older than 90 days is listed in `staleFlags[]`; no auto-demotion. +- **SC1g (folded immutable):** a candidate overlapping a folded `profile-field` static seed creates a separate `observed` fact and never mutates the folded one. +- **SC2 (immutability):** `proposeDiff` does not mutate its `current`/`candidates` inputs (asserted by structural equality of the inputs after the call). +- **SC3 (apply round-trip):** `applyDiff(current, proposeDiff(...))` yields a `ProfileDoc` that round-trips exactly through `parseProfile`/`serializeProfile`. +- **SC4 (idempotency):** re-running propose→apply with the same candidates over an already-applied profile yields no spurious additions (evidence-bumps/`last_seen` only; no duplicate facts). +- **SC5 (CLI gating + validation):** `--gather` and `--propose` never write `profile.md` (asserted byte-unchanged after each); `--propose` writes `pending-diff.{md,json}` to `brain/` and REJECTS a malformed candidate file (non-zero exit, no profile touch); only `--apply --confirm` writes `profile.md` and updates `brain/consolidation-state.json`. Verified by temp-dir CLI tests. +- **SC6 (session-start nudge, zero-dep):** the consolidation-due nudge fires when published records exist + `last_run` is stale/absent, and is silent when the brain is absent / nothing new; the edit adds no `tsx`/heavy dep and parses no `profile.md`. Verified by a hook-suite test (HOME + `LINKEDIN_STUDIO_DATA` set to separate temp dirs — the `session-start-trends-staleness.test.mjs` harness pattern — asserting the nudge string appears / is absent). +- **SC7 (no regression / no new seam / count-neutral):** `scripts/test-runner.sh` green; floors hold (trends ≥24, specifics ≥28, contract ≥33, brain ≥ new floor); hook tests pass; **no new agent/command/ref/skill** (counts unchanged); no new seam function (twin-sync untouched); `compile-hooks.py --check` clean if `hooks.json` is regenerated. + +## 7. Non-goals / scope fence (SB-S2 does NOT) + +- **No journal-capture** (deferred sub-slice) — S2 reads `published` + tributary deltas only; nothing writes `brain/journal/`. +- **No new agent** — the fuzzy extraction is done by the invoking session (no `agents/*.md`, no reload, no agent-count bump). +- **No profile.md READER** — no content agent/command is wired to consume `brain/profile.md` (S3). +- **No cross-silo id threading** into tributaries (S3); no `content-history.md` retirement / triple-post reconciliation (S3). +- **No AI at session-start** — only a deterministic nudge; the consolidation pass is operator-invoked. +- **No auto-apply** — no diff is ever applied without `--apply --confirm`; `profile.md` is mutated by exactly one gated path. +- **No new hook `.mjs`** — only the existing `session-start.mjs` is edited (pathguard: Write of a new `hooks/scripts/*.mjs` is blocked). +- **No new seam function**, no connector (S4), no GUI. + +## 8. Constraints / risks + +- **TDD iron law:** no production code without a failing test first. +- **Code placement / pathguard:** engine + CLI are TS under `scripts/brain/` (safe). The session-start change is an **Edit** of the existing `hooks/scripts/session-start.mjs` (Edit is unaffected by the Category-6 Write guard) — **no new `.mjs` under `hooks/scripts/`**. +- **Zero-dep hook discipline:** the session-start edit must NOT import tsx/the brain TS, must stay within the 10s timeout, and must use direct raw reads (the trend-nudge twin idiom). The brain consolidation is NOT run there. +- **Hook compile:** if the SessionStart entry changes shape, regenerate `hooks.json` via `compile-hooks.py` (drift-guarded). Editing only the script body needs no recompile. +- **Consolidation state is a brain sidecar, NOT the plugin state file** (fixes brief-review B1+B2): `brain/consolidation-state.json` under the data root, written by `--apply` (dataRoot) + read by the session-start hook (getDataRoot, zero-dep JSON). Do NOT route it through `~/.claude/linkedin-studio.local.md` — the brain CLI cannot reach HOME, and `state-updater.mjs` has no `last_import_date`-style writer to mimic (that scalar is an LLM/command write). Reading it at session-start is a `JSON.parse(readFileSync(...))`, the trend-`trends.json`-read twin. +- **Public repo (`open/`):** confirm before push; window; STATE.md gitignored. +- **Count-neutral:** no `agents/commands/references/skills` file added → no count guard fires (absent a version bump). **Version bump** likely 0.5.1 → 0.5.2 (new capability + session-start behavior) — confirm at "go". +- **Risk — engine rule ambiguity:** the conflict-vs-supersede call is the fuzzy heart. Mitigation: bias to keep-both-timestamped (deterministic + safe), reserve supersede for an explicit confirm signal; pin each rule with a unit test before wiring the CLI. +- **Risk — candidate extraction quality:** the *value* of the loop depends on the session extracting good candidates from the gathered deltas; the engine only guarantees the mechanics. Honest limit (the architecture's "voice fidelity is hard"): S2 ships the machinery, not a guarantee of insight. +- **Risk — unconsumed output:** profile.md has no reader until S3; S2's value is deferred (compounding for later). Accepted by the operator; stated in `consolidation-loop.md`. +- **Risk — session-start cost:** the nudge must stay cheap; counting `published/` files + one scalar read is O(files) — bound it (e.g. count via readdir, no parse) to respect the 10s budget. + +## 9. Open questions for the operator (settle at "go") + +1. **Promotion threshold N + decay window:** default `N = 3` independent observations to promote dynamic→static, `DECAY_DAYS = 90` for stale-flagging. Confirm or adjust. (Recommend N=3 / 90d — pinned as the test constants.) +2. **Version bump:** 0.5.1 → 0.5.2 for the new consolidation capability + session-start nudge? (Lean 0.5.2, consistent with SB-S1.) + +*Resolved by the brief-review (no longer open):* conflict = **keep-both only, no supersede in S2** (supersede→S3); diff artifacts = `brain/pending-diff.{md,json}` (git-trackable per "roll-back via git"). + +## 10. Definition of done (SB-S2) + +SC1a–g + SC2–SC7 pass; deliverables 1–7 exist; non-goals untouched; the engine is provenance-gated + keep-both-only (no supersede) in CODE (not just docs); consolidation state is the brain sidecar (not the plugin state file); the consolidation pass is operator-invoked + the diff operator-gated (no auto-apply); session-start nudge zero-dep (no profile.md parse) + tested; committed (push per window, confirm first); `architecture.md` SB-S2 row → landed; version/CLAUDE.md/README touched only if a bump is taken. diff --git a/docs/second-brain/brief-sb-s3a.md b/docs/second-brain/brief-sb-s3a.md new file mode 100644 index 0000000..6a888bf --- /dev/null +++ b/docs/second-brain/brief-sb-s3a.md @@ -0,0 +1,79 @@ +# Brief — SB-S3a: the first profile.md READER + +> **Slice:** SB-S3a (first sub-slice of SB-S3, the cross-silo + ops-centre arc step). +> **Status:** LANDED `4fa411f` (2026-06-23) — gate 95/0/0, `ASSERT_BASELINE_FLOOR` 80. Light-Voyage-hardened (brief-review APPROVE · scope-guardian ALIGNED · plan-critic REVISE→folded). SC1/2/3/6 gate-proven; SC4 inspection-pass; SC5 (read-back) deferred to a reloaded session. +> **Predecessors:** SB-S0 (id/profile substrate) · SB-S1 (published-gold ingest) · SB-S2 (consolidation motor — *motor-only, no reader*). + +## 1. Operator decision (2026-06-23) + +SB-S3 is the arc's largest slice and was decomposed into four sub-slices (value-first / risk-managed): **S3a reader · S3b supersede · S3c cross-silo id-threading · S3d hygiene+ops**. The operator picked **S3a — the profile.md reader — first**, and within S3a the first reader is **one agent: `strategy-advisor`**, wired **READ-only**. + +## 2. Why a reader, and why first + +The whole arc was built *"so S3's reader inherits rich data"* (`consolidation-loop.md:67`). S2 grows `brain/profile.md` but **no agent or command consumes it today** — verified: 0 references to `brain/profile.md` in `agents/`/`commands/`; every "profile" hit points at the *legacy* flat `profile/user-profile.md`, not the SB-S0 two-layer brain profile. The reader is the deferred payoff of S0→S2: it turns a motor-only system into one that **feeds content generation** — the first end-to-end proof of capture → consolidate → read-back-into-generation. + +It is first because it has **no dependency on id-threading** (it reads the profile *facts*, not the cross-silo graph), it is **additive / non-breaking**, and it is the **lowest-risk** of the four (read-only consumption of an existing file). + +## 3. Scope — what is IN (S3a) + +1. **Wire `strategy-advisor` to read `brain/profile.md`.** Add the brain profile to the agent's existing **Step 0: Load Context** list: + `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/brain/profile.md` + The agent reads the markdown in-context and reasons over it — no parser, consistent with the arc thesis (Claude *is* the retrieval engine; `architecture.md:14`). + +2. **Consumption contract (how the agent uses it).** A short subsection in the agent prompt that tells it: + - The profile has two layers — `## Static` (stable, high-confidence) and `## Dynamic` (emerging) — and each fact line carries `evidence_count` + `last_seen`. Higher `evidence_count` / more recent `last_seen` = stronger/fresher signal; weight accordingly. + - **Anti-sycophancy (binding, `architecture.md:58`):** treat every profile fact as *evidence to TEST, not flatter*. Counter-pressure it against analytics/state each time it is used (e.g. "the profile says you lean X, but your last imports show Y — test that"). The profile informs, it never dictates or flatters. + +3. **Graceful absence.** `brain/profile.md` does not exist until `brain init` runs (fresh installs have no brain). The agent must **degrade silently** when the file is missing or empty — no error, no "I couldn't find your profile" noise; it simply proceeds on its other context sources. + +4. **A deterministic wiring test** in the structure-lint / `test-runner.sh` gate: two UNCONDITIONAL assertions — (a) `strategy-advisor.md`'s context-load declares `brain/profile.md`; (b) the agent carries the anti-sycophancy sentinel **literal `evidence to TEST`** (exact-literal grep + a non-vacuity self-test, per the repo lint idiom in `test-runner.sh` Sections 13/16c — loose patterns match vacuously). This is the TDD anchor (see §6) and protects the wiring against future agent-file edits. + - **Assertion-floor lockstep (binding):** the two new checks are UNCONDITIONAL, so `ASSERT_BASELINE_FLOOR` in `test-runner.sh` (Section 18; currently **78** — verify at plan time) must be bumped by exactly +2 → **80** in the same change, per the assertion-erosion guard. Do NOT pin to a deps-present TOTAL. + +5. **Doc/count reconciliation.** Update the binding counts/docs touched (CLAUDE.md agent table note if needed, STATE telling, `consolidation-loop.md` reader-status line: "S3a wires the first reader — `strategy-advisor`"). + +## 4. Non-goals — what is OUT (deferred to later S3 sub-slices) + +- **More than one reader.** `content-optimizer` is the obvious second reader and the pattern generalizes trivially, but S3a wires **exactly one** agent — the smallest valuable proof. Follow-on readers are separate work. +- **A hook-level / `user-prompt-context.mjs` digest reader.** A broad "inject a profile digest into every prompt" reader is attractive but carries a twin `.mjs` parser (twin-sync with `profile.ts`) + per-prompt context cost — *not* a good first proof. Deferred. +- **Cross-silo id-threading (S3c).** The reader consumes profile facts, not the post↔specific↔trend↔analytics graph. No `mintEntityId` threading here. +- **Supersede arm (S3b)**, **content-history retirement / triple-post reconciliation + operations.md (S3d)** — separate sub-slices. +- **Any WRITE to `brain/profile.md`.** The profile is mutated ONLY via `brain consolidate --apply --confirm` (operator-gated). S3a is strictly read-only; it adds no write path. +- **A new parser or new `.mjs`.** S3a is markdown-agent edits + a lint assertion only. + +## 5. Boundaries / invariants (must hold) + +- **READ-only** — S3a never writes the profile; the `--apply --confirm` gate stays the sole writer. +- **Anti-sycophancy is a built-in default, not a toggle** (`architecture.md:58`). +- **Provenance spine intact** — the profile already encodes provenance; the reader must not undermine the published-only learning guard. +- **No tributary schema changes** (that's S3c). +- **Fresh-clone safe** — missing brain → silent degrade, no crash, no nag from the agent. +- **TDD iron law** — the failing wiring test lands before the agent edit. + +## 6. Success criteria (testable) + +- **SC1 — wired:** `strategy-advisor.md` Step 0 context-load includes `brain/profile.md`. *(lint assertion, deterministic)* +- **SC2 — anti-sycophancy framed:** `strategy-advisor.md`'s profile-consumption subsection contains the exact durable sentinel literal **`evidence to TEST`**, asserted by an exact-literal grep + a non-vacuity self-test (repo idiom, `test-runner.sh` §§13/16c). *(lint assertion)* +- **SC3 — gate green:** `scripts/test-runner.sh` (the structure lint) stays green with the new assertions; its `BRAIN_TESTS_FLOOR` (82) is unchanged — no brain TS is touched. The hook suite (~136) runs under the **separate** `node --test hooks/scripts/__tests__/*.test.mjs` runner (NOT part of `test-runner.sh`) and is untouched because no hook code path changes. +- **SC6 — assertion floor honoured:** `ASSERT_BASELINE_FLOOR` bumped by exactly the number of new unconditional checks (+2 → 80); the gate's self-count check passes. +- **SC4 — graceful absence (manual/behavioural):** running `strategy-advisor` with **no** `brain/profile.md` produces normal output, no error/noise about the missing file. +- **SC5 — read-back works (manual/behavioural):** running `strategy-advisor` with a **populated** `brain/profile.md` surfaces ≥1 profile fact as *evidence-to-test* in its recommendation, counter-pressured against analytics/state — not parroted. + +## 7. Verification + +- **Deterministic (gate):** SC1–SC3 via the structure-lint assertion in `test-runner.sh`. `cd scripts/brain` not required — this is an agent-file + lint change; brain TS is untouched. +- **Behavioural (manual, documented):** SC4 + SC5 — the honest limit. Agent-prompt behaviour is not unit-testable (the plugin's command-testing workstream is still open). The plan must include a documented manual run: (a) empty-brain run → clean output; (b) populated-brain run (seed via `brain init` + a `--apply --confirm` cycle, or a fixture profile) → confirm a profile fact appears as tested evidence. Record the result in STATE/changelog at land. + +## 8. Open questions for brief-review / the operator + +1. **Agent choice:** `strategy-advisor` recommended; operator may redirect to `content-optimizer` (post-level grounding) — confirm at the brief gate. +2. **Lint location:** which existing lint file in the `test-runner.sh` gate hosts the SC1/SC2 assertion (plan resolves precisely). +3. **Behavioural test honesty:** is the documented manual verification (SC4/SC5) acceptable for S3a's land, given agent-behaviour is not unit-testable — or does the operator want a fixture-driven harness scoped in (larger)? + +## 9. Brief-review (light-Voyage) — folded + +`voyage:brief-reviewer` verdict: **APPROVE** (scope crisp, non-goals correct, invariants inherited, deterministic-vs-behavioural split honest). Three [FIX]es folded above: +- SC3 no longer conflates two runners (brain-82 floor in `test-runner.sh` vs the separate hook runner). ✅ +- `ASSERT_BASELINE_FLOOR` lockstep bump (+2 → 80) made binding in §3.4 + SC6. ✅ +- SC2 pinned to the exact literal `evidence to TEST` + non-vacuity self-test (anti-vacuity idiom). ✅ + +Reviewer confirmed: `strategy-advisor` is the better first reader than `content-optimizer`; SC4/SC5 manual split is correct; do NOT scope a behavioural harness into S3a (keeps "smallest valuable proof"). diff --git a/docs/second-brain/brief-sb-s3b.md b/docs/second-brain/brief-sb-s3b.md new file mode 100644 index 0000000..3fbbd9f --- /dev/null +++ b/docs/second-brain/brief-sb-s3b.md @@ -0,0 +1,111 @@ +# Brief — SB-S3b: the supersede arm + +> **Slice:** SB-S3b (second sub-slice of SB-S3). **Status:** DRAFT — awaiting operator "go" before any code. Light-Voyage: brief-review APPROVE-WITH-FIXES (8 FIX folded; 3 RISK carried to the plan). +> **Predecessors:** SB-S0 (id/profile substrate — reserved the `superseded` status) · SB-S1 (published gold) · SB-S2 (consolidation motor — keep-both, *no supersede*) · **SB-S3a (first reader `strategy-advisor` — LANDED `4fa411f`, gate 95/0/0, ASSERT floor 80).** +> **Grounded in:** a read-only code map of `scripts/brain/` (file:line cited throughout) + a brief-review pass against the same code. + +## 1. Operator decision (2026-06-23) + +SB-S3 was decomposed into four sub-slices; after S3a (reader) landed, the recommended order is **S3b (supersede) → S3c (id-threading) → S3d (hygiene+ops, last)**. S3b is next because it has the **lowest blast radius** (bounded to the `scripts/brain/` TS package — pure functions, fully unit-testable, unlike S3a's behavioural SCs), it **completes the consolidation motor's one explicit TODO** (`consolidate.ts:11`: *"no supersede in S2 — that's S3"*), and it **strengthens the read path S3a just shipped** (a profile with supersede gives the new reader cleaner, non-contradictory facts). + +## 2. Why supersede, and the central design decision + +S2's engine handles a value-conflict by **keep-both**: the old fact is left untouched and a new alt fact is added with a distinct content-id (`consolidate.ts:120-129`). That is correct for a *genuine contradiction* (two views coexist), but wrong for a **temporal update** — when a new fact *replaces* an outdated one (the user changed roles, a number moved, a stance evolved). Today there is **no way to retire the stale fact**; it persists forever, and the S3a reader has to weigh a fact the user has already moved past. + +The `superseded` status was reserved at SB-S0 (`types.ts:17`, `:23`; grammar `profile.ts:58`; round-trip proven `profile.test.ts:35`) precisely for this — **but nothing ever sets it.** S3b is the code that does. + +**Central design decision (the "fuzzy heart", `brief-sb-s2.md:115`) — supersede is driven by an EXPLICIT signal, never engine auto-classification.** The S2 architecture splits cleanly: the **session** does the fuzzy extraction, the **engine** is deterministic mechanics, the **operator** gates the diff before apply. Classifying *temporal-update vs contradictory* is a fuzzy judgement — so it stays where the fuzziness already lives (the session's candidate extraction), not baked into a pure function. A candidate carries an explicit "this supersedes the prior fact for key X" intent; `proposeDiff` **deterministically** routes it; the operator **sees and confirms** the supersession in the rendered diff before `--apply --confirm` writes it. This honours the S2 promise (`brief-sb-s2.md`: *"reserve supersede for an explicit confirm signal"*) and keeps the engine deterministic and testable. + +**Consequence (the engine has no classification safety net).** Because the engine never validates that a supersede *should* be a supersede, the **operator gate is the only place a mis-classification is caught** — and supersede is the first op that *retires* state (a mis-call silently retires a fact that should have been kept-both, and `status: superseded` makes it look intentional). Two obligations follow: SC6's rendering must make every supersession **visually unmistakable** (old value → new value, both shown, flagged as a retirement), and the doc reconciliation (§3.7) must add an honest-limit line mirroring `consolidation-loop.md:64` — *the engine guarantees supersede mechanics; whether a contradiction is really a temporal update is the session's + operator's judgement.* + +## 3. Scope — what is IN (S3b) + +1. **Candidate supersede signal.** Extend the transient `Candidate` (`consolidate.ts:33-39`) with an explicit, optional supersede intent — minimal shape: `supersedes?: string` (the **key** of the active prior fact this candidate replaces). Set only by the session when it judges a temporal update; absent for ordinary candidates (fully backward-compatible). `validateCandidates` (`cli.ts:147-157`) gains an optional single-line check for the new field. + +2. **A sixth diff array.** `ProfileDiff` (`consolidate.ts:41-47`) gains `supersedes: { oldId, oldValue, newId, newValue }[]` (parallel to the existing `conflicts[]` — the five-array shape means a new op is a new array, not a union edit). + +3. **`proposeDiff` routing (deterministic), with a precise matching rule.** "Matching active prior fact" = the fact returned by `byId.get(mintEntityId({kind:'observed', key}))` — i.e. the live fact at the canonical key-id (see §3.5: retired facts no longer hold the key-id, so this is unambiguously the *active* one). When a candidate carries `supersedes: <key>` AND that lookup finds a fact, route to `supersedes[]`. When the signal is present but **no fact is found at the key-id**, degrade gracefully to a plain **add** (defined, tested behaviour — never a crash). Candidates without the signal are untouched (keep-both/bump/promote/decay exactly as S2). + +4. **`applyDiff` mutation (the first `superseded` writer), state-checked.** Add a branch (`consolidate.ts:144-162`) that, for each `supersedes[]` entry: (a) re-mints the retired fact to a distinct archival id and sets its `status: "superseded"`; (b) installs the new winner under the canonical key-id (see §3.5). The branch is **state-checked, not blind**: a `supersedes[]` entry whose `oldId` is absent from `current` or already `superseded` is **skipped** (this is what makes re-apply idempotent and guards stale diffs — §5). Superseded facts are **retained** in the doc (audit), per `architecture.md:69` (*"keep old `status: superseded` + date"*) — never deleted. Stays a **pure** function returning a new doc. + +5. **Id discipline — winner takes the key-id; retired fact is re-minted off it.** Because `mintEntityId` is value-independent (`id.ts:33`), only one fact may hold the canonical key-id, and it must be the **live** one. So on supersede: the **new winner takes `mintEntityId({observed, key})`** (the canonical key-id), and the **retired fact is re-minted to a distinct archival id** (`mintContentId` over an archival seed, e.g. `superseded:${key}::${oldValue}::${oldFirstSeen}`, so it is stable and collision-free). This preserves the **no-duplicate-id** invariant AND keeps `mintEntityId(key)` always pointing at the live fact — so every downstream match (future same-key candidates, the S3a reader) resolves to the winner exactly as before supersede existed. *(This inverts the naïve "keep old id, mint winner fresh", which would leave the key-id on a retired fact and resurrect it on the next same-key candidate — the brief-review's [FIX 1].)* + +6. **CLI rendering, non-vacuously checked.** `renderDiffMd` (`cli.ts:132-145`) gains a `## Supersessions (old → new)` section so the operator sees every retirement before confirming. **No new subcommand** — supersede rides the existing `--propose` / `--apply --confirm` flow; the sole `profile.md` writer stays `cli.ts:215`. Because the operator confirms via the **JSON** diff while reading the **MD**, a **brain-suite test** (`consolidate-cli.test.ts`, repo non-vacuity idiom) asserts **every `supersedes[]` JSON entry produces a corresponding rendered MD line** and a 0-entry diff renders no section — not merely that a section exists (RISK C). *(This is a brain-suite test, not a `test-runner.sh` Section — so it is counted by `BRAIN_TESTS_FLOOR`, not by a new unconditional structure-lint check; see §3.7.)* + +7. **Gate + doc reconciliation.** **Verified baseline (S3a landed):** `BRAIN_TESTS_FLOOR` = 82 (`test-runner.sh:716`), `ASSERT_BASELINE_FLOOR` = 80 (`:913`). Bump `BRAIN_TESTS_FLOOR` by the new brain-test count; **`ASSERT_BASELINE_FLOOR` stays 80** — no new *unconditional `test-runner.sh` structure-lint* check is added (the render-fidelity check lives in the brain suite, §3.6). `BRAIN_TESTS_FLOOR` is the only floor that moves. Reconcile the now-stale "no supersede" claims (`consolidation-loop.md:34`, `:71`; `consolidate.ts:11` docstring) to the true state, and add the honest-limit line from §2. + +8. **`--gather` active-only filter (plan-critic FIX 8b — completeness).** `runConsolidate --gather` (`cli.ts:174`) emits `profileFacts` to the extraction session with **no status filter** today. After supersede lands, that would re-present `status:superseded` archival facts as live context — re-introducing the exact retired values this slice exists to retire (§2). The gather output is filtered to `status === "active"` (one line). Without this the slice regresses its own goal. + +9. **Decay excludes superseded (plan-critic FIX 8c — completeness).** A retained *dynamic* superseded fact is still in `current.dynamic`, so the decay scan (`consolidate.ts:132-134`) would throw `staleFlags` for it forever. The decay filter excludes `status:superseded` (static archival facts are already decay-exempt — dynamic-only scan). Supersede and decay stay orthogonal. + +## 4. Non-goals — what is OUT (deferred) + +- **Engine auto-classification of temporal-update vs contradictory.** The signal is explicit (§2). The engine never guesses. +- **The other two contradiction-typology branches** — *condition-dependent (scope)* and *distractor (drop)* (`architecture.md:69`, `research/secondbrain-sota.md:37`). S3b ships **temporal-update → supersede ONLY**; contradictory stays keep-both (S2). The other two are later, purely-additive arrays (deferring them leaves no broken half-state — confirmed by brief-review). +- **Auto-demotion of stale facts.** Decay (`staleFlags`, `consolidate.ts:132-134`) stays **advisory** — never mutates status or removes a fact. Supersede and decay stay orthogonal (no shared field). A stale fact is *not* auto-superseded. +- **Cross-silo id-threading (S3c)** · **content-history retirement / operations.md / triple-post reconciliation (S3d)** · **any new reader** (S3a's reader unchanged; `content-optimizer` is later). +- **Chained / multi-fact supersede as a FEATURE.** One active prior → one new winner per candidate. Chains (A→B→C in one pass) and one-supersedes-many are out. **But the intra-batch SEMANTICS are defined, not undefined** (RISK A): the supersede fork carries a `touched`-style guard (analogous to `consolidate.ts:97`) so that within one candidate batch, two candidates targeting the same key, or a candidate superseding a fact another candidate just created, resolve deterministically (first-writer-wins + the later one degrades to add/bump) rather than corrupting. Deferring the *feature* is fine; the *batch rule* is in scope. +- **Grammar changes.** None needed — `superseded` is already accepted end-to-end. + +## 5. Boundaries / invariants (must hold) + +- **Operator-gated, single disk-writer.** No new write path to disk; `--apply --confirm` (`cli.ts:215`) stays the sole `profile.md` writer; `--apply` refuses without `--confirm`. +- **Pure, idempotent engine.** `proposeDiff`/`applyDiff` stay pure (no I/O). Idempotency holds at BOTH layers: `proposeDiff` produces no new `supersedes[]` entry once a supersession has landed (winner matches by value → bump; old fact already superseded), and `applyDiff`'s supersede branch is **state-checked** so re-applying the same diff does not double-install the winner or double-flip status. +- **Stale-diff safety (named assumption + guard).** A pending diff is meant to be applied against the profile it was proposed from; a profile mutated between propose and apply is operator error. S3b does not silently corrupt in that case: `applyDiff` **skips** any `supersedes[]` entry whose `oldId` is absent or already `superseded` in `current` (the §3.4 state-check), rather than retiring the wrong fact. +- **Audit-preserving.** Superseded facts are retained with `status: superseded`, never deleted — history is inspectable. +- **No-duplicate-id.** Every fact keeps a distinct id; the canonical key-id is held only by the live winner; the retired fact moves to a distinct archival id. +- **Backward-compatible.** Candidates without the signal behave exactly as S2; a profile with zero supersessions serialises byte-identically to today (`serializeProfile` unchanged; grammar already round-trips `superseded`). +- **Decay stays advisory** and orthogonal to supersede. +- **No grammar / no cross-silo threading / no new reader** in S3b. +- **TDD iron law** — each engine rule is pinned by a failing unit test before the CLI is wired (mirrors `brief-sb-s2.md:115`). + +## 6. Success criteria (testable — deterministic, unit-level) + +- **SC1 — routing:** a candidate with `supersedes: <key>` + a fact at `mintEntityId({observed, key})` → `proposeDiff` emits exactly one `supersedes[]` entry (oldId/oldValue + newId/newValue) and **no** `conflicts[]`/keep-both entry for it. *(unit)* +- **SC2 — mutation + concrete ids:** `applyDiff` produces a doc where (a) the **winner** carries `id == mintEntityId({observed, key})`, `status: "active"`, `evidence_count: 1`, the new value; (b) the **retired** fact carries `status: "superseded"` and an id `!= mintEntityId({observed, key})` (the archival id); (c) the retired fact is **still present** (count of facts for the key = 2, one active + one superseded). *(unit)* +- **SC3a — propose-level idempotency:** after a supersession has landed, a second `gather→propose` cycle with the *same* now-current value produces **no** new `supersedes[]` entry (the winner matches by value → bump). *(unit)* +- **SC3b — apply-level idempotency:** `applyDiff` of a supersede diff onto a doc where that supersession is already applied does **not** double-install the winner, double-flip status, or create a duplicate id (the §3.4 state-check skips it). *(unit)* +- **SC4 — graceful no-target:** a `supersedes: <key>` signal with **no fact at that key-id** (active or otherwise — superseded facts no longer hold the key-id) degrades to a plain add; no throw. *(unit)* +- **SC5 — round-trip through the real path:** `parseProfile(serializeProfile(applyDiff(cur, supersedeDiff)))` deep-equals `applyDiff(cur, supersedeDiff)` — a superseded fact *produced by the engine* round-trips, not just the hand-built `profile.test.ts:35` fixture. *(unit)* +- **SC6 — CLI gate + render fidelity:** `--propose` renders a `## Supersessions` section in `pending-diff.md` with one line per `supersedes[]` entry (old → new, both values shown), and a 0-entry diff renders no section (decoy); `--apply` writes the superseded status ONLY with `--confirm` (refuses otherwise). *(cli — brain suite)* +- **SC7 — no regression:** contradictory candidates (no signal) still keep-both; bump/promote/decay/published-only all unchanged (regression pins). *(unit)* +- **SC8 — intra-batch determinism:** a batch with two candidates superseding the same key (or one superseding a fact another candidate creates in the same batch) resolves deterministically per the §4 `touched`-guard rule — no duplicate id, no double-supersede. *(unit)* +- **SC9 — gate green:** brain suite ≥ bumped `BRAIN_TESTS_FLOOR`; `scripts/test-runner.sh` green; **`ASSERT_BASELINE_FLOOR` unchanged at 80** (the render-fidelity check is a brain-suite test, so `BRAIN_TESTS_FLOOR` is the only floor that moves). *(plan-critic FIX 7)* +- **SC10 — gather active-only:** `--gather` output excludes `status:superseded` facts (no retired value re-presented as live context). *(cli)* +- **SC11 — decay excludes superseded:** a retained dynamic superseded fact throws no `staleFlags`. *(unit)* +- **SC12 — supersede-wins ordering:** a diff that bumps/promotes AND supersedes the same id resolves to supersede-wins (the superseded fact is not bumped/promoted). *(unit)* + +## 7. Verification + +- **Deterministic (the whole slice):** SC1–SC9 are unit/CLI tests in `scripts/brain/tests/` + the structure-lint gate. **Unlike S3a, S3b has no behavioural-only SC** — supersede is pure engine mechanics, fully exercisable in the harness. A key reason it's a good second slice. +- **Run:** `(cd scripts/brain && npm test)` then `bash scripts/test-runner.sh`. + +## 8. Open questions — RESOLVED (operator 2026-06-23) + +The id-lifecycle is resolved — winner-takes-key-id (§3.5), per brief-review [FIX 1]. The four forks are now operator-confirmed: + +1. **Q-SCOPE → temporal-update → supersede ONLY** ✅ (condition-dependent + distractor deferred). Clean cut, no broken half-state. +2. **Q-LAYER → winner enters DYNAMIC and re-earns promotion** ✅ (N=3). A superseded *static* fact stays static-but-superseded (decay-exempt — `consolidate.ts:132` scans dynamic only — acceptable as retained audit, not live). No static-inherit. +3. **Q-SIGNAL → minimal `supersedes?: string` (target key)** ✅ (widen later only if condition-dependent/distractor are built). +4. **Q-RETAIN → retain superseded facts as audit trail** ✅ (no pruning in S3b; any pruning is an S3d hygiene concern). + +## 9. Brief-review (light-Voyage) — folded + +`voyage:brief-reviewer` verdict: **APPROVE-WITH-FIXES** (PROCEED_WITH_RISKS). All 8 [FIX] folded above: +- **[FIX 1]** post-supersede id lifecycle → §3.5 inverted to winner-takes-key-id + retired-fact-re-minted (the one latent-corruption fix). ✅ +- **[FIX 2]** SC3 idempotency layer → split into SC3a (propose) + SC3b (apply, state-checked). ✅ +- **[FIX 3]** SC2 concrete id/status/count assertions (non-vacuous). ✅ +- **[FIX 4]** stale-diff trust boundary → §5 named assumption + §3.4 skip-guard. ✅ +- **[FIX 5]** floor baseline → §3.7 states the **verified** post-S3a baseline (82 / 80; S3a landed `4fa411f`). ✅ +- **[FIX 6]** SC5 round-trips through `applyDiff` output, not the hand fixture. ✅ +- **[FIX 7]** Q-LAYER decided default → §3 IN-scope; only static-inherit stays open (§8.2). ✅ +- **[FIX 8]** SC4 names the scanned fact-set (the key-id lookup). ✅ + +The 3 [RISK] are carried as explicit plan obligations: +- **[RISK A]** intra-batch supersede semantics → defined in §4 + pinned by SC8 (`touched`-guard). +- **[RISK B]** operator gate is the only classification net → §2 consequence + §3.6 unmistakable render + §3.7 honest-limit doc line. +- **[RISK C]** `renderDiffMd` JSON↔MD divergence → §3.6 render-fidelity check + SC6 lint. + +Brief-review confirmed (no fix): byte-identical backward-compat, five→six-array idiom, no grammar change, the "temporal-update only" scope cut, and the optional-field backward-compat of `Candidate.supersedes?`. + +**Plan-critic pass (folded back into this brief).** Reviewing the plan, `voyage:plan-critic` surfaced two latent-corruption BLOCKs (applyDiff must replace-in-place not copy; proposeDiff must value-guard the supersede fork) — both resolved in the plan — and two completeness gaps that belong in the brief's scope: the **`--gather` active-only filter (§3.8)** and **decay excludes superseded (§3.9)**, with SC10/SC11/SC12 added to §6. `voyage:scope-guardian`: **ALIGNED** (0 creep / 0 gap). The plan (`plan-sb-s3b.md`) carries the full BLOCK/FIX ledger. diff --git a/docs/second-brain/brief-sb-s3c.md b/docs/second-brain/brief-sb-s3c.md new file mode 100644 index 0000000..4552d32 --- /dev/null +++ b/docs/second-brain/brief-sb-s3c.md @@ -0,0 +1,115 @@ +# Brief — SB-S3c: cross-silo id-threading (the "which raw material performs?" graph) + +> **Slice:** SB-S3c (third sub-slice of SB-S3). **Status:** **LANDED** — operator go 2026-06-23; TDD complete, brain suite 113/113, gate 95/0/0 (`BRAIN_TESTS_FLOOR` 94→113, `ASSERT_BASELINE_FLOOR` unchanged at 80). Light-Voyage: brief-review **APPROVE-WITH-FIXES** (5 FIX folded §9), plan-critic **REVISE → 1 BLOCK + 4 MAJOR + 4 MINOR folded** (§10), scope-guardian **ALIGNED** (0 creep/0 gap). +> **Predecessors:** SB-S0 (id/provenance substrate — `mintEntityId`/`mintContentId`, `id.ts:6-7` reserved "SB-S3 will thread this id through the tributaries") · SB-S1 (published-gold ingest — the post anchor `ingest/published/<contentId>.md`) · SB-S2 (consolidation motor) · SB-S3a (first reader `strategy-advisor` — LANDED `4fa411f`) · **SB-S3b (supersede arm — LANDED `585f972`, gate 95/0/0, BRAIN floor 94, ASSERT floor 80).** +> **Grounded in:** a read-only code map of all four tributaries + `scripts/brain/` (file:line cited throughout) — Explore sweep + firsthand reads of `id.ts`, `consolidate.ts`, `types.ts`, `profile.ts`, `ingest.ts`. + +## 1. Operator decision (2026-06-23) + +SB-S3 was decomposed into four sub-slices; standing operator direction is **S3a (reader) → S3b (supersede) → S3c (id-threading) → S3d (hygiene+ops, last)**. S3a + S3b have landed. **S3c is next** — it is the slice the whole arc was built toward: the architecture's single hardest problem (`architecture.md:17`) — *"A published post lands in three non-referencing places with no shared id. The question 'which raw material actually performs?' (specific → post → measured analytics) is **unanswerable today**"* — and the explicit SB-S3 payoff (`architecture.md:54`, `:80`: *"thread the id through tributaries → post ↔ specific ↔ trend ↔ analytics assemblable"*). + +The go-before-code gate still applies per slice: this brief + the plan are presented for operator "go" (with the genuine forks in §8) before any code. + +## 2. The north-star query, and the central design decision + +**What S3c must make answerable:** given a published post, **what raw material fed it** (the specific(s) that grounded it, the trend(s) that inspired it) and **how did it perform** (its measured analytics). The killer second-brain query is `specific → post → measured analytics`: *which raw material actually performs?* + +**The grounding surfaced two hard constraints that reshape the slice** (this is why the design below is leaner — and more arc-aligned — than the STATE's anticipated "mutate every tributary schema"): + +- **C-1 — the profile grammar is rigid.** `brain/profile.md` is a fixed **6-token line-grammar** (`profile.ts:27-37,57-58`; `FACT_RE`; `parse∘serialize===doc`, SC2). Persisting a fact→post provenance link **onto the fact** would add a 7th token = a **breaking `SCHEMA_VERSION` bump** (`types.ts:26`, "bumped only on a breaking grammar change") + a migration of every existing `profile.md`. That is its own slice, not S3c. **OUT (§4).** +- **C-2 — analytics has no body and no URN.** The LinkedIn CSV export carries only `title` (the post-content column, taken **verbatim** by the parser — `csv-parser.ts:181`; the "first ~100 chars" in `models/types.ts:3` is a property of *LinkedIn's export*, not a code truncation) + `publishedDate` + metrics — **no full body, no permalink/URN anywhere in the codebase**. So analytics **physically cannot compute** the brain's `mintContentId(body)` (`id.ts:51`, body-byte-exact). Nor does its own `id` help: `PostAnalytics.id = hash(title + date)` (`csv-parser.ts:138`) is derived from the **very fields the resolver already joins on**, so it carries **zero independent linking signal**. The analytics↔post link therefore **cannot be a stored shared id** — it must be a **resolver** (join by title-prefix + date). This is an honest heuristic, not a guarantee, and the design names it as such. + +**Central design decision — thread the cross-ids onto the HUB-side post record, not into each tributary; assemble analytics by resolver.** The architecture is explicit that **tributaries stay separate and the hub holds the distilled summary + pointers** (`architecture.md:43,49`: *"Keep tributaries; add a thin hub + a cross-silo id"*; *"Forcing one schema would destroy [each silo's] distinctions"*). The temporally-correct anchor is the **post**: trends + specifics exist *before* a post and feed it; analytics measures it *after*. The post's canonical object already exists — `ingest/published/<contentId>.md` — and it already carries the content-id (filename stem + `id:` header). Its **parse** side is tolerant of added header lines (`headerScalar` reads each key by regex, `ingest.ts:61-65`), so threading new fields is round-trip-safe; the **serializer** is a fixed 5-line array (`ingest.ts:51-57`) that S3c **edits** to conditionally append the two new lines (this is a deliberate edit, not an auto-handled field — corrected per brief-review FIX 5). Unlike the rigid profile grammar, this header grammar takes additive optional lines cleanly. So: + +1. the **published record** carries the post's raw-material provenance as **additive, optional header lines** (`specifics:` + `trends:`, lists of tributary ids); and +2. a **pure assembler** joins published-record ↔ analytics-row by title-prefix + date (with a confidence tier) to attach measured performance. + +This keeps **all four tributary schemas (trends, specifics, analytics, post-tracking) untouched**, needs **no analytics base-36-id reconciliation**, and is strictly additive + backward-compatible — while delivering the exact graph the north-star query needs. *(That S3c's blast radius is bounded to the `scripts/brain/` package, not the tributaries, is a finding that corrects the STATE's "rører tributær-skjemaer" expectation — surfaced as the headline fork, §8 Fork-1.)* + +## 3. Scope — what is IN (S3c) + +1. **Published record carries raw-material provenance (additive, optional).** Extend `PublishedRecord` (`ingest.ts:27-40`) with `specifics: string[]` + `trends: string[]` (tributary ids the post was built from; default empty). `serializePublishedRecord` (`ingest.ts:50-58`) emits them as header lines **only when non-empty** (omit-empty → old records serialize byte-identically); `parsePublishedRecord` (`ingest.ts:75-99`) reads them (absent → `[]`). Ids are validated to the 12-hex shape (the existing `id` guard idiom, `ingest.ts:82`); a malformed id throws (never silently dropped). The `\n---\n` sentinel split is unaffected (header lines never contain it). + +2. **Producer surface — tag raw material at ingest.** `ingestText` (`ingest.ts:165-180`) accepts optional `specifics?: string[]` / `trends?: string[]`, threaded onto the record. The `brain ingest` CLI gains **repeatable** `--specific <id>` / `--trend <id>` flags so the operator can tag a post's raw material at capture time. **This requires a parser change (FIX 1):** the shared `parseFlags` (`cli.ts:44-60`) returns `Record<string,string>` and **overwrites** on a repeated key (`out[key] = next`), so it cannot collect `[a,c]`. S3c extends flag-collection to gather repeated `--specific`/`--trend` into arrays — **without changing single-value flag behaviour** (`--file`/`--source`/`--date`/`--scan-inbox` parse exactly as today; regression-pinned, SC12). Minimal-disruption shape (a dedicated repeatable-flag pass for the two keys, or a `string|string[]` collect) is pinned in the plan. This makes the substrate **immediately usable end-to-end** (not fixture-only): ingest a post tagged `--specific X` → assemble → see the graph. Backward-compatible (flags absent → empty arrays = today's behaviour). + +3. **The assembler (the payoff) — a PURE function.** `assemblePostGraph({ records, analytics })` over already-loaded inputs (no I/O in the core) returns, per published post: its `contentId`, its `specifics[]`/`trends[]` ids, and its **matched analytics** — `{ row, confidence }` carrying the **whole `PostAnalytics` row reference** (FIX 4: the assembler attaches the full row; which metric the *CLI renders* is Fork-4, decoupling the data shape from presentation), or `none`. + **The join rule (named here, not deferred — FIX 3), absorbing the title↔body asymmetry (verbatim CSV title vs byte-exact body, C-2):** normalize **both** sides with the brain's content normalization (`normalizeContent` idiom: lowercase + collapse-whitespace + trim — the plan pins the exact fn / shared helper) and require **`norm(title)` is a prefix of `norm(body)`** with a **minimum prefix-length floor** (a too-short title must not false-match any body — floor value pinned in the plan, e.g. ≥ N chars). **Confidence tiers:** `high` = prefix-match (≥ floor) + same `published_date`; `low` = prefix-match (≥ floor) but date off by ≤ a small tolerance, OR a near-miss where LinkedIn truncated the title mid-word (the body's normalized text starts with `norm(title)` minus a trailing partial token); `none` = no qualifying prefix match. Deterministic, fully unit-testable (incl. a truncation near-miss case, SC7). The assembler reads tributary ids **as opaque 12-hex strings** and takes a **minimal analytics input shape** (`{ title, publishedDate, metrics, … }` — the raw `PostAnalytics` JSON shape, `models/types.ts:1-8`), so it does **not** import the trends/specifics/analytics packages (decoupled, §5). + +4. **A thin read-only IO + CLI surface.** A `brain assemble` (or `brain graph`) subcommand: loads published records (`dataRoot('ingest/published')`) + the analytics batches by **inlining a raw-JSON read** of `dataRoot('analytics/posts')/*.json` (each file is an `AnalyticsBatch` with `.posts[]`; storage path `<root>/posts/<date>-<shortId>.json`, `storage.ts:146-152`) — **NOT** by importing the analytics package's `loadAllPosts` (`storage.ts:197`), per the §5 decoupling. It extracts the minimal shape from each `posts[]` entry (note the field is **`publishedDate`**, not `date` — RISK B), runs `assemblePostGraph`, and prints the post → raw-material → performance join (newest first). **Read-only** — it never writes the brain. A missing `analytics/posts/` (gitignored — absent on a fresh clone, RISK C) or missing `ingest/published/` degrades to an empty/partial graph (every post `match: none`), never a throw. *(Fork-3, §8: pure-function-only with CLI deferred is the lighter alternative.)* + +5. **Gate + doc reconciliation.** Bump `BRAIN_TESTS_FLOOR` (currently **94**, `test-runner.sh:716`) by the new brain-test count; **`ASSERT_BASELINE_FLOOR` stays 80** — no new *unconditional structure-lint* check (the new tests are brain-suite tests, counted by `BRAIN_TESTS_FLOOR`, same idiom as S3b §3.7). Reconcile the now-true status lines (`consolidation-loop.md`, `architecture.md:80` SB-S3 row, CLAUDE.md/STATE counts, the `id.ts:6-7` "SB-S3 will thread" comment). + +## 4. Non-goals — what is OUT (deferred) + +- **Persisting the fact→post link on `brain/profile.md` (the profile-grammar bump, C-1).** The post→specifics/trends graph + the analytics resolver answer the north-star query **without** touching the rigid 6-token grammar. A 7th `source`/provenance-id token is a breaking `SCHEMA_VERSION` change with a migration — its own slice. The fact already carries `provenance`; that is enough for S3c. +- **Mutating the tributary schemas (trends/specifics/analytics/post-tracking).** Per `architecture.md:43,49` tributaries stay separate; the cross-ids live hub-side on the post record (§2). No `TrendRecord`/`Specific`/`PostAnalytics` field is added; no analytics base-36-id → sha256 reconciliation. *(Fork-1 is exactly this choice — operator may redirect to the broader mutate-tributaries shape.)* +- **post-tracking (`state-updater.mjs`) content-id.** The loosest silo (regex-mutated markdown, prune-regex risk, `state-updater.mjs:116,145`) and **not on the `specific→post→analytics` critical path**. The post-tracking ↔ published ↔ analytics "triple-post reconciliation" is the named **S3d** concern (`architecture.md:80`). OUT here. +- **A producer that auto-records which specifics/trends a draft used.** S3c gives the substrate + the **manual** `--specific/--trend` tag at ingest (§3.2). Auto-capture from a drafting command (e.g. `/linkedin:newsletter` recording its `livedSpecifics`) is follow-on wiring, like S3a wired one reader. +- **A fuzzy/semantic analytics match beyond title-prefix + date.** No embedding/edit-distance match. If title-prefix+date doesn't resolve it, it's `none` (honest), not a guess. Tightening the matcher is later, additive. +- **Backfilling existing untagged published records.** Old records parse with empty `specifics/trends` and round-trip byte-identically; no migration pass. + +## 5. Boundaries / invariants (must hold) + +- **Tributaries untouched.** Zero schema/field change to trends, specifics, analytics, post-tracking. The assembler **reads** their ids/rows; it never writes them. +- **Additive + backward-compatible.** A published record with no specifics/trends serializes **byte-identically** to today (omit-empty); `parse(serialize(rec))===rec` holds with the new fields; `serialize(parse(oldText))===oldText` for pre-S3c records. `mintContentId` is **body-only**, so adding headers never changes a record's id/filename (no dedupe disturbance). +- **Pure core, single read-only IO seam.** `assemblePostGraph` is pure (inputs in, graph out — no clock/FS/network). The only IO is the read-only `brain assemble` loader; **no new disk writer** (the `--apply --confirm` consolidation path stays the sole `profile.md` writer; ingest stays the sole `published/` writer). +- **Decoupled packages.** The brain assembler does not import the analytics/trends/specifics TS packages; it takes a minimal analytics input shape and treats tributary ids as opaque 12-hex strings. +- **Honest matching.** The analytics↔post join is a **named heuristic** with explicit confidence tiers and a `none` outcome — never presented as a guaranteed key. The CLI shows the confidence so the operator can eyeball low-confidence matches. +- **Anti-sycophancy / provenance spine intact** — S3c adds a read path; it does not weaken the published-only learning guard or the profile. +- **Fresh-clone safe.** Missing `ingest/published/` or missing analytics → the assembler returns an empty/partial graph and the CLI degrades cleanly (no crash, no nag). +- **TDD iron law** — each rule (round-trip with the new fields, the producer threading, the match-tier logic, the graceful-absence path) is pinned by a failing brain-suite test before the code. + +## 6. Success criteria (testable — deterministic, unit-level) + +- **SC1 — record round-trip with raw-material ids:** a `PublishedRecord` with non-empty `specifics`/`trends` satisfies `parsePublishedRecord(serializePublishedRecord(rec)) === rec` (deep-equal); ids preserved in order. *(unit)* +- **SC2 — byte-identical backward-compat (byte contract pinned, FIX 2):** the new header lines, when present, are appended **after `source:`** and before the `\n---\n` sentinel; a record with **empty** `specifics`/`trends` emits the **unchanged 5-line header** (omit-empty), so against a fixture pre-S3c record string `oldText`, `serializePublishedRecord(parsePublishedRecord(oldText)) === oldText` (byte-equal). *(unit)* +- **SC3 — id validation (parse + producer, FIX-add):** a `specifics`/`trends` entry that is not 12-hex throws on `parsePublishedRecord` (never silently dropped), mirroring the `id` guard (`ingest.ts:82`); and the symmetric producer-boundary case — `ingestText({ specifics:["XYZ"] })` with a non-12-hex id — is rejected (validated at the producer, or guaranteed to throw on the immediate round-trip; the plan picks the seam). *(unit)* +- **SC4 — producer threading:** `ingestText({ ..., specifics:[a], trends:[b] })` writes a record whose round-tripped header carries exactly `[a]`/`[b]`; absent → `[]` (today's behaviour, regression pin). *(unit)* +- **SC5 — CLI repeatable flags:** `brain ingest --specific a --specific c --trend b` produces a published record tagged `specifics:[a,c] trends:[b]`. *(cli — brain suite)* +- **SC6 — assemble, high confidence (full row, FIX 4):** for a record whose normalized body begins with an analytics row's normalized title (≥ prefix-floor) and shares its `publishedDate`, `assemblePostGraph` returns that post with `match.confidence === "high"`, the **whole `PostAnalytics` row reference** attached, and its `specifics`/`trends` ids surfaced. *(unit)* +- **SC7 — assemble, low / none / truncation near-miss (FIX 3):** (a) no qualifying prefix match → `match: none` (never a false `high`); (b) a too-short title (< prefix-floor) that is a literal prefix → **not** `high` (floor guards false positives); (c) a LinkedIn-truncated-mid-word title (body starts with `norm(title)` minus a trailing partial token) and/or a date off by ≤ tolerance → `"low"`, per the §3.3 rule. *(unit)* +- **SC8 — assemble is pure + total:** empty `records` → empty graph; empty `analytics` → every post present with `match: none`; no throw on either. *(unit)* +- **SC9 — read-only CLI:** `brain assemble` prints the join (post → specifics/trends → matched analytics + confidence), newest-first, and **writes nothing** (no `profile.md`/`published/` mutation — asserted). Missing analytics/published (RISK C, fresh-clone) → clean partial/empty output, no crash. *(cli — brain suite)* +- **SC10 — gate green:** brain suite ≥ bumped `BRAIN_TESTS_FLOOR`; `scripts/test-runner.sh` green; `ASSERT_BASELINE_FLOOR` unchanged at 80. *(gate)* +- **SC11 — no regression:** ingest dedup/collision-safety, the published-only invariant, consolidation, supersede — all unchanged (the new fields are additive). *(unit/regression pins)* +- **SC12 — single-value flags unregressed (FIX 1):** after the repeatable-flag change, the existing single-value flags (`--file`/`--source`/`--date`) and boolean flags (`--scan-inbox`/`--confirm`) parse exactly as today across all subcommands. *(unit/cli)* + +## 7. Verification + +- **Deterministic (the whole slice):** SC1–SC11 are unit/CLI tests in `scripts/brain/tests/` + the structure-lint gate. **Like S3b (and unlike S3a), S3c has no behavioural-only SC** — the substrate, the producer, and the assembler are pure/CLI mechanics, fully exercisable in the harness with fixtures. +- **Run:** `(cd scripts/brain && npm test)` then `bash scripts/test-runner.sh`. +- **End-to-end demo (documented, optional):** with a real `LINKEDIN_STUDIO_DATA`: import an analytics CSV (existing path) → `brain ingest --specific <id>` a matching published post → `brain assemble` → confirm the post shows its specific + the matched analytics row. Records the north-star query working once, manually. + +## 8. Open questions / genuine forks for brief-review + the operator (the go-gate) + +1. **Fork-1 (headline — where the cross-ids live).** **RECOMMENDED: hub-side** — thread `specifics`/`trends` ids onto the brain's published record + resolve analytics (§2). Tributaries stay untouched; blast radius is bounded to `scripts/brain/`; most arc-aligned (`architecture.md:43`). **Alternative:** the STATE-anticipated *mutate-every-tributary* shape (add a cross-id field to `TrendRecord`/`Specific`/`PostAnalytics` + reconcile analytics' base-36 id) — higher blast, crosses 3 package boundaries + 3 gates, and (per C-2) still can't give analytics a real shared id without a resolver. The recommendation is to take the leaner hub-side cut; **operator confirms or redirects.** +2. **Fork-2 (analytics↔post join).** **RECOMMENDED: pure resolver by title-prefix + date with confidence tiers** (§3.3) — the only mechanism available (C-2: no body/URN). Accept the heuristic + `none` outcome as honest. **Alternative:** require a manual `analyticsId` tag on the published record too (operator types it at ingest) — more precise but more friction and still operator-supplied. Recommend the resolver; a manual tag can be added later, additively. +3. **Fork-3 (assembler surface).** **RECOMMENDED: pure `assemblePostGraph` + a thin read-only `brain assemble` CLI** (demonstrable end-to-end). **Alternative:** pure function + tests only, CLI deferred to when a reader/command consumes the graph (smaller, but no operator-visible payoff this slice). Recommend including the thin CLI. +4. **Fork-4 (metric *rendered* — presentation only, FIX 4).** The assembler always attaches the **whole `PostAnalytics` row reference** (data shape fixed; SC6 depends on it). Fork-4 governs only what the **CLI renders** as the headline "performance" figure — `engagementRate` (compact) vs a small bundle (impressions + engagementRate + saves). RECOMMEND `engagementRate` as the headline with the row available for a `--verbose` expansion. Plan pins the rendered field; the data shape is not in question. + +## 9. Brief-review (light-Voyage) — folded + +`voyage:brief-reviewer` verdict: **APPROVE-WITH-FIXES**. The central design (hub-side id-threading + analytics resolver) was confirmed sound, honestly bounded against both C-1 and C-2, with a clean IN/OUT boundary (no broken half-state) and verified gate mechanics (`BRAIN_TESTS_FLOOR=94` `test-runner.sh:716`, `ASSERT_BASELINE_FLOOR=80` `:913`). Fork-1 was judged framed honestly (not a rigged fork — the recommendation concedes the alternative's only real advantage and notes C-2 defeats even that). All 5 [FIX] folded above: + +- **[FIX 1]** repeatable-flag blocker — `parseFlags` (`cli.ts:44-60`) returns `Record<string,string>` and overwrites on repeat → §3.2 now scopes the collect-into-arrays change + SC12 regression pin (the most material fix; SC5 was unsatisfiable as written). ✅ +- **[FIX 2]** SC2 byte contract pinned — new lines appended **after `source:`**, omit-empty, fixture `oldText` byte-equality (`ingest.ts:51-57`). ✅ +- **[FIX 3]** §3.3 join rule named in-brief (not deferred) — both-side `normalizeContent` + minimum prefix-length floor + truncation near-miss tier; SC7 expanded to floor + near-miss cases. ✅ +- **[FIX 4]** assembler attaches the **full `PostAnalytics` row reference**; Fork-4 governs only CLI *rendering* — decouples SC6 (data) from the open fork. ✅ +- **[FIX 5]** §2 citation corrected — *parse* is forgiving (`headerScalar`, `ingest.ts:61-65`); *serializer* is a fixed 5-line array (`:51-57`) S3c edits; + the C-2 note that `PostAnalytics.id = hash(title+date)` carries no independent linking signal. ✅ + +The 3 [RISK] are carried as explicit plan obligations (see §3.3/§3.4/§5; the plan's risk ledger pins them): +- **[RISK A]** all SCs use synthetic fixtures; the §7 end-to-end demo is the only real-data exercise of the resolver. A green gate does not prove a real LinkedIn CSV title matches a real captured body — the plan keeps the demo and treats a demo `none` as a *normalization-tightening signal* (honest-limit line, mirroring `consolidation-loop.md`/S3b §3.7), not a slice failure. +- **[RISK B]** `brain assemble` reads `analytics/posts/*.json` by inlining a raw-JSON read (`dataRoot('analytics/posts')`), **not** `loadAllPosts` — and extracts from `publishedDate` (not `date`). Pinned in §3.4. +- **[RISK C]** fresh-clone: `analytics/posts/` is gitignored/absent → the loader must degrade to every-post-`none` (SC8/SC9), never throw. Pinned in §3.4/§5. + +## 10. Plan-critic + scope-guardian (light-Voyage) — folded + +`voyage:plan-critic`: **REVISE → 1 BLOCK + 4 MAJOR + 4 MINOR**, all folded into `plan-sb-s3c.md` (the brief's design held; these were plan-precision defects): +- **[BLOCK]** the plan misstated `runIngest`/`main` wiring — `runIngest(flags)` is dispatched without `rest` (`cli.ts:92,238`); Step 4.2 now explicitly changes the signature AND the call site so `collectRepeated(rest,…)` has its input. +- **[MAJOR]** parse must NOT reuse `headerScalar` (`ingest.ts:61-65`) — it throws on absent keys, breaking every old record (SC2/SC11); Step 2.2 pins a non-throwing optional reader. +- **[MAJOR]** analytics-root skew — `ANALYTICS_ROOT` override (`storage.ts:67-72`) honoured by the analytics package but not by brain `dataRoot`; Step 3.5 + R-B name it as a doc-comment caveat (degrades to `none`, not silent). +- **[MAJOR]** `runAssemble` loader shape — needs full `PublishedRecord[]` (for `.body`), not `listPublished`'s summary; Step 4.3 pins full-record load, locally-derived `firstLine`, explicit desc sort. +- **[MAJOR]** producer-boundary throw (SC3) under-specified — Step 2.3 pins the guard (regex/message/placement) + empty-array skip so `scanInbox`/SC11 stay green. +- **[MINOR ×4]** tie-break determinism (Step 3.4 sorts rows), `PREFIX_FLOOR=24` rationale (Step 3.3), floor sub-count reconciliation (R-G), Step 1 compile-fixup of the two existing record literals. + +`voyage:scope-guardian`: **ALIGNED** — 0 creep / 0 gap. All SC1–SC12 + all 5 FIXes + all 3 RISKs map to concrete plan steps; every brief §4 OUT item is genuinely excluded; the "Not touched" scope fence is accurate (four tributaries untouched, analytics READ-only via inlined JSON, hooks/agents/profile-grammar/`parseFlags` untouched). diff --git a/docs/second-brain/brief-sb-s3d.md b/docs/second-brain/brief-sb-s3d.md new file mode 100644 index 0000000..17dce6a --- /dev/null +++ b/docs/second-brain/brief-sb-s3d.md @@ -0,0 +1,93 @@ +# Brief — SB-S3d: the operations centre (`operations.md` becomes a READ tributary) + +> **Slice:** SB-S3d (the ops-centre sub-slice of SB-S3). Splits the old "S3d hygiene+ops" charter: **S3d = ops centre only**; the hygiene + triple-post reconciliation (dead `content-history.md` retirement + post-tracking↔published↔analytics) moves to a new **SB-S3e** (the genuine LAST S3 slice). +> **Status:** DRAFT (brief + plan written; light-Voyage hardening + go-before-code gate pending). +> **Predecessors:** SB-S0 (scaffold seeds the bare `operations.md`) · SB-S3a (the profile.md reader pattern this mirrors) · SB-S3b/c (supersede + cross-silo graph — independent of this). + +## 1. Operator decision (2026-06-23) + +The S3d charter bundled three deliverables — (a) flesh out `operations.md`, (b) retire the dead `content-history.md`, (c) triple-post reconciliation. Grounding showed a clean fault line: **(a) is additive / new / self-contained** (touches only `scripts/brain/` + one reader agent, never the post silos), while **(b)+(c) are coupled** (the content-history back-fill question *is* the reconciliation question) and both touch the loose regex-mutated state silo S3c deliberately avoided. The operator chose **Split — ops centre first**: S3d ships (a) only; (b)+(c) become **SB-S3e**, the focused finale. This keeps S3d low-risk and isolates the silo surgery. + +## 2. Why the ops centre, and why now + +`architecture.md:9` frames the second brain as **"memory AND an operations centre."** S0→S3c delivered the *memory* half (profile, ingest, consolidation loop, cross-silo graph). The *operations centre* is the genuinely-new half (`architecture.md:44`: "the operations/plans centre is genuinely new"), and today it is **scaffold-only**: `operationsSeed()` (`scaffold.ts:52-64`) writes three bare headers — `## Who I am now (anchor)`, `## Plans`, `## Ideas` — that **no agent or command reads**. Verified: 0 references to `operations.md` in `consolidate.ts`/`assemble.ts`/`ingest.ts`/`cli.ts` and 0 in `agents/`. + +The architecture's load-bearing spec is the **frozen-past-self guard** (`architecture.md:59`): *"`operations.md` holds a periodic user-authored 'where I'm headed now' anchor that deprecates older inferences."* This is the human override on the machine's anti-drift mechanisms (evidence-threshold / decay-flag / anti-sycophancy): where those keep the machine honest, the dated anchor lets the user **declare current direction that outranks stale inferences**. S3d makes that real — a reader honours the anchor. + +It is the right next slice because it is **additive / non-breaking**, **read-only into generation** (no engine or silo write), and the natural successor to S3a (same "wire the first reader" shape), now extended with the genuinely-new *deprecation* semantics. + +## 3. Scope — what is IN (S3d) + +1. **Enrich the `operations.md` seed so the frozen-past-self guard is operational.** `operationsSeed()` (`scaffold.ts:52-64`) currently ships bare headers. The **genuinely-new seed content is the dated-anchor convention** `_As of YYYY-MM-DD:_` under the anchor (verified ABSENT from the current seed — `grep "As of" scaffold.ts` = 0) plus light item-format guidance for Plans/Ideas. Rationale: the guard "deprecates *older* inferences" — that is inherently temporal, so the anchor must carry a date the reader can compare against a profile fact's `last_seen`. **NB:** the phrase `deprecates older inferences` is NOT new to the seed — it already ships in the seed's guard comment (`scaffold.ts:58`) — so the seed's new-coverage test must key on the **date convention**, not that phrase (else it passes pre-enrichment; see SC3). Idempotency is **existence-skip**: `initBrain` writes a seed file only when it is absent (`scaffold.ts:105-113`), so an existing `operations.md` is never re-touched and a user edit is structurally safe regardless of seed content. + - *Fork (open question for the go-gate):* the lighter alternative is **reader-only, no seed change** (Option A) — wire the reader against the existing bare seed. Recommended: **Option B (enrich)**, because an undated anchor makes the guard vague. See §8. + +2. **Wire `strategy-advisor` to read `brain/operations.md`.** Add the file to the agent's existing Step 0 Load-Context block (`strategy-advisor.md:41-48`) and a short consumption subsection immediately after the profile subsection (`:63`). The agent reads the markdown in-context (no parser — Claude is the retrieval engine, `architecture.md:14`; consistent with S3a). + +3. **Consumption contract — the frozen-past-self guard (advisory / reader-side).** + - The **anchor** (`## Who I am now`) is the user's own **dated declaration of current direction** — it is **authoritative**, and it **deprecates older inferences**: when a `brain/profile.md` Dynamic fact predates the anchor's date or contradicts its stated direction, **prefer the anchor and flag the older fact as possibly-stale** — do not parrot it. + - **Anti-sycophancy INVERSION (the genuinely-new nuance):** profile facts are *evidence to TEST* (S3a — challenge them); the **anchor is the opposite** — it is user-declared direction, not an inference to challenge, so it is honoured rather than counter-pressured. (It is direction, not praise — honouring it never licenses flattery.) + - **Plans** = active commitments (current intent). **Ideas** = parking lot (suggestions, not commitments). Weigh accordingly. + - Durable sentinel literal for the lint: **`deprecates older inferences`** (verbatim `architecture.md:59`). + +4. **Graceful absence.** `brain/operations.md` (or an empty anchor) → the agent **degrades silently** — no error, no "missing ops centre" noise; it proceeds on its other context sources. (Fresh installs have no brain until `brain init`.) + +5. **Deterministic wiring test** — a new **Section 16e** in the `test-runner.sh` gate, modelled byte-for-byte on Section 16d (SB-S3a): two UNCONDITIONAL checks — (a) a non-vacuity self-test (full-wiring probe detected; 3 under-wired probes — incl. a sibling-file `brain/profile.md` decoy — rejected); (b) a real-file grep asserting `strategy-advisor.md` carries BOTH literals `brain/operations.md` and `deprecates older inferences`. `ASSERT_BASELINE_FLOOR` 80→**82** (+2, lockstep). If Option B: a new `scaffold.test.ts` case asserts the dated-anchor seed convention → `BRAIN_TESTS_FLOOR` 113→**114**. + +6. **Doc/count reconciliation.** Update `architecture.md:80` (the SB-S3 build-row) to reflect the split: S3d ✅ ops centre · **S3e remains** (content-history retirement / triple-post reconciliation). Update STATE telling (gate 95→97, ASSERT floor 80→82, brain 113→114). No CLAUDE.md count change (no new agent/command/reference doc; `strategy-advisor` model/frontmatter unchanged). + +## 4. Non-goals — what is OUT (deferred) + +- **The hygiene + reconciliation half (b+c)** — dead `content-history.md` retirement and post-tracking↔published↔analytics reconciliation. This is **SB-S3e** (the new last slice). S3d touches **no** post silo, **no** `state-updater.mjs`, **no** `content-history` surface. +- **Code-enforced deprecation.** S3d's guard is **advisory (reader-side)**: the agent prefers the dated anchor. It does NOT add a seam that makes `consolidate.ts` read the anchor and down-weight/flag older profile facts during `--propose`. That engine change is heavier (touches the sole writer of `profile.md`) and is an explicit **follow-up**, not S3d. +- **A second reader.** `content-planner` is the obvious next consumer (plans/ideas feed planning); S3d wires **exactly one** agent (`strategy-advisor`, which already reads `profile.md`) — the smallest valuable proof. Follow-on. +- **A parser / new `.mjs` for operations.md.** It is user-authored prose consumed in-context; it needs **no TS grammar** and no twin `.mjs` parser. (The only TS touched is the seed string in `scaffold.ts` + its test.) +- **Any WRITE path to `operations.md` from the motor.** It stays user-authored; the scaffold's existence-skip is the only code that touches the file, and only to create it once (never re-touched once present). +- **A "Maskinrommet cockpit" surface.** `architecture.md:4` reserves the day-to-day cockpit for a future thin layer that reads/writes *through* the store. S3d ships the store substrate (a read tributary), not an end-user cockpit. + +## 5. Boundaries / invariants (must hold) + +- **READ-only into generation** — S3d adds no write path to any brain file; `brain consolidate --apply --confirm` stays the sole writer of `profile.md`, and `operations.md` stays user-authored. +- **No post-silo contact** — zero edits to `state-updater.mjs`, `## Recent Posts`, `content-history`, analytics, or any tributary JSON. (That is S3e.) +- **No tributary schema change** — operations.md is a hub file, not a tributary schema; no `ingest.ts`/`assemble.ts`/`consolidate.ts` change. +- **Idempotent + no-clobber** — the enriched seed must preserve `initBrain`'s existence-skip (a seed is written only if the file is absent, `scaffold.ts:105-113`); a user-edited `operations.md` is never overwritten (`scaffold.test.ts:70-77` must stay green). +- **Fresh-clone safe** — missing brain → the reader degrades silently; the seed change cannot crash a fresh init. +- **Anti-sycophancy preserved** — the profile reader's `evidence to TEST` stance (S3a / Section 16d) is untouched; the anchor's *authoritative* stance is an addition for a different file, never a weakening of the profile stance. +- **TDD iron law** — the failing Section-16e check (and, Option B, the failing scaffold seed assert) lands BEFORE the agent/seed edit. + +## 6. Success criteria (testable) + +- **SC1 — wired:** `strategy-advisor.md` Step 0 context-load includes `brain/operations.md`. *(lint, deterministic — Section 16e Check B)* +- **SC2 — guard framed:** the operations-consumption subsection carries the exact durable literal **`deprecates older inferences`**, asserted by an exact-literal grep + a non-vacuity self-test with a sibling-file decoy (repo idiom, Sections 13/16c/16d). *(lint)* +- **SC3 — seed operational (Option B):** `operationsSeed()` ships the **dated-anchor convention** `_As of YYYY-MM-DD:_`; a NEW `scaffold.test.ts` case asserts that exact convention (the RED-bearing literal — verified absent from the current seed, so it fails first; the `deprecates older inferences` phrase already ships and is a non-RED companion only). `BRAIN_TESTS_FLOOR` 113→114 (verified current reported `tests 113`, so new floor = recorded + 1). **RED proof recorded:** run `(cd scripts/brain && npm test)` against the un-enriched seed and confirm the new case fails BEFORE the seed edit. *(brain suite)* +- **SC4 — gate green:** `scripts/test-runner.sh` stays green with Section 16e; TOTAL **95→97** (verified current 95; +2 unconditional checks; recount live at land rather than assume). The hook suite (separate runner) untouched (no hook path changes). +- **SC5 — assertion floor honoured:** `ASSERT_BASELINE_FLOOR` bumped by exactly the new unconditional checks (+2 → 82); the self-count check passes. +- **SC6 — graceful absence (manual/behavioural):** `strategy-advisor` with no `brain/operations.md` produces normal output, no missing-file noise. +- **SC7 — guard works (manual/behavioural):** `strategy-advisor` with a populated dated anchor that contradicts an older profile fact **prefers the anchor and flags the older fact as deprecated/stale**, not parroted — the frozen-past-self override observably fires. **Honesty hedge (verifiseringsplikt):** if a per-call data-root cannot be exercised, this is recorded as **wiring-inspected-only, NOT a behavioural pass** — never record a guard-fires pass that was not actually run. + +## 7. Verification + +- **Deterministic (gate):** SC1/SC2/SC4/SC5 via Section 16e in `test-runner.sh`; SC3 via the brain suite (`cd scripts/brain && npm install` before the gate, else it warn-skips — STATE rule). No hook path changes → hook suite untouched. +- **Behavioural (manual, documented — the honest limit):** SC6 + SC7 — agent-prompt behaviour is not unit-testable (command-testing workstream still open). The plan includes a documented manual run: (a) empty-brain → clean output; (b) seed a temp `operations.md` with a dated anchor that contradicts a hand-written profile fact → confirm the anchor wins and the older fact is flagged. Record the result in STATE/changelog at land; never overclaim a behavioural pass not run (verifiseringsplikt). + +## 8. Open questions for brief-review / the go-gate + +1. **Seed enrichment (Option B) vs reader-only (Option A).** Recommended **B** — a dated-anchor convention makes the temporal guard operational; A leaves the anchor undated and the guard vague. B costs one seed function + one brain test (`BRAIN_TESTS_FLOOR` +1). Confirm at the gate. +2. **Advisory vs code-enforced deprecation.** Recommended **advisory (reader-side)** for S3d — keeps it additive, no engine change. Code-enforcement (consolidate.ts reads the anchor date to flag older profile facts) is a flagged follow-up. Confirm the deferral. +3. **One reader (`strategy-advisor`) for S3d.** Confirm; `content-planner` as second reader is follow-on. +4. **Doc home for the contract.** Recommended: update `architecture.md:80` (the build-row, now partly false post-split) + let the brief/plan be the contract record. Confirm we do NOT touch `consolidation-loop.md` (that doc is the profile *motor* contract; operations.md is not in that loop). + +## 9. Light-Voyage review — folded + +Three reviewers ran on the brief + plan (2026-06-23): + +- **`voyage:scope-guardian`: ALIGNED** — 0 creep, 0 gaps, 0 dependency issues. Every IN item maps to a plan step; no OUT-list file is touched (the only mentions of post silos / `consolidate.ts` / `content-history` in the plan are the scope-fence list + the `architecture.md:80` doc string that assigns them to S3e). Doc reconciliation stays minimal. Proportional. +- **`voyage:brief-reviewer`: REVISE** → 5 [FIX]es folded: + - **FIX-1** (load-bearing): the sentinel `deprecates older inferences` **already ships in the seed** (`scaffold.ts:58`) and at `architecture.md:59` — so a seed test keyed on that phrase passes GREEN pre-enrichment, collapsing the TDD RED gate. Folded: SC3 + §3.1 now pin the new seed assertion to the **dated-anchor convention** `_As of YYYY-MM-DD:_` (verified absent: `grep "As of" scaffold.ts` = 0). The agent-file Check B still greps the phrase (correct there — the agent file does NOT yet carry it). ✅ + - **FIX-2:** SC3 now requires a **recorded RED proof** (run the brain suite against the un-enriched seed, confirm the new case fails first). ✅ + - **FIX-3:** "compare-then-skip" was a mischaracterization — `scaffold.ts:105-113` is **existence-skip** (writes only if absent; no content compare). Corrected in §3.1/§4/§5. ✅ + - **FIX-4:** SC7 now carries the honesty hedge (wiring-inspected-only unless a per-call data-root is exercised). ✅ + - **FIX-5:** gate TOTAL treated as "expected +2, recount live"; **verified live: 95** → 97. ✅ + - Floor arithmetic confirmed correct by the reviewer: `ASSERT` 80→82, `BRAIN` 113→114. +- **`voyage:plan-critic`: 3 major + 3 minor** (folded into the plan, see `plan-sb-s3d.md` §"Plan-critic — folded"): the brain-test RED literal (= FIX-1), the **recorded** brain floor (verified `tests 113` → 114), `grep -qF` fixed-string pinning, header-enumeration range `:33-37`, `operationsSeed()` cite incl. braces, and the load-bearing retained `##` anchors for `scaffold.test.ts:48-58`. + +**Net:** scope unchanged; the folds harden the TDD RED guarantee (the one real hole) + correct two prose mischaracterizations. Brief-reviewer's residual verdict after folds: structurally sound, well-scoped, honest. diff --git a/docs/second-brain/brief-sb-s3e.md b/docs/second-brain/brief-sb-s3e.md new file mode 100644 index 0000000..e0209be --- /dev/null +++ b/docs/second-brain/brief-sb-s3e.md @@ -0,0 +1,113 @@ +# Brief — SB-S3e: hygiene + triple-post reconciliation (the LAST S3 slice) + +> **Slice:** SB-S3e — the focused finale of SB-S3. Carries the two coupled deliverables the S3d split deferred: **(b) retire the dead `content-history.md`** + **(c) triple-post reconciliation** (silo 1 post-tracking ↔ silo 2 brain published-records ↔ silo 3 analytics). After this slice the second-brain arc is complete (S4 EØS DMA-connector is optional). +> **Status:** DRAFT (brief + plan written; light-Voyage hardening + go-before-code gate pending). +> **Predecessors:** SB-S0 (scaffold seeds the bare tree) · SB-S1 (`ingest/published/` gold-signal records = silo 2) · SB-S3c (`assemble.ts` built the silo 2↔3 join; deliberately did NOT touch silo 1) · SB-S3d (the ops-centre reader — independent). + +## 1. Operator decision context (2026-06-23) + +The original S3d charter bundled three deliverables; the S3d brief (§1) found a clean fault line and **split**: (a) the ops centre shipped as S3d (additive, no silo contact); **(b) + (c) are coupled and move here, to S3e**. They are coupled because **the content-history back-fill question *is* the reconciliation question**: `content-history.md` was a *second* auto-written record of created posts, so deciding its fate (retire vs resurrect) and deciding how the auto-tracked creation stream reconciles to the brain graph are the same decision. Both touch the loose, regex-mutated post-tracking silo that S3c deliberately avoided. S3e is the deliberate, isolated finale for that surgery. + +## 2. The gap — grounded in code + +A "post created via the plugin" leaves **three** separate records, and they do not agree: + +| # | Silo | Path / writer | Carries | Join key? | +|---|------|---------------|---------|-----------| +| **1** | **post-tracking** | `## Recent Posts` in `$HOME/.claude/linkedin-studio.local.md` — auto-written by the Stop-hook → `updatePostTracking` (`state-updater.mjs:114-126`) | `[date] "hook (≤60c preview)" (charCount) - topic` | **none** | +| **2** | **brain published-record** | `ingest/published/<contentId>.md` under the brain dataRoot — written **only** by manual `brain ingest --file …` (`cli.ts:122-138`) | `contentId`, `published_date`, `specifics[]`, `trends[]`, full `body` | `contentId` (internal) | +| **3** | **analytics** | `analytics/posts/*.json` under the brain dataRoot — from the LinkedIn CSV import | `title`, `publishedDate`, `metrics` | none (no body, no URN) | + +**S3c built the silo 2↔3 join** (`assemble.ts: assemblePostGraph`) by a confidence-tiered title-prefix + date heuristic, answering the arc's north-star query *"which raw material actually performs?"* (`specifics → post → measured analytics`). + +**Two gaps remain, and they are the same coin:** + +- **Silo 1 is joined to nothing.** The complete *auto-captured* stream of created posts (silo 1) never meets the graph. The graph (silo 2→3) sees **only** the subset the user *manually* ran `brain ingest` on — so the "which raw material performs" payoff is **blind to every post the user created but never hand-ingested.** Verified: silo 2 has **no auto-capture** — `assemble`'s empty-state message literally instructs the user to `brain ingest --file …` manually (`cli.ts:182`); STATE lists auto-capture as the explicit *optional* S3c-bonus, i.e. it does not exist. +- **The dead `content-history.md`** is a *fourth*, genuinely-orphaned record (date/hook/topic/format/words/chars/source), written by flaky Stop-hook prose (`state-update-reminder.md:67-86`) and read by **nobody** (verified: the only `content-history` mentions in code are a `migrate-data.mjs` MOVE entry, its test, the `.gitignore`/SC2 data-class guards, and a `session-start.mjs` *comment* — zero readers). It is redundant with silo 1 (both auto-written, both unread by the graph) and resurrecting it would solve nothing the join needs. **Honesty nuance (the prose lies):** the writer prose being deleted *claims* the log "enables `/linkedin:report` and `analytics-interpreter` to track content production" (`state-update-reminder.md:85`) — but that was **never wired** (grep-verified: no command/agent reads the file; the `profile.md`/`analyze.md`/`audit.md` "content history" hits are the *conceptual* "have you posted on this topic" signal, not file reads). The claim is aspirational dead prose, which *strengthens* retirement — but it must be named so a mid-deletion reader doesn't chase a phantom reader. + +## 3. Scope — what is IN (S3e) + +### (b) Retire the dead `content-history.md` — clean deletion across its plumbing + +Verified surfaces (grounded this session): + +1. **Writer prose** — remove Section 5 "Content History Log" from `hooks/prompts/state-update-reminder.md:67-86` (the flaky generator). This is the only thing that ever wrote the file. +2. **Template** — delete `config/content-history.template.md`. +3. **Migration** — remove the B1 entry from `MOVE_FILES` in `hooks/scripts/migrate-data.mjs:33` (`['assets/analytics/content-history.md', 'analytics/content-history.md']`). +4. **Migration test** — drop the content-history fixture write (`__tests__/migrate-data.test.mjs:37`), its two assertions (`:56`, `:61`), and trim the test-(a) description (`:50`). Test **count** unchanged (5 cases a–e stay) → hook-suite total unchanged. +5. **`.gitignore:44`** — remove `assets/analytics/content-history.md`. +6. **Gate guard** — remove the `assets/analytics/content-history\.md` alternative from `SC2_CLASSES` (`test-runner.sh:592`). The SC2 check stays (one pass/fail) → **gate total unchanged**. +7. **Reference doc** — remove `content-history.md` from the data-tree diagram (`references/data-path-convention.md:26`). +8. **Comment** — drop `content-history` from the `session-start.mjs:94` comment list. + +**NOT in scope of (b):** `state-updater.mjs`'s `pruneContentHistory` (despite the name, it operates on the **live `## Recent Posts`** in the state file — `:138-172` — and is untouched), and any *conceptual* "content history" prose (e.g. `README:120` `/linkedin:profile` "content-history alignment" reads as the concept, not the file — verify-then-leave; never delete a conceptual mention). + +### (c) Triple-post reconciliation — read-side joiner (recommended fork) + +Extend the brain's read-only assembler so silo 1 is reconciled against the graph, **without writing the state file** (faithful to S3c's "read, never write"). + +1. **A new pure reconcile core** (`scripts/brain/src/reconcile.ts`): `reconcileRecentPosts({ recentPosts, records, graph })` — takes the parsed silo-1 entries + the **full `PublishedRecord[]`** (which carry `body`) + the existing `PostGraphNode[]`, and returns, per silo-1 entry, whether it is present in the brain (a matched published record) and/or analytics, with an explicit confidence tier — surfacing the **coverage gap**: created posts that never reached the brain graph. **Why `records` and not graph-only:** `PostGraphNode` carries `contentId` + analytics match but **no `body`** (`assemble.ts:48-54`), so the hook→post prefix-match needs the `PublishedRecord.body`; the core matches `hook → record.body`, then `record.id → graph node` for the analytics tier. No FS/clock/network in the core (the `assemble.ts` discipline). +2. **A thin read-only state-file parser** (`parseRecentPosts(stateText)`): extract `[date] "hook" (chars) - topic` rows from the `## Recent Posts` section. The format source of truth is the **writer** `updatePostTracking` (`state-updater.mjs:116`: `- [date] "hook" (chars) - topic`) — **not** `pruneContentHistory`'s regex, which is **date-only** (`:145`, `/^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm`) and captures no hook/chars/topic. `parseRecentPosts` is the *first* full-field reader of this section; it shares only the date-anchor shape with the pruner. A golden-string test pins a writer-produced entry round-tripping through `parseRecentPosts`. +3. **A new brain IO seam for the state file.** Silo 1 lives at `STATE_FILE || $HOME/.claude/linkedin-studio.local.md` — **outside** the brain dataRoot (`LINKEDIN_STUDIO_DATA`). A new `loadRecentPosts()` IO resolves that path with the canonical precedence already exported as `data-root.mjs: getStateFile()` — `STATE_FILE` first, else `join(resolveHome(), '.claude', 'linkedin-studio.local.md')` where `resolveHome() = HOME || USERPROFILE || homedir()` (NOT bare `homedir()` — that would ignore a `HOME`/`USERPROFILE` override the writer honours). Reads read-only; absent → `[]` (fresh-clone safe). This is a **genuinely new root-skew caveat** (the state file resolves via `STATE_FILE`, a *different* root than the brain dataRoot — not a mirror of `assemble.ts`'s `ANALYTICS_ROOT` note, which is about the analytics *dir* under `LINKEDIN_STUDIO_DATA`). (A hook-side `.mjs` reconciler is **pathguard-blocked** — new `.mjs` under `hooks/scripts/` is forbidden — so the brain TS side is the only clean home; it copies `getStateFile()`'s chain since it cannot import the `.mjs`.) +4. **A CLI subcommand** `brain reconcile` (`cli.ts`): loads silo 1 + the assembled graph, prints the per-post coverage report (in brain / in analytics / orphaned-in-state), and a one-line summary ("N created posts, M in the brain graph, K un-ingested → `brain ingest` them to feed the graph"). Read-only. +5. **Deterministic gate section** (new Section, modelled on 16e/16d): unconditional checks that the reconcile wiring exists (CLI subcommand registered; core exported). `ASSERT_BASELINE_FLOOR` bumped by exactly the new unconditional checks. +6. **Brain-suite tests** (RED first): `reconcile.test.ts` — coverage tiers (present/absent/orphaned), the truncated-hook prefix limit, empty-state degradation. `BRAIN_TESTS_FLOOR` bumped by the new passing count (recorded live, not assumed). + +**Honest limits of read-side (documented, not hidden):** (i) silo 1 carries only a **≤60-char truncated hook preview**, a weaker prefix signal than silo 2's full body → lower match confidence, reported honestly as a tier, never as proof. (ii) Read-side **cannot retroactively assign** the `specifics`/`trends` ids a post was built from — those were never captured for un-ingested posts; the join can only say "this created post is missing from the graph," not reconstruct its raw material. (iii) The true gap-closer is **auto-capture at drafting** (silo-2 record minted with specifics/trends at creation) — explicitly a **follow-up, not S3e** (STATE's S3c-bonus). S3e ships the *honest read-side reconciliation + the coverage signal that motivates auto-capture next.* + +## 4. Non-goals — what is OUT (deferred) + +- **Write-side stamping** — embedding a `contentId` into the `## Recent Posts` entry. Rejected for S3e: it requires a `contentId` to *exist* at post-creation, i.e. **auto-capture** (out of scope), and it mutates the `$`-injection-defended regex silo (`updatePostTracking` already had to use replacement *functions* to stay `$`-safe — `state-updater.mjs:117-125`). Heavier + riskier + premature. See §8 fork 2. +- **Auto-capture at drafting** (the real gap-closer) — minting a silo-2 published record with specifics/trends ids at post-creation. The motivated follow-up; S3e's coverage report is what makes its value legible. +- **Resurrecting `content-history.md`** as a live read tributary (the back-fill arm of fork 1) — it would add a second weak auto-record the graph still can't join. Rejected; retirement chosen. +- **Any change to `pruneContentHistory` / `## Recent Posts` writer / `state-updater.mjs`** — S3e reads silo 1, never writes it. +- **A second reconcile consumer** (a command/agent that *acts* on the coverage report) — S3e ships the `brain reconcile` view; wiring an agent to nudge "ingest these" is follow-on. +- **`assemble.ts` join-heuristic changes** — reconcile *consumes* the existing graph; it does not re-tune the silo 2↔3 matcher. + +## 5. Boundaries / invariants (must hold) + +- **READ-only into the state silo** — S3e adds no write path to `## Recent Posts` or any state-file field; `state-updater.mjs` stays the sole writer of silo 1. +- **READ-only into generation** — `brain consolidate --apply --confirm` stays the sole writer of `profile.md`; reconcile writes nothing. +- **Retirement is plumbing-only** — (b) removes a dead file + its writer/migration/guards; it must not touch the live `## Recent Posts`, `pruneContentHistory`, or any conceptual "content history" prose. +- **No new hook `.mjs`** — pathguard forbids new `.mjs` under `hooks/scripts/`; reconcile lives in `scripts/brain/` (TS). Editing the existing `migrate-data.mjs` (a deletion) is an Edit of an existing file → allowed. +- **State-file root precedence mirrored exactly** — `loadRecentPosts()` resolves `STATE_FILE || $HOME/.claude/linkedin-studio.local.md`, identical to the `.mjs` side; a divergence would silently read the wrong file. +- **Fresh-clone / empty-state safe** — missing state file or empty `## Recent Posts` → reconcile degrades to a clean empty report, never a crash (mirrors `loadAnalyticsRows`). +- **Migration idempotency intact** — removing the B1 MOVE entry must not break `migrate-data`'s `.migrated`-marker idempotency or the COPY/MOVE classes; the remaining test cases (b–e) stay green. +- **TDD iron law** — the failing reconcile test (and the failing gate-section self-test) land BEFORE the reconcile code. + +## 6. Success criteria (testable) + +- **SC1 — content-history retired:** `grep -rn "content-history" hooks/ config/ scripts/test-runner.sh .gitignore references/data-path-convention.md` (NO `--include` filter — it must reach `.gitignore` and the `.md` ref-doc, which an extension-filter would silently skip) returns **zero** (file, writer, MOVE entry, gitignore line, SC2 alternative, ref-doc, comment all gone); the template file is deleted. The README `:120` *conceptual* mention is intentionally outside this grep set (concept, not file). *(grep + `ls` assertion — identical command in the plan, SC1)* +- **SC2 — migration green after retirement:** `(cd … && node --test hooks/scripts/__tests__/migrate-data.test.mjs)` passes with cases a–e; no content-history assertion remains. *(hook suite)* +- **SC3 — reconcile core correct (RED-first, LOGIC-RED not import-RED):** `reconcile.test.ts` asserts the three coverage tiers (in-graph / in-brain-only / orphaned-in-state), truncated-hook degradation, and empty-state → empty report. **RED proof recorded:** the artifact must show the **named tier assertions failing** (e.g. "expected `orphaned-in-state` for the un-ingested entry") against a **stub `reconcile.ts` that compiles but mis-classifies** (returns `[]` / wrong tiers) — a bare `MODULE_NOT_FOUND` import-RED proves nothing about the assertion logic and is rejected as vacuous. `BRAIN_TESTS_FLOOR` bumped to the recorded new count. *(brain suite)* +- **SC4 — reconcile wired:** `brain reconcile` runs end-to-end on a seeded fixture (silo 1 + published + analytics) and prints the coverage report + summary; a new gate Section asserts the CLI subcommand + core export exist (unconditional self-test + real-file grep). *(lint + manual run)* +- **SC5 — gate green + floor honoured:** `scripts/test-runner.sh` stays green; TOTAL recounted **live** at land (current 97; + the new unconditional checks). `ASSERT_BASELINE_FLOOR` bumped by exactly that many; SC2-dry-run still passes with the narrowed `SC2_CLASSES`. +- **SC6 — root-skew (manual/behavioural — EXPECTED to land as a real pass):** with `STATE_FILE` pointing at a temp state file, `brain reconcile` reads silo 1 from it; with it unset it falls back to `$HOME/.claude/linkedin-studio.local.md`. This is a trivial `STATE_FILE` env toggle in a temp dir — exercisable in the same shell that runs the gate — so the **default expected landing state is a real behavioural pass**, not a hedge. **Fallback (verifiseringsplikt):** only if the env toggle genuinely cannot run, record wiring-inspected-only, NOT a behavioural pass — the hedge must never become a standing excuse to skip a runnable check. +- **SC7 — counts reconciled:** `architecture.md` S3 build-row marked S3e ✅ (arc complete); STATE telling updated (gate total, ASSERT floor, brain floor); no CLAUDE.md command/agent/reference-doc count change (no new command/agent; `reconcile.ts` is a brain module + CLI subcommand, not a plugin surface — confirm at the gate whether `brain reconcile` warrants a one-line CLAUDE.md mention). + +## 7. Verification + +- **Deterministic (gate):** SC1/SC4/SC5 via grep + the new gate Section in `test-runner.sh`; SC2 via the migrate-data hook test; SC3 via the brain suite (`cd scripts/brain && npm install` before the gate, else warn-skip — STATE rule). +- **Behavioural (manual, documented — the honest limit):** SC4 end-to-end run + SC6 root-skew — recorded in STATE/changelog at land; never overclaim a behavioural pass not run (verifiseringsplikt). + +## 8. Open questions for brief-review / the go-gate + +1. **Fork 1 — content-history: retire vs back-fill.** Recommended **retire** — zero readers, redundant with silo 1, and back-fill resurrects a second weak auto-record the graph still cannot join. Confirm. +2. **Fork 2 — reconciliation: read-side joiner vs write-side stamp.** Recommended **read-side** — faithful to S3c's read-only invariant, avoids the `$`-injection regex silo, and write-side is blocked on auto-capture (no `contentId` exists at creation today) so it would be premature. Cost honestly stated: a new cross-seam read (brain TS → state file). Confirm; write-side + auto-capture become the flagged follow-up. +3. **Scope of (c): build the read-side joiner now, or document-the-design + defer the build?** Recommended **build now** — the coverage report is the genuine deliverable that closes the arc and motivates auto-capture; a doc-only finale under-delivers the last slice. Confirm the build. +4. **CLAUDE.md surface for `brain reconcile`.** The brain CLI is an internal tool, not a plugin command — recommended: a one-line architecture note, no command-count change. Confirm. + +## 9. Light-Voyage review — folded + +Three reviewers ran on the brief + plan (2026-06-23): + +- **`voyage:scope-guardian`: ALIGNED** — 0 creep, 0 gaps, 0 dependency issues. All 14 IN items (b1–b8, c1–c6) map to a plan step; no NON-GOAL file is touched (the scope fence is consistent with the steps, and slightly broader/defensive); doc reconciliation is proportional (architecture build-row + STATE telling only). Every referenced file/line/symbol verified against the live codebase. +- **`voyage:brief-reviewer`: REVISE** → 4 [FIX]es folded: + - **FIX-1** (load-bearing): the parser must mirror the **writer** `updatePostTracking` (`state-updater.mjs:116`), NOT `pruneContentHistory` — whose `:145` regex is **date-only** and captures no hook/chars/topic. Folded into §3(c).2 (+ a golden-string round-trip test). The "they never disagree" claim was rebuilt on the writer-format source of truth. ✅ + - **FIX-2** (honesty): the writer prose being deleted *claims* `/linkedin:report` + `analytics-interpreter` as readers, but they were never wired — aspirational dead prose. Named in §2 so the "zero readers" verdict is auditable, not asserted. ✅ + - **FIX-3** (testability): SC3 RED tightened to a **logic-RED** (named tier assertions fail against a mis-classifying stub), rejecting a vacuous import-RED. ✅ + - **FIX-4** (testability + consistency): SC6 pre-committed as an **expected real behavioural pass** (trivial env toggle), wiring-only as documented fallback; and the root-skew caveat is **genuinely new** (state file via `STATE_FILE`), not a mirror of `assemble.ts`'s `ANALYTICS_ROOT` note — cite `data-root.mjs: getStateFile()` as the canonical precedence. Folded into §3(c).3 + SC6. ✅ + - Also folded: the **core signature** corrected to `{ recentPosts, records, graph }` (graph-only cannot prefix-match — `PostGraphNode` has no `body`), and **SC1's grep** unified with the plan's (no `--include` filter, which would skip `.gitignore`/`.md`). +- **`voyage:plan-critic`: REVISE (3 blockers + 6 major + 4 minor)** — folded into `plan-sb-s3e.md` §"Plan-critic — folded". The three blockers were real factual errors against the code, each verified directly before folding: (B1) `listPublished()` is body-less → use `runAssemble`'s inline `parsePublishedRecord` loader (`cli.ts:162-176`, which already builds a `bodyById` Map); (B2) the core signature contradiction (= the brief's signature fold); (B3) the `:145`-regex misattribution (= FIX-1). Majors: the `getStateFile()` HOME-chain (not bare `homedir()`), single-literal gate decoys, Check-B literal-name constraint, recorded gate TOTAL (verified live **97**), demonstrated migrate idempotency, and the SC1 grep unification. + +**Net:** scope unchanged (ALIGNED); the folds correct three load-bearing factual errors in the reconcile design (body-less loader, wrong regex, wrong HOME seam) + harden the RED guarantee and the gate's non-vacuity. The retirement arm (b) was already nearly executable; the reconcile arm (c) is now grounded in the actual `assemble.ts`/`ingest.ts`/`cli.ts` shapes. diff --git a/docs/second-brain/brief.md b/docs/second-brain/brief.md new file mode 100644 index 0000000..a723aae --- /dev/null +++ b/docs/second-brain/brief.md @@ -0,0 +1,100 @@ +# Task Brief — Second Brain (compounding per-user knowledge engine) + +> **Status:** revised after adversarial brief-review (light-Voyage step 2 → findings folded in). Architecture approved by operator 2026-06-23 (`architecture.md`). +> **Inputs:** `architecture.md` (approved design) · `research/{connector-egress,secondbrain-sota,silo-inventory}.md`. +> **Build target of this brief:** the arc objective + scope fence, and the **first slice SB-S0** in detail. SB-S1..S4 are scoped one-line; their detailed plans come later, one per slice. +> **Review delta (2026-06-23):** B1 fold-source rewritten (template-as-field-source + source-absent no-op); B2 pathguard claim corrected (discrepancy flagged); M1 floors as `≥`; M2 scaffold trigger decided (idempotent CLI/TS subcommand, session-start wiring deferred to SB-S2); M3 profile grammar contract pinned; M4 `brain/` is runtime-only (no committed seed); minors m1–m4 folded. + +--- + +## 1. Objective + +Turn the plugin's existing siloed per-user stores into **one compounding "second brain"**: a per-user knowledge engine that accumulates everything about a creator (posts, articles, newsletters, plans, ideas), keeps a two-layer profile that improves over time, and stays aligned to who the user *is now* — drift-resistant and collapse-resistant. It is **memory AND an operations centre**. + +The engine is a **plugin capability** (domain-general, shareable). The user's data lives in the per-user data dir. The user's personal cockpit lives in Maskinrommet (out of scope here). + +## 2. Background (why this shape) + +- **Nothing to migrate** — M0 already routed the **data-root silos** (trends, specifics, analytics, drafts/queue, plans, voice, profile, scaffolds) through one tested seam (`getDataRoot`), all surviving reinstall. The **state file** (silo A) stays on its own resolver (`getStateFile()`, `~/.claude/linkedin-studio.local.md`) and is NOT under the seam (see §7). The task is **unify**, not move. (`silo-inventory.md`) +- **Plain-text + JSON + git, not a vector/graph DB** — Claude *is* the retrieval engine (reads files in-context); a heavyweight store adds infra + lock-in for no gain. (`secondbrain-sota.md` §B.1) +- **Ingest is manual-first as the contract** — only EU/EEA DMA portability gives automated *content* pull (no analytics); scraping is a ToS breach. Connector is an additive tributary, never a dependency. (`connector-egress.md`) + +## 3. Confirmed decisions (not open for re-litigation in review) + +1. **Boundary:** engine → plugin · user data → data dir · cockpit → Maskinrommet. +2. **Shape:** thin Markdown `brain/` hub (two-layer `profile.md` + episodic `journal/` + `operations.md` + `index.md` MOC) over the **existing typed tributaries** (voice-samples, specifics-bank, trends, analytics), fed by a provenance-tagged `ingest/` seam. +3. **Tributaries keep their own schema/dedupe/lifecycle** — do NOT force one schema across silos. Only the user-profile field-set folds into `brain/profile.md`. +4. **Invariants:** provenance-weighted learning (`published` only, never `ai-draft`); one canonical entity id + one provenance shape (`provenance ∈ {human, published, ai-draft}`); episodic/semantic split; evidence-threshold promotion; temporal-validity + freshness nudge; anti-sycophancy default; frozen-past-self anchor; zero required curation. + +Review SHOULD challenge: feasibility, sequencing, hidden coupling, testability, scope leakage, missing risks. Review SHOULD NOT re-open the four decisions above (operator-approved) unless it finds they are internally contradictory or technically impossible. + +## 4. Scope — the arc (slices) + +| Slice | One-line scope | +|-------|----------------| +| **SB-S0 — Foundation** *(this brief)* | `brain/` scaffold + two-layer `profile.md` (fold in the user-profile field-set) + `index.md` + `operations.md` + `journal/`; entity-id + provenance shape as a small typed, tested module; `ingest/{inbox,published}` dirs + the manual-import **contract** (shape only). No loop, no ingest-processing logic, no connector. | +| SB-S1 — Ingest + gold signal | Manual import populates `ingest/published/` with `provenance=published`; wire profile/voice to learn from published-only. | +| SB-S2 — Evolution loop | Sleep-time consolidation: journal+published+tributary deltas → profile diff w/ evidence_count/provenance/timestamps; threshold promotion; contradiction reconciliation; brain-wide freshness nudge. **Owns the session-start wiring of the scaffold init.** | +| SB-S3 — Cross-silo graph + ops centre | Thread the id through tributaries (post↔specific↔trend↔analytics); flesh out `operations.md`; retire dead `content-history.md` + triple-post reconciliation. | +| SB-S4 *(optional, later)* | EU/EEA DMA portability API as auto-tributary into `ingest/inbox/`. | + +## 5. SB-S0 in detail (the build target) + +**Deliverables:** + +1. **`brain/` scaffold initialiser — an idempotent CLI/TS subcommand** (the trends-store idiom: runtime-created under the data-dir seam, NOT a tracked repo seed). It creates `brain/{index.md,profile.md,operations.md}` + `brain/journal/` + `ingest/{inbox,published}/` under `getDataRoot(subdir)` (which already accepts arbitrary subdirs — no new seam function needed). **Idempotent + migration-safe** (running twice is a no-op; never clobbers existing content). **No per-session cost** — session-start *wiring* of this init is explicitly deferred to SB-S2; SB-S0 ships only the invokable subcommand. + +2. **`brain/profile.md` — two-layer schema** with `§ Static` and `§ Dynamic` sections. **Pinned contract (fixed):** one fact = one parseable record carrying all six fields `value · first_seen · last_seen · evidence_count · provenance(human|published|ai-draft) · status(active|superseded)`, and `serialize ∘ parse = identity`. **Open to the plan:** only the surface serialization syntax (frontmatter vs a defined line grammar). + +3. **Fold the user-profile field-set into `brain/profile.md` — lossless + idempotent, with a defined source-absent path.** Source of the field *structure* is `config/user-profile.template.md` (the only file that ships; `profile/user-profile.md` is `expected-absent`, migrate-data.mjs:36). Behaviour: + - **Source-absent (the common case):** scaffold an empty two-layer `profile.md` whose static layer contains every template field present-but-unfilled. + - **Runtime instance present** (`${data}/profile/user-profile.md` exists): fold its filled values into the static layer. + - Re-running never duplicates fields and never overwrites already-folded values. + +4. **Entity-id + provenance module** — a small typed module (TS, matching the trends/specifics idiom) that mints one canonical entity id at creation and defines the single provenance shape (`{human|published|ai-draft}`). Pure functions, deterministic, unit-tested. **Not yet threaded through tributaries** (that's SB-S3) — SB-S0 only establishes the module + shape. + +5. **`index.md` MOC + `operations.md` seeds (runtime-generated)** — minimal but real: `index.md` points to every tributary with a freshness placeholder; `operations.md` has the "who I am now" anchor section. Generated by the initialiser (D1), not committed to the repo tree. + +6. **Tests** — unit tests for the module + the scaffold + the fold (incl. the source-absent path). No structure-lint count change expected (SB-S0 adds no `agents/`, `commands/`, `references/`, or `skills/` file); a version bump, if any, updates README/CHANGELOG badges only. + +## 6. Success criteria (SB-S0 — testable) + +- **SC1 (scaffold, runtime):** with `LINKEDIN_STUDIO_DATA` set to a temp dir, invoking the init subcommand creates `brain/{index,profile,operations}.md` + `brain/journal/` + `ingest/{inbox,published}/` **at the runtime data-path** (not the repo tree), through `getDataRoot`; invoking it twice is a no-op (idempotent, no clobber). Verified by a test that sets the env var, runs init twice, asserts existence + unchanged content. +- **SC2 (profile parse):** `brain/profile.md` parses into `§Static`/`§Dynamic`; a fact round-trips through the pinned grammar with all six fields intact (`serialize ∘ parse = identity`). Verified by a parse/serialize unit test. +- **SC3 (fold lossless + source-absent):** (a) **source-absent:** running the fold with no runtime user-profile yields a `profile.md` whose static layer lists every `config/user-profile.template.md` field; (b) **populated:** given a synthetic populated fixture, the fold carries every filled field into the static layer (diff shows none dropped); (c) re-running either case does not duplicate or overwrite. Verified by fixture-based diff tests. +- **SC4 (id/provenance module):** the module mints a deterministic id (same input → same id) and **rejects** a malformed provenance value (throws; only `{human,published,ai-draft}` accepted); ≥1 unit test per public function. +- **SC5 (no regression):** `scripts/test-runner.sh` is green (currently 89 passed / 0 failed / 0 warnings); each floored suite stays **≥ its floor** (trends ≥24, specifics ≥28, contract ≥33); all hook tests pass. +- **SC6 (seam, no-op):** SB-S0 adds **no new seam function** (`brain/`/`ingest/` use the existing `getDataRoot(subdir)`), so the twin-sync surface is unchanged. Asserted as a no-op; *if* a future slice adds a seam function, the existing twin-sync test (`hooks/scripts/__tests__/data-root.test.mjs`) must be extended. + +## 7. Non-goals / scope fence (SB-S0 does NOT) + +- **No evolution loop / consolidation** (SB-S2). +- **No ingest processing logic** — `ingest/` dirs + the documented manual-import contract only; no parser that reads inbox files yet (SB-S1). +- **No connector** — no LinkedIn API code (SB-S4). +- **No cross-silo threading** — the id module exists but is NOT yet woven into trends/specifics/analytics (SB-S3). +- **No session-start wiring** of the scaffold init (SB-S2 owns it) — SB-S0 ships only the invokable subcommand. +- **Do NOT touch tributary schemas** (voice/specifics/trends/analytics stores keep their current shape). +- **Do NOT reconcile the two-roots question** (state file at `~/.claude/linkedin-studio.local.md` stays a sibling on `getStateFile()`; not folded in SB-S0). +- **Do NOT retire `content-history.md` or reconcile the triple-recorded post** (SB-S3). +- **No GUI / no cockpit** (Maskinrommet, out of repo scope). + +## 8. Research plan / knowledge status + +- **Done:** the three threads (connector reality, second-brain SOTA, silo inventory) are complete and persisted. No further research is required to start SB-S0. (Brief-review confirmed both §8 open assumptions are correctly non-blocking.) +- **Open assumptions to test (flagged, not blocking SB-S0):** + - "Claude-as-retrieval-engine over plain files is sufficient (no vector DB)" — testable once the brain holds content (post-SB-S1). Marked assumption. + - Newsletter-edition coverage by the portability `ARTICLES` domain — only relevant to SB-S4; verify against a real export then. + +## 9. Constraints / risks + +- **TDD iron law:** no production code without a failing test first. +- **New code placement:** SB-S0's new code is a **TS module under `scripts/`** (the trends/specifics idiom) + a runtime scaffold + edits to existing files. It creates **no new `.mjs` under `hooks/scripts/`**, so the pathguard does not affect SB-S0. **Verified 2026-06-23 (code-checked; STATE.md line 39 CONFIRMED correct, brief-review B2 was WRONG):** the llm-security plugin guard `pre-write-pathguard.mjs` IS enabled (`~/.claude/settings.json:148`) and registered as a PreToolUse hook with `matcher:"Write"` (Write-only); its Category 6 regex `/[\\/]hooks[\\/]scripts[\\/].*\.mjs$/` matches any `…/hooks/scripts/*.mjs`, **including this repo**. ⇒ a NEW `.mjs` under `hooks/scripts/` **cannot be written with the Write tool**; **editing an EXISTING `.mjs` is fine** (matcher is Write-only). Relevant to SB-S2 (which touches hook code → create new hook `.mjs` via Bash-heredoc, or edit an existing file). SB-S0 stays entirely in `scripts/`, so it is unaffected. +- **Twin data-root implementations** must stay byte-compatible (`hooks/scripts/data-root.mjs` ⇄ `scripts/analytics/src/utils/storage.ts`); SB-S0 adds no new seam function so the twin contract is untouched (SC6). +- **Public repo (`open/` Forgejo):** confirm before push; push only in the allowed window; STATE.md stays gitignored. +- **Structure lint** counts `agents/`, `commands/`, `references/`, `skills/` + the version badge. SB-S0 adds none of these, so no count guard fires absent a version bump. +- **Node v25 test quirk:** `node --test <dir>` doesn't work — use glob `__tests__/*.test.mjs`; run TS tools from their own dir. +- **Risk — fold ambiguity:** the user-profile→profile.md fold is the one data-shaping step in SB-S0; if the grammar is under-specified it risks lossy or non-idempotent folding. Mitigate with fixture-based diff tests (SC3) covering both the source-absent and populated paths before wiring. + +## 10. Definition of done (SB-S0) + +SC1–SC6 all pass; SB-S0 deliverables 1–6 exist; non-goals untouched; committed (push per window); `architecture.md` slice status updated to "SB-S0 landed"; `CLAUDE.md`/README touched only if a version bump is taken. diff --git a/docs/second-brain/consolidation-loop.md b/docs/second-brain/consolidation-loop.md new file mode 100644 index 0000000..6c71073 --- /dev/null +++ b/docs/second-brain/consolidation-loop.md @@ -0,0 +1,82 @@ +# Consolidation loop — the compounding mechanism (SB-S2) + +> How the second brain turns the published gold signal into an ever-improving, drift-resistant +> `brain/profile.md` — operator-invoked, operator-gated, deterministic. Part of the second-brain arc +> (`architecture.md`); landed in SB-S2. + +## The shape + +The loop is **operator-invoked** ("sleep-time" = when you run it), not automatic — the session-start +hook is zero-dep and cannot run AI, so it only **nudges** when consolidation is due. The pass itself is: + +``` +brain consolidate --gather # 1. dump new published deltas + the current profile + → (the session reads them and extracts a Candidate[] JSON) +brain consolidate --propose --candidates cand.json # 2. deterministic diff → brain/pending-diff.{md,json} + → (you review brain/pending-diff.md — the [OPERATØR] gate) +brain consolidate --apply --diff brain/pending-diff.json --confirm # 3. the ONLY write to profile.md +``` + +`--apply` records `brain/consolidation-state.json` `{last_run}`; the session-start nudge reads it + +counts `ingest/published/*.md` to know when to nudge again. Roll back any apply via git. + +## The deterministic engine (`scripts/brain/src/consolidate.ts`) + +`proposeDiff({current, candidates, today, opts})` classifies each candidate (matched to existing facts +by the candidate's `key`): + +| Rule | Condition | Effect | +|------|-----------|--------| +| **reject** | `provenance: ai-draft` | dropped — never learns from the engine's own drafts (model-collapse guard) | +| **add** | no matching fact, provenance `published`/`human` | new dynamic fact, `evidence_count: 1` | +| **evidence-bump** | matching fact, same value | `evidence_count++`, `last_seen = today` | +| **promote** | a dynamic fact reaches `N = 3` observations | dynamic → static | +| **conflict** | matching key, different value, NO supersede signal | **keep both**, timestamped, with DISTINCT ids; the old fact is untouched (a genuine contradiction — both views coexist) | +| **supersede** (SB-S3b) | matching key, different value, explicit `supersedes` signal on the candidate | retire the old fact (re-minted to an archival id, `status: superseded`, retained as audit) + install the new winner at the canonical key-id — a temporal update, not a contradiction | +| **decay-flag** | a dynamic **active** fact's `last_seen` > `90` days | listed in `staleFlags` (informational; never auto-removed; superseded facts are decay-exempt) | + +**Id model (no duplicate ids):** a concept's primary fact id is `mintEntityId({kind:'observed', key})`; +a conflict alt fact id is `mintContentId('observed-alt:'+key+'::'+value+'::'+date)` — byte-distinct, so +two facts never share an id. The SB-S0 folded `profile-field` static seeds use a different kind, so +consolidation never mutates them (immutable in S2). `applyDiff` produces a `ProfileDoc` that round-trips +exactly through the SB-S0 grammar; re-running is idempotent (bump, not duplicate). + +Defaults: `promoteThreshold = 3`, `decayDays = 90` (operator-confirmed). + +## The candidate file — the session↔engine contract + +`--propose --candidates <file.json>` takes a JSON **array** of candidates; each is validated +(malformed → non-zero exit, nothing written): + +```json +[ + { "key": "primary-expertise", "value": "AI governance in the public sector", + "provenance": "published", "source": "published:1a2b3c4d5e6f", "observed_date": "2026-05-26" } +] +``` + +- `key`, `value`, `source`, `observed_date` — non-empty strings; `provenance ∈ {human, published, ai-draft}`. +- `key` and `value` must be **single-line** (no newline/CR — the profile grammar is one fact per line). +- The session produces this from `--gather`'s output. The engine guarantees the *mechanics*; the *quality* + of the candidates is the session's job (see limits). + +## Honest limits + +- **The loop's value depends on the session's extraction.** The engine only guarantees threshold/conflict/ + decay/provenance mechanics. Garbage candidates → a garbage diff. The operator gate + candidate-shape + validation catch shape errors, not insight quality. +- **`brain/profile.md` has one reader as of SB-S3a.** S2 evolved the profile motor-only; SB-S3a wired the + first consumer — `strategy-advisor` reads it as *evidence-to-test* (anti-sycophancy: counter-pressured, never + parroted). Broader consumption (more content agents, a hook-level prompt digest) remains later S3 work, and + the profile is still mutated ONLY via `brain consolidate --apply --confirm`. +- **Supersede is explicit + operator-gated; no AUTO-demotion (SB-S3b).** A temporal update retires the old + fact ONLY when the candidate carries an explicit `supersedes` signal (the session's judgement), and only + after the operator confirms the rendered `## Supersessions` in the diff. The engine guarantees the supersede + *mechanics* (replace-in-place, archival re-mint, audit-retain); whether a contradiction is really a temporal + update vs a genuine contradiction is the session's + operator's call — **the operator gate is the only + classification net** (a mis-classified supersede silently retires a fact that should have been kept-both). + Genuine contradictions (no signal) still **keep both**; stale facts are still only *flagged*, never auto-removed. +- **No AI at session-start.** The nudge is a deterministic file-count + sidecar read; the consolidation + pass is always operator-invoked. +- **The session-start nudge is consolidation-due only** — it counts published records + days since last run; + it does not parse `profile.md` for per-fact staleness (that cost/parser is deferred). diff --git a/docs/second-brain/ingest-manual-import.md b/docs/second-brain/ingest-manual-import.md new file mode 100644 index 0000000..c34c0e7 --- /dev/null +++ b/docs/second-brain/ingest-manual-import.md @@ -0,0 +1,87 @@ +# Manual-import contract — ingest + the published gold signal (SB-S1) + +> The contract for getting your **actual published posts** into the second brain as the +> **gold signal** (`provenance=published`) that voice/profile learning is allowed to learn from. +> Part of the second-brain arc (`architecture.md`); landed in SB-S1. + +## Why this exists + +Profile and voice must learn from **human-published content only** — never from what the engine +itself drafted. A content engine that learns its own voice from its own drafts collapses toward its +own priors (model collapse). So the brain has one provenance vocabulary — `human | published | ai-draft` +— and the learning surfaces are bound to `published` and **forbidden from `provenance=ai-draft`**. + +A published post already lands in three non-referencing places today (the state file's `## Recent Posts`, +the dead `analytics/content-history.md`, and `analytics/posts/*.json` from the CSV) — but **none of them +holds the full post text**. Voice learning needs the verbatim text, so `ingest/published/` storing it is +additive, not a re-count. (Threading a shared id across those silos is a later slice, SB-S3.) + +## The drop-zone model + +``` +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ingest/ + inbox/ # drop-zone: paste a published post here as a .md file (manual, or a future connector) + published/ # processed gold records, provenance=published — one file per post, <id>.md +``` + +- **`inbox/`** — you drop raw post files here (top-level `*.md`, one post per file). +- **`published/`** — the `ingest` CLI writes processed, provenance-tagged records here. `<id>.md` where + `<id> = sha256(verbatim body)[:12]` — byte-identity, so the same post never duplicates and two + genuinely different posts never collide. + +## CLI usage + +Run from `scripts/brain` (`node --import tsx src/cli.ts <cmd>`), or via the package's `start` script. + +```bash +# one post from a file → ingest/published/<id>.md, tagged provenance=published +brain ingest --file path/to/post.md [--source manual] [--date 2026-05-26] + +# process everything dropped in ingest/inbox/ (top-level *.md, skips dotfiles), non-destructive +brain ingest --scan-inbox + +# inspect the gold corpus — id · provenance · date · first line (eyeball that nothing ai-draft leaked) +brain published list [--json] +``` + +- `--date` is the real publish date; it defaults to the ingest date when omitted. +- Re-ingesting the same body is a **no-op** (idempotent). An id-collision with a *differing* body is + disambiguated to `<id>-2.md` — a differing gold record is **never silently dropped**. +- `ingest` **creates `ingest/published/` on demand** — it does not require a prior `brain init`. + +## Record file grammar (no YAML) + +One file per post: a fixed five-line header, a `---` sentinel, then the **verbatim body**. + +``` +id: 1a2b3c4d5e6f +provenance: published +published_date: 2026-05-26 +captured_at: 2026-06-23 +source: manual +--- +The verbatim post body goes here. + +It may contain anything — blank lines, ] | characters, even a line that looks +like --- — because the parser splits on the FIRST sentinel only. +``` + +`parse ∘ serialize` is an exact identity over all six fields including the body. A record in +`published/` whose `provenance` is anything other than `published` is treated as a **corruption signal** +and rejected on parse. + +## The published-only learning rule (and its honest limit) + +- `agents/voice-trainer.md` is wired to read `ingest/published/` as its primary gold source and to + **exclude `provenance=ai-draft` unconditionally**. This wiring is **gate-enforced** by a structure-lint + (`scripts/test-runner.sh`, the Brain Published-Only Invariant section): the lint asserts the agent + carries both the `ingest/published` source and the `provenance=ai-draft` exclusion. +- **Honest limit:** the *wiring* is gate-enforced, but an agent instruction can still be ignored at + runtime — the gate proves the rule is present, not that the model obeyed it on a given run. The + strongest enforcement short of executing the agent. + +## What SB-S1 does NOT do + +No consolidation loop and **no automatic `brain/profile.md` mutation** (SB-S2 owns the sleep-time +profile-diff loop) · no session-start wiring · no cross-silo id threading (SB-S3) · no connector +(SB-S4). `source` carries a forward-compatible field for a future connector, but no connector code ships. diff --git a/docs/second-brain/plan-sb-s0.md b/docs/second-brain/plan-sb-s0.md new file mode 100644 index 0000000..8c0ec50 --- /dev/null +++ b/docs/second-brain/plan-sb-s0.md @@ -0,0 +1,162 @@ +# Implementation Plan — SB-S0 (Foundation) + +> **Status:** revised after adversarial plan-review (`plan-critic` + `scope-guardian`, both code-verified) → all findings folded in. Ready for operator approval. +> **Brief:** `brief.md` §5–§10. **Architecture:** `architecture.md`. +> **Scope:** SB-S0 only — the `brain/` scaffold, the two-layer `profile.md` + lossless user-profile fold, the entity-id/provenance module, the `ingest/` seam dirs. **No loop, no ingest logic, no connector, no cross-silo threading, no session-start wiring** (brief §7). +> **Review delta (2026-06-23):** B1 test convention → real TS idiom (`tests/*.test.ts` via tsx, `.js` specifiers); M2 profile format → **defined line-grammar (no YAML dep)**; M3 data-root → inline per-package resolver (repo idiom, not a new seam); M4 fold extraction rule pinned against the real template; m1 gate rationale corrected; m2 npm-install-before-gate; m3 provenance throws; m4 id keyed on stable slug; G1 push-confirm; C1 identity now whole-file. + +--- + +## 1. What SB-S0 delivers (recap) + +A new TS package `scripts/brain/` (an exact structural copy of `scripts/specifics-bank/`) exposing an idempotent `brain init` CLI that scaffolds the `brain/` + `ingest/` tree at the runtime data-path, a pinned two-layer `profile.md` **line-grammar** with parse/serialize round-trip, a lossless+idempotent user-profile fold (source-absent + populated), and a deterministic entity-id + provenance module. All unit-tested, TDD (failing test first). Gate stays green. + +## 2. Package layout (`scripts/brain/`) — exact copy of `scripts/specifics-bank/` conventions + +``` +scripts/brain/ + package.json # copy specifics-bank/package.json; "test": "node --import tsx --test tests/*.test.ts"; devDeps tsx + typescript ONLY (no yaml — see §3.3) + tsconfig.json # copied VERBATIM from scripts/specifics-bank/tsconfig.json (rootDir ./src, include src/**/*) + src/ + types.ts # Provenance, FactStatus, ProfileFact, ProfileDoc, constants + id.ts # mintEntityId(), slugify(), normalizeProvenance() + profile.ts # parseProfile(), serializeProfile(), foldUserProfile() + dataRoot.ts # inline resolver (repo idiom — §3.5) + cli.ts # `brain init` + tests/ # NOTE: tests/*.test.ts, run via tsx; import source via .js specifiers (e.g. "../src/id.js") + id.test.ts + profile.test.ts + scaffold.test.ts + fold.test.ts + fixtures/ + user-profile.populated.md # synthetic populated user-profile mirroring the template's extractable constructs (§3.4) +``` + +**B1 fix (verified):** all three sibling TS packages use `tests/*.test.ts` via `node --import tsx --test tests/*.test.ts` and import source through `.js` specifiers (`specifics-bank/package.json:8`, `specifics-bank/tests/bank.test.ts:15` `from "../src/bank.js"`). The `__tests__/*.test.mjs` idiom is the HOOKS convention (no tsx loader) and cannot import `.ts` source — NOT used here. + +## 3. Module designs + +### 3.1 `types.ts` +```ts +export type Provenance = 'human' | 'published' | 'ai-draft'; +export type FactStatus = 'active' | 'superseded'; +export interface ProfileFact { + id: string; // canonical entity id (id.ts), keyed on a stable slug + value: string; // single-line (no embedded newline) + first_seen: string; // YYYY-MM-DD + last_seen: string; // YYYY-MM-DD + evidence_count: number; + provenance: Provenance; + status: FactStatus; +} +export interface ProfileDoc { schemaVersion: 1; static: ProfileFact[]; dynamic: ProfileFact[]; } +export const PROVENANCE_VALUES = ['human','published','ai-draft'] as const; +``` + +### 3.2 `id.ts` +- `slugify(label: string): string` → lowercase, trim, non-alphanumeric → `-`, collapse repeats. Stable across value edits. +- `mintEntityId(seed: {kind: string; key: string}): string` → `sha256(`${kind}:${slugify(key)}`).slice(0,12)` (matches trends/specifics `sha256[:12]`; confirm the exact hash call against `scripts/specifics-bank/src/bank.ts` in step 1). Deterministic, pure. +- `normalizeProvenance(raw: string): Provenance` → trim+lowercase; return the match or **throw** on anything outside `PROVENANCE_VALUES`. (m3: throws — SC4 asserts `assert.throws`; "normalises" dropped.) + +### 3.3 `profile.ts` — pinned line-grammar (M2 decision: NO YAML dep) +**Verified:** the repo has no YAML parser (`state-updater.mjs:43` `extractField` is per-field regex on single scalars; zero `yaml` dependency in-tree). Frontmatter-with-nested-arrays would force a new dep + an untested hand-rolled emitter. **Decision — a defined line-grammar that round-trips with the regex idiom and needs no dep:** + +``` +# Profile + +schemaVersion: 1 + +## Static + +- [<provenance>|<status>|<first_seen>|<last_seen>|<evidence_count>|<id>] <value> + +## Dynamic + +- [<provenance>|<status>|<first_seen>|<last_seen>|<evidence_count>|<id>] <value> +``` + +- The bracket holds six pipe-joined **constrained** tokens (two enums, two ISO dates, an int, a 12-hex id) — none can contain `]` or `|`. `<value>` is the rest of the line after `] ` (free single-line text; may contain `]`/`|`/quotes). +- `parseProfile(text)`: split into `## Static` / `## Dynamic` sections; each `- [...]` line parsed by + `^- \[(human|published|ai-draft)\|(active|superseded)\|(\d{4}-\d{2}-\d{2})\|(\d{4}-\d{2}-\d{2})\|(\d+)\|([0-9a-f]{12})\] (.*)$`. + `schemaVersion` read by the existing scalar-regex idiom. +- `serializeProfile(doc)`: deterministic token order → `- [...] value`, sections in fixed order. +- **`parseProfile(serializeProfile(doc)) === doc`** over the WHOLE file (C1 resolved: there is no derived/ignored body — the lines ARE the authoritative representation). SC2 tests this identity. + +### 3.4 `profile.ts` — `foldUserProfile()` (M4: extraction rule pinned against the real template) +`config/user-profile.template.md` is heterogeneous (`config/user-profile.template.md:15-147`: bold-label scalars, a numbered expertise group, checkbox lists, non-labeled bullets, prose guidance). **Pinned extraction — exactly two productions:** +- **P1 (labeled scalar):** any line matching `^\s*(?:- )?\*\*(.+?):\*\*\s*(.*)$` → `label`=g1, `value`=g2 (the `[placeholder]` text; treated as *unfilled/empty* when it is a bracketed placeholder, literal otherwise). One `ProfileFact` per match. Covers Name/Role/Organization/Industry, the `- **Primary:**` etc. sub-labels, Signature Elements, Writing Quirks, Current LinkedIn Status. +- **P2 (expertise group):** under the `**Core Expertise Areas (...)**:` label, each `^\d+\.\s*(.*)$` line → one fact, label `expertise-area-N`. +- **Excluded (deferred, noted §8):** checkbox lines (`- [ ] …` — Tone, LinkedIn Goals, Research MCPs, Asset Utilization), non-labeled bullet lists (Content Style Mix), the Voice-Profile-Summary numbered `1. **[Quality N]:**` items, prose guidance blocks (Universal anti-patterns 95-104, Research-Tooling explainer), and headers. + +``` +foldUserProfile({ templateText, instanceText?, existing? }): ProfileDoc +``` +1. Apply P1+P2 to `templateText` → the canonical field-set (id = `mintEntityId({kind:'profile-field', key: label})`, value empty, `provenance:'human'`, `evidence_count:0`, `status:'active'`, dates = run date). → `static[]`. (`dynamic[]` empty in S0.) +2. **Source-absent (common):** no `instanceText` → return that field-set with empty values. +3. **Populated:** `instanceText` present → re-run P1+P2 on it, copy filled values onto the matching id. +4. **Idempotent:** if `existing` passed → merge by `id`; never duplicate, never overwrite a non-empty value, bump `last_seen` only. +Pure; the CLI supplies the file texts. (m4: id keyed on `slugify(label)`, stable across value edits; label-text stability is a known S0 limit — §8.) + +### 3.5 `dataRoot.ts` — inline resolver (M3: repo idiom, NOT a new seam) +**Verified:** no shared util; cross-package import not configured (`specifics-bank/tsconfig.json` rootDir `./src`, no path map/workspace); siblings each inline it (`trends/src/store.ts:180`, `specifics-bank/src/bank.ts:150`, plus `analytics/src/utils/storage.ts:54`). Match the idiom: +```ts +import { homedir } from 'node:os'; import { join } from 'node:path'; +export const dataRoot = (sub: string) => + join(process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), '.claude', 'linkedin-studio'), sub); +``` +This is a **private package resolver, not an exported seam function** → SC6 (no new seam, twin-sync untouched) holds. True de-duplication of the now-4 copies is an explicit out-of-scope refactor. + +### 3.6 `scaffold.ts` — `initBrain(rootSub?)` (D1, D5) + `cli.ts` +- Create idempotently (mkdir-recursive; **compare-then-skip**, never blind-write): `brain/journal/`, `ingest/inbox/`, `ingest/published/`; `brain/profile.md` (IF absent → `serializeProfile(foldUserProfile({templateText}))`), `brain/index.md` (IF absent → MOC seed: one line per tributary [voice-samples, specifics-bank, trends, analytics, ingest] with `freshness: —`), `brain/operations.md` (IF absent → `## Who I am now (anchor)` + empty `## Plans` / `## Ideas`). +- Returns `{created[], skipped[]}`. **No session-start wiring** (SB-S2). `cli.ts`: `brain init` → `initBrain()`, print report. Dispatch shape copied from `specifics-bank/src/cli.ts`. + +## 4. Files created / edited + +**Created:** `scripts/brain/{package.json,tsconfig.json}` · `scripts/brain/src/{types,id,profile,dataRoot,cli}.ts` · `scripts/brain/tests/{id,profile,scaffold,fold}.test.ts` · `scripts/brain/fixtures/user-profile.populated.md`. + +**Edited (minimal):** +- `scripts/test-runner.sh` — add a `BRAIN_TESTS_FLOOR` block copied from the `TRENDS_TESTS_FLOOR` block (`scripts/test-runner.sh:681-694`): cd `scripts/brain`, run the suite, grep `ℹ tests N` (`grep -oE 'tests [0-9]+' | tail -1`), assert `≥ BRAIN_TESTS_FLOOR`, emit one `pass()` line. Bump Section 18's anti-erosion note (`:770`, `TOTAL_CHECKS >= 74` grows by 1). +- `docs/second-brain/architecture.md` — flip SB-S0 row to "landed" (DoD). +- (NO change to `data-root.mjs`/`storage.ts` — no seam function added, SC6.) + +## 5. TDD test plan (failing-first) → success criteria + +| Test (`tests/*.test.ts`) | Asserts | SC | +|---|---|---| +| `id.test.ts` | `mintEntityId` deterministic (same in→same id; diff→diff); `slugify` stable; `normalizeProvenance` returns the 3 values, `assert.throws` on others | SC4 | +| `profile.test.ts` | `parseProfile(serializeProfile(doc)) === doc` over the whole file incl. a value containing `]`/`|`/quotes; sections `## Static`/`## Dynamic` present | SC2 | +| `fold.test.ts` | (a) source-absent → field-set from template (P1+P2), values empty; (b) populated fixture → every filled field carried (diff none-dropped); (c) re-run → no dup, no overwrite | SC3 | +| `scaffold.test.ts` | `LINKEDIN_STUDIO_DATA`=tmp → init creates all dirs+files at runtime path; second init = no-op (skipped[] covers them, files byte-identical) | SC1 | +| (gate) `scripts/test-runner.sh` | green; trends ≥24, specifics ≥28, contract ≥33, **brain ≥ floor**; hooks pass | SC5 | +| (n/a) | no new seam fn; unchanged `data-root.test.mjs` twin-sync still passes | SC6 | + +Each test written **before** its module (iron law): red → implement → green. + +## 6. Step sequence + +1. **Confirm idioms in code (verify-first):** read `scripts/specifics-bank/{package.json,tsconfig.json,src/bank.ts,tests/bank.test.ts}` → copy the package/test scaffolding + the exact sha256 call. `npm install` in `scripts/brain` so tsx is present (m2: the gate `warn`-skips a suite if `node_modules/.bin/tsx` is absent — "green" must not mean green-because-skipped). +2. `types.ts` (no logic). +3. TDD `id.ts` (`id.test.ts`). → SC4. +4. TDD `profile.ts` parse/serialize line-grammar (`profile.test.ts`). → SC2. +5. Write `fixtures/user-profile.populated.md` mirroring the P1+P2 constructs; TDD `foldUserProfile` (`fold.test.ts`, 3 cases). → SC3. +6. TDD `scaffold.ts` `initBrain` (`scaffold.test.ts`, temp-dir idempotency). → SC1. +7. `cli.ts` `brain init`; smoke-run against a temp `LINKEDIN_STUDIO_DATA`. +8. Wire `scripts/test-runner.sh` BRAIN floor (+ Section 18 note); run the FULL gate green. → SC5/SC6. +9. Flip `architecture.md` SB-S0 → landed. **Commit; confirm before push (PUBLIC `open/` Forgejo) and only inside the push window; STATE.md stays gitignored** (G1). + +## 7. Scope fence (echo — SB-S0 does NOT) + +No consolidation loop · no ingest file parsing · no LinkedIn API · no id threading into tributaries · no session-start wiring · no tributary-schema edits · no state-file/two-roots reconciliation · no `content-history.md` retirement · no GUI. + +## 8. Known limits / deferred (honest) + +- **Fold covers only P1 labeled-scalars + P2 expertise group.** Checkbox preference lists (tone, goals, MCPs, assets), non-labeled bullets, and the Voice-Profile-Summary qualities are NOT folded in S0 — they are template *guidance/preferences*, foldable in a later slice when the profile schema grows. Stated so SC3a's field-set is finite + testable. +- **Profile-field id keyed on `slugify(label)`** — stable across value edits (fixes the raw-label non-idempotency). A *renamed template label* would still mint a new id; acceptable in S0 (template is fixed) — re-confirm if SB-S1+ lets users edit labels. +- **`parse∘serialize` identity holds for single-line values.** A value with an embedded newline is out of grammar; the fold never produces one (template/instance fields are single-line), but `addFact` paths in later slices must enforce it. +- **Gate "green" is honest only with deps installed** (m2) — fresh clone must `npm install scripts/brain` or the brain suite `warn`-skips. + +## 9. Risks + +- **Fold under-specification** → mitigated: P1+P2 pinned + `fold.test.ts` covers source-absent + populated + re-run before the CLI wires it. +- **Idempotency edge:** `initBrain` + fold merge must compare-then-skip (never blind-write); tested in `scaffold.test.ts` / `fold.test.ts` (c). +- **Gate mechanics (corrected, m1):** 89 is the gate's own `PASS+FAIL` tally; each suite contributes exactly ONE `pass()` line (not its internal `tests N`); the only total guard is the growable `TOTAL_CHECKS >= 74` floor (`:770`). Adding BRAIN raises that floor by 1 — no `==89` guard exists, so no regression flag. The risk is the warn-skip (m2), handled in step 1/8. diff --git a/docs/second-brain/plan-sb-s1.md b/docs/second-brain/plan-sb-s1.md new file mode 100644 index 0000000..1617370 --- /dev/null +++ b/docs/second-brain/plan-sb-s1.md @@ -0,0 +1,160 @@ +# Implementation Plan — SB-S1 (Ingest + gold signal) + +> **Status:** review-hardened (`plan-critic` REVISE 63→folded; `scope-guardian` GAP→folded). Ready for operator approval / "go". +> **Brief:** `brief-sb-s1.md` (review-hardened). **Arc:** `architecture.md`. **Predecessor:** SB-S0 (`scripts/brain/`). +> **Review delta (2026-06-23):** B1 separator pinned to one exact string + edge battery; B2 id → verbatim-body hash (no `normalizeContent`) + collision-safe write; floors pinned EMPIRICALLY (not hand-arithmetic); SC5 grep → exact `grep -F` literals + `AI-generated` negative probe; CLI = net-new flag infra + `init` regression; per-step On-failure/Checkpoint + 2 circuit-breakers; D3 auto-append FENCE restored; voice-trainer anchors fixed (:136/:144); malformed-file try/catch + scanInbox `*.md`-only filter. +> **Scope:** SB-S1 only — the published-record data layer + `brain ingest` CLI, and wiring `voice-trainer` to the published-only invariant (gate-enforced). **No consolidation loop, no automatic profile mutation, no session-start wiring, no cross-silo threading, no connector** (brief §7). + +--- + +## 1. What SB-S1 delivers (recap) + +A new TS module `scripts/brain/src/ingest.ts` (matching the SB-S0/specifics-bank idiom) defining a `PublishedRecord` shape with a pinned file-per-post **constrained-header grammar** (no YAML) that round-trips, a content-hash id over the **verbatim body** (`sha256(body)[:12]` — byte-identity dedup, no normalization), and idempotent, collision-safe IO (`writePublished`, `scanInbox`, `listPublished`) under the brain package's `dataRoot`. The `brain` CLI grows `ingest` + `published list` subcommands. `agents/voice-trainer.md` is wired to add `ingest/published/` as the primary gold source and forbid learning from `provenance=ai-draft`, and that wiring is **gate-enforced** by a new structure-lint (Section 16c). All unit-tested, TDD (failing test first). Gate stays green; BRAIN + assertion floors rise. + +## 2. Package additions (`scripts/brain/`) — no new package, extend the existing one + +``` +scripts/brain/ + src/ + ingest.ts # NEW — PublishedRecord type + parse/serialize (pure) + writePublished/scanInbox/listPublished (IO) + id.ts # EDIT — add normalizeContent() + mintContentId() (the specifics-bank content-hash idiom) + cli.ts # EDIT — add `ingest` + `published` dispatch (parseFlags idiom from specifics-bank/src/cli.ts) + (types.ts, profile.ts, scaffold.ts, dataRoot.ts unchanged) + tests/ + ingest.test.ts # NEW — record round-trip + id determinism/dedup (pure) + publish.test.ts # NEW — temp-dir IO: write/idempotency/scan-inbox/create-on-demand + fixtures/ + sample-inbox-post.md # NEW — a synthetic published post for scan-inbox + round-trip tests +``` + +No `package.json`/`tsconfig.json` change (devDeps already tsx+typescript; no new dep — the grammar is regex, no YAML). The test command (`node --import tsx --test tests/*.test.ts`) already globs `tests/*.test.ts`, so the two new suites are picked up automatically. + +## 3. Module designs + +### 3.1 `id.ts` addition — `mintContentId` on the VERBATIM body (no normalization — fixes plan-critic B2) +**Verify-first (step 1):** mirror the `specificId` hash idiom at `scripts/specifics-bank/src/bank.ts:57` (the function is named `specificId`, NOT `mintContentId` — that name is net-new here; `normalizeContent` lives at `bank.ts:52`). **Decision: do NOT reuse `normalizeContent` for a post body.** +```ts +// mintContentId: sha256 of the VERBATIM body, first 12 hex. The id IS byte-identity. +export function mintContentId(text: string): string { + return createHash("sha256").update(text).digest("hex").slice(0, 12); +} +``` +- **Why verbatim, not `normalizeContent`:** `normalizeContent` (bank.ts:52) does `trim().toLowerCase().replace(/\s+/g," ")` — correct for *short* specifics dedupe, but for a full post body it collapses ALL whitespace + case, so two **structurally different** published posts (same words, different line breaks/paragraphing/case) would mint the **same id** and the second would be silently skipped on write (data loss of the exact gold signal SB-S1 exists to capture, plan-critic B2). Hashing the verbatim body makes the id true byte-identity: only an identical re-ingest collides; any real difference mints a distinct id. +- `mintContentId` is content-keyed — distinct from `mintEntityId({kind,key})` which slugifies a *label* (a body must never be slugified). Pure, deterministic. SC3. +- **Defence-in-depth (write-side, §3.3):** even with byte-identity hashing, `writePublished` compares bodies on any id-collision and never silently drops a differing body — so no hash strategy can cause data loss. + +### 3.2 `ingest.ts` — `PublishedRecord` + constrained-header grammar (no YAML) +```ts +export interface PublishedRecord { + id: string; // mintContentId(body) — 12 hex, the dedupe key + filename stem + provenance: 'published'; // S1 only ever writes `published`; the type pins it + published_date: string; // YYYY-MM-DD (operator-supplied or defaults to captured_at) + captured_at: string; // YYYY-MM-DD (ingest run date; supplied by caller — pure fn) + source: string; // 'manual' (default) | future connector token + body: string; // the VERBATIM post text (may contain ] | quotes newlines ---) +} +``` +**File grammar** (`ingest/published/<id>.md`) — the EXACT byte layout, used identically by serialize and parse (fixes plan-critic B1): +``` +id: <12hex> +provenance: published +published_date: YYYY-MM-DD +captured_at: YYYY-MM-DD +source: manual +--- +<verbatim body> +``` +- **Pinned separator (one string, both directions):** the five header lines, then a line that is exactly `---`, then the verbatim body. Concretely: `serialize` = `headerLines.join("\n") + "\n---\n" + body`. There is **NO** blank-line padding around the `---` and **NO** `# Published Record` title (both removed — they were the B1 contradiction). The header block is fixed at exactly 5 lines. +- **`parsePublishedRecord(text)`** → split on the FIRST occurrence of `\n---\n`. Left = header (parse the 5 scalars by the in-tree scalar-regex idiom: `^id:\s*([0-9a-f]{12})$/m`, `^published_date:\s*(\d{4}-\d{2}-\d{2})$/m`, etc.). Right = `body`, **verbatim, byte-exact** (a body that itself contains a `\n---\n` is preserved — we split on the FIRST sentinel only, via `indexOf`, never a global split). **`provenance`** is parsed and asserted to equal `published` (a `published/` record with any other provenance is a corruption signal → throw/skip, never silently accepted — fixes plan-critic minor 11; the type pins `'published'`). +- **`parse(serialize(rec)) === rec`** over all six fields incl. the body (SC2). **Edge cases the round-trip test MUST cover** (plan-critic B1): empty body (`""`), body = `"\n"`, body with trailing newlines, body starting with `---`, body containing `\n---\n` mid-text, and a body whose first line is header-shaped (e.g. `id: 0123456789ab`). Because the header is a fixed 5-line block terminated by the first `\n---\n`, a header-shaped body line cannot leak into the header (it sits after the sentinel). The only constraint on the body: none needed — it is captured byte-exact after the first sentinel. + +### 3.3 `ingest.ts` — IO (impure; caller supplies the run date, like `scaffold.ts`) +- **`writePublished(rec): {written: boolean; path: string; collision?: boolean}`** — `dataRoot('ingest/published')`, mkdir-recursive (**create-on-demand — no hard dependency on `brain init`**, SC1), path = `<id>.md`. **Collision-safe compare-then-skip (fixes plan-critic B2 defence-in-depth):** if `<id>.md` exists, READ it, parse its body: (a) body byte-identical to `rec.body` → true duplicate → `{written:false}` (idempotent, no clobber — the `initBrain` discipline); (b) body DIFFERS (astronomically rare 48-bit collision, or a truncation artifact) → write to a disambiguated path `<id>-2.md` (next free suffix) and return `{written:true, collision:true}` — **never silently drop a differing body.** +- **`ingestText({body, source?, published_date?, captured_at})`** — `mintContentId(body)`, build the record (`provenance:'published'`; `published_date` defaults to `captured_at` when absent), `writePublished`. Returns the record + written flag. +- **`scanInbox({captured_at})`** — read **top-level `*.md` files only**, skipping dotfiles (`.DS_Store` etc. — macOS), no recursion (fixes plan-critic minor 9), in `dataRoot('ingest/inbox')` (empty/absent dir → clean no-op, SC4); for each, `ingestText({body: fileContent, source:'manual', captured_at})`. **Non-destructive in S1:** inbox files are LEFT in place (idempotent via the published dedup — a re-scan re-skips); auto-move/delete of processed inbox files is deferred (noted §8). Returns `{processed[], skipped[]}`. +- **`listPublished()`** — read `ingest/published/*.md`; **parse each inside a try/catch — a malformed/hand-dropped file is skipped+counted, never crashes the command** (`parsePublishedRecord` can throw via the provenance assertion / `normalizeProvenance`, id.ts:45; fixes plan-critic minor 8). Return `{records: [{id, provenance, published_date, firstLine}], skipped: number}` sorted by date. Surfaces `provenance` so the operator can eyeball that nothing `ai-draft` leaked (brief §5.1 D2). + +### 3.4 `cli.ts` — add NET-NEW flag-routing infra (not just "extend dispatch" — fixes plan-critic major 6) +The brain `cli.ts` currently has **no** `parseFlags`, **no** `--json`, **no** `today()` — it dispatches purely on positional `argv[2]` (just `init`). So this step ADDS that infrastructure (copy `parseFlags` from `specifics-bank/src/cli.ts:41`; a local `today()` — `ingest.ts` itself stays pure with a caller-supplied date, so the CLI's `today()` is the only clock and does not shadow `scaffold.ts:28`'s private one). The **existing `init` positional branch is preserved verbatim**, coexisting with the new flag-driven branches: +- **`brain ingest --file <path> [--source <s>] [--date <YYYY-MM-DD>]`** — read the file, `ingestText`, print `Wrote ingest/published/<id>.md` / `Duplicate — already published: <id>` / `Collision → wrote <id>-2.md`. +- **`brain ingest --scan-inbox [--source <s>]`** — `scanInbox`, print the processed/skipped split. +- **`brain published list [--json]`** — `listPublished`; human form prints `id · provenance · date · first-line`; `--json` emits `{records:[{id,provenance,published_date,firstLine}], skipped}` (the `listPublished` return verbatim — pinned shape, fixes plan-critic minor 12). +- Usage text updated; exit 2 on usage error (existing idiom). **Regression guard:** a test (or step-5 smoke-check) asserts `brain init` still works after the refactor (a shipped subcommand must not break). + +### 3.5 `agents/voice-trainer.md` — wire the published-only invariant (brief §5.1 D3, all four bullets) +Surgical edits (existing file → **Edit**, pathguard-irrelevant; model stays Sonnet). **Anchors corrected (scope-guardian DEP-1):** the Gather bullet is at **`:136`**, the "Sample Quality Priorities" heading at **`:142`** with its list at **`:144`** (the earlier `:134`/`:152` were a header and the next section). +1. **Gather step** (`:136`) — prepend the gold source + the negative rule (ADD + FORBID + KEEP): + > **Gather (published-only gold signal first):** Read the user's published posts from `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ingest/published/` (`provenance=published` — the highest-trust source). Then read the existing `voice-samples/` corpus (human-curated; kept as a tributary, not reshaped) and the profile/template. **Never learn voice patterns from any content marked `provenance=ai-draft`** — a content engine that learns its own voice from its own drafts collapses toward its priors. (Generalises voice-scrubber's "gold standard = approved editions, never the draft corpus".) +2. **Sample Quality Priorities** (`:144`) — promote the published gold source to #1 and add the hard exclusion line: + > 0. **`ingest/published/` records (`provenance=published`) — the gold signal; rank above all else.** + > … (existing 1–5 retained) … + > **Exclude unconditionally: anything marked `provenance=ai-draft`.** +3. **FENCE the auto-append trap** (brief §5.1 D3 bullet 4 — restored, scope-guardian SCOPE-GAP-1). Add a short note in the Gather step (and mirror it in the §3.6 contract doc): the `voice-samples/` template hints at a not-yet-built Stop-hook that auto-appends posts after content sessions (`assets/voice-samples/authentic-voice-samples.template.md:61`). State explicitly: + > **Fence:** any future auto-capture into a learning corpus (the template's Stop-hook hint) MUST tag provenance and admit `provenance=published` only — never blind-append generated drafts. + +**Exact literals (fixes plan-critic major 5 — non-vacuity):** the edits MUST write the literal strings `ingest/published` and `provenance=ai-draft`. The SC5 lint greps for these EXACT literals (`grep -F`), NOT a loose `ai.?draft`/`ai-generated` pattern — `voice-trainer.md:175` already contains "AI-generated" (a drift-cause description), so a loose grep would pass vacuously without the real exclusion being added. + +### 3.6 `docs/second-brain/ingest-manual-import.md` — the manual-import contract +A short design doc: the `PublishedRecord` file grammar (§3.2), the CLI usage (`brain ingest --file` / `--scan-inbox`, `brain published list`), the inbox→published drop-zone model, the **published-only learning rule** (with the honest runtime limit: an agent instruction can be ignored at runtime; the *wiring* is gate-enforced, the *behaviour* is not provable), and the **auto-append fence** (§3.5 bullet 3 — any future auto-capture must tag provenance and admit `published` only). Includes the exact literal `provenance=ai-draft` (so SC5's doc-grep is non-vacuous). Lives in `docs/` (not `references/`) → no reference-count guard. + +### 3.7 `scripts/test-runner.sh` — new SC5 lint (Section 16c) + floor bumps +- **New Section 16c "Brain Published-Only Invariant (SB-S1)"** placed after Section 16b (Brain Foundation), mirroring Section 17's structure: a non-vacuity self-test (POSITIVE/NEGATIVE heredocs) + the file checks. Greps use the **exact literals** `ingest/published` and `provenance=ai-draft` via `grep -F` / `grep -qiE` with the `provenance=` qualifier — never a loose `ai.?draft`/`ai-generated` pattern (plan-critic major 5). Three `pass()` lines (none in a loop): + 1. **self-test** — POSITIVE probe contains BOTH literals → must pass; NEGATIVE probes: one missing `ingest/published`, one missing `provenance=ai-draft`, **and one containing `AI-generated` but NOT `provenance=ai-draft`** (proves the check doesn't pass on voice-trainer's pre-existing drift prose) → each must fail. Non-vacuous like Sections 8/13/17. + 2. **`voice-trainer.md`** contains `ingest/published` AND `provenance=ai-draft`. + 3. **`docs/second-brain/ingest-manual-import.md`** contains `provenance=ai-draft` (the rule is documented). +- **`BRAIN_TESTS_FLOOR=34` (`:714`) → the actual post-implementation count.** Pinned at step 8 by reading the brain suite's real `tests N` AFTER the new suites land (TDD determines it; do NOT guess). **Step 8 must first assert the brain suite actually RAN (`BR_TESTS` non-empty — i.e. tsx installed) before pinning**, else a warn-skip would let the gate go green while SB-S1's own tests never ran (plan-critic major 4). +- **`ASSERT_BASELINE_FLOOR=75` (`:798`) → the actual printed `TOTAL_CHECKS`.** The floor counts **runtime** `PASS+FAIL` increments, not static `pass()` call-sites (the file has 72 static sites but a 75 floor because some checks loop). The three new Section 16c checks are non-looping, so the delta is +3 → 78 — **but pin it empirically: run the gate at step 8, read the printed `TOTAL_CHECKS`, set the floor to that exact number** rather than hand-arithmetic (plan-critic major 3). Update the inline history comment at `:796`. + +## 4. Files created / edited + +**Created:** `scripts/brain/src/ingest.ts` · `scripts/brain/tests/{ingest,publish}.test.ts` · `scripts/brain/fixtures/sample-inbox-post.md` · `docs/second-brain/ingest-manual-import.md`. + +**Edited:** `scripts/brain/src/id.ts` (+normalizeContent/+mintContentId) · `scripts/brain/src/cli.ts` (+ingest/+published) · `agents/voice-trainer.md` (gather + priorities) · `scripts/test-runner.sh` (Section 16c + two floor bumps) · `docs/second-brain/architecture.md` (flip SB-S1 row → landed). + +**Conditional (only if Q4 = bump to 0.5.1):** `CLAUDE.md` header + README badge + CHANGELOG + the marketplace catalog `ref` (via `catalog/scripts/release-plugin.mjs` — the polyrepo release path; tag + catalog bump atomic). Decided at "go". + +## 5. TDD test plan (failing-first) → success criteria + +| Test (`tests/*.test.ts`) | Asserts | SC | +|---|---|---| +| `ingest.test.ts` | `parsePublishedRecord(serializePublishedRecord(rec)) === rec` over the **B1 edge-case battery**: empty body, body=`"\n"`, trailing newlines, body starting with `---`, body containing `\n---\n` mid-text, header-shaped first body line (`id: 0123456789ab`), and a body with `]`/`|`/quotes; `mintContentId` deterministic (same body→same id; whitespace/case difference→DIFFERENT id, proving no normalize-collision); a `published/` record with non-`published` provenance throws (corruption assertion) | SC2, SC3 | +| `publish.test.ts` | `LINKEDIN_STUDIO_DATA`=tmp, NO prior init → `writePublished` creates `ingest/published/<id>.md` with `provenance=published` + verbatim body (create-on-demand); re-ingest identical body = `{written:false}`, no dup; **id-collision with a DIFFERING body → disambiguated `<id>-2.md`, no data loss**; `scanInbox` processes top-level `*.md` only (skips a `.DS_Store` fixture), → published; empty/absent inbox = no-op; re-scan re-skips; `listPublished` skips a malformed dropped file without throwing; **`brain init` still works after the CLI refactor** (regression) | SC1, SC3, SC4 | +| (gate) `scripts/test-runner.sh` Section 16c | self-test non-vacuous (incl. the `AI-generated`-but-not-`ai-draft` negative probe); `voice-trainer.md` + contract doc carry the exact literals | SC5 | +| (gate) `scripts/test-runner.sh` | green; trends ≥24, specifics ≥28, contract ≥33, **brain ≥ new floor (brain suite confirmed RAN, not skipped)**; hooks pass; assertion floor = printed `TOTAL_CHECKS` | SC6 | +| (n/a) | no new seam fn; `data-root.test.mjs` twin-sync untouched | SC6 | + +Each test written **before** its module (iron law): red → implement → green. + +## 6. Step sequence + +**Discipline (plan-critic major 7):** each TDD step is a **Checkpoint** — commit after its cycle goes green (small, revertible commits). Each step carries an **On-failure** clause; two hard **circuit-breakers**: if step 3 (round-trip) cannot be made green after pinning the separator, HALT (the grammar is wrong — do not proceed); if step 8 (full gate) is not green, HALT (do not commit a red gate). + +1. **Verify-first:** read `scripts/specifics-bank/src/bank.ts:52,57` → confirm the `specificId` hash idiom (mirror it as `mintContentId` on the VERBATIM body; do NOT reuse `normalizeContent`). Confirm `scripts/brain/node_modules/.bin/tsx` exists (else `npm install` in `scripts/brain` — the gate warn-skips a depless suite; "green" must not mean green-because-skipped). *On-failure: if tsx won't install, STOP and report — the slice cannot be gate-verified.* +2. TDD `id.ts` `mintContentId` (`ingest.test.ts` id cases: determinism + whitespace/case → different id). → SC3. *Checkpoint.* +3. TDD `ingest.ts` grammar parse/serialize over the B1 edge-case battery (`ingest.test.ts` round-trip). → SC2. *On-failure: CIRCUIT-BREAKER — if identity can't hold, the separator is wrong; halt and re-pin §3.2 before any further step.* *Checkpoint.* +4. Write `fixtures/sample-inbox-post.md` (+ a `.DS_Store` + a malformed file in test temp dirs); TDD `ingest.ts` IO — `writePublished` (incl. collision→disambiguate), `ingestText`, `scanInbox` (filter), `listPublished` (try/catch) (`publish.test.ts`). → SC1, SC4. *Checkpoint.* +5. Add the CLI flag-routing infra + `ingest`/`published` branches (preserve `init`); regression-test `brain init`; smoke-run against a temp `LINKEDIN_STUDIO_DATA` (ingest a file, scan an inbox, list). *On-failure: if `brain init` regresses, revert the CLI refactor and isolate.* *Checkpoint.* +6. Edit `agents/voice-trainer.md` (`:136` gather + `:144` priorities + the `provenance=ai-draft` exclusion + the auto-append fence — exact literals). *Checkpoint.* +7. Write `docs/second-brain/ingest-manual-import.md` (incl. the `provenance=ai-draft` literal). *Checkpoint.* +8. Add `scripts/test-runner.sh` Section 16c (self-test + voice-trainer + doc checks, exact-literal greps); run the gate, **confirm the brain suite RAN (`BR_TESTS` non-empty)**, then pin `BRAIN_TESTS_FLOOR` to the real `tests N` and `ASSERT_BASELINE_FLOOR` to the printed `TOTAL_CHECKS`; re-run the FULL gate green. → SC5, SC6. *On-failure: CIRCUIT-BREAKER — a red gate is never committed.* *Checkpoint.* +9. Flip `architecture.md` SB-S1 row → landed. **Exact edit (scope-guardian/plan-critic minor 10):** the row at `architecture.md:78` begins `| **SB-S1 — Ingest + gold signal** |` — change its trailing status cell to the "landed" marker matching SB-S0's row style (verify the exact current cell text at edit time). Persist brief + plan. **Commit; confirm before push (PUBLIC `open/` Forgejo), only inside the window; STATE.md stays gitignored.** Conditional version bump per Q4. + +## 7. Scope fence (echo — SB-S1 does NOT) + +No consolidation loop · **no write to `brain/profile.md`** (ingest writes only under `ingest/`) · no session-start wiring · no id threading into tributaries · no triple-post reconciliation · no `content-history.md` retirement · no tributary-schema edits (voice-samples store untouched; only `voice-trainer.md` instructions edited) · no LinkedIn API/connector · no new seam function · no state-file/two-roots reconciliation · no GUI. + +## 8. Known limits / deferred (honest) + +- **Invariant is wired, not behaviourally proven.** SC5 gate-enforces that `voice-trainer` *carries* the published-only wiring + exclusion; it cannot prove the agent *obeys* it at runtime (an instruction can be ignored). Stated in the contract doc. The strongest feasible enforcement short of running the agent. +- **scan-inbox is non-destructive in S1** — processed inbox files are left in place (idempotent via published dedup). Auto-move/delete of processed inbox files (the full drop-zone lifecycle) is deferred; the operator clears inbox manually for now. +- **`published_date` defaults to `captured_at`** when the operator doesn't pass `--date` (and for scan-inbox, which has no per-file date). A real published date is operator-supplied; SB-S2's loop can refine from analytics. +- **`voice-samples/` stays un-provenanced.** S1 keeps it as a human-curated tributary and does not retro-tag it (that would be a tributary-schema change, §7). The published-only invariant bites on the NEW gold source + the ai-draft exclusion; voice-samples is trusted as human by its existing contract. +- **No cross-silo id.** A published post ingested here mints a content-hash id but is NOT yet linked to its analytics/state/specifics records (SB-S3). + +## 9. Risks + +- **Grammar identity edge (B1):** a body containing `\n---\n` could confuse the header/body split — mitigated by splitting on the **first** sentinel only (`indexOf`, never global) + the fixed 5-line header; the B1 edge-case battery (§5) is the proof. Circuit-breaker at step 3. +- **Silent data loss (B2) — CLOSED:** id is now `sha256(verbatim body)[:12]` (no `normalizeContent`), so structurally-different posts never collide; and `writePublished` compares bodies on any id-collision and disambiguates rather than skipping — so no hash strategy can drop a differing gold record. Tested in `publish.test.ts`. +- **Gate "green" honest only with deps installed** — `npm install scripts/brain` (step 1) AND step 8 asserts `BR_TESTS` non-empty before pinning the floor; otherwise a warn-skip lets the gate go green while SB-S1's own tests never ran. +- **Floor-bump arithmetic (corrected):** `ASSERT_BASELINE_FLOOR` counts **runtime** `PASS+FAIL`, not static `pass()` sites — so do NOT hand-compute 75+3; read the printed `TOTAL_CHECKS` at step 8 and set the floor to it. `BRAIN_TESTS_FLOOR` = the actual brain `tests N` after the new suites land. Both pinned empirically at step 8, never guessed. +- **Scope creep into S2:** the temptation is to consume `published/` into `profile.md`. Fenced by §7 + the test plan (no test asserts profile mutation; `writePublished` targets only `ingest/`). diff --git a/docs/second-brain/plan-sb-s2.md b/docs/second-brain/plan-sb-s2.md new file mode 100644 index 0000000..41259d1 --- /dev/null +++ b/docs/second-brain/plan-sb-s2.md @@ -0,0 +1,160 @@ +# Implementation Plan — SB-S2 (Evolution loop) + +> **Status:** review-hardened (`plan-critic` REPLAN 58→folded; `scope-guardian` ALIGNED). Ready for operator approval / "go". +> **Brief:** `brief-sb-s2.md` (review-hardened). **Arc:** `architecture.md`. **Predecessors:** SB-S0/S1 (`scripts/brain/`, v0.5.1). +> **Review delta (2026-06-23):** B1 conflict id → distinct (primary `mintEntityId(key)` + alt `mintContentId('observed-alt:'+key+'::'+value+'::'+date)`, no duplicate ids); B2 `--gather` reads published files directly for bodies (not `listPublished`), tributary reads CUT; B3 hook tests run SEPARATELY (`node --test hooks/scripts/__tests__/`) as an explicit step — NOT via test-runner.sh (which runs no hook test) → SC7 reworded, assertion floor +0; M4 new hook-test `.mjs` authored via Bash-heredoc (pathguard-safe under either guard); M5 scaffold-ensure runs UNCONDITIONALLY (fresh-install path); M6 `\\n` escaped-newline idiom; M8 per-step commit msg + revert target; minors: single-line value validation, static-decay-exempt, tsx fail-loud. +> **Scope:** the deterministic consolidation engine + `brain consolidate` CLI (operator-invoked, operator-gated) + the session-start consolidation-due nudge + scaffold-ensure. **No journal-capture, no new agent, no profile.md reader, no supersede, no cross-silo threading, no auto-apply, no AI at session-start** (brief §7). + +--- + +## 1. What SB-S2 delivers (recap) + +A new PURE TS engine `scripts/brain/src/consolidate.ts` (`proposeDiff` + `applyDiff` over the SB-S0 two-layer `ProfileDoc`), a `brain consolidate` CLI (`--gather` / `--propose` / `--apply --confirm`) that closes the loop operator-gated, a brain data-root sidecar `brain/consolidation-state.json` for the last-run timestamp, and a zero-dep **consolidation-due nudge** + **scaffold-ensure** edit to `hooks/scripts/session-start.mjs`. Count-neutral (no agent/command/ref/skill). All TDD; gate stays green; floors rise. + +## 2. Package additions (`scripts/brain/`) — extend the existing package + +``` +scripts/brain/ + src/ + consolidate.ts # NEW — Candidate/ProfileDiff types + proposeDiff + applyDiff (pure) + consolidation-state IO + cli.ts # EDIT — add `consolidate` dispatch (--gather/--propose/--apply) + (types.ts, id.ts, profile.ts, ingest.ts, scaffold.ts, dataRoot.ts unchanged) + tests/ + consolidate.test.ts # NEW — engine: per-rule (SC1a–g) + immutability + round-trip + idempotency + consolidate-cli.test.ts # NEW — CLI gating + candidate-validation + sidecar (SC5) +hooks/scripts/ + session-start.mjs # EDIT (existing → pathguard-safe) — scaffold-ensure + consolidation-due nudge + __tests__/ + session-start-brain-consolidation.test.mjs # NEW — SC6 (mirror of session-start-trends-staleness.test.mjs) +``` + +No new dep (engine is pure TS; the hook stays zero-dep). The brain `test` script already globs `tests/*.test.ts`. + +## 3. Module designs + +### 3.1 `consolidate.ts` — types +```ts +import { mintEntityId, mintContentId, slugify } from "./id.js"; +import { SCHEMA_VERSION } from "./types.js"; +import type { ProfileDoc, ProfileFact, Provenance } from "./types.js"; + +export interface Candidate { + key: string; // the fact's stable label/topic/concept + value: string; // single-line (no embedded newline/CR — profile grammar) + provenance: Provenance; // published | human | ai-draft + source: string; // e.g. "published:<id>" | "manual" + observed_date: string; // YYYY-MM-DD +} +export interface ProfileDiff { + additions: ProfileFact[]; // new dynamic facts (primary + conflict-alt) + evidenceBumps: { id: string; newCount: number; last_seen: string }[]; + promotions: { id: string }[]; // dynamic→static (post-bump count ≥ N) + conflicts: { primaryId: string; primaryValue: string; altId: string }[]; // both retained, distinct ids + staleFlags: { id: string; last_seen: string; daysStale: number }[]; +} +export interface ConsolidateOpts { promoteThreshold?: number; decayDays?: number } // defaults 3 / 90 +const OBSERVED_KIND = "observed"; +``` + +### 3.2 `consolidate.ts` — `proposeDiff` (pure; the classification heart) — id model fixes plan-critic B1 +**Id model (the B1 fix — no duplicate ids):** +- A concept's **primary fact** id = `mintEntityId({ kind: OBSERVED_KIND, key })` (key-only → one primary per concept). +- A conflict **alt fact** id = `mintContentId(\`observed-alt:${key}::${value}::${observed_date}\`)` (SB-S1's verbatim sha256[:12] — byte-distinct, so primary and alt never collide and two alts for the same concept differ by value/date). Matching always starts from the *candidate's* `key`, so we never need to recover a key from an existing fact. + +```ts +proposeDiff({ current, candidates, today, opts }): ProfileDiff +``` +- `N = opts.promoteThreshold ?? 3`, `DECAY = opts.decayDays ?? 90`. +- Build a `byId` lookup over `current.dynamic ∪ current.static`. (Folded `profile-field` static seeds use a DIFFERENT kind → their ids never collide with `observed` ids → immutable, SC1g.) +- For each candidate: + - `provenance === 'ai-draft'` → skip entirely (SC1b). + - `primaryId = mintEntityId({kind:OBSERVED_KIND, key})`; `prev = byId.get(primaryId)`. + - **no `prev`** → `additions` gets a new dynamic `ProfileFact` (`id:primaryId`, `evidence_count:1`, `first_seen:observed_date`, `last_seen:today`, `status:'active'`, `provenance`) (SC1a). + - **`prev`, `prev.value === candidate.value`** → `evidenceBumps` (`newCount = prev.evidence_count+1`, `last_seen:today`); if `prev` is in `dynamic` AND `newCount ≥ N` → also `promotions[{id:primaryId}]` (SC1c/SC1d). + - **`prev`, `prev.value !== candidate.value`** → CONFLICT (SC1e): build an alt fact `{id: mintContentId(\`observed-alt:${key}::${candidate.value}::${observed_date}\`), value:candidate.value, dynamic, evidence_count:1, …}`, push it to `additions`, record `conflicts[{primaryId, primaryValue: prev.value, altId}]`, and leave `prev` UNTOUCHED (no bump). **No supersede** (S3). (If the alt id already exists from a prior run → treat as the same-value bump path on the alt id, so re-running is idempotent.) +- After the candidate pass: `staleFlags` = every **`current.dynamic`** fact whose `daysSince(last_seen) > DECAY` (SC1f). **Static facts are decay-exempt by design** (promoted = stable). Informational only — no mutation. +- **Purity:** never mutate `current`/`candidates` (build new objects); SC2 asserts inputs structurally unchanged. + +### 3.3 `consolidate.ts` — `applyDiff` (pure) + sidecar IO +- `applyDiff(current, diff): ProfileDoc` — returns a NEW doc: append `additions` (which already includes both new primary facts AND conflict alt facts) to `dynamic`; apply `evidenceBumps` (update count+last_seen on the fact with the matching id); move `promotions` facts from `dynamic`→`static`. `conflicts[]` is informational (the alt fact is already in `additions`); `staleFlags` cause NO mutation. **Id-uniqueness invariant:** because primary ids (`mintEntityId(key)`) and alt ids (`mintContentId(...)`) are byte-distinct, no two facts share an id — so the bump/promote target is unambiguous and the doc stays well-formed. Output round-trips through `parseProfile`/`serializeProfile` (SC3). +- **Sidecar IO** (the only impure bit, kept here for cohesion): `readConsolidationState(): {last_run: string|null}` + `writeConsolidationState(date)` over `dataRoot('brain/consolidation-state.json')` (`{ "last_run": "YYYY-MM-DD" }`). Tiny JSON; absent → `{last_run:null}`. + +### 3.4 `cli.ts` — `consolidate` dispatch (extend the SB-S1 router) +Add `command === "consolidate"` with a `--gather` / `--propose` / `--apply` mode (the parseFlags idiom already present): +- **`--gather [--json]`** — read-only. Reads the published gold corpus **directly** (`readdir(dataRoot('ingest/published'))` → `parsePublishedRecord` each — `listPublished()` returns no body/`captured_at`, so it can't feed extraction; fixes plan-critic B2), filters to records with `published_date > readConsolidationState().last_run` (null → all), and prints a digest of `{id, published_date, body}` per new record + the current `brain/profile.md` facts (parsed), for the **invoking session** to turn into a `Candidate[]` JSON. **Tributary newest-timestamp reads are CUT** (informational-only, underspecified — the published corpus is the S2 signal; plan-critic M12). Writes nothing. +- **`--propose --candidates <file.json>`** — read + **validate** each candidate against the `Candidate` shape: every item has key/value/provenance∈{human,published,ai-draft}/source/observed_date, AND **`key`/`value` contain no newline or carriage-return** (the profile grammar is single-line — a `\n` would corrupt serialization; plan-critic M9). Any violation → non-zero exit, no write (SC5). `proposeDiff` over the current profile. Write `brain/pending-diff.json` (the typed `ProfileDiff`) AND `brain/pending-diff.md` (operator-readable: sections Additions / Evidence-bumps / Promotions / ⚠ Conflicts / Stale, neutral framing). Print both paths. Does NOT touch `profile.md`. +- **`--apply --diff <path> --confirm`** — require `--confirm` (else refuse). Read the JSON diff, `parseProfile(brain/profile.md)`, `applyDiff`, write `brain/profile.md` (`serializeProfile`), then `writeConsolidationState(today())`. The ONLY path that writes the profile. +- Usage text + exit 2 on misuse. `init`/`ingest`/`published` branches unchanged (regression-guarded). + +### 3.5 `hooks/scripts/session-start.mjs` — scaffold-ensure + consolidation-due nudge (Edit, zero-dep) +- Add `readdirSync` to the existing `node:fs` import (line 5). +- **Helpers (twins of `trendsNewestCapture`, lines 38–52):** + - `brainLastRun(path)` — `JSON.parse(readFileSync)` of `consolidation-state.json` → `last_run` or null (try/catch → null). + - `countPublished(dir)` — `existsSync(dir) ? readdirSync(dir).filter(f=>f.endsWith('.md')).length : 0` (no parse — cost-bounded). +- **Scaffold-ensure — UNCONDITIONAL** (fixes plan-critic M5: the `reminders`/context block runs only inside `if (existsSync(STATE_FILE))`, so a fresh install (else branch ~line 432) would never scaffold/nudge). Place it next to the M0 migration block (~line 73), BEFORE the state-file branch, so it always runs: `for (const d of ['brain/journal','ingest/inbox','ingest/published']) mkdirSync(join(getDataRoot(''),d),{recursive:true})` (idempotent, zero-dep; confirm `getDataRoot('')` returns the bare data root — step 1). The optional "run `brain init` to seed your profile" nudge is appended to **`context` directly** (not `reminders`) right after scaffold-ensure when `!existsSync(join(getDataRoot('brain'),'profile.md'))`, so it survives the no-state-file path. +- **Consolidation-due nudge** (inside the reminders block, beside the trends nudge ~line 342 — an established user with published content has a state file, so the reminders path is correct here). **Use the `\\n` escaped-newline idiom** (the file un-escapes once via `context.replace(/\\n/g,'\n')` at line 479 — a raw `\n` would mis-render; plan-critic M6): + ```js + const published = countPublished(join(getDataRoot('ingest'), 'published')); + const lastRun = brainLastRun(join(getDataRoot('brain'), 'consolidation-state.json')); + const daysSinceRun = daysSince(lastRun); + if (published > 0 && (lastRun === null || (daysSinceRun !== null && daysSinceRun >= 7))) { + const since = lastRun === null ? 'never run' : `${daysSinceRun}d ago`; + reminders += `- ${published} published post(s) captured, last brain consolidation ${since}. Run \\\`brain consolidate\\\` to evolve your profile.\\n`; + } + ``` + Null-safe + fires only when published records exist (a never-used brain never nags — the trend-nudge discipline). **No `profile.md` parse.** + +## 4. Files created / edited + +**Created:** `scripts/brain/src/consolidate.ts` · `scripts/brain/tests/{consolidate,consolidate-cli}.test.ts` · `hooks/scripts/__tests__/session-start-brain-consolidation.test.mjs` · `docs/second-brain/consolidation-loop.md`. + +**Edited:** `scripts/brain/src/cli.ts` (+consolidate) · `hooks/scripts/session-start.mjs` (scaffold-ensure + nudge + `readdirSync` import) · `scripts/test-runner.sh` (BRAIN floor + assertion floor) · `docs/second-brain/architecture.md` (flip SB-S2 row). + +**Conditional (Q2 = bump to 0.5.2):** `plugin.json` + README badge + CLAUDE.md header + CHANGELOG; polyrepo release via `release-plugin.mjs` (tag + catalog), pushed per window + confirm. + +## 5. TDD test plan (failing-first) → success criteria + +| Test | Asserts | SC | +|---|---|---| +| `consolidate.test.ts` | add / reject-ai-draft / evidence-bump / promote-at-3 / **conflict-keep-both with DISTINCT primary+alt ids, old fact untouched** / decay-flag (dynamic only) / **static-decay-exempt** / folded-immutable; input immutability; `applyDiff∘proposeDiff` round-trips via parse/serialize; idempotent (re-run → bumps not dup facts, alt-id stable) | SC1a–g, SC2, SC3, SC4 | +| `consolidate-cli.test.ts` | temp-dir: `--gather` reads published bodies (filtered by `published_date>last_run`) + leaves `profile.md` byte-unchanged; `--propose` leaves profile.md unchanged, writes `pending-diff.{md,json}`, REJECTS a malformed candidate file AND a candidate whose value contains `\n` (non-zero, no write); `--apply --confirm` writes profile.md + `consolidation-state.json`; `--apply` without `--confirm` refuses; `init`/`ingest`/`published` still work | SC5 | +| `session-start-brain-consolidation.test.mjs` (authored via **Bash-heredoc** — pathguard-safe) | subprocess + isolated HOME/`LINKEDIN_STUDIO_DATA` (trends-staleness harness): nudge fires (rendered on its own line) when published records exist + last_run stale/absent; silent when brain absent / no published; scaffold-ensure runs on the fresh-install (no-state-file) path; hook still emits valid JSON | SC6 | +| (separate) `node --test hooks/scripts/__tests__/` | the new SC6 test + all existing hook tests pass — **run as an explicit step, NOT via test-runner.sh** (which runs no hook test; plan-critic B3) | SC6 | +| (gate) `scripts/test-runner.sh` | green; floors hold; brain ≥ new floor; counts unchanged; `compile-hooks.py --check` clean if hooks.json regenerated. (No new test-runner section → assertion floor +0.) | SC7 | + +Each test written **before** its module (iron law). **Circuit-breakers:** halt if the engine round-trip (SC3) can't be made green, or if the full gate (step 8) is red. + +## 6. Step sequence (each step: TDD red→green; Checkpoint = commit with the named message; On-failure → revert the named target + halt + report) + +1. **Verify-first:** re-confirm `proposeDiff` inputs against `types.ts` (`ProfileFact`/`ProfileDoc`/`Provenance`) + `mintEntityId`/`mintContentId`/`slugify` signatures + `daysSince`/`getDataRoot('')` behavior in session-start.mjs (does the empty subdir return the bare data root?). **tsx fail-loud:** assert `scripts/brain/node_modules/.bin/tsx` present (the gate brain section *warn-skips* if absent — for this slice that would let the gate go green with SB-S2's tests never run; `npm install` and FAIL if still absent; plan-critic M11). *On-failure: stop, report — the slice cannot be verified.* +2. TDD `consolidate.ts` types + `proposeDiff` per-rule (`consolidate.test.ts`, SC1a–g + SC2; incl. the distinct primary/alt id assertion). *Checkpoint: `feat(linkedin-studio): SB-S2 consolidation engine — proposeDiff classification [skip-docs]`. On-failure: revert `scripts/brain/src/consolidate.ts` + its test.* +3. TDD `applyDiff` + round-trip + idempotency (SC3/SC4). *Circuit-breaker: if the parse/serialize round-trip can't be made green, the id/shape is wrong — halt.* *Checkpoint: fold into step-2 commit or `feat(...): SB-S2 applyDiff + round-trip [skip-docs]`.* +4. TDD sidecar IO (`readConsolidationState`/`writeConsolidationState`) — fold into the consolidate-cli temp-dir tests. *Checkpoint with step 5.* +5. Extend `cli.ts` `consolidate` (gather/propose/apply); TDD `consolidate-cli.test.ts` (SC5) incl. candidate validation (shape + no-newline), no-confirm refusal, `--gather` body-read, `init`/`ingest`/`published` regression. *Checkpoint: `feat(linkedin-studio): SB-S2 brain consolidate CLI [skip-docs]`. On-failure: revert `cli.ts` + the cli test.* +6. Edit `session-start.mjs` (import `readdirSync`; UNCONDITIONAL scaffold-ensure near the migration block; consolidation-due nudge in the reminders block, `\\n` idiom); author `session-start-brain-consolidation.test.mjs` **via Bash-heredoc** (pathguard-safe); run `node --test hooks/scripts/__tests__/` (the WHOLE hook suite, since the gate doesn't — SC6 + no hook regression). If `hooks.json` shape unchanged (only script body edited) no recompile; else `compile-hooks.py` + verify `--check`. *Checkpoint: `feat(linkedin-studio): SB-S2 session-start scaffold-ensure + consolidation nudge [skip-docs]`. On-failure: `git checkout hooks/scripts/session-start.mjs` (do NOT leave the hook emitting invalid JSON) + halt.* +7. Write `docs/second-brain/consolidation-loop.md` (CLI usage, engine rules, the candidate-file schema as the session↔engine contract, the operator-gate, honest limits incl. no-reader-until-S3 + the loop's value-depends-on-extraction caveat). *Checkpoint: `docs(...)`.* +8. Bump `scripts/test-runner.sh` `BRAIN_TESTS_FLOOR` to the real `tests N` (assert the brain suite RAN — `BR_TESTS` non-empty, not warn-skipped). **`ASSERT_BASELINE_FLOOR` unchanged (+0): SB-S2 adds NO new test-runner section** (the hook test runs separately, the new TS tests run inside the existing brain `npm test` which contributes one unchanged `pass()` line) — confirm by reading the printed `TOTAL_CHECKS` is unchanged. Run the FULL gate green AND the hook suite green. *Circuit-breaker: no red gate committed.* *Checkpoint: `chore(...): SB-S2 gate brain floor`.* +9. Flip `architecture.md` SB-S2 row → landed (exact edit: the `| **SB-S2 — Evolution loop** |` row → add the `✅ *landed*` marker, matching SB-S0/S1 row style); persist brief + plan. **Commit; confirm before push (PUBLIC `open/`), window; STATE.md gitignored.** Conditional 0.5.2 release per Q2 (tag + catalog via `release-plugin.mjs`). + +## 7. Scope fence (echo — SB-S2 does NOT) + +No journal-capture · no new agent (session extracts) · no `profile.md` reader (S3) · no supersede (keep-both only; S3) · no cross-silo id threading (S3) · no `content-history.md` retirement · no AI at session-start (deterministic nudge only) · no auto-apply (only `--apply --confirm` writes profile.md) · no new hook `.mjs` (edit existing session-start.mjs) · no new seam function · no connector (S4) · no GUI. + +## 8. Known limits / deferred (honest) + +- **The loop's VALUE depends on the session's candidate extraction** — the engine guarantees only the mechanics (threshold/conflict/decay/provenance-gate). Garbage candidates → a garbage diff (but the operator gate + the candidate-schema validation catch shape errors, not quality). Documented in `consolidation-loop.md`. +- **profile.md has no reader until S3** — S2 evolves an artifact nothing consumes yet; the value is deferred compounding. Stated honestly. +- **Evidence inflates only on genuinely-new candidates** — `--gather` is delta-gated (published since `last_run`), so a re-run after `--apply` surfaces nothing; the engine itself would bump again given the same candidate, but the loop never re-feeds one. Documented. +- **No supersede / no stale-fact demotion in S2** — conflicts keep both; stale facts are flagged, never auto-removed (operator/ S3). +- **Session-start nudge is new-published-count only** — no per-fact staleness at session-start (no zero-dep profile parser). + +## 9. Risks + +- **Duplicate-id corruption (was plan-critic B1) — CLOSED:** primary ids (`mintEntityId(key)`) and conflict-alt ids (`mintContentId('observed-alt:'+key+'::'+value+'::'+date)`) are byte-distinct, so no two facts share an id; the bump/promote target is unambiguous and `parseProfile`/`serializeProfile` stays well-formed. Pinned by SC1e (distinct ids) + SC3 (round-trip) + SC4 (idempotency). +- **`--gather` data source (was plan-critic B2) — CLOSED:** gather reads the published files directly (`parsePublishedRecord` → body), not `listPublished()` (which lacks body/`captured_at`); filtered by `published_date > last_run`. +- **Hook tests not gate-run (was plan-critic B3):** `scripts/test-runner.sh` runs NO hook test, so SC6 is verified by an explicit `node --test hooks/scripts/__tests__/` step (the whole suite), NOT the structure gate. SC7 reworded; no false "hook tests pass at the gate" claim. **Flagged (not done):** wiring the hook suite into test-runner.sh is a separate hardening opportunity. +- **Engine rule ambiguity (conflict vs bump):** deterministic value-equality decides bump-vs-conflict; keep-both is the safe default; each rule pinned by its own SC1x test before the CLI wires it. +- **Sidecar reachability (was the brief-review blocker) — CLOSED:** both `--apply` (dataRoot) and session-start (getDataRoot) resolve the SAME data root → the sidecar closes the loop; SC6 sets HOME + data root to separate temp dirs (the trends-test pattern) to prove it. +- **Zero-dep hook discipline:** no tsx, no profile parse, bounded readdir, `\\n` idiom; scaffold-ensure runs unconditionally (fresh-install path); SC6 asserts valid JSON output + the nudge rendering. +- **Gate floors:** BRAIN floor = actual post-impl count (assert suite RAN, not warn-skipped — step 1 fail-loud on missing tsx); `ASSERT_BASELINE_FLOOR` +0 (no new test-runner section). Confirm `TOTAL_CHECKS` unchanged at step 8. +- **Pathguard:** session-start.mjs is an existing file → Edit (safe). The new hook TEST `.mjs` under `hooks/scripts/__tests__/` is authored via **Bash-heredoc** — safe whether or not the active Category-6 guard matches `__tests__/` (the verified llm-security `.mjs` guard matches `hooks/scripts/.*\.mjs`; heredoc sidesteps it regardless). diff --git a/docs/second-brain/plan-sb-s3a.md b/docs/second-brain/plan-sb-s3a.md new file mode 100644 index 0000000..ffd0a3b --- /dev/null +++ b/docs/second-brain/plan-sb-s3a.md @@ -0,0 +1,83 @@ +# Plan — SB-S3a: wire the first profile.md READER (`strategy-advisor`) + +> Brief: `docs/second-brain/brief-sb-s3a.md` (APPROVE, FIXes folded). Slice: SB-S3a. +> Operator confirmed: **`strategy-advisor`**, READ-only, **manual SC4/SC5** (no behavioural harness). +> Order is TDD: the failing lint check lands BEFORE the agent edit (iron law). + +## Goal + +`strategy-advisor` consumes `brain/profile.md` as *evidence-to-test*, guarded by a deterministic wiring lint. No write path, no parser, no tributary/hook change. Smallest end-to-end proof that the second brain feeds generation. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | Why | +|------|--------|-----| +| `scripts/test-runner.sh` | **NEW Section 16d** (2 unconditional checks) + bump `ASSERT_BASELINE_FLOOR` 78→80 + extend floor-history comment + header enumeration | SC1/SC2/SC6 — the TDD anchor | +| `agents/strategy-advisor.md` | add `brain/profile.md` to Step 0 Load Context + a short consumption subsection (anti-sycophancy literal + graceful-absence) | SC1/SC2/SC5 — the wiring itself | +| `docs/second-brain/consolidation-loop.md` | **append** an "SB-S3a landed" reader-status line (do NOT rewrite the existing :67 contract prose) | doc reconciliation | + +**Not touched (scope fence):** any `scripts/brain/**` TS · any `hooks/scripts/**` · any tributary JSON · `architecture.md`/`brief.md` (arc-level "motor-only until S3" stays true until all of S3 lands) · `content-optimizer.md` (second reader, not S3a) · CLAUDE.md agent table (responsibility text unchanged — the count/model lint is unaffected; a note is optional and deferred to avoid churn). + +## Step 1 — (RED) Lint: Section 16d + floor bump + +Insert a new section after Section 16c (`test-runner.sh:780`, before Section 17 at `:782`), modelled byte-for-byte on 16c's idiom (literals + `*_wired()` + non-vacuity self-test + real-file grep with `grep -qF`): + +- **Literals:** `READER_LIT='brain/profile.md'`, `ANTISYC_LIT='evidence to TEST'`. +- **`reader_wired()`** — text wired iff BOTH literals present (`grep -qF` each; echo twice as 16c does). +- **Check A (self-test, unconditional):** a fully-wired probe (`"reads brain/profile.md as evidence to TEST"`) MUST be detected; three under-wired probes MUST be rejected: + 1. `"reads brain/profile.md but never frames how to weigh it"` (has reader lit, missing anti-syc) + 2. `"treats facts as evidence to TEST but names no source file"` (has anti-syc, missing reader lit) + 3. `"reads profile/user-profile.md and tests the evidence"` (decoy: legacy path + lowercase "evidence" — must NOT match either exact literal) + → `pass`/`fail` "profile-reader self-test: full wiring detected; 3 under-wired forms rejected". +- **Check B (real-file grep, unconditional):** `grep -qF "$READER_LIT" agents/strategy-advisor.md && grep -qF "$ANTISYC_LIT" agents/strategy-advisor.md` → `pass` "strategy-advisor.md wired to brain profile reader (names '…profile.md', frames 'evidence to TEST')" else `fail`. +- **Header enumeration (`:19-35`):** add a sentence — "the brain profile-reader guard (SB-S3a: `strategy-advisor` names `brain/profile.md` AND carries the anti-sycophancy literal `evidence to TEST`, with a non-vacuity self-test) in Section 16d". Range "Sections 8–18" unchanged (16d is in range). +- **Floor (`:849-865`):** `ASSERT_BASELINE_FLOOR=78` → `80`; extend the history comment: "+2 for SB-S3a's two UNCONDITIONAL Section-16d checks (profile-reader self-test + strategy-advisor wiring grep) = 80." Both new checks are deps-free → they lift the deps-absent minimum, so the +2 bump is correct (NOT pinned to deps-present TOTAL). + +- **Non-collisions (verified by plan-critic):** the new prose carries no algorithm-magnitude token (Section 8 stat-consistency safe) and no `Significant?(` column (Section 11 render-chain safe); `brain` is absent from Section 13's `BARE_DATA` regex (`test-runner.sh:514`) AND the edit uses the exempt `${LINKEDIN_STUDIO_DATA:-…}` external form → Section 13 data-dir guard safe. Agent count (19) + `strategy-advisor` model/frontmatter unchanged → Sections 2/9/10 unaffected. + +**RED gate:** `bash scripts/test-runner.sh` → Check A passes, **Check B FAILS** (agent not yet wired), floor check passes (TOTAL 95 ≥ 80) → exit 1. This is the required failing test. + +## Step 2 — (GREEN) Wire `strategy-advisor.md` + +1. **Step 0 Load Context** (`strategy-advisor.md:41-47`): insert one line **inside** the fenced code block, immediately BEFORE the closing ``` fence (currently `:47`) — i.e. the new path becomes `:47` and the fence shifts to `:48`. Do NOT place it after the fence (that breaks the code block). The line: + `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/brain/profile.md → evolving second-brain profile (consolidated, evidence-weighted facts about the user)` +2. **New subsection** immediately after the Load Context block (after `:49`), e.g. `### Consuming the evolving profile (brain/profile.md)`: + - Two layers: `## Static` (stable, high-confidence) vs `## Dynamic` (emerging); each fact carries `evidence_count` + `last_seen` — weight by strength/recency. + - **Anti-sycophancy (binding):** "Treat every profile fact as **evidence to TEST**, not flatter — counter-pressure it against analytics/state each time (e.g. *the profile says you lean X, but your imports show Y — test that*). The profile informs; it never dictates or flatters." ← carries the exact literal `evidence to TEST`. + - **Graceful absence:** "If `brain/profile.md` is missing or empty (fresh installs have no brain yet), proceed silently on the other context sources — no error, no note about a missing profile." + +**GREEN gate:** `bash scripts/test-runner.sh` → Check A + Check B pass, FAIL=0, **95/0/0**, brain floor 82 untouched. + +## Step 3 — Doc reconciliation + +`consolidation-loop.md:67-68` currently reads *"`brain/profile.md` has no reader yet … wiring content agents/commands to consume it is SB-S3."* — which becomes **false** the moment S3a lands a reader. **No lint guards `docs/`** (Section 11 scans `references/ commands/ skills/ hooks/prompts/ agents/ assets/` only, and `PROP_FORBIDDEN` matches only the significance-verdict column — verified by plan-critic; `grep consolidation-loop test-runner.sh` = 0 hits). So **EDIT the bullet** (it lives under "## Honest limits" — keep it a limit) to the true partial state, e.g.: +"**`brain/profile.md` has one reader as of SB-S3a.** S2 evolved the profile motor-only; SB-S3a wired the first consumer — `strategy-advisor` reads it as *evidence-to-test*. Broader consumption (more content agents, a hook-level digest) remains later S3 work, and the profile is still mutated only via `brain consolidate --apply --confirm`." +This removes the stale claim rather than appending a self-contradicting line beneath it. + +## Step 4 — Behavioural verification (manual, recorded — SC4/SC5) + +Agent-prompt behaviour is not unit-testable (operator-accepted). Honest procedure, result recorded in STATE/changelog at land: +- **SC4 (graceful absence):** with no/empty `brain/profile.md` at the data-root, the wiring degrades silently — verified by inspection of the subsection's graceful-absence clause + (best-effort) a strategy-advisor run on the empty real root → no missing-file noise. +- **SC5 (read-back works):** seed a temp profile (e.g. `LINKEDIN_STUDIO_DATA=<tmp> brain init` + a couple of hand-written facts) and run the agent → confirm ≥1 fact surfaces as tested evidence, counter-pressured, not parroted. If the harness cannot fully exercise a per-call data-root, record that honestly and fall back to inspection of the wiring + framing (do NOT overclaim a behavioural pass we did not run). + +## Step 5 — Land + +STATE "Telling" + "👉 NESTE" updated (S3a done → S3b/c/d remain); commit (Conventional, `[skip-docs]` on the code/feat parts; brief+plan+consolidation-loop are docs → judge suffix per the mixed change); **push only inside the window** (`date '+%u %H:%M'` first; `origin` is the PUBLIC `open/` remote → confirm before push). No version bump (S3a is additive within v0.5.2 dev; a release bump is a separate decision). + +## Verification (testable) + +| SC | Check | Command | Expected | +|----|-------|---------|----------| +| SC1+SC2 | reader wired + literal | `bash scripts/test-runner.sh` | Section 16d Check B passes | +| SC3 | gate green, brain floor intact | same run | 95/0/0; `BRAIN_TESTS_FLOOR=82` unchanged; hook suite (separate runner) untouched | +| SC6 | assertion floor honoured | same run | "assertion-count anti-erosion: 95 … >= floor 80" passes | +| (red proof) | failing-test-first | gate run BETWEEN Step 1 and Step 2 | exit 1, exactly Check B fails | +| SC4 | graceful absence | manual run, empty root | clean output, no missing-file noise | +| SC5 | read-back | manual run, seeded profile | ≥1 fact surfaced as evidence-to-test (or honest fallback recorded) | + +## Risks + +- **R1 — vacuous lint.** Mitigated by the 16c-style non-vacuity self-test with the legacy-path/lowercase decoy. +- **R2 — floor false-fail on fresh clone.** Mitigated: both new checks are deps-free (lift the deps-absent minimum), so +2 → 80 is the correct floor; not pinned to deps-present TOTAL. +- **R3 — behavioural overclaim.** Mitigated by Step 4's honest-fallback clause (verifiseringsplikt): never report a behavioural pass we did not run. +- **R4 — stale doc claim.** `consolidation-loop.md:67` ("no reader yet") becomes false once S3a lands; **no automated guard protects `docs/`** (Section 11 scans non-docs dirs + only the significance column — verified). Mitigated by EDITING :67 to the true partial state (one reader, broader consumption deferred) within the "Honest limits" framing — by discipline, not by a blind append. diff --git a/docs/second-brain/plan-sb-s3b.md b/docs/second-brain/plan-sb-s3b.md new file mode 100644 index 0000000..5ce9dc0 --- /dev/null +++ b/docs/second-brain/plan-sb-s3b.md @@ -0,0 +1,101 @@ +# Plan — SB-S3b: the supersede arm + +> Brief: `docs/second-brain/brief-sb-s3b.md`. Slice: SB-S3b. +> **Operator-confirmed (2026-06-23):** temporal-update→supersede ONLY · winner→dynamic (re-earn) · minimal `supersedes?: string` · retain superseded as audit. +> **Light-Voyage:** brief-review APPROVE-WITH-FIXES (8 folded); plan-critic **REVISE → 2 BLOCK + 6 FIX folded below**; scope-guardian **ALIGNED** (0 creep/0 gap, 1 minor SC9 reconciliation folded). +> Order is TDD: failing engine unit tests land BEFORE the engine logic (iron law); each rule pinned before the CLI is wired (`brief-sb-s2.md:115`). + +## Goal + +The consolidation engine can **retire** a fact via an operator-confirmed, explicitly-signalled supersede: a temporal-update candidate flips the stale fact to `status: "superseded"` (re-minted to an archival id, **replaced in place**, retained as audit) and installs the new winner under the canonical key-id. Pure, idempotent, deterministic; rides the existing `--propose`/`--apply --confirm` gate; superseded facts never leak back into gather or decay; no grammar change; fully unit-testable. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|------|--------|-----| +| `scripts/brain/src/consolidate.ts` | `Candidate` (+`supersedes?: string`, 33-39) · `ProfileDiff` (+`supersedes: SupersedeOp[]`, 41-47; `SupersedeOp` carries `provenance`) · `proposeDiff` routing fork before keep-both (~120), **value-guarded** + intra-batch `touched`-guard · `applyDiff` **replace-in-place** supersede branch, state-checked, ordered (144-162) · decay scan excludes `superseded` (132-134) · local `archivalId()` helper · engine docstring `:11` | SC1–SC5,7,8,11,12 | +| `scripts/brain/src/cli.ts` | `renderDiffMd` (+`## Supersessions (old → new)`, appended last via `section()`, 132-145) · `validateCandidates` (+optional single-line `supersedes`, 147-157) · **`--gather` profileFacts filtered to `status === "active"`** (`:174`) | SC6, SC10 | +| `scripts/brain/tests/consolidate.test.ts` | unit tests SC1, SC2, SC3a, SC3b, SC4, SC5, SC7, SC8, SC11, SC12 | engine | +| `scripts/brain/tests/consolidate-cli.test.ts` | CLI tests SC6 (render + fidelity non-vacuity + `--confirm` gate), SC10 (gather active-only) | CLI | +| `scripts/test-runner.sh` | bump `BRAIN_TESTS_FLOOR` 82→(82+N) + breakdown comment (`:716`); **`ASSERT_BASELINE_FLOOR` UNCHANGED at 80** | SC9 | +| `docs/second-brain/consolidation-loop.md` | reconcile "no supersede" claims (`:34`, `:71`) to true state + honest-limit line (engine guarantees mechanics; temporal-update-vs-contradictory is session+operator judgement — the operator gate is the only classification net) | doc | + +**Not touched (scope fence):** `types.ts` (`FactStatus` already has `superseded`) · `profile.ts` (grammar already round-trips `superseded`) · `id.ts` (reuse `mintEntityId`/`mintContentId`; `archivalId` is local to `consolidate.ts`) · `ingest.ts`/`scaffold.ts`/`dataRoot.ts` · `hooks/scripts/**` · `agents/**` (S3a reader unchanged) · `architecture.md` (its `:69` design promise is fulfilled; `:79` "S2 had no supersede" stays historically true — arc-status note deferred, not load-bearing) · condition-dependent/distractor branches (Q-SCOPE). + +## Step 0 — pre-flight (verify baseline, no edits) + +`(cd scripts/brain && npm install)` if `node_modules` absent. Confirm `(cd scripts/brain && npm test)` = **82/82** and `bash scripts/test-runner.sh` green (95/0/0, `BRAIN_TESTS_FLOOR=82`, `ASSERT_BASELINE_FLOOR=80`). Confirm `renderDiffMd`/`--gather`/CLI-propose coverage lives in `consolidate-cli.test.ts` (host SC6/SC10 there). Re-read `applyDiff` (`:144-162`) to see how promotions are removed via `filter` (`:156`) — the supersede branch mirrors that. + +## Step 1 — (RED) types + failing tests + +1. **Type scaffolding only** (suite still compiles, existing 82 green): + - `Candidate` (`:33-39`): `supersedes?: string`. + - `ProfileDiff` (`:41-47`): `supersedes: SupersedeOp[]`, with `interface SupersedeOp { oldId: string; oldValue: string; newId: string; newValue: string; provenance: Provenance }`. + - `proposeDiff` returned object: initialise `supersedes: []` (no routing yet). + - `applyDiff`: unchanged for now (ignores the new array). +2. **Add failing unit tests** (mirror the SC1e keep-both test at `:78-91`): SC1, SC2, SC3b, SC4, SC8, SC11, SC12 → expected FAIL; SC3a, SC5, SC7 → guards (may pass). SC6, SC10 in `consolidate-cli.test.ts` → FAIL. + +**RED gate (single, unambiguous):** run **`(cd scripts/brain && npm test)` directly** → the new behavioural tests fail, the 82 existing pass. **Do NOT run `bash scripts/test-runner.sh` at RED** — Section 16b (`test-runner.sh:717`) fails the whole gate on a non-zero brain exit, which is design-noise, not signal. The direct brain-suite RED is the failing-test proof. (The gate is run GREEN only, after Step 4.) + +## Step 2 — (GREEN) engine logic + +1. **`proposeDiff` routing fork** — BEFORE the keep-both branch (`~:120`), after the ai-draft reject (`:105`, so a supersede can never carry ai-draft provenance — confirmed): + - If `c.supersedes` is set: `targetId = mintEntityId({kind:"observed", key: c.supersedes})`; `target = byId.get(targetId)`. + - Route to `supersedes[]` **only when** `target` exists AND `target.status === "active"` AND `target.value !== c.value` AND `targetId` not `touched` this batch. **The `target.value !== c.value` guard is load-bearing** (plan-critic BLOCK 2): a re-sent stale signal whose target already holds the candidate's value is NOT a supersession — it falls through to the ordinary bump path, so SC3a holds. + - On route: push `{ oldId: targetId, oldValue: target.value, newId: mintEntityId({kind:"observed", key: c.key}), newValue: c.value, provenance: c.provenance }`; `touched.add(targetId)` + `touched.add(newId)` (intra-batch guard — a later same-key candidate degrades; winner id reserved). Do **not** also push to `additions`. `continue`. + - Otherwise (no target / not active / value already equal / touched): fall through to the ordinary add/bump path (SC4 graceful no-target, SC8 intra-batch). A supersede only retires a fact present at batch start (`byId` built once from `current`). +2. **`archivalId()` helper** (local to `consolidate.ts`): `archivalId(oldId, oldValue) = mintContentId(\`superseded:${oldId}::${oldValue}\`)`. Seeding with the retired fact's **pre-archival id** (unique among active facts by the no-dup invariant) makes the archival id collision-free in practice (plan-critic NICE 9 — stronger than a key+date seed). +3. **`applyDiff` supersede branch — REPLACE IN PLACE, not copy** (`:144-162`; plan-critic BLOCK 1). Explicit ordering: + - (a) Compute `passing = diff.supersedes.filter(s => { const f = factById(current, s.oldId); return f && f.status === "active" && f.value === s.oldValue })`. The **`f.value === oldValue` state-check** is the idempotency + stale-diff guard: on re-apply the winner holds `oldId` with `newValue ≠ oldValue` → not passing → skipped (SC3b); a profile mutated under the diff → skipped (R3). Build `retireById = new Map(passing.map(s => [s.oldId, s]))`. + - (b) When mapping `current.static` and `current.dynamic`: a fact whose `id ∈ retireById` is **transformed in place** to `{ ...f, id: archivalId(f.id, f.value), status: "superseded" }` (keeps its layer + its OWN `provenance`/`first_seen` — a `published→human` update must NOT rewrite the archived fact's provenance, plan-critic FIX 3). It is **excluded from bump/promote** (a superseded fact is never bumped or promoted — resolves the ordering hazard, plan-critic FIX 6: supersede-wins over a same-id bump/promote in one diff). + - (c) Append each winner `{ id: newId, value: newValue, first_seen: today, last_seen: today, evidence_count: 1, provenance, status: "active" }` to **dynamic** (Q-LAYER re-earn). Because (b) vacates `oldId` and the winner re-occupies it (when `key===supersedes`), there is exactly one fact at the canonical key-id (the winner) + one archival fact — **no duplicate id**. Winner `first_seen: today` is intentional (a genuinely new observation; the original first-observation date lives on the archived copy — plan-critic FIX 8a). + - `applyDiff` stays **pure** (new `ProfileDoc`); existing bump/promote/addition handling preserved. +4. **Decay scan excludes superseded** (`consolidate.ts:132-134`; plan-critic FIX 8c): add `f.status !== "superseded"` to the `current.dynamic` decay filter so a retained dynamic archival fact never throws perpetual `staleFlags`. (Static archival facts are already decay-exempt — dynamic-only scan.) + +**GREEN gate:** `(cd scripts/brain && npm test)` → all engine SCs pass; existing 82 pass. + +## Step 3 — (GREEN) CLI render + validation + gather filter + +1. `renderDiffMd` (`cli.ts:132-145`): add a `## Supersessions (old → new)` section, **appended LAST** (after Stale, `:143`) via the existing `section()` helper, **only when `supersedes.length > 0`** — one line per entry `- \`<oldValue>\` → \`<newValue>\``. Zero supersessions → no section → byte-identical to today (plan-critic FIX 5). +2. `validateCandidates` (`:147-157`): if `supersedes` present, assert non-empty single-line string; absent is valid. +3. **`--gather` active-only filter** (`cli.ts:174`; plan-critic FIX 8b): `profileFacts` emitted to the extraction session is filtered to `status === "active"` — superseded archival facts must NOT be re-presented as live context (else the slice re-introduces the retired values it exists to retire). One-line filter. +4. **CLI tests** (`consolidate-cli.test.ts`): SC6 — (a) `--propose` writes one `## Supersessions` line per entry; (b) **render-fidelity non-vacuity** — 2 entries → 2 lines, 0 entries → no section (decoy); (c) `--apply` without `--confirm` refuses (exit 1, no write); with `--confirm` writes superseded status. SC10 — `--gather` output excludes superseded facts. + +## Step 4 — gate floor + doc reconciliation + +1. `test-runner.sh:716`: `BRAIN_TESTS_FLOOR` 82 → **82 + N** (N = the EXACT counted new brain tests from Steps 1–3 — set after counting, never guessed). Extend the breakdown comment `… + SB-S2 19 + SB-S3b N`. **`ASSERT_BASELINE_FLOOR` stays 80** — no new *unconditional `test-runner.sh` structure-lint* check is added; the render-fidelity check is a brain-suite test counted by `BRAIN_TESTS_FLOOR`. (This reconciles brief SC9/§3.7 "ASSERT bump" wording: the render-fidelity check lives in the brain suite, so `BRAIN_TESTS_FLOOR` is the only floor that moves — see SC9 below.) +2. `consolidation-loop.md`: edit `:34` + `:71` to the true state — temporal-update supersede landed in SB-S3b (explicit signal, operator-gated, replace-in-place, audit-retained); contradictory still keep-both; decay still advisory. Add the honest-limit line (engine guarantees mechanics; the temporal-update-vs-contradictory call is session+operator judgement). +3. `consolidate.ts:11` docstring: update the "no supersede in S2 — that's S3" line to note S3b implements temporal-update supersede. + +## Step 5 — Land + +STATE "Telling" + "👉 NESTE" updated (S3b done → S3c/S3d remain); brief+plan committed as docs alongside code. Commit (Conventional; `[skip-docs]` on code/feat; brief+plan+consolidation-loop+docstring are docs → feat-dominant `[skip-docs]`). **Push only inside the window** (`date '+%u %H:%M'` first) and **confirm before push** (`origin` is PUBLIC `open/`). No version bump (additive within v0.5.2 dev). + +## Verification (testable) + +| SC | Check | Expected | +|----|-------|----------| +| SC1 | routing | supersede candidate (target value differs) → 1 `supersedes[]` entry, 0 keep-both | +| SC2 | mutation+ids | winner id=`mintEntityId(c.key)`, active, ev=1, value=new; retired→`status:superseded`, id=archival≠key-id, OWN provenance/first_seen kept; both present (no dup at key-id) | +| SC3a | propose-idempotent | post-land re-propose (value now matches) → 0 `supersedes[]`, 1 bump — incl. when a stale signal persists (value-guard) | +| SC3b | apply-idempotent | re-apply same diff → value-match skip → no double winner, no re-supersede, no dup id | +| SC4 | graceful no-target | signal + no active fact at key-id → plain add, no throw | +| SC5 | round-trip | `parse(serialize(applyDiff(...)))` deep-equals `applyDiff(...)` (superseded fact from the real path) | +| SC6 | CLI render + gate | `## Supersessions` line/entry; 0 entries → no section (byte-identical); `--apply` needs `--confirm` | +| SC7 | no regression | contradictory still keep-both; bump/promote/published-only unchanged | +| SC8 | intra-batch | two candidates same key / supersede-a-batch-created-fact → deterministic, no dup id | +| SC9 | gate green | `bash scripts/test-runner.sh` green; brain suite ≥ new `BRAIN_TESTS_FLOOR`; **`ASSERT_BASELINE_FLOOR` unchanged at 80** | +| SC10 | gather active-only | `--gather` output excludes `status:superseded` facts | +| SC11 | decay excludes superseded | a retained dynamic superseded fact throws no `staleFlags` | +| SC12 | supersede-wins ordering | a diff that bumps/promotes AND supersedes the same id → supersede wins (superseded fact not bumped/promoted) | +| (red proof) | failing-test-first | `(cd scripts/brain && npm test)` BETWEEN Step 1 and Step 2 → new SCs fail, 82 pass | + +## Risks + +- **R1 — id corruption (plan-critic BLOCK 1).** `applyDiff` must **replace in place** (filter+map, mirroring promotions `:156`), never copy — else two facts share the canonical key-id. Idempotency/stale guard keys off `oldId` AND `oldValue`. Pinned by SC2 + SC3b. +- **R2 — propose self-supersede (plan-critic BLOCK 2).** A persisted signal whose target already holds the candidate's value must NOT route to supersede (value-guard); else spurious archival churn every run. Pinned by SC3a. +- **R3 — superseded facts leak back.** `--gather` (FIX 8b) and decay (FIX 8c) must filter `status:active`/exclude `superseded` — else the slice regresses its own goal / spams stale flags. Pinned by SC10 + SC11. +- **R4 — stale diff vs mutated profile.** Value-matched state-check skips. Pinned by SC3b/SC4. +- **R5 — render JSON↔MD divergence.** SC6 render-fidelity non-vacuity (every entry → a line; 0 → no section). +- **R6 — floor-count drift.** `BRAIN_TESTS_FLOOR` bumped by the EXACT counted N; `ASSERT_BASELINE_FLOOR` deliberately unchanged at 80 (no new unconditional structure-lint check). Verified by the green gate. +- **R7 — unguarded doc.** No lint protects `docs/`; `consolidation-loop.md` reconciled by discipline (as the S3a `:67` edit). diff --git a/docs/second-brain/plan-sb-s3c.md b/docs/second-brain/plan-sb-s3c.md new file mode 100644 index 0000000..f30bd1e --- /dev/null +++ b/docs/second-brain/plan-sb-s3c.md @@ -0,0 +1,114 @@ +# Plan — SB-S3c: cross-silo id-threading + +> Brief: `docs/second-brain/brief-sb-s3c.md`. Slice: SB-S3c. +> **Status:** **LANDED** (operator go 2026-06-23) — brain 113/113, gate 95/0/0, `BRAIN_TESTS_FLOOR` 94→113. **Design:** hub-side threading — `specifics`/`trends` ids onto the brain's published record + a pure analytics resolver. Tributaries untouched. +> **Light-Voyage:** brief-review APPROVE-WITH-FIXES (5 FIX folded into the brief; 3 RISK carried here). plan-critic **REVISE → 1 BLOCK + 4 MAJOR + 4 MINOR folded** (Step 1 compile-fixup · Step 2.2 non-throwing parse reader · Step 2.3 producer guard · Step 3.3 floor rationale · Step 3.4 deterministic row-sort · Step 3.5 analytics-root caveat · Step 4.2 `runIngest` signature+call-site · Step 4.3 full-record load + desc sort · R-G sub-count). scope-guardian **ALIGNED** (0 creep / 0 gap). +> Order is TDD: failing brain-suite tests land BEFORE the code (iron law); each rule pinned before the CLI is wired. + +## Goal + +A published post records the **raw material it was built from** (`specifics`/`trends` ids, additive + backward-compatible on the post record), and a **pure assembler** joins published-record ↔ analytics-row (by normalized title-prefix + date, with honest confidence tiers) so the north-star query — *which raw material actually performs?* (`specific → post → measured analytics`, `architecture.md:17`) — is **assemblable**. All four tributary schemas stay untouched; the only new disk surface is a **read-only** `brain assemble`. Fully unit/CLI-testable with fixtures. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | SC | +|------|--------|-----| +| `scripts/brain/src/ingest.ts` | `PublishedRecord` (+`specifics: string[]` +`trends: string[]`, `:27-40`) · `serializePublishedRecord` append the two lines **after `source:`**, **omit-empty** (`:50-58`) · `parsePublishedRecord` read them via a new list-scalar reader, **12-hex-validate each** (reuse the `:82` guard idiom), absent→`[]` (`:75-99`) · `ingestText` (+optional `specifics?`/`trends?`, `:165-180`) | SC1,SC2,SC3,SC4,SC11 | +| `scripts/brain/src/assemble.ts` | **NEW.** Pure `assemblePostGraph({records, analytics})` + local `normalize()` + `matchRow()` (tiers) + minimal input type `AnalyticsRowInput` + output types · thin read-only `loadAnalyticsRows()` IO (inline raw-JSON read of `dataRoot('analytics/posts')/*.json` → `AnalyticsBatch.posts[]`; **never** imports the analytics package) | SC6,SC7,SC8 | +| `scripts/brain/src/cli.ts` | `collectRepeated(args,key): string[]` helper (leaves `parseFlags` **untouched** — SC12 by construction) · **change `runIngest` signature `(flags)`→`(rest, flags)` AND the `main` dispatch `runIngest(flags)`→`runIngest(rest, flags)`** (`:92`, `:238` — BLOCK 1), thread `collectRepeated(rest,"specific")`/`"trend"` · `runAssemble(flags)` (read-only print, full-record load) · dispatch `+ if (command==="assemble")` (`:240`) · `usage` text (+`assemble`, +ingest flags) | SC5,SC9,SC12 | +| `scripts/brain/tests/ingest.test.ts` | SC1 (round-trip w/ ids) · SC2 (byte-identical old-record fixture) · SC3 (parse + producer malformed-id throw) · SC4 (producer threading) · SC11 (dedup/collision/published-only regress) | unit | +| `scripts/brain/tests/assemble.test.ts` | **NEW.** SC6 (high) · SC7 (none / below-floor / ellipsis-near-miss / different-date→low) · SC8 (pure+total) | unit | +| `scripts/brain/tests/cli.test.ts` | SC5 (repeatable flags) · SC9 (read-only assemble print + missing-dir degrade) · SC12 (single-value flags unregressed) | cli | +| `scripts/test-runner.sh` | bump `BRAIN_TESTS_FLOOR` 94→(94+N) + breakdown comment (`:716`); **`ASSERT_BASELINE_FLOOR` UNCHANGED at 80** | SC10 | +| `docs/second-brain/consolidation-loop.md` · `architecture.md` · `scripts/brain/src/id.ts` | reconcile status to true state: S3c threads the cross-silo graph; `id.ts:6-7` "SB-S3 will thread" → "S3c threads (post→specifics/trends + analytics resolver)"; `architecture.md:80` SB-S3 row note | doc | +| `CLAUDE.md` · STATE | telling/counts: brain test count, S3c landed line; reference-doc count unchanged (briefs aren't counted reference docs) | doc | + +**Not touched (scope fence):** `scripts/trends/**` · `scripts/specifics-bank/**` · `scripts/analytics/**` (READ-only, via inlined JSON — no import, no schema/field change, no base-36-id reconciliation) · `hooks/scripts/**` (no `.mjs`; pathguard untouched) · `agents/**` (no reader wired this slice) · `scripts/brain/src/{consolidate,types,profile,scaffold,dataRoot}.ts` (no consolidation/grammar/scaffold change) · `brain/profile.md` grammar (C-1 — the 7th-token bump stays OUT) · `parseFlags` (untouched — `collectRepeated` is additive) · the plugin command surface (29 unchanged — `brain assemble` is a TS-CLI subcommand, not a `/linkedin:*` command). + +## Step 0 — pre-flight (verify baseline, no edits) + +`(cd scripts/brain && npm install)` if `node_modules` absent. Confirm `(cd scripts/brain && npm test)` = **94/94** and `bash scripts/test-runner.sh` green (95/0/0, `BRAIN_TESTS_FLOOR=94`, `ASSERT_BASELINE_FLOOR=80`). Re-read `ingest.ts:50-99` (serialize/parse round-trip), `cli.ts:44-60,92-116,233-245` (parseFlags/runIngest/main). Confirm **no structure-lint** in `test-runner.sh` asserts the brain-CLI subcommand set (so `assemble` adds no lint debt) and that `specifics-bank/src/bank.ts:52` `normalizeContent` is the idiom to **copy locally** (not import). + +## Step 1 — (RED) types + failing tests + +1. **Type scaffolding + compile-fixups** (suite still compiles, 94 green): + - `PublishedRecord` (`ingest.ts:27-40`): `+ specifics: string[]; + trends: string[];` (required). + - **Compile-fixup (plan-critic MINOR 4 — REQUIRED fields, so existing full-`PublishedRecord` literals must gain them or the suite won't compile):** add `specifics: [], trends: []` to the literals at `tests/ingest.test.ts:11` (`baseRec`) and `tests/publish.test.ts:78` (`squatter`). Tests that build records via `ingestText`/`parsePublishedRecord` need no change. + - `ingestText` opts (`:165-180`): `+ specifics?: string[]; + trends?: string[];`; record literal sets `specifics: opts.specifics ?? []`, `trends: opts.trends ?? []`. + - **`parsePublishedRecord` defaults the new fields to `[]` from the start** (absent → `[]`) — so existing round-trips (records carrying `[]`) stay **green** at Step 1 (serialize omits empty, parse sets `[]`, deep-equal holds). The *new behaviour* (emit non-empty, read present values, validate, producer-throw) is Step 2 RED→GREEN. + - `assemble.ts`: stub `assemblePostGraph` returning `[]` + the types; `normalize`/`matchRow` declared. +2. **Add failing tests** → expected FAIL: SC1 (parse must read **non-empty** lines), SC2 (byte-identical fixture — fails until omit-empty serialize), SC3 (validate-throw), SC4 (producer threading with non-empty ids), SC6/SC7/SC8 (assembler logic), SC5/SC9/SC12 (CLI). Existing 94 (carrying `[]`) PASS. + +**RED gate (single, unambiguous):** run **`(cd scripts/brain && npm test)` directly** → new tests fail, existing 94 **compile** (with the two added literal fields) and **pass** (parse defaults `[]`). **Do NOT run `bash scripts/test-runner.sh` at RED** (Section 16b fails the whole gate on a non-zero brain exit — design-noise, per S3b plan `:38`). The direct brain-suite RED is the failing-test proof; the gate runs GREEN-only after Step 4. + +## Step 2 — (GREEN) the post record carries raw material + +1. **`serializePublishedRecord`** (`ingest.ts:50-58`): after the fixed 5-line header array, conditionally append — **only when non-empty** — `specifics: <id,id>` then `trends: <id,id>` (comma-joined, no spaces), **before** the `SENTINEL`. Empty array → line omitted → **byte-identical 5-line header** (SC2). Order fixed: `…source:` → `specifics:`(if any) → `trends:`(if any) → `---`. +2. **`parsePublishedRecord`** (`ingest.ts:75-99`): a **new non-throwing optional-list reader** — **NOT `headerScalar`** (plan-critic MAJOR 1: `headerScalar` `:61-65` THROWS `missing "<key>:" header` on an absent key, which would break **every** pre-S3c record / SC2 / SC11). Instead: `const m = header.match(new RegExp(\`^${key}:\\s*(.*?)\\s*$\`, "m")); if (!m) return []` (absent → `[]`); else split the captured value on `,`, trim, drop empties; **validate each id `/^[0-9a-f]{12}$/`** (the `:82` hex guard) → a non-12-hex entry **throws** `malformed published record: bad <specifics|trends> id <JSON.stringify(entry)>` (never silently dropped). Read from the **header slice only** (pre-sentinel), so a body line that looks like `specifics:` cannot leak. +3. **Producer-side validation (SC3 symmetric, plan-critic MAJOR 4 — specify the guard):** in `ingestText` (`:165-180`), BEFORE `mintContentId`, validate each id of `opts.specifics ?? []` and `opts.trends ?? []` against `/^[0-9a-f]{12}$/`; a non-match throws `ingest: bad <specifics|trends> id <JSON.stringify(entry)>`. **Empty/absent arrays skip validation** (no throw) — so `scanInbox` (`:204`, calls `ingestText` with no specifics/trends) and every existing caller stay green (SC11). This adds a throw path to `ingestText`'s previously throw-free contract — that is intended and bounded to malformed-id input. + +**GREEN gate (record):** `(cd scripts/brain && npm test)` → SC1–SC4 + SC11 pass; existing 94 pass. + +## Step 3 — (GREEN) the assembler (the payoff) + +In `assemble.ts`: + +1. **`normalize(s)`** (local copy of the `normalizeContent` idiom — NOT imported): `s.normalize?` → `s.trim().toLowerCase().replace(/\s+/g, " ")`. +2. **Title prep:** `nt = stripTrailingEllipsis(normalize(title))` where `stripTrailingEllipsis` removes a trailing `…` or `...` (+ surrounding ws) — this absorbs LinkedIn's truncation marker so a `"…"`-suffixed export title still prefix-matches the body. (Mid-word truncation **without** a marker already prefix-matches — `body.startsWith(title)` stays true — so only the marker needs stripping.) +3. **`matchRow(record, row)` → `{ confidence: "high"|"low", row } | null`:** + - `nb = normalize(record.body)`; `nt` as above. + - `if (nt.length < PREFIX_FLOOR) return null` — **`PREFIX_FLOOR = 24`** (plan-critic MINOR 2 — rationale: the hook quality-rule floor is 110 chars and a LinkedIn export title is the content's opening run; 24 normalized chars (~3–5 words) is the shortest opener specific enough that a prefix-match is not coincidental, while staying well under any real hook. Tunable constant with this one-line justification; below floor → `none`). + - `if (!nb.startsWith(nt)) return null` → `none`. + - else: `record.published_date === row.publishedDate ? "high" : "low"` (prefix match + same date = high; prefix match + different date = low, surfaced for operator eyeball). *(Note the field is `publishedDate` on the analytics side, `published_date` on the record — RISK B.)* +4. **`assemblePostGraph({records, analytics})`:** for each record → `{ contentId: record.id, specifics, trends, match }` where `match` = the **best** of `analytics.map(r => matchRow(record, r))`: prefer `high` over `low`; tie-break by **longest matched `nt`** (plan-critic MINOR 1 — to make the tie fully deterministic regardless of input/file order, the assembler **sorts `analytics` once at entry** by `(publishedDate desc, title asc)` before matching, so an exact length tie resolves stably, not by `readdirSync` order); none qualifying → `match: { confidence: "none" }`. Pure — no FS/clock/network; empty `records`→`[]`; empty `analytics`→every post `none` (SC8). The match carries the **whole row reference** (FIX 4). +5. **`loadAnalyticsRows(): AnalyticsRowInput[]`** (thin IO, read-only): `const dir = dataRoot("analytics/posts")`; if absent → `[]` (RISK C, fresh-clone). Read each `*.json`, `JSON.parse`, take `.posts` (an `AnalyticsBatch`), map each to the minimal `{ title, publishedDate, metrics }` (extra fields ignored). A malformed/unreadable file is skipped in a try/catch (mirrors `listPublished` `ingest.ts:230`), never a crash. **No import of the analytics package** (§5 decoupling). **Doc-comment caveat (plan-critic MAJOR 2 / R-B):** this resolves the analytics root via the brain's `dataRoot` (`${LINKEDIN_STUDIO_DATA}/analytics/posts`); the analytics package additionally honours the **deprecated `ANALYTICS_ROOT`** env override (`storage.ts:67-72`) which the brain read path does NOT — so if `ANALYTICS_ROOT` is set to a non-default path, `brain assemble` reads the default root and degrades to every-post-`none`. This is the accepted cost of the §5 no-import decoupling (the M0 default leaves `ANALYTICS_ROOT` unset); name it in the loader doc-comment, do not silently skew. + +**GREEN gate (assembler):** `(cd scripts/brain && npm test)` → SC6/SC7/SC8 pass. + +## Step 4 — (GREEN) CLI: repeatable producer flags + read-only assemble + +1. **`collectRepeated(args, key): string[]`** in `cli.ts` (near `parseFlags`): scan the raw `args` for every `--key <value>` pair (value not starting with `--`) and collect all values. **`parseFlags` is left untouched** → single-value flags parse exactly as today (SC12 by construction). +2. **`runIngest` signature + call-site change (plan-critic BLOCK 1 — this is a CHANGE, not an existing affordance):** the current code is `function runIngest(flags)` (`cli.ts:92`) dispatched as `runIngest(flags)` (`main`, `cli.ts:238`) — `rest` is NOT passed today. Change BOTH: the signature to `runIngest(rest: string[], flags)` AND the `main` dispatch to `runIngest(rest, flags)`. Then build `specifics = collectRepeated(rest, "specific")`, `trends = collectRepeated(rest, "trend")`, thread into `ingestText({ …, specifics, trends })`. **`runIngest` reads specifics/trends ONLY via `collectRepeated`, never `flags.specific`/`flags.trend`** (R-E). Empty → `[]` (today's behaviour). Update `usage` (`:71`): `ingest --file <path> [--source <s>] [--date <YYYY-MM-DD>] [--specific <id>]… [--trend <id>]…`. +3. **`runAssemble(flags)`** + dispatch `if (command === "assemble") return runAssemble(flags);` (`:240`): load **full `PublishedRecord[]`** via the `runConsolidate --gather` record-load idiom (`cli.ts:170-178` — `parsePublishedRecord` over each `dataRoot('ingest/published')/*.md`, NOT `listPublished` which omits `.body` and sorts ascending — plan-critic MAJOR 3) + `analytics = loadAnalyticsRows()`; run `assemblePostGraph(...)`, then **sort the result newest-first explicitly** (`published_date` desc — do not rely on load order) and print: per post `contentId · published_date · <firstLine>` where `firstLine = record.body.split("\n", 1)[0]` (derived locally), then `specifics: …`, `trends: …`, `analytics: <confidence> [eng <engagementRate>%]` (Fork-4: `engagementRate` headline; whole row available for a future `--verbose`). **Writes nothing** (asserted SC9). Missing `ingest/published/` or `analytics/posts/` → clean empty/partial output, no crash (RISK C). Add `assemble` to `usage`. +4. **CLI tests** (`cli.test.ts`): SC5 (`--specific a --specific c --trend b` → record tagged `[a,c]`/`[b]` via round-trip read); SC12 (single-value `--file`/`--source`/`--date` + boolean `--scan-inbox`/`--confirm` parse as today, across subcommands); SC9 (`assemble` over a seeded `LINKEDIN_STUDIO_DATA` tmp dir prints the join + writes nothing; over an empty/missing dir prints clean + no crash). + +**GREEN gate:** `(cd scripts/brain && npm test)` → all SCs pass. + +## Step 5 — gate floor + doc reconciliation + +1. `test-runner.sh:716`: `BRAIN_TESTS_FLOOR` 94 → **94 + N** (N = EXACT counted new brain tests from Steps 1–4 — set after counting, never guessed). Extend the breakdown comment `… + SB-S3c N [ingest(a)+assemble(b)+cli(c)]`. **`ASSERT_BASELINE_FLOOR` stays 80** — no new *unconditional structure-lint* check (all new tests are brain-suite tests, counted by `BRAIN_TESTS_FLOOR`; same idiom as S3b §3.7 / SC10). +2. `id.ts:6-7`: "SB-S3 will thread this id through the tributaries" → "SB-S3c threads it (post record carries `specifics`/`trends` ids; analytics joined by resolver)". +3. `consolidation-loop.md` + `architecture.md:80`: mark the SB-S3 cross-silo graph as landed for the `specific→post→analytics` spine via the hub-side design; note the resolver's **honest limit** (RISK A): the analytics↔post join is a title-prefix+date heuristic with `high/low/none` tiers — a real-CSV `none` is a normalization-tightening signal, not a guarantee of no match (mirror the `consolidation-loop.md` honest-limit idiom). + +## Step 6 — Land + +STATE "Telling" + "👉 NESTE" updated (S3c done → S3d remains, last); brief+plan committed as docs alongside code. **GREEN gate proof:** `(cd scripts/brain && npm test)` = (94+N)/(94+N) and `bash scripts/test-runner.sh` green. Commit (Conventional; code/feat-dominant → `[skip-docs]`). **Push only inside the window** (`date '+%u %H:%M'` first) and **confirm before push** (`origin` is PUBLIC `open/`). No version bump (additive within v0.5.2 dev). Optional documented end-to-end demo (RISK A) recorded in STATE/changelog. + +## Verification (testable) + +| SC | Check | Expected | +|----|-------|----------| +| SC1 | record round-trip | `parse(serialize(rec))===rec` w/ non-empty specifics/trends; order preserved | +| SC2 | byte backward-compat | empty arrays → unchanged 5-line header; `serialize(parse(oldText))===oldText` for fixture old record | +| SC3 | id validation | non-12-hex specifics/trends id throws on parse AND at `ingestText` producer boundary | +| SC4 | producer threading | `ingestText({specifics:[a],trends:[b]})` → round-tripped `[a]`/`[b]`; absent → `[]` | +| SC5 | repeatable flags | `--specific a --specific c --trend b` → record `[a,c]`/`[b]` | +| SC6 | assemble high | normalized body starts with row title (≥floor) + same date → `high`, whole row attached, specifics/trends surfaced | +| SC7 | assemble low/none | no-prefix → none; `<floor` prefix → none; `…`-truncated title + same date → high (ellipsis stripped); prefix + different date → low | +| SC8 | pure + total | `records:[]`→`[]`; `analytics:[]`→every post `none`; no throw | +| SC9 | read-only CLI | `brain assemble` prints join newest-first + writes nothing; missing dirs → clean, no crash | +| SC10 | gate green | `bash scripts/test-runner.sh` green; brain ≥ new `BRAIN_TESTS_FLOOR`; `ASSERT_BASELINE_FLOOR` unchanged at 80 | +| SC11 | no regression | ingest dedup/collision/published-only unchanged | +| SC12 | single-value flags | `--file`/`--source`/`--date`/`--scan-inbox`/`--confirm` parse as today | +| (red proof) | failing-test-first | brain suite BETWEEN Step 1 and Step 2 → new SCs fail, 94 pass | + +## Risks + +- **R-A (from RISK A) — synthetic-only proof.** All SCs use fixtures; only the §Step-6 demo touches a real CSV. A green gate does **not** prove the resolver matches a real LinkedIn title against a real body. Mitigation: keep the demo; treat a demo `none` as a normalization-tightening signal (honest-limit doc line), not a slice failure. `PREFIX_FLOOR`/ellipsis-strip are the tunables. +- **R-B (from RISK B) — analytics extraction-point.** `brain assemble` must inline the raw-JSON read (`dataRoot('analytics/posts')`), **not** `loadAllPosts`, and read **`publishedDate`** (not `date`). Pinned in Step 3.5. A try/catch skips malformed batch files. **Root-skew caveat (plan-critic MAJOR 2):** the brain `dataRoot` ignores the analytics package's deprecated `ANALYTICS_ROOT` override (`storage.ts:67-72`); if set, the join silently degrades to every-post-`none`. Accepted cost of the §5 no-import decoupling (M0 default leaves it unset); named in the `loadAnalyticsRows` doc-comment, not silent. +- **R-C (from RISK C) — fresh-clone.** `analytics/posts/` is gitignored/absent on a fresh clone → `loadAnalyticsRows()` returns `[]` → every post `none` (SC8), `brain assemble` degrades cleanly (SC9). Confirm no throw on missing dir. +- **R-D — round-trip byte-break.** New header lines MUST be omit-empty + appended after `source:`; an interleaved or always-emitted line breaks SC2 silently. Pinned by the SC2 fixture (byte-equality, not just deep-equal). +- **R-E — `collectRepeated` vs `parseFlags` divergence.** `parseFlags` still records the *last* `--specific` value (harmless — `runIngest` ignores `flags.specific`/`flags.trend` and uses `collectRepeated`). Pin: `runIngest` reads specifics/trends ONLY via `collectRepeated`, never `flags`. +- **R-F — false-positive match.** A generic short opener could prefix-match the wrong post; `PREFIX_FLOOR=24` + same-date→high (else low) guards it; low matches are surfaced (not hidden) so the operator eyeballs. No silent high on a weak match. +- **R-G — floor-count drift (plan-critic MINOR 3).** `BRAIN_TESTS_FLOOR` bumped by the EXACT counted N; the breakdown comment's per-file sub-counts `[ingest(a)+assemble(b)+cli(c)]` MUST sum to N (reconcile after counting). `ASSERT_BASELINE_FLOOR` deliberately unchanged at 80. Verified by the green gate; nothing lints the comment (discipline-only, as S3a/S3b). +- **R-H — unguarded doc.** No lint protects `docs/`; reconciled by discipline (as S3a/S3b). diff --git a/docs/second-brain/plan-sb-s3d.md b/docs/second-brain/plan-sb-s3d.md new file mode 100644 index 0000000..25fbff8 --- /dev/null +++ b/docs/second-brain/plan-sb-s3d.md @@ -0,0 +1,141 @@ +# Plan — SB-S3d: wire the operations centre (`operations.md` READER + dated anchor) + +> Brief: `docs/second-brain/brief-sb-s3d.md`. Slice: SB-S3d (ops centre only; hygiene+reconciliation → SB-S3e). +> Operator confirmed (2026-06-23): **Split, ops-centre first.** Recommendations pending the go-gate: **Option B** (enrich seed) + **advisory** deprecation + **one reader** (`strategy-advisor`). +> Order is TDD: the failing lint/seed checks land BEFORE the agent/seed edits (iron law). + +## Goal + +`strategy-advisor` reads `brain/operations.md` and honours the **dated frozen-past-self anchor** — preferring it over older/contradicting profile facts (the guard, advisory) — guarded by a deterministic wiring lint, with an enriched seed that ships the dated-anchor convention. No engine write, no post-silo contact, no parser. + +## Files touched (exhaustive — for scope-guardian) + +| File | Change | Why | +|------|--------|-----| +| `scripts/test-runner.sh` | **NEW Section 16e** (2 unconditional checks) + bump `ASSERT_BASELINE_FLOOR` 80→82 + extend the Section-18 history comment + header enumeration (`:33-36`) | SC1/SC2/SC5 — the TDD anchor | +| `scripts/brain/tests/scaffold.test.ts` | **NEW test()** asserting the dated-anchor seed convention (Option B) | SC3 — fails first (RED) | +| `scripts/brain/src/scaffold.ts` | enrich `operationsSeed()` (`:52-64`): dated-anchor convention + light Plans/Ideas guidance | SC3 — makes the guard operational | +| `agents/strategy-advisor.md` | add `brain/operations.md` to Step 0 Load-Context (`:41-48`) + a consumption subsection after `:63` (anchor authoritative + `deprecates older inferences` literal + graceful absence) | SC1/SC2/SC6/SC7 — the wiring | +| `docs/second-brain/architecture.md` | edit the SB-S3 build-row (`:80`) to the post-split truth (S3d ✅ ops · S3e remains hygiene+reconciliation) | doc reconciliation | + +**Not touched (scope fence):** any post silo (`hooks/scripts/state-updater.mjs`, `## Recent Posts`, `content-history*`, analytics, tributary JSON) — that is **S3e** · `consolidate.ts`/`assemble.ts`/`ingest.ts`/`cli.ts` (no engine/graph change; advisory guard only) · `consolidation-loop.md` (profile-motor contract, not this loop) · `content-planner.md` (second reader, not S3d) · CLAUDE.md agent table (count/model lint unaffected — no new agent, frontmatter unchanged) · Section 16d (the profile reader stays exactly as-is). + +## Step 1 — (RED) Lint: Section 16e + floor bump + +Insert a new section after Section 16d (`test-runner.sh:833`, before Section 17 at `:834`), modelled byte-for-byte on 16d's idiom (two exact literals + `*_wired()` + non-vacuity self-test + real-file grep with `grep -qF`): + +- **Literals:** `OPS_LIT='brain/operations.md'`, `ANCHOR_LIT='deprecates older inferences'`. +- **`ops_reader_wired()`** — wired iff BOTH literals present. Pin the body VERBATIM from 16d (`test-runner.sh:802`), `grep -qF` (fixed-string — `OPS_LIT` contains `.`/`/`, so `-F` is mandatory or the `.` is a regex wildcard): `echo "$1" | grep -qF "$OPS_LIT" && echo "$1" | grep -qF "$ANCHOR_LIT"` (echo twice — grep consumes stdin). +- **Check A (self-test, unconditional):** a fully-wired probe (`"reads brain/operations.md; the anchor deprecates older inferences"`) MUST be detected; three under-wired probes MUST be rejected: + 1. `reads brain/operations.md but never says the anchor outranks anything` (file lit, missing anchor lit) + 2. `the anchor deprecates older inferences but names no source file` (anchor lit, missing file lit) + 3. `reads brain/profile.md and tests the evidence` (**sibling-file decoy** — names the OTHER brain file + no anchor lit; must NOT match either operations literal) + → `pass`/`fail` "ops-reader self-test: full wiring detected; 3 under-wired forms rejected (incl. sibling-file `brain/profile.md` decoy)". +- **Check B (real-file grep, unconditional):** `grep -qF "$OPS_LIT" agents/strategy-advisor.md && grep -qF "$ANCHOR_LIT" agents/strategy-advisor.md` → `pass` "strategy-advisor.md wired to brain operations reader (names '…operations.md', frames 'deprecates older inferences')" else `fail`. +- **Header enumeration (`:33-37`):** insert the 16e clause **immediately after** "…with a non-vacuity self-test) in Section 16d;" (mid-`:36`), BEFORE the "the assertion-count anti-erosion floor (SC6) in Section 18. All are live below (Sections 8–18)." tail — preserving the 16d → 16e → 18 ordering. Clause: "the brain operations-reader guard (SB-S3d: `strategy-advisor` names `brain/operations.md` AND carries the frozen-past-self literal `deprecates older inferences`, with a non-vacuity self-test) in Section 16e;". The "Sections 8–18" range is unchanged (16e is in range). +- **Floor (`:905-913`):** `ASSERT_BASELINE_FLOOR=80` → `82`; extend the history comment: "+2 for SB-S3d's two UNCONDITIONAL Section-16e checks (ops-reader self-test + strategy-advisor ops-wiring grep) = 82." Both new checks are deps-free → they lift the deps-absent minimum, so +2 is correct (NOT pinned to deps-present TOTAL). + +**Non-collisions (verify with plan-critic):** new prose carries no algorithm-magnitude token (Section 8 stat-consistency safe) and no `Significant?(` column (Section 11 render-chain safe); the edit uses the exempt `${LINKEDIN_STUDIO_DATA:-…}` external form → Section 13 data-dir guard safe; agent count (19) + `strategy-advisor` model/frontmatter unchanged → Sections 2/9/10 unaffected; Section 16d's two literals remain present in the (unchanged) profile subsection. + +## Step 2 — (RED) Brain seed test (Option B) + +In `scripts/brain/tests/scaffold.test.ts`, add ONE new `test()` (bumps the suite count → floor +1) asserting the enriched seed — e.g. `test("operations.md ships a dated frozen-past-self anchor convention", ...)`: +- `initBrain()`, read `brain/operations.md`. +- **The single RED-bearing assertion** is the **date convention**, which is verified ABSENT from the current seed (`grep "As of" scaffold.ts` = 0 → the test fails first): `assert.match(ops, /_As of YYYY-MM-DD:_/)` (or `/As of/` — but the underscore form is the exact seed literal). +- Do NOT assert `deprecates older inferences` as the failing literal — it ALREADY ships in the current seed comment (`scaffold.ts:58`, verified `grep -c` = 1), so it passes pre-enrichment and would make the RED gate vacuous. It may be asserted only as an explicit **non-RED companion** (documented as such), or omitted. +- Keep matching the existing `Who I am now` / `## Plans` / `## Ideas` anchors so the new case is a strict superset of the old guarantee. +- Leave the existing 5 tests untouched (the no-clobber test at `:70-77` already proves idempotency of the new seed). +- **Baseline (verified live, 2026-06-23):** brain suite reports `tests 113` and the gate reports 95 checks — so the floor becomes **recorded + 1 = 114** (not a hardcoded guess) and the gate goes 95→97. + +**RED proof (recorded):** `(cd scripts/brain && npm test)` against the un-enriched seed → the new test FAILS (no `_As of` in the seed yet). `bash scripts/test-runner.sh` → Section 16e Check A passes, **Check B FAILS** (agent not yet wired), floor self-count passes (TOTAL 96 ≥ 82). Capture this failing output in STATE/changelog as the iron-law artifact. This is the required failing state. + +## Step 3 — (GREEN) Enrich the seed + +In `scripts/brain/src/scaffold.ts`, rewrite `operationsSeed()` (`:52-64`, the full function incl. the closing brace at `:64` — body string is `:53-63`) to ship the dated-anchor convention + light guidance. **Load-bearing:** the rewrite MUST retain `## Who I am now`, `## Plans`, and `## Ideas` verbatim — the existing anchor test `scaffold.test.ts:48-58` asserts all three, so dropping one regresses that test. Idempotency is untouched: `initBrain` **existence-skips** (writes a seed only when the file is absent, `scaffold.ts:105-113`), so the new seed string never reaches an already-created `operations.md`. Sketch: + +```ts +function operationsSeed(): string { + return `# Operations + +> The operations centre — where you're headed now, what you're working on, what you might do next. +> User-authored: the brain motor never writes here (a \`brain init\` re-run never clobbers your edits). + +## Who I am now (anchor) + +<!-- Frozen-past-self guard: a DATED, user-authored "where I'm headed now" statement. + It DEPRECATES older inferences — when a profile.md fact predates or contradicts this + anchor, the anchor wins. Re-date it when your direction shifts. --> + +_As of YYYY-MM-DD:_ <one or two sentences on your current direction> + +## Plans + +<!-- Active commitments — what you're working toward now. One per line. --> + +## Ideas + +<!-- Parking lot — possible content/strategy ideas, not yet commitments. One per line. --> +`; +} +``` + +**GREEN gate (brain):** `(cd scripts/brain && npm test)` → new test passes; suite reports `tests 114` (verified baseline 113 + 1); bump `BRAIN_TESTS_FLOOR` 113→**114** (`test-runner.sh:716`) + extend its inline breakdown comment ("+ SB-S3d 1 [scaffold dated-anchor seed]"). The no-clobber test (`scaffold.test.ts:70-77`) runs in this same gate and must stay green. + +## Step 4 — (GREEN) Wire `strategy-advisor.md` + +1. **Step 0 Load Context** (`strategy-advisor.md:41-48`): insert one line INSIDE the fenced block, immediately before the closing ``` fence (`:48`), after the profile line (`:47`): + `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/brain/operations.md → the operations centre: dated "who I am now" anchor + plans + ideas (user-authored)` +2. **New subsection** immediately after the profile subsection's graceful-absence line (`:63`), e.g. `### Consuming the operations centre (brain/operations.md)`: + - **Anchor is authoritative:** "The `## Who I am now` anchor is the user's own dated declaration of current direction. It is authoritative and **deprecates older inferences**: when a `brain/profile.md` Dynamic fact predates the anchor's date or contradicts its stated direction, prefer the anchor and flag the older fact as possibly-stale — do not parrot it." ← carries the exact literal `deprecates older inferences`. + - **Inversion (make the contrast explicit):** "Unlike profile facts, which you treat as **evidence to TEST**, the anchor is user-declared direction, not an inference to challenge — honour it. (It is direction, not praise; honouring it never licenses flattery.)" + - **Plans / Ideas:** "`## Plans` are active commitments (current intent); `## Ideas` are a parking lot (suggestions, not commitments) — weigh accordingly." + - **Graceful absence:** "If `brain/operations.md` (or its anchor) is missing or empty, proceed silently on the other context sources — no error, no note about a missing ops centre." + +**GREEN gate (lint):** `bash scripts/test-runner.sh` → Section 16e Check A + Check B pass, FAIL=0, **97/0/0**, ASSERT floor 82 honoured. (16d still green — profile literals untouched.) + +## Step 5 — Doc reconciliation + +Edit `architecture.md:80` (the SB-S3 build-row) so the post-split status is true: S3a/b/c ✅ as today, then "**S3d ✅ ops centre** — `operations.md` is a read tributary; `strategy-advisor` honours the dated anchor that *deprecates older inferences* (advisory). **S3e remains** — dead `content-history.md` retirement + triple-post (post-tracking↔published↔analytics) reconciliation." No other architecture edit (the §59 guard spec already stands; the §80 row was the stale claim). + +## Step 6 — Behavioural verification (manual, recorded — SC6/SC7) + +Agent-prompt behaviour is not unit-testable (operator-accepted). Honest procedure, result recorded in STATE/changelog at land: +- **SC6 (graceful absence):** with no `brain/operations.md` at the data-root, the wiring degrades silently — inspect the subsection's graceful-absence clause + (best-effort) a `strategy-advisor` run on an empty root → no missing-file noise. +- **SC7 (guard fires):** seed a temp root (`LINKEDIN_STUDIO_DATA=<tmp> brain init`), write a dated anchor (`_As of 2026-06-20:_ pivoting from X to Z`) and a contradicting older profile fact (`leans toward X`, `last_seen` < 2026-06-20) → run the agent → confirm it prefers the anchor (Z) and flags the older X fact as deprecated, not parroted. If the harness cannot exercise a per-call data-root, record that honestly and fall back to inspection of the wiring + framing (no overclaim). + +## Step 7 — Land + +STATE "Telling" + "👉 NESTE" updated (S3d done → **S3e** is the new last slice); commit. Mixed change: the seed/agent/lint are code → `[skip-docs]` judged per the repo convention; brief+plan+architecture are docs. **Push only inside the window** (`date '+%u %H:%M'` first; `origin` = PUBLIC `open/` → confirm before push). No version bump (S3d additive within v0.5.2 dev; release is a separate decision). + +## Verification (testable) + +| SC | Check | Command | Expected | +|----|-------|---------|----------| +| SC1+SC2 | ops reader wired + literal | `bash scripts/test-runner.sh` | Section 16e Check B passes | +| SC3 | seed convention | `(cd scripts/brain && npm test)` | new test passes; floor 114 | +| SC4 | gate green | `bash scripts/test-runner.sh` | 97/0/0; 16d still green; hook suite (separate runner) untouched | +| SC5 | assertion floor | same run | "anti-erosion: 97 … >= floor 82" passes | +| (red proof) | failing-test-first | gate run BETWEEN Step 1/2 and Step 3/4 | exit 1: scaffold test fails (Step 2), 16e Check B fails (Step 1) | +| SC6 | graceful absence | manual, empty root | clean output, no missing-file noise | +| SC7 | guard fires | manual, seeded anchor+stale fact | anchor wins, older fact flagged (or honest fallback recorded) | + +## Risks + +- **R1 — vacuous lint.** Mitigated by the 16d-style non-vacuity self-test with the sibling-file (`brain/profile.md`) + "outranks anything" decoys. +- **R2 — floor false-fail on fresh clone.** Both new lint checks are deps-free (lift the deps-absent minimum) → +2 → 82 correct, not pinned to deps-present TOTAL. The brain floor (114) only checked when the brain suite runs (deps-present); a fresh clone warn-skips it (unchanged mechanism). +- **R3 — behavioural overclaim.** Step 6 honest-fallback clause (verifiseringsplikt): never report a guard-fires pass not actually run. +- **R4 — anti-sycophancy weakening.** The anchor's "authoritative" stance must not read as "flatter the user." Mitigated by the explicit "direction, not praise" clause + the profile `evidence to TEST` stance staying verbatim (Section 16d unchanged, still grepped). +- **R5 — seed breaks idempotency.** Mitigated: the enriched seed is still a static string; `scaffold.test.ts:70-77` (no-clobber) stays green and is run in Step 3's GREEN gate. +- **R6 — stale §80 doc.** The build-row claim "S3d remains (ops + hygiene + reconciliation)" becomes false at land; mitigated by EDITING `:80` to the split truth (no automated guard covers `architecture.md`; Section 11 scans non-docs dirs only). + +## Plan-critic — folded + +`voyage:plan-critic` (no blockers; 3 major + 3 minor) folded above: +- **Major-1 — vacuous brain RED:** the seed already ships `deprecates older inferences` (`scaffold.ts:58`), so the RED-bearing literal must be the **date convention** `_As of YYYY-MM-DD:_` (verified absent). Folded into Step 2. ✅ +- **Major-2 — unverified brain floor:** recorded the live count (`tests 113`) → floor = recorded + 1 = 114, not a hardcoded guess. Folded into Step 2/3. ✅ +- **Major-3 — `grep -qF` fixed-string:** pinned the `ops_reader_wired()` body verbatim from `test-runner.sh:802` (`-F` mandatory — `OPS_LIT` has `.`/`/`). Folded into Step 1. ✅ +- **Minor-1 — header range:** corrected to `:33-37`; the 16e clause inserts after "…in Section 16d;", before the SC6/Section-18 tail. Folded into Step 1. ✅ +- **Minor-2 — function range:** cite `operationsSeed()` as `:52-64` incl. the closing brace (body `:53-63`). Folded into Step 3. ✅ +- **Minor-3 — retained anchors:** the three `##` anchors are load-bearing for `scaffold.test.ts:48-58`; the rewrite retains them verbatim. Folded into Step 3. ✅ + +Cross-checks the critic confirmed sound: floor arithmetic (ASSERT 80→82, BRAIN 113→114), the 3 decoys each miss ≥1 literal, the sibling-file decoy `brain/profile.md` cannot match `OPS_LIT`, Section 16d stays green (profile literals untouched; the new subsection adds a *second* `evidence to TEST` occurrence, harmless), and no collision with Sections 8/11/13. diff --git a/docs/second-brain/plan-sb-s3e.md b/docs/second-brain/plan-sb-s3e.md new file mode 100644 index 0000000..7d7f19e --- /dev/null +++ b/docs/second-brain/plan-sb-s3e.md @@ -0,0 +1,158 @@ +# Plan — SB-S3e: hygiene (retire dead `content-history.md`) + triple-post reconciliation (read-side) + +> Brief: `docs/second-brain/brief-sb-s3e.md`. Slice: SB-S3e — the LAST S3 slice (arc complete after; S4 optional). +> Recommendations pending the go-gate: **(b) retire** content-history · **(c) read-side joiner** (`brain reconcile`) · build now (not doc-only) · brain-CLI-only (no new plugin command/agent). +> Order is TDD: the failing reconcile test + gate self-test land BEFORE the reconcile code (iron law). The retirement is deletion — its proof is grep-zero + the migrate suite staying green. + +## Goal + +Two coupled deliverables: **(b)** the dead, zero-reader `content-history.md` is removed across all eight of its plumbing surfaces (writer prose, template, migration entry+test, gitignore, gate guard, ref-doc, comment); **(c)** a new read-only `brain reconcile` reconciles silo 1 (`## Recent Posts`, auto-tracked creation) against the silo 2↔3 graph, surfacing the coverage gap (created posts never ingested into the brain) — without ever writing the state silo. No state-file write, no `$`-injection surface, no auto-capture. + +## Files touched (exhaustive — for scope-guardian) + +### (b) Retirement + +| File | Change | SC | +|------|--------|-----| +| `hooks/prompts/state-update-reminder.md` | remove Section 5 "Content History Log" (`:67-86`) — the sole writer | SC1 | +| `config/content-history.template.md` | **DELETE** | SC1 | +| `hooks/scripts/migrate-data.mjs` | remove the B1 `MOVE_FILES` entry (`:33`) | SC1 | +| `hooks/scripts/__tests__/migrate-data.test.mjs` | drop content-history fixture (`:37`), its 2 assertions (`:56`,`:61`), trim test-(a) description (`:50`); cases a–e stay (count unchanged) | SC2 | +| `.gitignore` | remove `assets/analytics/content-history.md` (`:44`) | SC1 | +| `scripts/test-runner.sh` | narrow `SC2_CLASSES` (`:592`) — drop the `content-history\.md` alternative (check stays → gate count unchanged) | SC1/SC5 | +| `references/data-path-convention.md` | remove `content-history.md` from the data-tree diagram (`:26`) | SC1 | +| `hooks/scripts/session-start.mjs` | drop `content-history` from the comment list (`:94`) | SC1 | + +### (c) Reconciliation (read-side) + +| File | Change | SC | +|------|--------|-----| +| `scripts/brain/tests/reconcile.test.ts` | **NEW** — RED-first: coverage tiers + truncated-hook degradation + empty-state | SC3 | +| `scripts/brain/src/reconcile.ts` | **NEW** — pure `reconcileRecentPosts` + `parseRecentPosts` + `loadRecentPosts` IO seam | SC3/SC4 | +| `scripts/brain/src/cli.ts` | add `reconcile` subcommand (`runReconcile`) + dispatch (`:312`) + usage line | SC4 | +| `scripts/test-runner.sh` | **NEW Section 16f** (2 unconditional checks) + `ASSERT_BASELINE_FLOOR` 82→**84** + `BRAIN_TESTS_FLOOR` 114→**recount** + header enumeration | SC4/SC5 | +| `docs/second-brain/architecture.md` | mark the SB-S3 build-row **S3e ✅** (arc complete) | SC7 | + +**Not touched (scope fence):** `hooks/scripts/state-updater.mjs`, `## Recent Posts`, `pruneContentHistory` (S3e **reads** silo 1, never writes) · `assemble.ts` join heuristic (reconcile *consumes* the graph) · `consolidate.ts`/`ingest.ts` (no engine change) · `consolidation-loop.md` · CLAUDE.md command/agent tables (no new command/agent; `brain reconcile` is an internal CLI subcommand) · README `:120` conceptual "content-history alignment" (concept, not the file — verify-then-leave). + +## Step 1 — (RED) Reconcile test against the absent module + +`scripts/brain/tests/reconcile.test.ts` — model the IO/pure split on `assemble.test.ts`. The RED is a **logic-RED, not an import-RED**: land a **stub `reconcile.ts`** first that compiles + exports the symbols but mis-classifies (returns `[]` / a wrong tier), so the named tier assertions go red against *logic*, not a `MODULE_NOT_FOUND`. Cases: + +1. **`parseRecentPosts`** extracts `{date, hook, charCount, topic}` from a `## Recent Posts` block whose entries match the **writer's** exact format (`updatePostTracking`, `state-updater.mjs:116`: `- [YYYY-MM-DD] "hook" (chars) - topic`) — the format source of truth, NOT the date-only pruner regex (`:145`). A **golden-string** case feeds a literal writer-produced entry and asserts the round-trip. A non-`## Recent Posts` doc → `[]`; a `$`-bearing topic/hook round-trips verbatim (capture/`match` on read — no `String.replace` → no regex injection). +2. **`reconcileRecentPosts({recentPosts, records, graph})`** (the pinned 3-arg signature — `records` carry `body`, which `PostGraphNode` lacks) classifies each silo-1 entry: + - **in-graph** — a published record matches (date + hook-prefix on `record.body`) AND its graph node has an analytics match → the full chain is observable. + - **in-brain-only** — record matches but its graph node's analytics `confidence==='none'`. + - **orphaned-in-state** — no record matches (created via the plugin, never `brain ingest`-ed) → the coverage gap. + - **summary** counts (created N · in-graph M · orphaned K). +3. **Truncated-hook degradation** — a ≤60-char preview ending `...` strips the marker before prefix-matching (mirror `assemble.ts: stripTrailingEllipsis`); a preview too short to discriminate (below a `PREFIX_FLOOR`-style guard) → `orphaned`, never a false `in-graph`. +4. **Empty-state** — empty/absent `## Recent Posts` → empty report, no throw. + +**RED proof (recorded — logic-RED):** with the mis-classifying stub in place, `(cd scripts/brain && npm test)` → the **named tier assertions** fail (e.g. "expected `orphaned-in-state`, got …"). Capture *that* transcript as the iron-law artifact in STATE/changelog — not a bare import error. **Circuit-breaker:** if the RED run is not recorded before the real `reconcile.ts` logic lands, halt and redo Step 1 (the RED proof is the only circuit breaker in this plan). + +## Step 2 — (GREEN) `reconcile.ts` — pure core + parser + IO seam + +`scripts/brain/src/reconcile.ts`: + +- **`parseRecentPosts(stateText: string): RecentPost[]`** — regex the `## Recent Posts` section against the **writer's** format (`updatePostTracking`, `state-updater.mjs:116`: `- [${postDate}] "${hookPreview}" (${charCount}) - ${postTopic}`) → e.g. `/^- \[(\d{4}-\d{2}-\d{2})\] "(.*)" \((\d+)\) - (.+)$/gm`. **NB:** this is NOT `pruneContentHistory`'s regex — that one (`:145`) is **date-only** (`/^- \[(\d{4}-\d{2}-\d{2})\] .+$/gm`) and captures no hook/chars/topic; `parseRecentPosts` is the *first* full-field reader of this section and tracks the writer, not the pruner. Pure. +- **`reconcileRecentPosts({recentPosts, records, graph}): ReconcileNode[]` + `summarize(...)`** — pure, no FS/clock. `records` is the **full `PublishedRecord[]`** (carry `body`); `graph` is `PostGraphNode[]` (carry `contentId` + analytics `match`, NO `body`). Reuse `assemble.ts`'s `normalize` / `stripTrailingEllipsis` idiom (brain-local copy, NOT a new shared util — the repo's accepted duplication, `dataRoot.ts` header). Match `recentPost.hook` (ellipsis-stripped, normalized) as a **prefix of** `record.body`; confidence by date equality; below the prefix floor → `orphaned`. Then look up the matched `record.id` in `graph` to read its analytics tier (`in-graph` if the node's `match.confidence !== 'none'`, else `in-brain-only`). +- **`loadRecentPosts(): RecentPost[]`** — the new cross-seam IO. Resolve the state file with the **canonical precedence already exported as `data-root.mjs: getStateFile()`** — `process.env.STATE_FILE || join(resolveHome(), ".claude", "linkedin-studio.local.md")` where `resolveHome() = process.env.HOME || process.env.USERPROFILE || homedir()` (`data-root.mjs:17-19`). **NOT bare `homedir()`** — that silently ignores a `HOME`/`USERPROFILE` override the writer honours (`state-updater.mjs:12`); and **NOT `dataRoot()`** (that is the *dir* under `LINKEDIN_STUDIO_DATA`). The brain TS copies `getStateFile()`'s chain because it cannot import the `.mjs`. Read read-only; absent → `[]` (fresh-clone safe). Header caveat: this is a **new** root-skew seam (state file via `STATE_FILE`, a different root than the brain dataRoot) — not a mirror of `assemble.ts`'s `ANALYTICS_ROOT` note. + +**GREEN gate (brain):** `(cd scripts/brain && npm test)` → reconcile cases pass; record the new live `tests` total → `BRAIN_TESTS_FLOOR` 114→**that number** (recorded, not guessed; update the inline breakdown comment `test-runner.sh:718` with "+ SB-S3e N [reconcile]"). + +## Step 3 — (GREEN) Wire `brain reconcile` into the CLI + +`scripts/brain/src/cli.ts`: +- Add `runReconcile()` (model on `runAssemble`, `:161-191`). **Loader (BLOCKER fix):** do NOT use `listPublished()` — it takes no args and returns body-less `PublishedSummary[]` (`ingest.ts:262-289`). Instead reuse `runAssemble`'s inline loader (`cli.ts:163-174`): `readdirSync(pubDir).filter(.md).map(parsePublishedRecord)` → real `PublishedRecord[]` (with `body`/`specifics`/`trends`). Then `const analytics = loadAnalyticsRows()`, `const graph = assemblePostGraph({records, analytics})`, `const recentPosts = loadRecentPosts()`, and **call the core by its literal name** `reconcileRecentPosts({recentPosts, records, graph})` (no alias/re-export wrapper — Section 16f Check B greps `cli.ts` for the literal `reconcileRecentPosts`). Print per-post lines (`· <date> · <tier> · "<hook>"`) + summary + nudge ("K created posts are not in the brain graph — `brain ingest --file <p>` to feed them"). Read-only. +- Dispatch: insert `if (command === "reconcile") return runReconcile(flags);` **after** the `assemble` dispatch line (`:312`, i.e. at the new `:313` before `usage(...)`). +- Usage: add `" reconcile"` to the usage block **after** the `" assemble"` line (`:94`). + +**GREEN gate (lint):** Section 16f Check B now passes (see Step 5). + +## Step 4 — (b) Retire `content-history.md` (deletions) + +Apply the eight edits in the (b) table. Order: remove the **writer** (`state-update-reminder.md` Section 5) first, then template, migration entry, gitignore, gate guard, ref-doc, comment. Then trim `migrate-data.test.mjs` — drop the content-history fixture write + the two assertions + the words "content-history moved" from the test-(a) name; cases a–e remain. + +**Gate (hook suite — separate runner), recorded:** `node --test hooks/scripts/__tests__/migrate-data.test.mjs` → cases a–e green (capture the transcript — R8 is demonstrated, not asserted). Confirm cases b–e never expected `analytics/content-history.md` as a present path (they don't — only the deleted case-(a) fixture/assertions referenced it; b–e exercise MOVE_DIRS / idempotency / collision / empty), so removing the B1 MOVE entry cannot regress them. **SC1:** `grep -rn "content-history" hooks/ config/ scripts/test-runner.sh .gitignore references/data-path-convention.md` → zero (the README `:120` *concept* mention is out of this grep set; confirm it is the concept, leave it). + +## Step 5 — (RED→GREEN) Gate Section 16f + floors + SC2 narrow + +Insert **Section 16f** after Section 16e (`test-runner.sh:885`, before Section 17 `:886`), modelled on 16e's two-check idiom (self-test + real-file grep, `grep -qF` fixed-string): + +- **Literals:** `RECON_CLI_LIT='command === "reconcile"'`, `RECON_CORE_LIT='reconcileRecentPosts'`. +- **`reconcile_wired()`** — wired iff a probe carries BOTH literals (echo twice; `grep -qF` — both literals contain regex-special chars, so `-F` is mandatory). +- **Check A (self-test, unconditional, non-vacuous):** a fully-wired probe MUST be detected; the rejected probes must each miss **exactly one** literal (the load-bearing discriminators), plus a specificity decoy: + - **D1 (discriminator):** dispatch present, core call absent — `command === "reconcile"` but no `reconcileRecentPosts` → must REJECT (proves the core literal is load-bearing). + - **D2 (discriminator):** core call present, dispatch absent — `reconcileRecentPosts(...)` but no `command === "reconcile"` → must REJECT (proves the dispatch literal is load-bearing). + - **D3 (specificity):** a sibling command fully wired to the WRONG domain — `command === "assemble"` + `assemblePostGraph` (neither reconcile literal) → must REJECT (proves the predicate is reconcile-specific, not "any command + any core"). + → `pass`/`fail` "reconcile self-test: full wiring detected; D1/D2 single-literal forms + D3 `assemble` specificity decoy rejected". +- **Check B (real-file grep, unconditional):** `grep -qF "$RECON_CLI_LIT" scripts/brain/src/cli.ts && grep -qF "$RECON_CORE_LIT" scripts/brain/src/cli.ts` → `pass` "brain CLI wired to reconcile (dispatch + core call by literal name)" else `fail`. Both literals live in `cli.ts` (the dispatch `if` + `runReconcile`'s literal-name call to the core — Step 3 pins "no alias"), so one file carries both → deps-free, like 16e Check B. +- **Header enumeration (`:33-39`):** insert the 16f clause **between** the 16e clause's terminal "…in Section 16e;" (`:38`) and the "the assertion-count anti-erosion floor (SC6) in Section 18." tail — preserving 16e → 16f → 18 ordering. The range "Sections 8–18" is unchanged (16f is in range). +- **`ASSERT_BASELINE_FLOOR` (`:966`):** 82 → **84** (+2 deps-free unconditional checks — they lift the deps-absent minimum, NOT pinned to the deps-present TOTAL); extend the history comment ("+2 for SB-S3e's two Section-16f checks (reconcile self-test + brain-CLI reconcile grep) = 84"). +- **`SC2_CLASSES` (`:592`):** delete the `assets/analytics/content-history\.md|` alternative (now retired). The SC2 check is still one pass/fail → gate TOTAL change comes only from 16f's +2. + +**RED proof:** between Steps 1 and 3, a gate run shows Section 16f Check A passing (self-test is self-contained) and **Check B FAILING** (cli.ts not yet wired); after Step 3 both pass. + +**GREEN gate (full):** `bash scripts/test-runner.sh` → FAIL=0; TOTAL recounted **live**. **Recorded pre-change baseline (verified live this session, 2026-06-23): 97/0/0** → expected **99** after 16f's +2 (recount at land, never pin); ASSERT floor 84 honoured; SC2-dry-run green with the narrowed regex. + +## Step 6 — Doc reconciliation + +Edit the `architecture.md` SB-S3 build-row: `S3e ✅` — "dead `content-history.md` retired; `brain reconcile` reconciles post-tracking ↔ published ↔ analytics (read-side; auto-capture = follow-up)" — and note the arc is complete (S4 EØS-connector optional). No other architecture edit. + +**CLAUDE.md surface (open-Q4, resolved in-plan):** `brain reconcile` is an **internal brain-CLI subcommand**, the same class as `brain assemble`/`ingest`/`consolidate` — none of which appear in CLAUDE.md's command/agent tables (those count *plugin* commands/agents). So **no CLAUDE.md count change**; the `brain reconcile` mention lives in the `architecture.md` build-row above. Confirm at the gate, but the default is no-plugin-surface. + +## Step 7 — Behavioural verification (manual, recorded — SC4/SC6) + +Not unit-testable (operator-accepted command-testing gap). Honest procedure, result recorded at land: +- **SC4 (e2e):** seed a temp root — a `## Recent Posts` block (via `STATE_FILE`), 1–2 `ingest/published` records (one matching a Recent-Posts entry, one not), an analytics batch — run `brain reconcile`; confirm it prints in-graph / orphaned tiers + the summary + nudge. +- **SC6 (root-skew):** with `STATE_FILE=<tmp>` it reads silo 1 from the temp file; unset → falls back to `$HOME/.claude/linkedin-studio.local.md`. **Honesty hedge (verifiseringsplikt):** if the per-call seam can't be exercised, record wiring-inspected-only, NOT a behavioural pass. + +## Step 8 — Land + +STATE "Telling" + "👉 NESTE" updated (S3e done → arc complete; next = strategic checkpoint / product-maturity). Commit. Mixed: reconcile + retirement + gate are code (`[skip-docs]`); brief+plan+architecture are docs. **Push only inside the window** (`date '+%u %H:%M'` first; `origin` = PUBLIC `open/` → confirm before push). No version bump (additive within v0.5.2 dev; release separate). + +## Verification (testable) + +| SC | Check | Command | Expected | +|----|-------|---------|----------| +| SC1 | content-history retired | `grep -rn content-history hooks/ config/ scripts/test-runner.sh .gitignore references/` + `ls config/content-history.template.md` | zero hits; file gone | +| SC2 | migration green | `node --test hooks/scripts/__tests__/migrate-data.test.mjs` | cases a–e pass | +| SC3 | reconcile core (RED→GREEN) | `(cd scripts/brain && npm test)` | reconcile cases pass; floor recounted | +| SC4 | reconcile wired | `bash scripts/test-runner.sh` + manual `brain reconcile` | Section 16f Check B passes; report prints | +| SC5 | gate green + floor | same run | 99/0/0 (confirm live); ASSERT floor 84; SC2-dry-run green | +| (red proof) | failing-test-first | gate + brain run BETWEEN Steps 1 and 3 | reconcile test fails; 16f Check B fails | +| SC6 | root-skew | manual, `STATE_FILE` set/unset | reads the pointed file / falls back (or honest fallback recorded) | +| SC7 | counts reconciled | inspect | architecture S3e ✅; STATE telling updated; no CLAUDE.md command/agent count change | + +## Risks + +- **R1 — vacuous gate.** Mitigated by the 16e-style non-vacuity self-test with the `assemble` sibling-command decoy. +- **R2 — seam-mismatch (the load-bearing one).** `loadRecentPosts()` MUST resolve the state FILE via `getStateFile()`'s exact chain — `STATE_FILE || join(resolveHome(), '.claude', 'linkedin-studio.local.md')`, `resolveHome() = HOME||USERPROFILE||homedir()` (`data-root.mjs:17-19,30-31`) — NOT bare `homedir()` (ignores a HOME/USERPROFILE override) and NOT `dataRoot()` (the DIR under `LINKEDIN_STUDIO_DATA`). A divergence silently reads the wrong path. Mitigated: chain copied from the canonical `getStateFile()` (not `state-updater.mjs:12`, which falls through to `''` — a latent bug `data-root.mjs:14-16` warns against); SC6 exercises both `STATE_FILE` branches. +- **R3 — parse drift.** The reconcile entry regex must track the **writer** `updatePostTracking` (`state-updater.mjs:116`); if the writer's format changes, the reader silently misses. `pruneContentHistory` (`:145`) is date-only and would NOT catch such drift, so it is not a co-mover. Mitigated: a **golden-string test** feeds a literal writer-format entry through `parseRecentPosts` and asserts the round-trip — pinning the exact write format as the contract. +- **R4 — retirement over-reach.** Deleting a *conceptual* "content history" mention (README `:120`, or `pruneContentHistory`'s name) would break live behaviour. Mitigated: the (b) table is file-plumbing-only; the scope fence + SC1's bounded grep set exclude the concept; `pruneContentHistory` (operates on `## Recent Posts`) is explicitly out. +- **R5 — `$`-injection on READ.** `parseRecentPosts` uses `match`/capture (read), never `String.replace` with an untrusted replacement — so a `$`-bearing hook/topic round-trips verbatim (Step 1 case 1 pins it). No write path → no Section-13 `state-updater` surface touched. +- **R6 — floor false-fail on fresh clone.** 16f's two checks are deps-free (+2 → 84, correct). `BRAIN_TESTS_FLOOR` only checked when the brain suite runs (deps-present); a fresh clone warn-skips it. +- **R7 — honest-limit overclaim.** The brief/CLI must state plainly that read-side reports the coverage gap but cannot reconstruct un-captured specifics/trends (auto-capture = follow-up). Mitigated: §3 honest-limits + Step 7 verifiseringsplikt hedge. +- **R8 — migrate idempotency.** Removing the B1 MOVE entry must not break the `.migrated` marker / COPY-MOVE classes. Mitigated: cases b–e (idempotent re-run, collision, empty) stay green in Step 4's run. + +## Plan-critic — folded + +`voyage:plan-critic` (3 blockers + 6 major + 4 minor) — each verified directly against the code before folding: + +- **B1 — body-less loader (load-bearing):** `listPublished()` takes no args and returns `PublishedSummary` (`{id,provenance,published_date,firstLine}`, `ingest.ts:262-289`) — NO `body`. The reconcile prefix-match AND `assemblePostGraph` both need `PublishedRecord.body`. **Folded Step 3:** use `runAssemble`'s inline `readdirSync().map(parsePublishedRecord)` loader (`cli.ts:163-174`), which yields real `PublishedRecord[]` (and already builds a `bodyById` Map). ✅ +- **B2 — core-signature contradiction:** brief said `{recentPosts, graph}`; `PostGraphNode` has no `body` → graph-only can't prefix-match. **Folded** to the 3-arg `{recentPosts, records, graph}` in brief §3(c).1 + plan Steps 1/2 — one signature, agreed across brief/test/core. ✅ +- **B3 — regex misattribution:** plan cited `pruneContentHistory`'s `:145` regex as full-field; it is **date-only**. **Folded Step 2 + R3:** the parser tracks the **writer** `:116`; `parseRecentPosts` is the first full-field reader; a golden-string test pins the write format. ✅ +- **M4 — HOME seam:** `loadRecentPosts` must use `getStateFile()`'s `resolveHome()` chain (`HOME||USERPROFILE||homedir()`), not bare `homedir()` (ignores an override the writer honours). **Folded Step 2 + R2**, citing `data-root.mjs:17-19,30-31` as canonical (not `state-updater.mjs:12`, which falls through to `''`). ✅ +- **M5 — weak gate decoys:** the both-absent `assemble` decoy was trivial. **Folded Step 5:** D1/D2 now miss **exactly one** literal each (the load-bearing discriminators); D3 (`assemble` sibling) is reframed as a specificity decoy. ✅ +- **M6 — Check-B literal name:** `reconcileRecentPosts` appears in `cli.ts` only if `runReconcile` calls the core by its literal name. **Folded Step 3** ("no alias/wrapper") + Step 5 Check-B wording. ✅ +- **M7 — unrecorded TOTAL:** **Folded Step 5** — recorded the live pre-change gate **97/0/0 (2026-06-23)**; 99 is the expected post-16f count, recount at land. ✅ +- **M8 — migrate idempotency asserted not shown:** **Folded Step 4** — require the recorded `node --test migrate-data.test.mjs` green run; confirm cases b–e never expected content-history present. ✅ +- **M9 — SC1 grep divergence:** the brief's `--include`-filtered grep would skip `.gitignore`/`.md`. **Folded** — brief SC1 unified with the plan's unfiltered explicit-list grep. ✅ +- **m10 — header insertion point:** **Folded Step 5** — insert the 16f clause between the 16e clause terminal and the Section-18 tail. ✅ +- **m11 — `:312`/`:94` line refs:** **Folded Step 3** — "after :312" / "after :94" (insertion, not the existing line). ✅ +- **m12 — CLAUDE.md deferred:** **Folded Step 6** — resolved in-plan: `brain reconcile` is internal brain-CLI (like `assemble`), no CLAUDE.md count change. ✅ +- **m13 — circuit-breaker:** **Folded Step 1** — "if RED not recorded before reconcile.ts logic lands, halt and redo Step 1." ✅ + +**`voyage:scope-guardian`: ALIGNED** — 0 creep / 0 gaps / 0 dependency issues. All 14 IN items map to a step; no NON-GOAL file touched; scope fence consistent (slightly broader/defensive); doc reconciliation proportional. Every file/line/symbol verified live. + +Cross-checks confirmed sound: the (b) retirement arm was already nearly executable (all 8 surfaces verified); floor arithmetic (ASSERT 82→84 from 16f's +2; SC2-narrow nets 0; BRAIN 114→recount); the "read-side does not close the gap" honesty (auto-capture = the real closer, follow-up) stands; `$`-injection is absent on the read path (capture/`match`, never `String.replace`). diff --git a/docs/second-brain/research/connector-egress.md b/docs/second-brain/research/connector-egress.md new file mode 100644 index 0000000..a9cbbb5 --- /dev/null +++ b/docs/second-brain/research/connector-egress.md @@ -0,0 +1,65 @@ +# Research — LinkedIn content/data egress for an individual creator (2026-06-23) + +> Research thread `connector-recon`, 2026-06-23. Verification-duty: every technical claim backed by an official source/URL; unverifiable items marked "IKKE VERIFISERT". This is research INPUT to `../architecture.md` — preserved verbatim so nothing is lost. + +**Bottom line:** For ONE creator pulling their OWN content out of LinkedIn there is exactly one realistic *automated* path, and it is geofenced to **EU/EEA + Switzerland**: the **DMA Member Data Portability API** (self-serve, scope `r_dma_portability_self_serve`). Outside that region the honest answer is **manual native export** for content + **manual analytics CSV download** for per-post metrics. Full hands-off automation is NOT available outside the EU/EEA. Engagement/impression analytics are NOT in any portability/export *content* path — they live only in the separate manual analytics CSV. + +## 1. Path-by-path table + +| # | Path | What it captures (your OWN data) | Verdict | Source | +|---|------|----------------------------------|---------|--------| +| 1a | **DMA Member Data Portability API — Member Snapshot** (`r_dma_portability_self_serve` / `r_dma_portability_member`) | **Full post text** (`MEMBER_SHARE_INFO`: all shares/re-shares incl. date, URL, shared comments, visibility), **articles** (`ARTICLES`), profile, connections, comments (`ALL_COMMENTS`), reactions you gave (`ALL_LIKES`), reposts, messages — ~60+ domains. **Historical** data included. **NO impression/reach/engagement-received analytics domain exists.** | **AUTO** — but **EU/EEA + Switzerland members ONLY** | learn.microsoft.com/en-us/linkedin/dma/member-data-portability/member-data-portability-member/?view=li-dma-data-portability-2026-05 ; …/shared/snapshot-domain?view=li-dma-data-portability-2026-05 | +| 1b | **DMA Member Data Portability API — Member Changelog** | Real-time stream of your interactions (posts created, comments, reactions) from consent onward; **queryable only for the last 28 days** | **AUTO** (rolling 28-day window) — EU/EEA + CH only | (same member page, Changelog section) | +| 1c | **Marketing API / Community Management API** | Org/Page-level posting + analytics; NOT a self-serve member-content path | **RISKY/BLOCKED for individuals** — LinkedIn Partner Program approval (legal entity, use-case review, weeks/months) | learn.microsoft.com/en-us/linkedin/marketing/community-management/community-management-overview?view=li-lms-2026-05 ; connectsafely.ai/articles/linkedin-api-complete-guide-2026 | +| 2 | **Native "Get a copy of your data"** (Settings → Data Privacy) | ZIP of **CSV + JSON + HTML**: profile, connections, messages, comments, **posts** (Shares), **articles** (HTML folder). Basic categories "within minutes"; **larger archive ~24h** (72h download window). **Saved-items file is URLs+dates only.** **No analytics.** | **MANUAL** | linkedin.com/help/linkedin/answer/a1339364/downloading-your-account-data | +| 3a | **Native newsletter content** | **NO native RSS feed.** Subscription = email/notification per edition only. Edition text lives as articles. | **MANUAL** | linkedin.com/help/linkedin/answer/a517925 | +| 3b | **Newsletter via your own subscription email** | Each new edition arrives as an **email to you** → ingest your own mailbox / email-to-RSS | **MANUAL (clean)** | linkedin.com/help/linkedin/answer/a517925 | +| 3c | **Third-party newsletter→RSS scrapers** (rss.app, linkedin-newsletter-rss) | Scrapes public newsletter/article URL into RSS | **RISKY** (third-party scraping) | rss.app/rss-feed/linkedin ; github.com/chrisns/linkedin-newsletter-rss | +| 4a | **Per-post analytics CSV export** (native dashboard "Export") | **Per-post** impressions, unique viewers, clicks, reactions, comments, shares, engagement rate. **Manual, ~1-year range cap.** Profile views NOT exportable. **ONLY path to your engagement metrics.** | **MANUAL** | tryordinal.com/blog/how-to-export-analytics-from-linkedin-to-excel-or-a-csv ; linkedin.com/help/linkedin/answer/a551206/ | +| 4b | **Zapier / Make** | Trigger on *your* new posts (publish-side); thin/no historical pull of post bodies+analytics; bound by LinkedIn's official connector scopes | **MANUAL-ish / limited** | connectsafely.ai/articles/is-linkedin-automation-safe-tos-scraping-guide-2026 | +| 4c | **Phantombuster / scrapers / browser extensions** | Can extract post text + visible engagement counts | **RISKY** — breaches User Agreement (prohibited automation); active ban-wave enforcement | linkedin.com/help/linkedin/answer/a1341387 ; connectsafely.ai/articles/is-linkedin-automation-safe-tos-scraping-guide-2026 | + +## 2. Single cleanest realistic ingest path for ONE user (own data) + +Splits by region, and even at best it is TWO streams, not one: + +**EU/EEA or Switzerland → near-full automation for CONTENT:** +- **Member Data Portability API, self-serve.** Create a dev app **using LinkedIn's mandated default Company Page** ("Member Data Portability (Member) Default Company" — you must NOT create your own page), request the *Member Data Portability (Member)* product, generate a token via the OAuth Token Generator with scope `r_dma_portability_self_serve`, consent, then call **Member Snapshot** (`GET /rest/memberSnapshotData?q=criteria`) for historical full post text (`MEMBER_SHARE_INFO`) + `ARTICLES`, and poll **Member Changelog** for the rolling 28-day live stream. Token valid up to **1 year** before re-consent. Genuinely automatable for a second-brain content store. +- **BUT analytics still come from the manual CSV (4a)** — portability exposes reactions/comments *you made*, not impressions/reach *received*. + +**Outside EU/EEA/CH → manual is the honest answer:** +- Content: native "Get a copy of your data" (larger archive, ~24h, CSV+JSON+HTML) — paths 2 + 3. +- Analytics: native per-post analytics CSV (4a). +- Newsletter: own subscription email (3b) or article HTML from the archive. + +**Is full automation possible?** Only inside EU/EEA/CH, and only for content (not received-analytics). Outside, **manual export/paste is the correct, ToS-safe answer.** Scraper/extension/Phantombuster routes are a **User Agreement breach** with active enforcement (LinkedIn's reported Q1-2026: 23.5M automated sessions flagged) — do not bake into the plugin's ingest. + +## 3. What's uncertain / changing (date: 2026-06-23) + +- **Geofence may widen.** Portability API is DMA-driven, explicitly "**at this time**… EU/EEA + Switzerland." Re-check the MS Learn moniker (`li-dma-data-portability-2026-05`) each quarter. IKKE VERIFISERT that it will ever cover non-EU members. +- **Analytics-in-portability gap.** No Snapshot domain carries received impressions/reach/engagement-rate (`ALL_LIKES`/`ALL_COMMENTS` = actions *you* took). Treat "portability gives you your analytics" as FALSE until a future domain appears. Native analytics CSV stays the only metrics source. +- **Newsletter editions in export/snapshot.** `ARTICLES` covers authored articles; whether every *newsletter edition* is reliably captured as an article vs. needing the email path is **partially uncertain** — native export help does not enumerate newsletter editions. Verify against a real export. +- **Saves / dwell** remain non-exportable (consistent with the plugin's existing S16 note). IKKE VERIFISERT any change. +- **Versioned API drift.** Snapshot uses `LinkedIn-Version: YYYYMM`; domain list/schemas change month-to-month. Pin a version, re-validate. + +## 4. Sources (accessed 2026-06-23) + +**Official / authoritative:** +- Member Data Portability (Member), MS Learn (2026-05): https://learn.microsoft.com/en-us/linkedin/dma/member-data-portability/member-data-portability-member/?view=li-dma-data-portability-2026-05 +- Member Snapshot API, MS Learn (2026-05): https://learn.microsoft.com/en-us/linkedin/dma/member-data-portability/shared/member-snapshot-api?view=li-dma-data-portability-2026-05 +- Member Snapshot Domain list, MS Learn (2026-05): https://learn.microsoft.com/en-us/linkedin/dma/member-data-portability/shared/snapshot-domain?view=li-dma-data-portability-2026-05 +- Member portability APIs, LinkedIn Help: https://www.linkedin.com/help/linkedin/answer/a6214075 +- Community Management Overview, MS Learn (2026-05): https://learn.microsoft.com/en-us/linkedin/marketing/community-management/community-management-overview?view=li-lms-2026-05 +- Download your account data, LinkedIn Help: https://www.linkedin.com/help/linkedin/answer/a1339364/downloading-your-account-data +- Export your Page analytics report, LinkedIn Help: https://www.linkedin.com/help/linkedin/answer/a551206/ +- Manage a newsletter, LinkedIn Help: https://www.linkedin.com/help/linkedin/answer/a517925 +- Prohibited software and extensions, LinkedIn Help: https://www.linkedin.com/help/linkedin/answer/a1341387 +- LinkedIn DMA Portability API Terms: https://www.linkedin.com/legal/l/portability-api-terms + +**Secondary (corroborating, not authoritative):** +- connectsafely — LinkedIn API 2026: https://connectsafely.ai/articles/linkedin-api-complete-guide-2026 +- connectsafely — automation/ToS 2026: https://connectsafely.ai/articles/is-linkedin-automation-safe-tos-scraping-guide-2026 +- tryordinal — Export LinkedIn analytics (Jan 2026): https://www.tryordinal.com/blog/how-to-export-analytics-from-linkedin-to-excel-or-a-csv +- rss.app LinkedIn / chrisns/linkedin-newsletter-rss: https://rss.app/rss-feed/linkedin · https://github.com/chrisns/linkedin-newsletter-rss + +**Verification log (key claims → source):** Portability API exists + self-serve scope + EU/EEA+CH-only + 1-yr token → MS Learn member page. Full post text + articles + historical, no analytics domain → MS Learn snapshot-domain list (verbatim `MEMBER_SHARE_INFO`, `ARTICLES`, `ALL_LIKES`, `ALL_COMMENTS`; no impressions domain present). Changelog 28-day window → MS Learn member page. Native export = CSV+JSON+HTML, articles as HTML, ~24h, saved-items URLs-only → LinkedIn Help + corroborating guides. No native newsletter RSS → LinkedIn Help newsletter page (silent on RSS) + third-party scrapers fill the gap. Per-post analytics CSV manual, ~1yr cap, only metrics source → LinkedIn Help + tryordinal. Scraping = User Agreement breach w/ enforcement → LinkedIn Prohibited Software + connectsafely. diff --git a/docs/second-brain/research/secondbrain-sota.md b/docs/second-brain/research/secondbrain-sota.md new file mode 100644 index 0000000..a947399 --- /dev/null +++ b/docs/second-brain/research/secondbrain-sota.md @@ -0,0 +1,132 @@ +# Research — Second Brain / Personal-Memory SOTA 2026 (synthesis + architecture rec) + +> Research thread `secondbrain-recon`, 2026-06-23. Four streams (agent memory, PKM, profile-evolution, pitfalls) with ESTABLISHED / EMERGING / SPECULATIVE labels. Research INPUT to `../architecture.md` — the architecture's design choices trace to this. Preserved so nothing is lost. + +## PART A — STATE OF THE ART (sourced, calibrated) + +### 1. Agent memory architectures + +**Three-tier taxonomy is the design default** (ESTABLISHED). Episodic (timestamped *what happened*), semantic (distilled *what is true*), procedural (*how to behave*). Formalized for LLM agents by CoALA (arXiv:2309.02427); shared vocabulary across Letta, Mem0, Zep, LangMem. Load-bearing for us: **keep episodic and semantic physically separate** — mixing raw events with distilled facts degrades retrieval both ways. Procedural is rarest; only LangMem makes it first-class. + +**Hybrid vector + structured/graph beats pure vector** (ESTABLISHED). Pure embeddings fail on multi-hop, temporal ("what did the user want in Q1 vs now"), exact-identifier, and contradiction queries. 2026 pattern = query router over vector (semantic) + graph/relational (entities + temporal validity) + BM25 (keyword), fused via Reciprocal Rank Fusion + cross-encoder rerank (+39.7% MRR@3, arXiv:2604.01733). Named systems: +- **Mem0** (arXiv:2504.19413, ~48K stars) — vector-primary + entity linking; LLM extracts facts per turn; ~90% token reduction. Benchmark numbers vendor-disputed. +- **Zep/Graphiti** (arXiv:2501.13956) — **bi-temporal knowledge graph**: every fact an edge with `valid_from`/`valid_until`. 63.8% vs Mem0's 49.0% on independent LongMemEval. **Most important pattern for us** (staleness). +- **Letta/MemGPT** (arXiv:2310.08560) — OS-tiered (core/recall/archival), agent self-manages via tools. 74.0% LoCoMo. +- LangMem (EMERGING), Cognee (EMERGING, graph-RAG for docs), A-MEM (EMERGING, NeurIPS 2025). + +**Consolidation = the "reflection/sleep" pattern** (ESTABLISHED original; sleep-time-compute EMERGING). Stanford Generative Agents (arXiv:2304.03442): append-only memory stream → LLM importance-scores each event → periodic reflection synthesizes higher-order insights stored back as semantics. Letta sleep-time compute (arXiv:2504.13171) runs it as background agents during idle so the main loop stays fast. + +**Forgetting is mandatory** (ESTABLISHED TTL/LRU; EMERGING importance-weighted decay). "Append-only memory stores are a trap." Tiers: session ~24h, project ~6mo, core indefinite, cold-archive after 90–180d unused. Mem0 decay is a *retrieval re-ranking* layer (recent → 1.5× boost, unused → 0.3× damp), not deletion. The Generative-Agents **retrieval triad** (recency + importance + relevance) is still the reference scoring formula. + +### 2. PKM methods + AI-native tools +- **PARA** (Forte) — top-down, organize-by-*actionability*; strong for ops, no native linking. +- **Zettelkasten / Evergreen** (Luhmann/Matuschak) — atomic note (one idea), dense links, **emergent** bottom-up structure; the cognitive work is *writing the note*, which embeddings can't replace. +- **LYT/Maps of Content** (Milo) — navigation layer over the graph. + +**2026 consensus is NOT "links win"** — embeddings make folder-vs-link partly moot for *retrieval* while reaffirming neither scales alone. Mem (OpenAI embeddings + Pinecone), Tana (every bullet a graph node, typed Supertags), Obsidian Smart Connections (*local* embeddings — privacy). None *auto-organize* — they auto-*retrieve*. AI collapses CODE's **Organize** step (misfiling recoverable) and accelerates **Capture**; it does NOT do **Distill** (cross-temporal relevance) or **Express** (human synthesis). Sober dissent: "the best memory tool is the one you actually keep feeding" — capture/maintenance friction dominates retrieval sophistication. + +### 3. Profile-evolution / preference-learning +**Two-layer profile is standard** (ESTABLISHED): static (stable) + dynamic (evolving). PersonaMem (COLM 2025): frontier models **freeze on the static snapshot, fail to track the dynamic layer** — frozen-past-self is empirically real. + +**A plugin gets only app-level adaptation** (no model-level RLHF/DPO/per-user-LoRA). App-level = **RAG** (retrieve past interactions) + **PAG** (summarize history into a compact profile, inject, re-summarize on update — PURE, SIGIR 2025). **Style/voice = few-shot from the user's own corpus**; content-similar + length-matched selection beats random (arXiv:2509.14543), but fidelity to a private individual's implicit style is limited even at SOTA. + +**Drift guards (the crux):** +- **Recency weighting** (ESTABLISHED): `score = α·sim + (1-α)·f_time(Δt)`. +- **Event-driven invalidation** (EMERGING): OpenAI "Dreaming" auto-rewrites "going to X in July" → "went to X" after the date (recall 41.5%→82.8%). State-typed facts (active/completed/expired). +- **Explicit user curation** (ESTABLISHED): ChatGPT/Claude/Gemini all expose view/edit/delete; ChatGPT (Oct 2025) added **roll-back to prior versions**. Claude is notably transparent about *when* memory influences a reply. +- **Contradiction detection** (EMERGING): 4-type typology (MemConflict, arXiv:2605.20926) — temporal-update (supersede), contradictory (keep both timestamped), condition-dependent (scope), distractor (drop). Zep handles structurally via validity intervals. +- **Confidence/evidence thresholds before storing** (EMERGING): don't promote a single observation to the stable profile — require confirmation, ≥N independent observations, or a min confidence score. Separate episodic (session) from semantic (cross-session). + +**Sycophancy is the personalization-specific landmine** (ESTABLISHED). RLHF reward models prefer agreement over truth; **providing user memory/profile context measurably INCREASES sycophancy** (arXiv:2509.12517), worse across multi-turn pressure (SYCON Bench). Documented OpenAI insider admission: showing users their own profile assessments triggered backlash, and one response was to make the model *less honest about the user*. Best app-level mitigations: **explicit anti-sycophancy system instruction** ("maintain your position unless given new evidence/logic" — up to 64% improvement on Turn-of-Flip) and **"ask, don't tell"** framing. Users *prefer* sycophancy when offered the choice → must be a built-in default, not a toggle. + +### 4. Pitfalls +- **Drift / model collapse** (ESTABLISHED, Shumailov et al., Nature 2024): a profile learning from its *own* AI output narrows and degrades. **Never "replace"; always "accumulate" with provenance.** ACUTE FOR US: a content engine that learns voice from posts *it drafted* collapses the voice toward its own priors. Voice must learn from *actual published, human-edited* output only — the existing `voice-scrubber` already encodes this ("gold standard = approved editions, not the English post corpus"). +- **Staleness** (ESTABLISHED, STALE benchmark arXiv:2605.06527): *all* tested models+frameworks (incl. Zep, Mem0) fail to notice a valid memory went obsolete. RankSquire 50K-session prod test: 93.4% at deploy → 58.2% at month 3 (41% stale). Mitigate: timestamp every write, surface at retrieval, RAG live sources for fast-changing facts. +- **Privacy / memory poisoning** (ESTABLISHED, OWASP **ASI06**): MINJA (NeurIPS 2025) — 98.2% injection success from query-only access; ZombieAgent (Jan 2026) — zero-click persistent injection via ChatGPT Connectors. Persistent poisoning is *worse* than ordinary prompt injection (attack/execution temporally decoupled). **Local-first/plain-file shrinks the blast radius** (no remote exfil endpoint; git = audit/rollback). +- **Lock-in** (ESTABLISHED): proprietary embedding stores can't export; plain-text/Markdown + git is the answer ("swap the model, feed it the same Markdown"). +- **Frozen past self** (ESTABLISHED): profile over-represents who you were, pulls present output backward. Mitigate: time-weighted retrieval + periodic user-authored "who am I now" anchor that deprecates old inferences. +- **Context rot / over-retrieval** (ESTABLISHED, Chroma report Jul 2025, all 18 models): more retrieved memory → *lower* accuracy; coherent context can hurt more than shuffled. Strict top-k budgets, multi-signal retrieval, pruning. +- **Maintenance graveyard** (ESTABLISHED): 82% abandon second brains within 6 months; ">30 min/week upkeep predicts failure." Retrieval-first, not archive-first. FOR US: the evolution loop must be automated and zero-curation-by-default, curation available but never required. + +## PART B — ARCHITECTURE RECOMMENDATION FOR LINKEDIN STUDIO + +### B.0 What we already have (the silos) +Five per-user stores under `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/`, all surviving reinstall, all routed through one tested seam (`hooks/scripts/data-root.mjs` + TS twin `storage.ts:getDataRoot`): +1. **voice-samples/** — authentic samples + chronicle-voice-drift-log (style layer) +2. **trends/trends.json** — deterministic, provenance-bearing, dedup-by-title+URL +3. **specifics-bank/specifics-bank.json** — typed lived-specifics (`number|named-case|what-broke|contrarian|mind-change`), verification-tracked; already self-described as "the first concrete tributary of the broader second-brain architecture" +4. **analytics/** — exports, per-post, weekly/monthly reports, content-history +5. **drafts/, plans/, profile/user-profile.md, examples/, templates/, audience-insights/** + +The seam is the most valuable thing we own — the SOTA "route all memory I/O through one resolver" pattern is **already built and tested**. We unify + add an evolution loop, not start from zero. + +### B.1 The shape: a thin Markdown hub over typed tributaries (NOT a vector/graph DB) +**Recommendation: plain-Markdown+JSON two-layer "second brain," not Pinecone/Neo4j.** The portable, auditable, drift-resistant, zero-new-infra SOTA is plain-text + git (Manus, AGENTS.md, "markdown files are all you need"), augmented with the retrieval-triad + temporal-validity ideas implemented as deterministic JSON/MD. A heavyweight store would be wrong here: it adds infra, breaks portability, and **Claude IS the semantic-retrieval engine** (it reads files in-context). Keep silos as files; add a hub that points at them + a loop that maintains them. + +Two layers, physically separated (episodic/semantic split): +``` +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ + brain/ + profile.md # SEMANTIC, two-layer: §static + §dynamic; each fact carries + # value · first_seen · last_seen · evidence_count · + # provenance(human|published|ai-draft) · status(active|superseded) + index.md # the HUB / Map-of-Content: one-screen pointer to every tributary + # + freshness flags. The "memory AND ops center" front page. + operations.md # PLANS/IDEAS: goals, bets, parked ideas, the "who I am now" + # restatement anchor (frozen-past-self guard lives here) + journal/ # EPISODIC, append-only: YYYY-MM-session.md — raw, never edited, + # the source the loop consolidates FROM + voice-samples/ # tributary (style) — stays its own store + specifics-bank/ # tributary (raw material) — stays its own store + trends/ # tributary (external signal) — stays its own store + analytics/ # tributary (performance) — stays its own store + ingest/ # NEW: raw real LinkedIn output lands here + inbox/ # drop-zone: manual import OR connector output + published/ # processed, provenance=published — the GOLD signal +``` + +**Tributaries vs hub — explicit call:** +- **Stay separate (tributaries), feed the hub via PAG summaries:** voice-samples, specifics-bank, trends, analytics, ingest. Each has a *different dedupe key + lifecycle* (trends: title+URL, fast decay; specifics-bank: human-only, near-permanent; analytics: numeric, immutable). Forcing one schema would destroy those distinctions. The hub holds a distilled pointer-summary of each. +- **Fold INTO the hub:** the flat `profile/user-profile.md` → two-layer `brain/profile.md`. Scattered `audience-insights/`, `examples/` → dynamic-layer sections or tributary-summaries referenced from `index.md`. The **operations/plans center** is genuinely new. + +### B.2 The evolution loop (the compounding mechanism) +Run on a cadence (reuse the existing session-start staleness-nudge surface) as a **sleep-time consolidation pass**, not inline cost: +1. **Capture (episodic, auto):** real-signal sessions append to `brain/journal/`. The **ingest seam** (`ingest/inbox/`) takes the user's actual published posts — *manual import first* (LinkedIn has no clean self-serve content API; analytics is already manual CSV per CLAUDE.md S16 — so manual is the honest default; any connector is additive). Published output tagged `provenance=published`. +2. **Consolidate (reflection):** an Opus agent reads new journal + published + tributary deltas, proposes profile updates as a **diff**, never a silent overwrite. Each candidate carries `evidence_count`, `provenance`, timestamps. +3. **Promote with thresholds (anti-overfit):** a candidate enters the **static** layer only on explicit confirmation OR ≥N independent observations; one-offs stay in the **dynamic** layer (which decays). Single weird sessions never reshape identity. +4. **Reconcile contradictions:** classify (temporal → supersede + keep old `status: superseded` w/ date; contradictory → both timestamped; condition-dependent → scope; distractor → drop). Zep's validity-interval idea in plain frontmatter. +5. **Decay + freshness:** every fact has `last_seen`; retrieval weights recent over old; session-start nudge flags facts not refreshed in T days (extend the existing trend-staleness nudge to the whole brain). +6. **Curate (optional, never required):** diff is presentable; user can confirm/edit/delete/roll-back (git = free rollback). Default path needs zero curation — graveyard guard. + +### B.3 Keeping it user-aligned + drift-resistant (the hard part, concrete) +- **Provenance-weighted voice learning (THE critical guard):** profile/voice **learn from `provenance=published` only, never `ai-draft`.** Model-collapse guard for our exact risk. Generalizes `voice-scrubber`'s "gold standard = approved editions" to a system-wide invariant: AI-generated content is *never* a training signal for who the user is. +- **Anti-sycophancy as built-in default, not toggle:** consolidation + content agents carry an explicit instruction to treat the profile as evidence to *test*, not flatter ("you mentioned X — still true?"), and surface profile-grounded claims as checkable, not asserted. Users prefer sycophancy when given the choice → don't offer it. +- **Frozen-past-self guard:** `operations.md` holds a periodic user-authored "where I'm headed now" anchor that deprecates older inferences; time-weighted retrieval; the dynamic layer decays so the present dominates. +- **Confidence before commitment:** episodic ≠ semantic; promotion needs evidence density. Highest-leverage anti-overfit mechanism, and cheap (a counter + a threshold). + +### B.4 What's genuinely hard (flagged honestly) +1. **Voice fidelity to a private individual is limited even at SOTA** (arXiv:2509.14543) — the real win is grounded *content* (specifics-bank) over mimicked *style*. Manage expectations. +2. **Detecting that a high-relevance fact went stale is UNSOLVED field-wide** (STALE: everyone fails). We can timestamp + nudge; we cannot reliably auto-detect "user changed jobs." Keep humans in that loop. +3. **Contradiction classification** (real change vs context-scoped vs noise) is EMERGING + error-prone; bias toward "keep both, timestamped, surface the conflict" over silent overwrite. +4. **The connector** — no clean LinkedIn self-serve content/analytics API. Build the **manual ingest seam as the contract**; any future connector is a tributary into the same inbox, never a dependency. +5. **Sycophancy is structural** — app-level mitigations reduce, not eliminate; personalization context increases it, so counter-pressure it every time the profile is used. +6. **Avoiding the graveyard is a PRODUCT problem** — the loop must compound value with ~zero required curation or it dies in 6 months like 82% of second brains. + +### B.5 Why this fits us +- Reuses the one tested seam (`getDataRoot`) — SOTA single-resolver is done. +- Plain-text + git — portable, auditable, survives reinstall, no new infra; Claude is the semantic-retrieval engine reading files in-context (no vector DB). +- specifics-bank + trends already prove the tributary pattern (deterministic, typed, provenance-bearing, dedup-disciplined) — extend, don't reinvent. +- The four SOTA ideas that port cleanly to files: episodic/semantic split, provenance-weighting, evidence-threshold promotion, temporal-validity frontmatter. The vector/graph machinery does NOT port and we don't need it. + +**Bottom line (one line):** Build a thin two-layer Markdown hub (`brain/`: semantic `profile.md` + episodic `journal/` + ops `operations.md` + `index.md` MOC) over the existing typed tributaries, fed by a provenance-tagged ingest seam, maintained by a sleep-time consolidation loop with evidence-threshold promotion, temporal-validity reconciliation, and a built-in anti-sycophancy/anti-collapse stance — every silo stays a tributary except the flat `user-profile.md`, which folds in. + +## KEY SOURCES (anchors) +- CoALA arXiv:2309.02427 · Generative Agents arXiv:2304.03442 · MemGPT/Letta arXiv:2310.08560 · Letta sleep-time arXiv:2504.13171 +- Mem0 arXiv:2504.19413 · Zep/Graphiti arXiv:2501.13956 · A-MEM (NeurIPS 2025) arXiv:2502.12110 · Cognee arXiv:2505.24478 +- PersonaMem (COLM 2025) arXiv:2504.14225 · PURE (SIGIR 2025) · style-imitation arXiv:2509.14543 · MemConflict arXiv:2605.20926 +- interaction-context-sycophancy arXiv:2509.12517 · OpenAI Dreaming (openai.com/index/chatgpt-memory-dreaming) · Anthropic sycophancy research +- Shumailov model collapse (Nature 2024, s41586-024-07566-y) · STALE benchmark arXiv:2605.06527 · MINJA (NeurIPS 2025) arXiv:2503.03704 · OWASP ASI06 · Chroma Context Rot (trychroma.com/research/context-rot) · Lost-in-the-Middle (Liu et al. 2023) +- PKM: buildingasecondbrain.com · zettelkasten.de · notes.andymatuschak.org · Mem/Pinecone engineering · Tana · Obsidian Smart Connections +- Portability: AGENTS.md (Linux Foundation) · "markdown files are all you need" (voxos.ai, dev.to) + +> NOTE on sources: a few arXiv ids above carry 2025–2026 future-dated identifiers as returned by the research thread. Treat the *patterns* as the durable takeaway; re-verify any specific citation before quoting it externally (verification duty). diff --git a/docs/second-brain/research/silo-inventory.md b/docs/second-brain/research/silo-inventory.md new file mode 100644 index 0000000..9b25748 --- /dev/null +++ b/docs/second-brain/research/silo-inventory.md @@ -0,0 +1,110 @@ +# Research — Per-User Data Silo Inventory (LinkedIn Studio, 2026-06-23) + +> Research thread `silo-inventory`, 2026-06-23. Every silo read from the actual files (TS types/store code, .mjs writers, templates, command/agent prose), not inferred. Research INPUT to `../architecture.md`. Preserved so nothing is lost. (file:line refs were accurate at capture time — re-confirm before editing those lines.) + +The unifying seam is the **data-path convention** (`hooks/scripts/data-root.mjs:24` `getDataRoot()` / `scripts/analytics/src/utils/storage.ts getDataRoot`), default `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/`. Twin implementations (Node `.mjs` for hooks, TS for analytics/trends/specifics CLIs) because runtimes differ — `data-root.mjs:1-8` documents the must-stay-in-sync contract. The **M0 migration manifest** (`hooks/scripts/migrate-data.mjs:30-54`) is the single source of truth for what moves out of the plugin tree on session-start. + +## The silos + +### A. State file (`## Recent Posts`, streaks, milestones, first-hour, outreach) +- **Location:** `~/.claude/linkedin-studio.local.md` — **NOT** under the data-root seam. Own resolver (`state-updater.mjs:11-12`, `STATE_FILE` override; mirrored in `data-root.mjs:30 getStateFile()`). The data root "mirrors" this file (`data-path-convention.md:15`) but they are two separate locations. +- **Format:** YAML frontmatter + markdown sections. +- **Schema** (`config/state-file.template.md`): frontmatter — `last_post_date`, `first_post_date`, `last_post_topic`, `posts_this_week`, `weekly_goal`, `current_streak`, `longest_streak`, `current_week`, `last_import_date/week`, `follower_count/target`, `target_date`, `monthly_growth[]`, `projected_10k_date`, `growth_rate_needed`, `next_planned_topic`, `pending_5x5x5`, `content_series_active`, `last_firsthour_date`, `firsthour_active`, `last_outreach_date`, `outreach_active`, `expertise_areas[5]`. Sections — `## Recent Posts` (`- [YYYY-MM-DD] "hook…" (chars) - topic`), `## Session Notes`, `## Planned Content`, `## Milestone Log` (`- [YYYY-MM] count (+delta)`), `## First-Hour Plans`, `## Outreach Pipeline`. +- **Writers:** `state-updater.mjs` — `updatePostTracking` (:55), `updateFollowerCount` (:181), `recordFirstHourPlan` (:235), `recordOutreachContact` (:294), `pruneContentHistory` (:138). From Stop hook + `/firsthour`, `/outreach`, `/calendar` publish. **Readers:** SessionStart hook, `/analyze`, `/report`, content-planner. +- **Lifecycle:** `## Recent Posts` auto-pruned at 90d on session-start (`state-updater.mjs:138`). Streak resets if post gap >2d (:94). Week rollover resets `posts_this_week` via `week-rollover.mjs`. +- **Survives reinstall:** YES (in `~/.claude/`, not plugin tree). + +### B. Trends store +- **Location:** `${data}/trends/trends.json` — through seam (`scripts/trends/src/store.ts:180 defaultStorePath()`). +- **Format:** JSON (TS-managed), `{ schemaVersion:1, trends:TrendRecord[] }`. +- **Schema** (`trends/src/types.ts:26`): `TrendRecord` = `id` (sha256[:12] of normalized title+url = dedupe key), `title`, `url`, `source`, `capturedAt` (ISO), `topics[]`, `summary?`. +- **Writers:** CLI `addTrend` (`store.ts:111`), populated by trend-spotter. **Readers:** `queryByTopic` (:137), `history` (:156), `newestCaptureDate` (:171). +- **Lifecycle:** Dedup on title+url; re-capture **unions topics**, keeps first-sight provenance. `newestCaptureDate` drives the SessionStart trend-freshness nudge (B-S3). No prune. +- **Survives reinstall:** YES. + +### C. Lived-specifics bank +- **Location:** `${data}/specifics-bank/specifics-bank.json` — through seam (`scripts/specifics-bank/src/bank.ts:150 defaultBankPath()`). +- **Format:** JSON (TS-managed), `{ schemaVersion:1, specifics:Specific[] }`. +- **Schema** (`specifics-bank/src/types.ts:46`): `Specific` = `id` (sha256[:12] of normalized content = dedupe key), `type` (`number`|`named-case`|`what-broke`|`contrarian`|`mind-change`|`other`), `content` (operator's own words, never AI-generated — invariant `types.ts:13`), `topicTags[]`, `provenance{capturedAt,source}` (source = edition id/command/"manual"), `verification` (`verified`|`unverified`|`n/a`), `status` (`active`|`archived`). +- **Writers:** CLI `addSpecific` (`bank.ts:102`), from `/newsletter` Step 1.5 elicitation. **Readers:** `queryByTopic` (:128) — excludes archived. Companions `binding.ts` (Step 2.5 gate), `kilder.ts`. +- **Lifecycle:** Dedup on content; re-capture unions tags. Archive = soft-hide. No time-prune. `verification` gates numbers. +- **Survives reinstall:** YES. + +### D. Analytics store +- **Location root:** `${data}/analytics/` via `getAnalyticsRoot()` (`scripts/analytics/src/utils/storage.ts:67`) — through seam; legacy `ANALYTICS_ROOT` honored (deprecated). Subdirs: `exports/`, `posts/`, `weekly-reports/`, `monthly-reports/`, `ab-tests/`. +- **Format:** JSON (TS-managed) posts/reports; CSV input; markdown ab-tests. +- **Schema** (`analytics/src/models/types.ts`): `PostAnalytics` = `id`, `title`, `publishedDate`, `metrics`, `importedAt`, `exportSource` — file `posts/YYYY-MM-DD-<8char>.json` inside `AnalyticsBatch` (`batchId`, `importedAt`, `exportFilename`, `dateRange{from,to}`, `postCount`, `posts[]`). `PostMetrics` = `impressions`, `reactions`, `comments`, `shares`, `clicks`, `engagementRate`, `saves?` — **no `dwell`** (intentional). `WeeklyReport`(`weekly-reports/YYYY-WXX.json`)/`MonthlyReport`(`monthly-reports/YYYY-MM.json`) = `summary{totals incl. totalSaves?, avgEngagementRate, avgImpressionsPerPost}`, `topPerformers[]`, `underperformers[]`/`byWeek[]`, `trends{…percentChange}`, `alerts[]`. +- **`saves`** (`parseOptionalCount`, `csv-parser.ts:71`): blank/non-numeric/negative→`undefined` (unknown, never 0); genuine `0` kept; **not folded into `engagementRate`**; surfaced only when ≥1 post carries it. +- **Writers:** analytics CLI (`cli.ts`) — `import`→`posts/`, `report`→weekly/monthly; ab-tests written by `/ab-test` prose. **Readers:** CLI report/trends/heatmap subcommands; analytics-interpreter agent (no agent re-reads posts JSON directly — report path is CLI-internal). +- **Lifecycle:** Posts deduped by post id on import (latest wins). No time-prune. Reports overwrite by key. +- **Survives reinstall:** YES. + +### E. Drafts queue +- **Location:** `${data}/drafts/queue.json` — through seam (`queue-manager.mjs:10` `join(getDataRoot('drafts'),'queue.json')`). Migrated `migrate-data.mjs:32`. +- **Format:** JSON, `{ version:1, queue:[] }`. +- **Schema** (`queue-manager.mjs:63-79`, the actual writer): `id`, `draft_path`, `scheduled_date`, `scheduled_time`, `pillar`, `format`, `hook_preview`, `character_count`, `status` (`'scheduled'` on add; →`posted`/`archived`/`skipped`/`draft` via `queueUpdateStatus`), `created_at`. +- **Writers:** `queueAdd`/`queueUpdateStatus` (:63,:82) — `/batch`, `/calendar`. **Readers:** `queueRead`/`Today`/`Upcoming`/`Overdue` (:41-99), SessionStart, `/calendar`, `/pipeline`, ical-generator. +- **Lifecycle:** No prune. Dedup-by-id on add (:64). No week reset. +- **Survives reinstall:** YES. + +### F. Draft files on disk (week / carousel / multiplatform / repurposed) +- **Location (all through seam, confirmed in prose):** `${data}/drafts/week-*/…md` (migrated `migrate-data.mjs:46,129`), `${data}/drafts/carousel-YYYYMMDD-SLUG/slide-N.png` (`commands/carousel.md:133,147`), `${data}/drafts/multiplatform/[platform]-[slug].md` (`commands/multiplatform.md:118`), `${data}/drafts/repurposed/` (`agents/content-repurposer.md:597`). Only `week-*` is in the migration manifest; carousel/multiplatform/repurposed are born external (command/agent prose writes straight to the data root). +- **Format:** Markdown (+ YAML frontmatter) / PNG. +- **Writers:** creation commands + content-repurposer. **Readers:** `/calendar`, `/pipeline`, repurpose/carousel/video. +- **Lifecycle:** No automatic prune — files accumulate; manual cleanup. Dedup by filename/dir slug only. +- **Survives reinstall:** YES. + +### G. Content plans +- **Location:** `${data}/plans/<weekly|monthly>-plan-*.md` — through seam (`agents/content-planner.md:7,40,464`). Born external (not in manifest). +- **Format:** Markdown (+ frontmatter: `plan_date`, `generated`, `status`, `posts_planned`, pillars, `content_mix`). +- **Writers:** content-planner agent (:464, can also create Linear issues). **Readers:** content-planner (scans prior plans to avoid repetition :40), SessionStart, `/calendar`. +- **Lifecycle:** No prune; planner does content-level dedup vs prior plans. **Survives reinstall:** YES. + +### H. Voice profile + voice-drift log +- **Location:** `${data}/voice-samples/authentic-voice-samples.md` (external canonical; in-plugin **placeholder seed** at `assets/voice-samples/authentic-voice-samples.md` with `<!-- VOICE_PLACEHOLDER -->` sentinel; migrated MOVE dropping `.local`, `migrate-data.mjs:31`). Plus `${data}/voice-samples/chronicle-voice-drift-log.md` (external-only, no seed; written/read by `voice-scrubber`). +- **Resolution:** `personalization-score.mjs` `pick(dataRel,pluginRel)` (:30-32) prefers external, falls back to seed. Voice scores 25pts iff line-count>50 AND no sentinel. +- **Format:** Markdown sections (Core Voice Characteristics, Do's/Don'ts, Signature Phrases, Vocabulary, Language Guidelines, Update Log, auto-appended "Collected Post Samples"). +- **Writers:** `/setup`/`/onboarding` (overwrite placeholder), voice-trainer (appends samples), voice-scrubber (drift log). **Readers:** `personalization-score.mjs`, `user-prompt-context.mjs`, content commands. +- **Survives reinstall:** external instance YES; seed NO (it's the seed). + +### I. User profile +- **Location:** `${data}/profile/user-profile.md` (migrated MOVE from `config/user-profile.local.md`, `migrate-data.mjs:34`; template seed `config/user-profile.template.md`). Scored 20pts when <3 `[Your ` placeholders remain (`personalization-score.mjs:54`). +- **Format/schema:** Markdown — Personalization Settings (identity, expertise areas, audience, goals), Voice & Style, Strategic Context, **Research Tooling** (which MCPs available), Asset Utilization Preferences. +- **Writers:** `/setup`/`/onboarding` + manual edit. **Readers:** `personalization-score.mjs`, `user-prompt-context.mjs`, commands route MCPs from it. **Survives reinstall:** external YES. + +### J. COPY-class scaffolds (examples / audience-insights / templates) +Four files ship a tracked seed + canonical external instance (`migrate-data.mjs:49-54 COPY_FILES`; external never clobbered): `${data}/examples/high-engagement-posts.md`, `${data}/audience-insights/{demographics,engagement-patterns}.md`, `${data}/templates/my-post-templates.md`. Markdown. Written by user/voice-trainer; read by `personalization-score.mjs` (pick-resolved) + content commands. **Survives reinstall:** external YES, seed NO. + +### K. content-history.md — vestigial second surface +- **Location:** `${data}/analytics/content-history.md` (migrated MOVE, `migrate-data.mjs:33`; template `config/content-history.template.md`). Markdown table (`Date|Hook|Topic|Format|Words|Chars|Source`). +- **Status:** No hook/agent code actively writes it — the live content-history surface is the **state file's `## Recent Posts`** (silo A). Migrated-but-superseded scaffold. +- **Survives reinstall:** YES (but inactive). + +### L. iCal export +`${data}/drafts/week-WXX/schedule.ics`, generated from queue by `ical-generator.mjs` (`/batch`). RFC-5545. Transient/regenerated, not a knowledge store. Survives reinstall: YES. + +## Synthesis + +### Already per-user + reinstall-surviving (the "done" set) +A–L all route through the seam or `~/.claude/`. **There is essentially no in-plugin/tmp data left to migrate** — M0 already did it. Only `/tmp/linkedin-hooks/session-active` (a session marker, not data) and the in-plugin **seeds** stay in-tree by design. + +### What is NOT yet unified (the real gap) +Per-user but **siloed and heterogeneous** — three storage idioms, two roots, no cross-references: +1. **Two roots, not one.** State file at `~/.claude/linkedin-studio.local.md` (own resolver, `state-updater.mjs:12`); everything else under `${data}/`. Unification must decide whether the state file folds in or stays a sibling. +2. **Two writer disciplines.** TS-managed JSON (trends, specifics, analytics) with `schemaVersion` + sha256-id dedup + tested pure functions — **vs.** regex markdown-section mutation (state file, `state-updater.mjs`) — **vs.** schema-less free-form markdown written by command/agent prose (drafts, plans, voice, scaffolds). + +### Natural overlaps / relationships +- **Posts triple-counted.** A published post lands in three non-referencing places: state `## Recent Posts` (A, live), `analytics/content-history.md` (K, dead), `analytics/posts/*.json` (D, from CSV). Same event, three schemas, no shared id. +- **specifics ↔ posts ↔ analytics.** A `Specific` (number/named-case) → a post (F) → a `PostAnalytics` row (D). Nothing links the raw material to the post it seeded or to that post's measured performance — "which raw material performs" is unanswerable today. +- **trends ↔ plans ↔ drafts.** trend-spotter captures `TrendRecord` (B); content-planner plan (G) should cite it; draft (F) executes it — all free-text, no id linkage. +- **voice ↔ content-history ↔ analytics.** voice-trainer learns from "Collected Post Samples"; the high performers that should train it live in analytics (D) + examples (J) — three disconnected views of "your good posts." +- **provenance is the common latent field.** trends (`source`+`capturedAt`), specifics (`provenance{source,capturedAt}`), analytics (`importedAt`,`exportSource`), queue (`created_at`) each independently reinvented capture-provenance under different names. + +### The 5 hardest unification problems +1. **Schema-idiom mismatch.** Folding regex-mutated markdown (state file) and prose-written free-form markdown (drafts/plans/voice) into the typed `schemaVersion`+dedup discipline of the JSON stores is the central rewrite. JSON stores (B/C/D) are already second-brain-shaped; the markdown silos are not. +2. **Identity / dedup across silos.** Each store has its own id — sha256(title+url) trends, sha256(content) specifics, hash(title+date) analytics, slug ids queue. **No stable cross-silo entity id**, so the post→specific→trend→analytics graph can't be assembled. Needs one canonical id minted at creation and threaded through. +3. **Two roots + legacy-alias window.** Reconcile `getStateFile()` with `getDataRoot()` while three deprecated aliases (`ANALYTICS_ROOT`/`STATE_FILE`/`PLUGIN_ROOT`) are honored "for one minor version" (`data-path-convention.md:17`) and **two twin implementations** (`data-root.mjs` ⇄ `storage.ts`) must stay byte-compatible. +4. **Triple-recorded post + dead content-history.** K is a migrated-but-unwritten duplicate of A, and neither links to D. Unification must pick one canonical post record and retire/back-fill the other two — a data-reconciliation problem, not just schema. +5. **Provenance normalization + lifecycle divergence.** Each silo reinvented provenance under different field names; retention is inconsistent — 90-day prune (state Recent Posts only), soft-archive (specifics), topic-union-merge (trends/specifics), overwrite-by-key (reports), **no prune at all** for drafts/plans/queue/voice/trends. Needs one provenance shape and one coherent retention policy. + +Key files: `hooks/scripts/data-root.mjs`, `hooks/scripts/migrate-data.mjs:30-54`, `hooks/scripts/state-updater.mjs`, `hooks/scripts/queue-manager.mjs:10-91`, `scripts/trends/src/{types,store}.ts`, `scripts/specifics-bank/src/{types,bank}.ts`, `scripts/analytics/src/{utils/storage,models/types,parsers/csv-parser}.ts`, `hooks/scripts/personalization-score.mjs:30-152`, `config/state-file.template.md`, `references/data-path-convention.md`. diff --git a/hooks/prompts/state-update-reminder.md b/hooks/prompts/state-update-reminder.md index 499110e..cb8c529 100644 --- a/hooks/prompts/state-update-reminder.md +++ b/hooks/prompts/state-update-reminder.md @@ -39,7 +39,7 @@ writeState(content => updateFollowerCount(content, { **3. Queue Status Check** -If posts were added to the queue during this session (`assets/drafts/queue.json` was modified): +If posts were added to the queue during this session (`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json` was modified): - Confirm how many posts were queued and their scheduled dates - Remind: "View your full schedule with /linkedin:calendar" @@ -54,7 +54,7 @@ Provide reminders naturally based on what was done in the session. If no LinkedI If a LinkedIn post was created or finalized in this session, save the full post text as a voice sample: - Read the full post text from the draft that was just created -- Check if `assets/voice-samples/authentic-voice-samples.md` exists +- Check if `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` exists - Append the full post to the `## Collected Post Samples` section: ``` ### [YYYY-MM-DD] — [post type] ([char count] chars) @@ -63,23 +63,3 @@ If a LinkedIn post was created or finalized in this session, save the full post - **Ask the user for confirmation** before writing: "I'll save this post as a voice sample for drift detection. OK?" - This builds the voice sample library that enables automatic drift scoring (needs 5+ samples for reliable scoring) - The more samples collected, the more accurate the voice-trainer's drift detection becomes - -**5. Content History Log** (if a post was created) - -If a LinkedIn post was created or finalized, append an entry to the content history log: - -- If `assets/analytics/content-history.md` does not exist, initialize it from `config/content-history.template.md` -- Append a new row to the "## Content Log" table: - ``` - | YYYY-MM-DD | "Hook text..." | topic_area | format | word_count | char_count | source | - ``` - Where: - - `date`: Today's date - - `hook`: First 60 characters of the hook line - - `topic`: Matching expertise_area value (for pillar tracking) - - `format`: post/quick/react/video/pipeline - - `word_count`: Word count of the full post - - `char_count`: Character count of the full post - - `source`: original/url/curated (where the idea came from) -- This is append-only — never edit or delete existing entries -- This log enables `/linkedin:report` and `analytics-interpreter` to track content production over time without requiring LinkedIn CSV imports diff --git a/hooks/prompts/voice-guardian.md b/hooks/prompts/voice-guardian.md index 65ca5ba..ff9239d 100644 --- a/hooks/prompts/voice-guardian.md +++ b/hooks/prompts/voice-guardian.md @@ -28,7 +28,7 @@ If two or more of these are missing, flag it alongside the AI-pattern alert: the ## 2. Six-Dimension Voice Drift Scoring -Read the voice profile and collected post samples from `${CLAUDE_PLUGIN_ROOT}/assets/voice-samples/authentic-voice-samples.md`. +Read the voice profile and collected post samples from `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md`. Score the draft against these 6 dimensions (0 = perfect match, 1 = minor drift per dimension): diff --git a/hooks/scripts/__tests__/clipboard-helper.test.mjs b/hooks/scripts/__tests__/clipboard-helper.test.mjs index 3407b4e..409811f 100644 --- a/hooks/scripts/__tests__/clipboard-helper.test.mjs +++ b/hooks/scripts/__tests__/clipboard-helper.test.mjs @@ -58,7 +58,7 @@ describe('copyToClipboard', () => { }); test('handles unicode/emoji text', () => { - const unicode = '🚀 Thought leadership → impact'; + const unicode = '🚀 Authority → impact'; const result = copyToClipboard(unicode); assert.equal(result.success, true); }); diff --git a/hooks/scripts/__tests__/data-root.test.mjs b/hooks/scripts/__tests__/data-root.test.mjs new file mode 100644 index 0000000..bf4b8a8 --- /dev/null +++ b/hooks/scripts/__tests__/data-root.test.mjs @@ -0,0 +1,120 @@ +import { describe, test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { getDataRoot, getStateFile } from '../data-root.mjs'; + +// Resolver contract for the per-user data dir (M0). The resolver reads env at +// CALL time (not module load) so these env stubs take effect; restore in +// afterEach. Pattern: scripts/analytics/tests/storage-root.test.ts:14-26. +describe('data-root resolver', () => { + const saved = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA, + STATE_FILE: process.env.STATE_FILE, + }; + + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + describe('getDataRoot', () => { + test('default subdir form: getDataRoot("analytics") = HOME/.claude/linkedin-studio/analytics', () => { + process.env.HOME = '/home/tester'; + delete process.env.USERPROFILE; + delete process.env.LINKEDIN_STUDIO_DATA; + + assert.equal( + getDataRoot('analytics'), + join('/home/tester', '.claude', 'linkedin-studio', 'analytics'), + ); + }); + + test('no-arg form returns the root itself (M5 — needed by the REMEMBER write)', () => { + process.env.HOME = '/home/tester'; + delete process.env.USERPROFILE; + delete process.env.LINKEDIN_STUDIO_DATA; + + assert.equal(getDataRoot(), join('/home/tester', '.claude', 'linkedin-studio')); + }); + + test('LINKEDIN_STUDIO_DATA override wins (subdir and root forms)', () => { + process.env.HOME = '/home/tester'; + process.env.LINKEDIN_STUDIO_DATA = '/tmp/lis-data'; + + assert.equal(getDataRoot('analytics'), join('/tmp/lis-data', 'analytics')); + assert.equal(getDataRoot(), '/tmp/lis-data'); + }); + + test('empty HOME + USERPROFILE falls back to os.homedir(), never "" (no relative-path trap)', () => { + delete process.env.HOME; + delete process.env.USERPROFILE; + delete process.env.LINKEDIN_STUDIO_DATA; + + const root = getDataRoot('analytics'); + assert.equal(root, join(homedir(), '.claude', 'linkedin-studio', 'analytics')); + assert.ok(!root.startsWith('.claude'), `root must be absolute, got relative: ${root}`); + }); + }); + + describe('getStateFile', () => { + test('default = HOME/.claude/linkedin-studio.local.md (mirrors state-updater.mjs:12)', () => { + process.env.HOME = '/home/tester'; + delete process.env.USERPROFILE; + delete process.env.STATE_FILE; + + assert.equal(getStateFile(), join('/home/tester', '.claude', 'linkedin-studio.local.md')); + }); + + test('STATE_FILE override wins', () => { + process.env.HOME = '/home/tester'; + process.env.STATE_FILE = '/tmp/custom-state.md'; + + assert.equal(getStateFile(), '/tmp/custom-state.md'); + }); + }); +}); + +// Twin consistency with scripts/analytics/src/utils/storage.ts:getDataRoot. +// The TS twin cannot be imported into this no-tsx .mjs run, so we assert this +// .mjs resolver against the SAME contract literals that storage-root.test.ts +// asserts for the TS twin (the behavioral-consistency model — a header-only +// "bit-identical" claim is not a test). Change one twin → update this block and +// its TS mirror in lockstep. R2's mitigation. +describe('twin consistency (storage.ts:getDataRoot)', () => { + const saved = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA, + }; + + afterEach(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + // These literals are the shared contract — storage-root.test.ts asserts the + // identical strings for the TS twin; keep them character-for-character in sync. + test('default matches the TS twin contract literal (HOME-stubbed)', () => { + process.env.HOME = '/home/tester'; + delete process.env.USERPROFILE; + delete process.env.LINKEDIN_STUDIO_DATA; + + assert.equal( + getDataRoot('analytics'), + join('/home/tester', '.claude', 'linkedin-studio', 'analytics'), + ); + }); + + test('LINKEDIN_STUDIO_DATA override matches the TS twin contract literal', () => { + process.env.LINKEDIN_STUDIO_DATA = '/tmp/lis-data'; + + assert.equal(getDataRoot('analytics'), join('/tmp/lis-data', 'analytics')); + }); +}); diff --git a/hooks/scripts/__tests__/linkedin-content-filter.test.mjs b/hooks/scripts/__tests__/linkedin-content-filter.test.mjs index 3a88e60..2b2ffb9 100644 --- a/hooks/scripts/__tests__/linkedin-content-filter.test.mjs +++ b/hooks/scripts/__tests__/linkedin-content-filter.test.mjs @@ -85,3 +85,35 @@ describe('isLinkedInContent — code/config/template/meta files (negative)', () assert.equal(isLinkedInContent(undefined), false); }); }); + +// M0-10: after drafts relocate to the external data root, the gate must still +// fire on them. `.claude` is an infraDir, so a naive check rejects any /.claude/ +// path — the external-drafts positive must short-circuit BEFORE that negative, +// while the state file (.local.md) and REMEMBER.md stay excluded. +describe('isLinkedInContent — external data-root drafts (M0-10)', () => { + const H = '/Users/test'; + + test('fires on a relocated draft under ~/.claude/linkedin-studio/drafts/', () => { + assert.equal(isLinkedInContent(`${H}/.claude/linkedin-studio/drafts/2026-06-20-post.md`), true); + }); + + test('fires on a relocated carousel slide under the external drafts dir', () => { + assert.equal(isLinkedInContent(`${H}/.claude/linkedin-studio/drafts/carousel-x/slide-1.png`), true); + }); + + test('does NOT fire on the external state file (.local.md)', () => { + assert.equal(isLinkedInContent(`${H}/.claude/linkedin-studio.local.md`), false); + }); + + test('does NOT fire on the external REMEMBER.md', () => { + assert.equal(isLinkedInContent(`${H}/.claude/linkedin-studio/REMEMBER.md`), false); + }); + + test('does NOT fire on ~/.claude/settings.json', () => { + assert.equal(isLinkedInContent(`${H}/.claude/settings.json`), false); + }); + + test('still fires on in-plugin assets/drafts/ (migration-window back-compat)', () => { + assert.equal(isLinkedInContent('assets/drafts/2026-06-20-post.md'), true); + }); +}); diff --git a/hooks/scripts/__tests__/migrate-data.test.mjs b/hooks/scripts/__tests__/migrate-data.test.mjs new file mode 100644 index 0000000..01e9c4e --- /dev/null +++ b/hooks/scripts/__tests__/migrate-data.test.mjs @@ -0,0 +1,106 @@ +import { describe, test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, +} from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { migrateData } from '../migrate-data.mjs'; + +function writeDeep(p, content) { + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, content); +} + +describe('migrateData', () => { + let pluginRoot; + let dataRoot; + const savedData = process.env.LINKEDIN_STUDIO_DATA; + + afterEach(() => { + for (const d of [pluginRoot, dataRoot]) { + if (d && existsSync(d)) rmSync(d, { recursive: true, force: true }); + } + pluginRoot = undefined; + dataRoot = undefined; + if (savedData === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = savedData; + }); + + function setup({ withRuntime = false, withScaffolds = false } = {}) { + pluginRoot = mkdtempSync(join(tmpdir(), 'lis-plugin-')); + dataRoot = mkdtempSync(join(tmpdir(), 'lis-data-')); + process.env.LINKEDIN_STUDIO_DATA = dataRoot; // never the real $HOME + if (withRuntime) { + writeDeep(join(pluginRoot, 'assets/voice-samples/authentic-voice-samples.local.md'), 'REAL VOICE 227 lines'); + writeDeep(join(pluginRoot, 'assets/drafts/queue.json'), '{"version":1,"queue":[]}'); + writeDeep(join(pluginRoot, 'assets/analytics/exports/content-2026-W22.csv'), 'a,b\n1,2\n'); + writeDeep(join(pluginRoot, 'assets/analytics/posts/2026-05-26.json'), '{"x":1}'); + writeDeep(join(pluginRoot, 'assets/analytics/weekly-reports/2026-W22.json'), '{"w":22}'); + } + if (withScaffolds) { + writeDeep(join(pluginRoot, 'assets/examples/high-engagement-posts.md'), 'MY REAL POST'); + writeDeep(join(pluginRoot, 'assets/audience-insights/demographics.md'), 'demo'); + writeDeep(join(pluginRoot, 'assets/audience-insights/engagement-patterns.md'), 'patterns'); + writeDeep(join(pluginRoot, 'assets/templates/my-post-templates.md'), 'my templates'); + } + } + + test('(a) gitignored runtime files MOVED: source gone, dest byte-equal, voice drops .local', () => { + setup({ withRuntime: true }); + migrateData({ pluginRoot }); + + assert.ok(!existsSync(join(pluginRoot, 'assets/voice-samples/authentic-voice-samples.local.md')), 'voice source gone'); + assert.ok(!existsSync(join(pluginRoot, 'assets/drafts/queue.json')), 'queue source gone'); + assert.ok(!existsSync(join(pluginRoot, 'assets/analytics/exports/content-2026-W22.csv')), 'export source gone'); + + assert.equal(readFileSync(join(dataRoot, 'voice-samples/authentic-voice-samples.md'), 'utf-8'), 'REAL VOICE 227 lines'); + assert.equal(readFileSync(join(dataRoot, 'drafts/queue.json'), 'utf-8'), '{"version":1,"queue":[]}'); + assert.ok(existsSync(join(dataRoot, 'analytics/exports/content-2026-W22.csv'))); + assert.ok(existsSync(join(dataRoot, 'analytics/posts/2026-05-26.json'))); + assert.ok(existsSync(join(dataRoot, 'analytics/weekly-reports/2026-W22.json'))); + assert.ok(existsSync(join(dataRoot, '.migrated')), 'marker written'); + }); + + test('(b) tracked scaffolds COPIED: external instance written, tracked source preserved', () => { + setup({ withScaffolds: true }); + migrateData({ pluginRoot }); + + assert.ok(existsSync(join(pluginRoot, 'assets/examples/high-engagement-posts.md')), 'scaffold source preserved'); + assert.ok(existsSync(join(pluginRoot, 'assets/templates/my-post-templates.md')), 'scaffold source preserved'); + + assert.equal(readFileSync(join(dataRoot, 'examples/high-engagement-posts.md'), 'utf-8'), 'MY REAL POST'); + assert.equal(readFileSync(join(dataRoot, 'audience-insights/demographics.md'), 'utf-8'), 'demo'); + assert.equal(readFileSync(join(dataRoot, 'audience-insights/engagement-patterns.md'), 'utf-8'), 'patterns'); + assert.equal(readFileSync(join(dataRoot, 'templates/my-post-templates.md'), 'utf-8'), 'my templates'); + }); + + test('(c) re-run is an idempotent no-op (.migrated short-circuits, no throw, dest unchanged)', () => { + setup({ withRuntime: true, withScaffolds: true }); + migrateData({ pluginRoot }); + const voiceDest = join(dataRoot, 'voice-samples/authentic-voice-samples.md'); + const before = readFileSync(voiceDest, 'utf-8'); + const mtimeBefore = statSync(voiceDest).mtimeMs; + + const r2 = migrateData({ pluginRoot }); + assert.equal(r2.status, 'already-migrated'); + assert.equal(readFileSync(voiceDest, 'utf-8'), before); + assert.equal(statSync(voiceDest).mtimeMs, mtimeBefore); + }); + + test('(d) empty fixture (no user data) completes without error', () => { + setup({}); + const r = migrateData({ pluginRoot }); + assert.ok(r); + assert.ok(existsSync(join(dataRoot, '.migrated'))); + }); + + test('(e) collision: existing external file kept, in-plugin source NOT clobbered', () => { + setup({ withRuntime: true }); + writeDeep(join(dataRoot, 'voice-samples/authentic-voice-samples.md'), 'EXTERNAL CANONICAL'); + migrateData({ pluginRoot }); + + assert.equal(readFileSync(join(dataRoot, 'voice-samples/authentic-voice-samples.md'), 'utf-8'), 'EXTERNAL CANONICAL'); + assert.equal(readFileSync(join(pluginRoot, 'assets/voice-samples/authentic-voice-samples.local.md'), 'utf-8'), 'REAL VOICE 227 lines'); + }); +}); diff --git a/hooks/scripts/__tests__/personalization-score.test.mjs b/hooks/scripts/__tests__/personalization-score.test.mjs index 96feea1..dfc9ab2 100644 --- a/hooks/scripts/__tests__/personalization-score.test.mjs +++ b/hooks/scripts/__tests__/personalization-score.test.mjs @@ -5,62 +5,131 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { calculateScore } from '../personalization-score.mjs'; -// Step 5 (remediation): the shipped voice profile is a PII-free placeholder -// carrying the sentinel below. Detection must key on the sentinel — NOT the old -// `[Your Name]` heuristic — so the placeholder earns 0 of the 25 voice points, -// and a populated profile (sentinel removed) earns them. +// M0-7: the scorer now reads INSTANCE data from the EXTERNAL data root +// (getDataRoot, stubbed via LINKEDIN_STUDIO_DATA), mirroring the migration's +// dests, and falls back to the plugin tree only for back-compat. The placeholder +// sentinel still keys voice detection (Step-5 remediation): the placeholder earns +// 0 of the 25 voice points; a populated profile (sentinel removed) earns them. const SENTINEL = '<!-- VOICE_PLACEHOLDER -->'; -// A >50-line body that contains NEITHER the sentinel NOR `[Your Name]`, so the -// only thing distinguishing placeholder from real profile is the sentinel. +// A >50-line body with NEITHER the sentinel NOR `[Your Name]`, so the sentinel is +// the only thing distinguishing placeholder from real profile. const LONG_BODY = Array.from({ length: 60 }, (_, i) => `- voice characteristic line ${i + 1}`).join('\n'); -function makePluginRoot() { - const root = mkdtempSync(join(tmpdir(), 'lis-personalization-')); - mkdirSync(join(root, 'assets', 'voice-samples'), { recursive: true }); - return root; +function makeRoots() { + const dataDir = mkdtempSync(join(tmpdir(), 'lis-score-data-')); + const pluginRoot = mkdtempSync(join(tmpdir(), 'lis-score-plugin-')); + mkdirSync(join(dataDir, 'voice-samples'), { recursive: true }); + return { dataDir, pluginRoot }; } -function writeVoice(root, content) { - writeFileSync(join(root, 'assets', 'voice-samples', 'authentic-voice-samples.md'), content, 'utf-8'); +function writeExternalVoice(dataDir, content) { + writeFileSync(join(dataDir, 'voice-samples', 'authentic-voice-samples.md'), content, 'utf-8'); } -describe('calculateScore — voice samples (sentinel-based placeholder detection)', () => { - let root; +describe('calculateScore — reads external instance data (M0-7)', () => { + let dataDir, pluginRoot; + const saved = { LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA }; afterEach(() => { - if (root && existsSync(root)) { - rmSync(root, { recursive: true, force: true }); + for (const d of [dataDir, pluginRoot]) { + if (d && existsSync(d)) rmSync(d, { recursive: true, force: true }); } - root = undefined; + dataDir = pluginRoot = undefined; + if (saved.LINKEDIN_STUDIO_DATA === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = saved.LINKEDIN_STUDIO_DATA; }); - test('placeholder with the sentinel scores 0 voice points (even >50 lines, no [Your Name])', () => { - root = makePluginRoot(); - writeVoice(root, `# Voice Profile (placeholder)\n${SENTINEL}\n\n${LONG_BODY}\n`); + test('external placeholder with the sentinel scores 0 voice points (even >50 lines, no [Your Name])', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + writeExternalVoice(dataDir, `# Voice Profile (placeholder)\n${SENTINEL}\n\n${LONG_BODY}\n`); - const { score, personalized } = calculateScore(root); + const { score, personalized } = calculateScore(pluginRoot); assert.equal(score, 0, 'placeholder must not earn the 25 voice points'); assert.equal(personalized, 0); }); - test('a real profile (no sentinel, >50 lines) earns the 25 voice points', () => { - root = makePluginRoot(); - writeVoice(root, `# My Voice Profile\n\n${LONG_BODY}\n`); + test('a real EXTERNAL profile (no sentinel, >50 lines) earns the 25 voice points', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + writeExternalVoice(dataDir, `# My Voice Profile\n\n${LONG_BODY}\n`); - const { score, personalized } = calculateScore(root); + const { score, personalized } = calculateScore(pluginRoot); - assert.equal(score, 25, 'a populated profile must earn the 25 voice points'); + assert.equal(score, 25, 'a populated external profile must earn the 25 voice points'); assert.equal(personalized, 1); }); - test('a short placeholder (<50 lines) also scores 0 voice points', () => { - root = makePluginRoot(); - writeVoice(root, `# Voice Profile (placeholder)\n${SENTINEL}\n`); + test('profile absent at external profile/user-profile.md → 0, no crash (graceful degradation)', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + // nothing seeded — no voice, no profile, no scaffolds anywhere - const { score } = calculateScore(root); + const { score, personalized } = calculateScore(pluginRoot); + + assert.equal(score, 0); + assert.equal(personalized, 0); + }); + + test('a short external placeholder (<50 lines) also scores 0 voice points', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + writeExternalVoice(dataDir, `# Voice Profile (placeholder)\n${SENTINEL}\n`); + + const { score } = calculateScore(pluginRoot); assert.equal(score, 0); }); }); + +describe('calculateScore — scaffold categories read the external instance (M0-13)', () => { + let dataDir, pluginRoot; + const saved = { LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA }; + + afterEach(() => { + for (const d of [dataDir, pluginRoot]) { + if (d && existsSync(d)) rmSync(d, { recursive: true, force: true }); + } + dataDir = pluginRoot = undefined; + if (saved.LINKEDIN_STUDIO_DATA === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = saved.LINKEDIN_STUDIO_DATA; + }); + + test('a populated external high-engagement-posts instance earns the 10 points', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + mkdirSync(join(dataDir, 'examples'), { recursive: true }); + const posts = ['## Post 1', '## Post 2', '## Post 3'].join('\n\n'); + writeFileSync(join(dataDir, 'examples', 'high-engagement-posts.md'), `# Posts\n\n${posts}\n`, 'utf-8'); + + const { score, personalized } = calculateScore(pluginRoot); + + assert.equal(score, 10, '3+ saved posts in the external instance earn the 10 points'); + assert.equal(personalized, 1); + }); + + test('the generic placeholder seed (no line-leading ## Post N) scores 0 — no crash', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + mkdirSync(join(dataDir, 'examples'), { recursive: true }); + writeFileSync(join(dataDir, 'examples', 'high-engagement-posts.md'), `# Posts\n\nPlaceholder — add a ## Post N section per saved post.\n`, 'utf-8'); + + const { score, personalized } = calculateScore(pluginRoot); + + assert.equal(score, 0, 'a placeholder with no ## Post [0-9] section scores 0'); + assert.equal(personalized, 0); + }); + + test('scaffold absent at the external root → 0, no crash (graceful degradation)', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + // examples/ dir never created — the category is simply skipped, never throws + + const { score, personalized } = calculateScore(pluginRoot); + + assert.equal(score, 0); + assert.equal(personalized, 0); + }); +}); diff --git a/hooks/scripts/__tests__/queue-manager.test.mjs b/hooks/scripts/__tests__/queue-manager.test.mjs new file mode 100644 index 0000000..9c36f49 --- /dev/null +++ b/hooks/scripts/__tests__/queue-manager.test.mjs @@ -0,0 +1,57 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// M0-5: queue-manager must resolve its queue under the EXTERNAL data root +// (getDataRoot('drafts')), never the plugin tree. The module binds QUEUE_FILE at +// load time, so LINKEDIN_STUDIO_DATA is stubbed BEFORE the dynamic import. A +// separate PLUGIN_ROOT tempdir lets us assert the queue does NOT land in the +// plugin tree without polluting the real working tree (the old default would +// have written there). +// Pattern: data-root.test.mjs env-stub + personalization-score.test.mjs tempdir. +describe('queue-manager — external data root (M0-5)', () => { + let dataDir, pluginDir, qm; + const saved = { + LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA, + PLUGIN_ROOT: process.env.PLUGIN_ROOT, + }; + + before(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'lis-queue-data-')); + pluginDir = mkdtempSync(join(tmpdir(), 'lis-queue-plugin-')); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + process.env.PLUGIN_ROOT = pluginDir; + qm = await import('../queue-manager.mjs'); + }); + + after(() => { + for (const d of [dataDir, pluginDir]) { + if (d && existsSync(d)) rmSync(d, { recursive: true, force: true }); + } + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + test('an empty queue materializes under <dataDir>/drafts/queue.json', () => { + assert.deepEqual(qm.queueRead(), []); + assert.ok( + existsSync(join(dataDir, 'drafts', 'queue.json')), + 'queue file must be created under the external drafts dir', + ); + }); + + test('queueAdd persists to the external root, never the plugin tree', () => { + qm.queueAdd('m0-5', '/tmp/draft.md', '2026-06-20', '09:00', 'ai', 'text', 'hook', 1234); + const entries = qm.queueRead(); + assert.equal(entries.length, 1); + assert.equal(entries[0].id, 'm0-5'); + assert.ok( + !existsSync(join(pluginDir, 'assets', 'drafts', 'queue.json')), + 'queue must NOT land in the plugin tree (PLUGIN_ROOT default removed)', + ); + }); +}); diff --git a/hooks/scripts/__tests__/quick-import.test.mjs b/hooks/scripts/__tests__/quick-import.test.mjs new file mode 100644 index 0000000..726ff08 --- /dev/null +++ b/hooks/scripts/__tests__/quick-import.test.mjs @@ -0,0 +1,45 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// M0-6: quick-import must resolve its exports dir under the EXTERNAL data root, +// and the manual import command it prints must no longer pin ANALYTICS_ROOT to +// the in-plugin tree. The module's executable body (browser open + poll timer) +// sits behind a standalone guard, so importing it for tests is side-effect-free. +// LINKEDIN_STUDIO_DATA is stubbed before the dynamic import (EXPORTS_DIR binds at +// load). Pattern: data-root.test.mjs env-stub + queue-manager.test.mjs. +describe('quick-import — external data root + de-pinned command (M0-6)', () => { + let dataDir, mod; + const saved = { LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA }; + + before(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'lis-quickimport-')); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + mod = await import('../quick-import.mjs'); + }); + + after(() => { + if (dataDir && existsSync(dataDir)) rmSync(dataDir, { recursive: true, force: true }); + if (saved.LINKEDIN_STUDIO_DATA === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = saved.LINKEDIN_STUDIO_DATA; + }); + + test('EXPORTS_DIR resolves under <dataDir>/analytics/exports', () => { + assert.equal(mod.EXPORTS_DIR, join(dataDir, 'analytics', 'exports')); + }); + + test('the printed import command no longer pins a bare in-plugin assets/analytics path', () => { + const cmd = mod.buildImportCommand('/plugin/root', 'export.csv'); + assert.ok( + !cmd.includes('assets/analytics'), + 'manual command must not pin ANALYTICS_ROOT to the in-plugin assets/analytics tree', + ); + assert.ok( + cmd.includes('scripts/analytics/src/cli.ts'), + 'manual command must still invoke the in-plugin tsx CLI', + ); + assert.ok(cmd.includes('export.csv')); + }); +}); diff --git a/hooks/scripts/__tests__/session-start-brain-consolidation.test.mjs b/hooks/scripts/__tests__/session-start-brain-consolidation.test.mjs new file mode 100644 index 0000000..d07a504 --- /dev/null +++ b/hooks/scripts/__tests__/session-start-brain-consolidation.test.mjs @@ -0,0 +1,80 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, copyFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// SB-S2: SessionStart emits a "run brain consolidate" nudge driven by the count of +// published gold records + the consolidation-state.json last_run sidecar (read via +// getDataRoot — the SAME root the brain CLI --apply writes to). Zero-dep, no +// profile.md parse. Subprocess harness with isolated HOME + LINKEDIN_STUDIO_DATA, +// mirroring session-start-trends-staleness.test.mjs. +const here = dirname(fileURLToPath(import.meta.url)); +const hookPath = join(here, '..', 'session-start.mjs'); +const PLUGIN_ROOT = join(here, '..', '..', '..'); +const STATE_TEMPLATE = join(PLUGIN_ROOT, 'config', 'state-file.template.md'); + +const NUDGE = 'brain consolidate'; +const isoDaysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10); + +const record = (id) => + `id: ${id}\nprovenance: published\npublished_date: 2026-01-01\ncaptured_at: 2026-01-01\nsource: manual\n---\nbody`; + +function runHook({ withState = true, published = [], lastRun = undefined } = {}) { + const home = mkdtempSync(join(tmpdir(), 'lis-bc-home-')); + const data = mkdtempSync(join(tmpdir(), 'lis-bc-data-')); + try { + if (withState) { + mkdirSync(join(home, '.claude'), { recursive: true }); + copyFileSync(STATE_TEMPLATE, join(home, '.claude', 'linkedin-studio.local.md')); + } + if (published.length) { + mkdirSync(join(data, 'ingest', 'published'), { recursive: true }); + for (const id of published) writeFileSync(join(data, 'ingest', 'published', id + '.md'), record(id)); + } + if (lastRun !== undefined) { + mkdirSync(join(data, 'brain'), { recursive: true }); + writeFileSync(join(data, 'brain', 'consolidation-state.json'), JSON.stringify({ last_run: lastRun })); + } + const stdout = execFileSync('node', [hookPath], { + input: '', + env: { ...process.env, HOME: home, USERPROFILE: home, LINKEDIN_STUDIO_DATA: data }, + encoding: 'utf-8', + }); + return JSON.parse(stdout).hookSpecificOutput.additionalContext; + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(data, { recursive: true, force: true }); + } +} + +describe('session-start — brain consolidation-due nudge (SB-S2)', () => { + test('fires when published records exist and consolidation never ran', () => { + const ctx = runHook({ published: ['aaaaaaaaaaaa', 'bbbbbbbbbbbb'] }); + assert.ok(ctx.includes(NUDGE), 'nudge expected when published exist + never consolidated'); + assert.ok(ctx.includes('2 published'), 'reports the published count'); + }); + + test('fires when last_run is stale (>=7d)', () => { + const ctx = runHook({ published: ['aaaaaaaaaaaa'], lastRun: isoDaysAgo(10) }); + assert.ok(ctx.includes(NUDGE), 'nudge expected for a 10-day-old last_run'); + }); + + test('silent when last_run is fresh (<7d)', () => { + const ctx = runHook({ published: ['aaaaaaaaaaaa'], lastRun: isoDaysAgo(2) }); + assert.ok(!ctx.includes(NUDGE), 'fresh consolidation => no nudge'); + }); + + test('silent when there are no published records (never nags)', () => { + const ctx = runHook({ published: [] }); + assert.ok(!ctx.includes(NUDGE), 'no published => no nudge'); + }); + + test('fresh install (no state file) emits valid JSON and nudges brain init', () => { + const ctx = runHook({ withState: false }); + assert.equal(typeof ctx, 'string', 'valid JSON additionalContext on fresh install'); + assert.ok(ctx.includes('Brain not initialised'), 'brain-init nudge survives the fresh-install branch'); + }); +}); diff --git a/hooks/scripts/__tests__/session-start-morning-brief.test.mjs b/hooks/scripts/__tests__/session-start-morning-brief.test.mjs new file mode 100644 index 0000000..c957169 --- /dev/null +++ b/hooks/scripts/__tests__/session-start-morning-brief.test.mjs @@ -0,0 +1,77 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, copyFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// RE-R2b: SessionStart surfaces the latest dated morning brief — a "## Morning +// Brief" block built from the brief's frontmatter (date + pre-rendered summary), +// read zero-tsx. session-start is a procedural hook with no exports, so we run it +// as a subprocess with an isolated HOME + LINKEDIN_STUDIO_DATA and inspect the +// additionalContext. Pattern: session-start-trends-staleness.test.mjs. +const here = dirname(fileURLToPath(import.meta.url)); +const hookPath = join(here, '..', 'session-start.mjs'); +const PLUGIN_ROOT = join(here, '..', '..', '..'); +const STATE_TEMPLATE = join(PLUGIN_ROOT, 'config', 'state-file.template.md'); + +const HEADING = '## Morning Brief'; +// ASCII fixture summary (the hook surfaces whatever the frontmatter carries verbatim). +const SUMMARY = '3 fresh signals match your pillars. Top: Alpha (AI, 2d).'; + +// Run the hook against an isolated HOME + data root; optionally seed a dated brief +// at <data>/trends/morning-brief/<name> — the SAME path defaultBriefDir() resolves +// to under this LINKEDIN_STUDIO_DATA (CLI-write path == hook-read path cross-check). +function runHook({ briefName }) { + const home = mkdtempSync(join(tmpdir(), 'lis-mb-home-')); + const data = mkdtempSync(join(tmpdir(), 'lis-mb-data-')); + try { + mkdirSync(join(home, '.claude'), { recursive: true }); + copyFileSync(STATE_TEMPLATE, join(home, '.claude', 'linkedin-studio.local.md')); + + if (briefName) { + const briefDir = join(data, 'trends', 'morning-brief'); + mkdirSync(briefDir, { recursive: true }); + writeFileSync( + join(briefDir, briefName), + `---\ndate: 2026-06-24\nsummary: ${SUMMARY}\nstore: { trends: 10, matched: 3, fresh: 3 }\nschemaVersion: 1\n---\n\n# Morgen-brief\n\nbody\n`, + ); + } + + const stdout = execFileSync('node', [hookPath], { + input: '', + env: { ...process.env, HOME: home, USERPROFILE: home, LINKEDIN_STUDIO_DATA: data }, + encoding: 'utf-8', + }); + return JSON.parse(stdout).hookSpecificOutput.additionalContext; + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(data, { recursive: true, force: true }); + } +} + +describe('session-start — morning-brief surfacing (RE-R2b)', () => { + test('surfaces the latest brief: heading + summary + file pointer', () => { + const ctx = runHook({ briefName: '2026-06-24.md' }); + assert.ok(ctx.includes(HEADING), 'expected the Morning Brief block'); + assert.ok(ctx.includes('Morning Brief (2026-06-24)'), 'block carries the brief date'); + assert.ok(ctx.includes(SUMMARY), 'block surfaces the pre-rendered summary'); + assert.ok(ctx.includes('Full brief:'), 'block carries the full-brief pointer'); + // The summary must be its OWN line (proves it did not bleed into adjacent lines — + // single-line summary + the \n idiom held). + assert.ok(ctx.split('\n').includes(SUMMARY), 'summary is a standalone line in the block'); + }); + + test('newest brief wins when several dated files exist', () => { + // (single-file harness asserts the surface; lexical name sort picks the newest — + // covered structurally by the .md-anchored filter + .sort() in latestMorningBrief.) + const ctx = runHook({ briefName: '2026-06-24.md' }); + assert.ok(ctx.includes('Morning Brief (2026-06-24)')); + }); + + test('no brief dir -> no block, no crash', () => { + const ctx = runHook({ briefName: null }); + assert.ok(!ctx.includes(HEADING), 'no brief => no Morning Brief block'); + }); +}); diff --git a/hooks/scripts/__tests__/session-start-remember.test.mjs b/hooks/scripts/__tests__/session-start-remember.test.mjs new file mode 100644 index 0000000..3f65140 --- /dev/null +++ b/hooks/scripts/__tests__/session-start-remember.test.mjs @@ -0,0 +1,36 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// M0-9: REMEMBER.md must materialize under the EXTERNAL data root (getDataRoot()), +// not inside the plugin tree (the old anti-pattern wrote join(PLUGIN_ROOT, +// 'REMEMBER.md')). session-start is a procedural hook with no exports, so we run +// it as a subprocess with an isolated HOME + LINKEDIN_STUDIO_DATA and check where +// REMEMBER.md lands. Pattern: user-prompt-context.test.mjs subprocess harness. +const hookPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'session-start.mjs'); + +describe('session-start — REMEMBER.md materializes external (M0-9)', () => { + test('REMEMBER.md is created under the external data root, not the plugin tree', () => { + const home = mkdtempSync(join(tmpdir(), 'lis-ss-home-')); + const dataDir = mkdtempSync(join(tmpdir(), 'lis-ss-data-')); + try { + execFileSync('node', [hookPath], { + input: '', + env: { ...process.env, HOME: home, USERPROFILE: home, LINKEDIN_STUDIO_DATA: dataDir }, + encoding: 'utf-8', + }); + + assert.ok( + existsSync(join(dataDir, 'REMEMBER.md')), + 'REMEMBER.md must be created under the external data root', + ); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/hooks/scripts/__tests__/session-start-trends-staleness.test.mjs b/hooks/scripts/__tests__/session-start-trends-staleness.test.mjs new file mode 100644 index 0000000..a304333 --- /dev/null +++ b/hooks/scripts/__tests__/session-start-trends-staleness.test.mjs @@ -0,0 +1,79 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, copyFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// B-S3: SessionStart emits a warn-only "trend signals are N days old" nudge, +// driven by the trend store's newest capturedAt, and ONLY when the store already +// holds captures. session-start is a procedural hook with no exports, so we run +// it as a subprocess with an isolated HOME + LINKEDIN_STUDIO_DATA and inspect the +// additionalContext. Pattern: session-start-remember.test.mjs subprocess harness. +const here = dirname(fileURLToPath(import.meta.url)); +const hookPath = join(here, '..', 'session-start.mjs'); +const PLUGIN_ROOT = join(here, '..', '..', '..'); +const STATE_TEMPLATE = join(PLUGIN_ROOT, 'config', 'state-file.template.md'); + +const NUDGE = 'Trend signals are'; +const isoDaysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 10); + +// Run the hook against an isolated HOME + data root; optionally seed a trend +// store. Returns the additionalContext string the hook injected at session start. +function runHook({ captures }) { + const home = mkdtempSync(join(tmpdir(), 'lis-ts-home-')); + const data = mkdtempSync(join(tmpdir(), 'lis-ts-data-')); + try { + // The reminders block only runs when the state file already exists; the + // template is the canonical valid state, so copy it into the isolated HOME. + mkdirSync(join(home, '.claude'), { recursive: true }); + copyFileSync(STATE_TEMPLATE, join(home, '.claude', 'linkedin-studio.local.md')); + + if (captures) { + mkdirSync(join(data, 'trends'), { recursive: true }); + writeFileSync( + join(data, 'trends', 'trends.json'), + JSON.stringify({ schemaVersion: 1, trends: captures }), + ); + } + + const stdout = execFileSync('node', [hookPath], { + input: '', + env: { ...process.env, HOME: home, USERPROFILE: home, LINKEDIN_STUDIO_DATA: data }, + encoding: 'utf-8', + }); + return JSON.parse(stdout).hookSpecificOutput.additionalContext; + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(data, { recursive: true, force: true }); + } +} + +const trend = (capturedAt) => ({ + id: 't' + capturedAt, + title: 'Some trend ' + capturedAt, + url: 'https://example.com/' + capturedAt, + source: 'tavily', + capturedAt, + topics: ['x'], +}); + +describe('session-start — trend-store staleness nudge (B-S3)', () => { + test('fires when newest capture is >=7 days old', () => { + const ctx = runHook({ captures: [trend('2026-01-01'), trend(isoDaysAgo(10))] }); + assert.ok(ctx.includes(NUDGE), 'expected the trend-staleness nudge for a 10-day-old store'); + }); + + test('does NOT fire when newest capture is fresh (<7 days)', () => { + const ctx = runHook({ captures: [trend(isoDaysAgo(2))] }); + assert.ok(!ctx.includes(NUDGE), 'a 2-day-old store must not trigger the nudge'); + }); + + test('does NOT fire (and does not crash) when the store is absent/empty', () => { + const absent = runHook({ captures: null }); + assert.ok(!absent.includes(NUDGE), 'no store => no nudge'); + const empty = runHook({ captures: [] }); + assert.ok(!empty.includes(NUDGE), 'empty store => no nudge (never-scanned user is never nagged)'); + }); +}); diff --git a/hooks/scripts/__tests__/user-prompt-context.test.mjs b/hooks/scripts/__tests__/user-prompt-context.test.mjs new file mode 100644 index 0000000..43ebff5 --- /dev/null +++ b/hooks/scripts/__tests__/user-prompt-context.test.mjs @@ -0,0 +1,65 @@ +import { describe, test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// M0-8 / SC7: the UserPromptSubmit hook must point the voice-profile hint at the +// REAL artefact in the EXTERNAL data root (getDataRoot('voice-samples')), not the +// in-plugin placeholder. This hook is a procedural stdin->stdout CLI with no +// exports, so we test it end-to-end as a subprocess: pipe the prompt JSON, stub +// LINKEDIN_STUDIO_DATA + STATE_FILE, and inspect the injected systemMessage. +const hookPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'user-prompt-context.mjs'); + +function runHook(prompt, env) { + const out = execFileSync('node', [hookPath], { + input: JSON.stringify({ prompt }), + env: { ...process.env, ...env }, + encoding: 'utf-8', + }); + return JSON.parse(out); +} + +describe('user-prompt-context — voice hint points at external data root (M0-8/SC7)', () => { + let dataDir; + + afterEach(() => { + if (dataDir && existsSync(dataDir)) rmSync(dataDir, { recursive: true, force: true }); + dataDir = undefined; + }); + + test('real external voice present → systemMessage references the external voice file', () => { + dataDir = mkdtempSync(join(tmpdir(), 'lis-upc-')); + mkdirSync(join(dataDir, 'voice-samples'), { recursive: true }); + writeFileSync(join(dataDir, 'voice-samples', 'authentic-voice-samples.md'), '# My Voice\n\nreal content\n', 'utf-8'); + + const result = runHook('write a linkedin post', { + LINKEDIN_STUDIO_DATA: dataDir, + STATE_FILE: join(dataDir, 'no-state.local.md'), + }); + + assert.ok(result.systemMessage, 'a LinkedIn prompt must produce an enrichment systemMessage'); + assert.ok( + result.systemMessage.includes(join(dataDir, 'voice-samples', 'authentic-voice-samples.md')), + 'voice hint must point at the external voice file, not the in-plugin placeholder', + ); + }); + + test('external voice absent → no voice-profile hint injected (graceful degradation)', () => { + dataDir = mkdtempSync(join(tmpdir(), 'lis-upc-')); + // no voice-samples dir/file seeded under the external root + + const result = runHook('write a linkedin post', { + LINKEDIN_STUDIO_DATA: dataDir, + STATE_FILE: join(dataDir, 'no-state.local.md'), + }); + + assert.ok(result.systemMessage, 'still enriches (scorecard reminder), just without the voice hint'); + assert.ok( + !result.systemMessage.includes('Voice Profile:'), + 'no voice hint when the external voice artefact is absent', + ); + }); +}); diff --git a/hooks/scripts/data-root.mjs b/hooks/scripts/data-root.mjs new file mode 100644 index 0000000..6c0aa5d --- /dev/null +++ b/hooks/scripts/data-root.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// CANONICAL — twin of scripts/analytics/src/utils/storage.ts:getDataRoot. +// MUST STAY IN SYNC: identical default (~/.claude/linkedin-studio) + identical +// LINKEDIN_STUDIO_DATA override semantics. A twin exists (not a shared import) +// because the runtimes differ: hooks are plain .mjs run by Node with zero npm +// deps and cannot import the TypeScript module (which needs tsx). Change one → +// change the other; the twin-consistency test in __tests__/data-root.test.mjs +// guards the contract behaviorally. + +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +// HOME with the codebase idiom (state-updater.mjs:11), but NEVER fall through to +// '' — that would turn the absolute data path into a CWD-relative one. os.homedir() +// is the floor (risk-assessor Low #2). +function resolveHome() { + return process.env.HOME || process.env.USERPROFILE || homedir(); +} + +// Canonical per-user data root: ~/.claude/linkedin-studio (mirrors the state-file +// location), overridable via LINKEDIN_STUDIO_DATA (test + power-user seam). +// No-arg / '' returns the root itself; a subdir returns root/subdir. +export function getDataRoot(subdir = '') { + const dataBase = process.env.LINKEDIN_STUDIO_DATA || join(resolveHome(), '.claude', 'linkedin-studio'); + return subdir ? join(dataBase, subdir) : dataBase; +} + +// State file lives beside the data dir (mirrors state-updater.mjs:12). +export function getStateFile() { + return process.env.STATE_FILE || join(resolveHome(), '.claude', 'linkedin-studio.local.md'); +} + +// Standalone: print the resolved paths for debugging (pattern: personalization-score.mjs:120). +if (process.argv[1] === fileURLToPath(import.meta.url)) { + console.log('data root:', getDataRoot()); + console.log('analytics:', getDataRoot('analytics')); + console.log('state file:', getStateFile()); +} diff --git a/hooks/scripts/linkedin-content-filter.mjs b/hooks/scripts/linkedin-content-filter.mjs index a93bed1..58e3213 100644 --- a/hooks/scripts/linkedin-content-filter.mjs +++ b/hooks/scripts/linkedin-content-filter.mjs @@ -23,9 +23,16 @@ export function isLinkedInContent(filePath) { const nonContent = ['.local.md', 'CLAUDE.md', 'README.md', 'CHANGELOG.md', 'REMEMBER.md', 'BACKLOG.md', 'DEVELOPMENT-LOG.md']; if (nonContent.some(n => base.endsWith(n) || base === n)) return false; + const normalized = filePath.replace(/\\/g, '/'); + + // POSITIVE (external): relocated drafts under the per-user data root are the + // only external data class the Write/Edit quality gate guards (voice-samples / + // analytics / profile are not editor-gated content). Must come BEFORE the + // infraDirs negative below, which would otherwise reject any /.claude/ path. + if (normalized.startsWith('.claude/linkedin-studio/drafts/') || normalized.includes('/.claude/linkedin-studio/drafts/')) return true; + // NEGATIVE: infrastructure paths const infraDirs = ['hooks', 'scripts', 'config', 'commands', 'agents', 'skills', 'references', 'docs', '.claude', '.claude-plugin', 'node_modules']; - const normalized = filePath.replace(/\\/g, '/'); for (const dir of infraDirs) { if (normalized.startsWith(dir + '/') || normalized.includes('/' + dir + '/')) return false; } diff --git a/hooks/scripts/migrate-data.mjs b/hooks/scripts/migrate-data.mjs new file mode 100644 index 0000000..3d05c01 --- /dev/null +++ b/hooks/scripts/migrate-data.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// M0 migration: relocate per-user data from the plugin tree to the external data +// root (getDataRoot()). Created at Step 4, WIRED into session-start at Step 12 — +// this module only acts when called. Atomic, locked, idempotent, and +// external-canonical (never overwrites an existing external file). +// +// Two move-classes: +// (a) gitignored runtime data → atomic MOVE (source removed only after a +// verified copy). +// (b) tracked D2 scaffolds → COPY (tracked source preserved; Step 13 later +// templatizes the in-plugin file). +// +// MOVE_FILES/COPY_FILES below are the SINGLE SOURCE OF TRUTH for external paths. +// Step 7's personalization-score split MUST resolve the same scaffold dests +// (import COPY_FILES from here, or mirror it) — keep them in lockstep. +// External dest = in-plugin path minus 'assets/' (the brief 7.1 convention). + +import { + existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, + renameSync, unlinkSync, rmSync, readdirSync, + openSync, fsyncSync, closeSync, +} from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getDataRoot } from './data-root.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// [in-plugin (rel pluginRoot), external (rel dataRoot)] — atomic MOVE. +const MOVE_FILES = [ + ['assets/voice-samples/authentic-voice-samples.local.md', 'voice-samples/authentic-voice-samples.md'], // D6: drop .local + ['assets/drafts/queue.json', 'drafts/queue.json'], + ['config/user-profile.local.md', 'profile/user-profile.md'], // D1 (expected-absent today) +]; + +// Whole-directory MOVE: every file under src → matching path under dest. +const MOVE_DIRS = [ + ['assets/analytics/exports', 'analytics/exports'], + ['assets/analytics/posts', 'analytics/posts'], + ['assets/analytics/weekly-reports', 'analytics/weekly-reports'], + ['assets/analytics/monthly-reports', 'analytics/monthly-reports'], // expected-absent +]; + +// drafts/week-* runtime subdirs → external drafts/week-*. +const DRAFTS_WEEK = ['assets/drafts', 'drafts', /^week-/]; + +// move-class (b): tracked scaffolds → external instance (COPY, source kept). +const COPY_FILES = [ + ['assets/examples/high-engagement-posts.md', 'examples/high-engagement-posts.md'], + ['assets/audience-insights/demographics.md', 'audience-insights/demographics.md'], + ['assets/audience-insights/engagement-patterns.md', 'audience-insights/engagement-patterns.md'], + ['assets/templates/my-post-templates.md', 'templates/my-post-templates.md'], +]; + +export { MOVE_FILES, COPY_FILES }; + +// Atomic copy: tmp in dest dir → fsync → byte-verify → rename. Returns false +// (without touching anything) if dest already exists — external is canonical. +function atomicCopy(src, dest) { + if (existsSync(dest)) return false; + mkdirSync(dirname(dest), { recursive: true }); + const tmp = `${dest}.migrating.${process.pid}`; + copyFileSync(src, tmp); + const fd = openSync(tmp, 'r+'); + try { fsyncSync(fd); } finally { closeSync(fd); } + if (!readFileSync(src).equals(readFileSync(tmp))) { + unlinkSync(tmp); + throw new Error(`migrate verify failed: ${src}`); + } + renameSync(tmp, dest); + return true; +} + +function moveFile(srcAbs, destAbs, log) { + if (!existsSync(srcAbs)) return; // expected-absent → no-op + if (atomicCopy(srcAbs, destAbs)) { + unlinkSync(srcAbs); // remove source only after verified copy + log.moved.push(destAbs); + } else { + log.skipped.push(destAbs); // collision: external kept, source untouched + } +} + +function copyFile(srcAbs, destAbs, log) { + if (!existsSync(srcAbs)) return; + if (atomicCopy(srcAbs, destAbs)) log.copied.push(destAbs); + else log.skipped.push(destAbs); // source always preserved for COPY class +} + +function walkFiles(dir) { + const out = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) out.push(...walkFiles(p)); + else if (e.isFile()) out.push(p); + } + return out; +} + +function moveDir(srcDir, destDir, log) { + if (!existsSync(srcDir)) return; + for (const f of walkFiles(srcDir)) { + moveFile(f, join(destDir, f.slice(srcDir.length + 1)), log); + } +} + +export function migrateData({ pluginRoot, dataRoot } = {}) { + pluginRoot = pluginRoot || join(__dirname, '..', '..'); + dataRoot = dataRoot || getDataRoot(); + + const marker = join(dataRoot, '.migrated'); + if (existsSync(marker)) return { status: 'already-migrated', moved: [], copied: [], skipped: [] }; + + mkdirSync(dataRoot, { recursive: true }); + const lockDir = join(dataRoot, '.migrate.lock'); + try { + mkdirSync(lockDir); // atomic; throws EEXIST if held + } catch (e) { + if (e.code === 'EEXIST') return { status: 'locked', moved: [], copied: [], skipped: [] }; + throw e; + } + + const log = { status: 'migrated', moved: [], copied: [], skipped: [] }; + try { + for (const [src, dest] of MOVE_FILES) moveFile(join(pluginRoot, src), join(dataRoot, dest), log); + for (const [src, dest] of MOVE_DIRS) moveDir(join(pluginRoot, src), join(dataRoot, dest), log); + + const [wSrc, wDest, wRe] = DRAFTS_WEEK; + const wSrcAbs = join(pluginRoot, wSrc); + if (existsSync(wSrcAbs)) { + for (const e of readdirSync(wSrcAbs, { withFileTypes: true })) { + if (e.isDirectory() && wRe.test(e.name)) { + moveDir(join(wSrcAbs, e.name), join(dataRoot, wDest, e.name), log); + } + } + } + + for (const [src, dest] of COPY_FILES) copyFile(join(pluginRoot, src), join(dataRoot, dest), log); + + // Marker LAST → the whole run is idempotently skippable on re-entry. + writeFileSync(marker, `linkedin-studio M0 data migration\nmoved=${log.moved.length} copied=${log.copied.length} skipped=${log.skipped.length}\n`); + } finally { + rmSync(lockDir, { recursive: true, force: true }); + } + return log; +} + +// Standalone: run against the real roots. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const r = migrateData(); + console.log(`Migration: ${r.status} — moved ${r.moved.length}, copied ${r.copied.length}, skipped ${r.skipped.length}`); +} diff --git a/hooks/scripts/personalization-score.mjs b/hooks/scripts/personalization-score.mjs index 393b28a..f1a5dbb 100644 --- a/hooks/scripts/personalization-score.mjs +++ b/hooks/scripts/personalization-score.mjs @@ -1,26 +1,46 @@ #!/usr/bin/env node // Personalization score calculator for linkedin-studio plugin -// Checks 8 asset categories for real user data vs placeholder templates +// Checks 8 asset categories for real user data vs placeholder templates. +// Instance data is read from the EXTERNAL data root (getDataRoot, M0); the plugin +// tree is a back-compat fallback during the migration window (external wins). // Standalone: outputs SCORE:N|M/8 assets personalized // Import: export function calculateScore(pluginRoot) => { score, personalized, categories } import { readFileSync, existsSync, readdirSync } from 'node:fs'; -import { join, basename, dirname } from 'node:path'; +import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { getDataRoot } from './data-root.mjs'; +import { MOVE_FILES, COPY_FILES } from './migrate-data.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); +// Mirror the migration's external dests (single source of truth) so the scorer +// reads exactly where migrate-data.mjs writes — never a drifted path +// (migrate-data.mjs:13-16, STATE.md M0). Keyed by in-plugin source path. +const DEST = Object.fromEntries([...MOVE_FILES, ...COPY_FILES].map(([src, dest]) => [src, dest])); + export function calculateScore(pluginRoot) { + const dataRoot = getDataRoot(); let score = 0; let personalized = 0; const categories = 8; + // Instance lives in the external root; fall back to the plugin tree for + // back-compat during the migration window. External wins when present. + const pick = (dataRel, pluginRel) => { + const dataPath = join(dataRoot, dataRel); + return existsSync(dataPath) ? dataPath : join(pluginRoot, pluginRel); + }; + // --- 1. Voice samples (25 points) --- // The shipped file is a PII-free placeholder carrying the VOICE_PLACEHOLDER - // sentinel. Key detection on the sentinel (deterministic) rather than the old - // `[Your Name]` heuristic: a populated profile removes the sentinel and earns - // the points; the placeholder (or any file still carrying it) scores 0. - const voiceFile = join(pluginRoot, 'assets', 'voice-samples', 'authentic-voice-samples.md'); + // sentinel. Key detection on the sentinel (deterministic): a populated profile + // removes it and earns the points; the placeholder (or any file still carrying + // it) scores 0. + const voiceFile = pick( + DEST['assets/voice-samples/authentic-voice-samples.local.md'], + join('assets', 'voice-samples', 'authentic-voice-samples.md'), + ); if (existsSync(voiceFile)) { const content = readFileSync(voiceFile, 'utf-8'); const lineCount = content.split('\n').length; @@ -31,7 +51,10 @@ export function calculateScore(pluginRoot) { } // --- 2. User profile (20 points) --- - const profileFile = join(pluginRoot, 'config', 'user-profile.local.md'); + const profileFile = pick( + DEST['config/user-profile.local.md'], + join('config', 'user-profile.local.md'), + ); if (existsSync(profileFile)) { const content = readFileSync(profileFile, 'utf-8'); const placeholderCount = (content.match(/\[Your /g) || []).length; @@ -41,8 +64,9 @@ export function calculateScore(pluginRoot) { } } - // --- 3. Case studies (15 points) --- - const caseDir = join(pluginRoot, 'assets', 'case-studies'); + // --- 3. Case studies (15 points) --- dir; not in the move tables (migrated + // per-file) → brief-7.1 convention: external dest = in-plugin path minus assets/. + const caseDir = pick('case-studies', join('assets', 'case-studies')); if (existsSync(caseDir)) { let realCases = 0; try { @@ -57,7 +81,7 @@ export function calculateScore(pluginRoot) { } // --- 4. Frameworks (10 points) --- - const fwDir = join(pluginRoot, 'assets', 'frameworks'); + const fwDir = pick('frameworks', join('assets', 'frameworks')); if (existsSync(fwDir)) { let realFw = 0; try { @@ -72,7 +96,10 @@ export function calculateScore(pluginRoot) { } // --- 5. High-engagement posts (10 points) --- - const postsFile = join(pluginRoot, 'assets', 'examples', 'high-engagement-posts.md'); + const postsFile = pick( + DEST['assets/examples/high-engagement-posts.md'], + join('assets', 'examples', 'high-engagement-posts.md'), + ); if (existsSync(postsFile)) { const content = readFileSync(postsFile, 'utf-8'); const postCount = (content.match(/^## Post [0-9]/gm) || []).length; @@ -81,7 +108,10 @@ export function calculateScore(pluginRoot) { } // --- 6. Demographics (8 points) --- - const demoFile = join(pluginRoot, 'assets', 'audience-insights', 'demographics.md'); + const demoFile = pick( + DEST['assets/audience-insights/demographics.md'], + join('assets', 'audience-insights', 'demographics.md'), + ); if (existsSync(demoFile)) { const content = readFileSync(demoFile, 'utf-8'); const placeholderCount = (content.match(/\[Industry name\]|\[Function\]|\[Country\]|\[X\]%/g) || []).length; @@ -92,7 +122,10 @@ export function calculateScore(pluginRoot) { } // --- 7. Engagement patterns (7 points) --- - const patternsFile = join(pluginRoot, 'assets', 'audience-insights', 'engagement-patterns.md'); + const patternsFile = pick( + DEST['assets/audience-insights/engagement-patterns.md'], + join('assets', 'audience-insights', 'engagement-patterns.md'), + ); if (existsSync(patternsFile)) { const content = readFileSync(patternsFile, 'utf-8'); const placeholderCount = (content.match(/\[Day\]|\[Time\]|\[Topic\]|\[Format\]|\[Hook type\]/g) || []).length; @@ -103,7 +136,10 @@ export function calculateScore(pluginRoot) { } // --- 8. Post templates (5 points) --- - const templatesFile = join(pluginRoot, 'assets', 'templates', 'my-post-templates.md'); + const templatesFile = pick( + DEST['assets/templates/my-post-templates.md'], + join('assets', 'templates', 'my-post-templates.md'), + ); if (existsSync(templatesFile)) { const content = readFileSync(templatesFile, 'utf-8'); const unfilled = (content.match(/\[Name - e\.g\./g) || []).length; diff --git a/hooks/scripts/queue-manager.mjs b/hooks/scripts/queue-manager.mjs index 871a202..928a61b 100644 --- a/hooks/scripts/queue-manager.mjs +++ b/hooks/scripts/queue-manager.mjs @@ -5,11 +5,9 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { getDataRoot } from './data-root.mjs'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const PLUGIN_ROOT = process.env.PLUGIN_ROOT || join(__dirname, '..', '..'); -const QUEUE_FILE = join(PLUGIN_ROOT, 'assets', 'drafts', 'queue.json'); +const QUEUE_FILE = join(getDataRoot('drafts'), 'queue.json'); function ensureQueue() { if (!existsSync(QUEUE_FILE)) { diff --git a/hooks/scripts/quick-import.mjs b/hooks/scripts/quick-import.mjs index 8dde107..2fa5ea7 100644 --- a/hooks/scripts/quick-import.mjs +++ b/hooks/scripts/quick-import.mjs @@ -6,16 +6,25 @@ import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'node import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { exec } from 'node:child_process'; +import { getDataRoot } from './data-root.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); const HOME = process.env.HOME || process.env.USERPROFILE || ''; -const EXPORTS_DIR = join(PLUGIN_ROOT, 'assets', 'analytics', 'exports'); +const EXPORTS_DIR = join(getDataRoot('analytics'), 'exports'); const DOWNLOADS_DIR = join(HOME, 'Downloads'); const POLL_INTERVAL = 3000; const MAX_WAIT = 300000; // 5 minutes -mkdirSync(EXPORTS_DIR, { recursive: true }); +// Manual import command we print for the user. It must NOT pin ANALYTICS_ROOT to +// the in-plugin tree anymore — the CLI defaults to the external data root +// (storage.ts:getDataRoot), so the data path is omitted. The plugin path stays +// only for the in-plugin tsx executable (cli.ts), which is bundled, not data. +export function buildImportCommand(pluginRoot, filename) { + return `node --import tsx "${pluginRoot}/scripts/analytics/src/cli.ts" import "${filename}"`; +} + +export { EXPORTS_DIR }; // Snapshot existing CSV files function getCsvFiles() { @@ -34,53 +43,61 @@ function openUrl(url) { exec(`${cmd} "${url}"`, () => {}); } -const beforeFiles = new Set(getCsvFiles()); +function main() { + mkdirSync(EXPORTS_DIR, { recursive: true }); -console.log('Opening LinkedIn Analytics in your browser...'); -openUrl('https://www.linkedin.com/analytics/creator/content/'); + const beforeFiles = new Set(getCsvFiles()); -console.log('\nInstructions:'); -console.log(' 1. Click \'Export\' (top right) in LinkedIn Analytics'); -console.log(' 2. LinkedIn will download a CSV to ~/Downloads'); -console.log(' 3. This script will detect it automatically\n'); -console.log('Watching ~/Downloads for new CSV files (max 5 minutes)...\n'); + console.log('Opening LinkedIn Analytics in your browser...'); + openUrl('https://www.linkedin.com/analytics/creator/content/'); -let elapsed = 0; -const timer = setInterval(() => { - elapsed += POLL_INTERVAL; + console.log('\nInstructions:'); + console.log(' 1. Click \'Export\' (top right) in LinkedIn Analytics'); + console.log(' 2. LinkedIn will download a CSV to ~/Downloads'); + console.log(' 3. This script will detect it automatically\n'); + console.log('Watching ~/Downloads for new CSV files (max 5 minutes)...\n'); - const currentFiles = getCsvFiles(); - const newFiles = currentFiles.filter(f => !beforeFiles.has(f)); + let elapsed = 0; + const timer = setInterval(() => { + elapsed += POLL_INTERVAL; - for (const filename of newFiles) { - const filePath = join(DOWNLOADS_DIR, filename); - try { - const age = (Date.now() - statSync(filePath).mtime.getTime()) / 1000; - if (/linkedin|analytics|content|export/i.test(filename) || age < 60) { - console.log(`Detected: ${filename}`); - copyFileSync(filePath, join(EXPORTS_DIR, filename)); - console.log(`Copied to: ${EXPORTS_DIR}/${filename}\n`); - console.log('File is ready for import. Run:'); - console.log(' /linkedin:import\n'); - console.log('Or import directly with:'); - console.log(` ANALYTICS_ROOT="${PLUGIN_ROOT}/assets/analytics" node --import tsx "${PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import "${filename}"`); - clearInterval(timer); - process.exit(0); - } - } catch { /* ignore */ } - } + const currentFiles = getCsvFiles(); + const newFiles = currentFiles.filter(f => !beforeFiles.has(f)); - if (elapsed % 15000 === 0) { - const remaining = Math.floor((MAX_WAIT - elapsed) / 60000); - console.log(` Still waiting... (${remaining}m remaining)`); - } + for (const filename of newFiles) { + const filePath = join(DOWNLOADS_DIR, filename); + try { + const age = (Date.now() - statSync(filePath).mtime.getTime()) / 1000; + if (/linkedin|analytics|content|export/i.test(filename) || age < 60) { + console.log(`Detected: ${filename}`); + copyFileSync(filePath, join(EXPORTS_DIR, filename)); + console.log(`Copied to: ${EXPORTS_DIR}/${filename}\n`); + console.log('File is ready for import. Run:'); + console.log(' /linkedin:import\n'); + console.log('Or import directly with:'); + console.log(` ${buildImportCommand(PLUGIN_ROOT, filename)}`); + clearInterval(timer); + process.exit(0); + } + } catch { /* ignore */ } + } - if (elapsed >= MAX_WAIT) { - console.log('\nTimed out after 5 minutes. No new CSV detected.\n'); - console.log('You can manually copy the file:'); - console.log(` mv ~/Downloads/<linkedin-csv-file>.csv ${EXPORTS_DIR}/`); - console.log(' /linkedin:import'); - clearInterval(timer); - process.exit(1); - } -}, POLL_INTERVAL); + if (elapsed % 15000 === 0) { + const remaining = Math.floor((MAX_WAIT - elapsed) / 60000); + console.log(` Still waiting... (${remaining}m remaining)`); + } + + if (elapsed >= MAX_WAIT) { + console.log('\nTimed out after 5 minutes. No new CSV detected.\n'); + console.log('You can manually copy the file:'); + console.log(` mv ~/Downloads/<linkedin-csv-file>.csv ${EXPORTS_DIR}/`); + console.log(' /linkedin:import'); + clearInterval(timer); + process.exit(1); + } + }, POLL_INTERVAL); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/hooks/scripts/session-start.mjs b/hooks/scripts/session-start.mjs index 4c067b7..e451b48 100644 --- a/hooks/scripts/session-start.mjs +++ b/hooks/scripts/session-start.mjs @@ -2,12 +2,14 @@ // SessionStart hook for linkedin-studio plugin // Reads persistent state and session context, outputs JSON with additionalContext -import { readFileSync, existsSync, copyFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { readFileSync, existsSync, copyFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { calculateScore } from './personalization-score.mjs'; import { queueToday, queueOverdue, queueUpcoming } from './queue-manager.mjs'; import { applyWeekRollover } from './week-rollover.mjs'; +import { getDataRoot } from './data-root.mjs'; +import { migrateData } from './migrate-data.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); @@ -27,6 +29,75 @@ function daysSince(dateStr) { return Math.floor((Date.now() - epoch) / 86400000); } +// B-S3: newest trend capture (ISO date) from the persistent trend store, read +// as raw JSON. The hook must NOT spawn the tsx CLI at session start (slow + +// needs node_modules/tsx); the store schema (trends[].capturedAt) is stable and +// versioned, so a direct read is safe. Twin of scripts/trends/src/store.ts: +// newestCaptureDate (max capturedAt). Returns null when the store is absent, +// unreadable, or empty — no captures yet ⇒ no nudge. +function trendsNewestCapture(storePath) { + if (!existsSync(storePath)) return null; + try { + const parsed = JSON.parse(readFileSync(storePath, 'utf-8')); + const trends = Array.isArray(parsed?.trends) ? parsed.trends : []; + let newest = null; + for (const t of trends) { + const d = typeof t?.capturedAt === 'string' ? t.capturedAt : null; + if (d && (newest === null || d > newest)) newest = d; + } + return newest; + } catch { + return null; + } +} + +// RE-R2b: the most recent morning brief (date + pre-rendered summary) for +// session-start surfacing. Reads the dated Markdown directly — NO tsx (same +// zero-dep discipline as trendsNewestCapture above): the brief's frontmatter +// carries a single-line `summary` the hook surfaces verbatim (extractYaml's +// [^"\n]* + .trim() guarantees date/summary are newline-free). Returns null when +// the dir is absent / empty / unreadable — no brief yet => no block. +function latestMorningBrief(briefDir) { + if (!existsSync(briefDir)) return null; + try { + const files = readdirSync(briefDir) + .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)) + .sort(); + const name = files[files.length - 1]; + if (!name) return null; + const content = readFileSync(join(briefDir, name), 'utf-8'); + return { + date: extractYaml(content, 'date'), + summary: extractYaml(content, 'summary'), + file: join(briefDir, name), + }; + } catch { + return null; + } +} + +// SB-S2: brain consolidation freshness. Both read zero-dep through getDataRoot — +// the SAME data root the brain CLI's --apply writes to (sidecar reachable by both). +// No tsx, no profile.md parse: a readdir count + a tiny JSON read, cost-bounded. +function brainLastRun(statePath) { + if (!existsSync(statePath)) return null; + try { + const j = JSON.parse(readFileSync(statePath, 'utf-8')); + return typeof j?.last_run === 'string' ? j.last_run : null; + } catch { + return null; + } +} + +function countPublished(publishedDir) { + if (!existsSync(publishedDir)) return 0; + try { + return readdirSync(publishedDir).filter((f) => f.endsWith('.md') && !f.startsWith('.')).length; + } catch { + return 0; + } +} + function isoWeek() { const d = new Date(); const dayNum = d.getUTCDay() || 7; @@ -43,6 +114,32 @@ function dayOfWeek() { let context = ''; +// M0: relocate per-user data to the external root on first run. Idempotent +// (silent no-op once .migrated exists); must precede every moved-path read +// (voice/profile/scaffold scores, queue). +let m0Migration = { status: 'skip', moved: [], copied: [] }; +try { + m0Migration = migrateData(); +} catch { + // Non-critical: never block session start on migration failure. +} + +// SB-S2: ensure the brain/ + ingest/ scaffold exists (zero-dep, idempotent). +// UNCONDITIONAL — must run on the fresh-install path too (the reminders block +// below only runs when the state file exists). Profile.md SEEDING needs the tsx +// fold, so it stays the manual `brain init`; here we only mkdir, and remember +// whether to nudge (the nudge is appended AFTER the if/else, since the else +// branch reassigns `context` and would clobber an early append). +let brainProfileMissing = false; +try { + for (const d of ['brain/journal', 'ingest/inbox', 'ingest/published']) { + mkdirSync(join(getDataRoot(''), d), { recursive: true }); + } + brainProfileMissing = !existsSync(join(getDataRoot('brain'), 'profile.md')); +} catch { + // Non-critical: never block session start on scaffold-ensure failure. +} + if (existsSync(STATE_FILE)) { const stateContent = readFileSync(STATE_FILE, 'utf-8'); @@ -297,6 +394,27 @@ if (existsSync(STATE_FILE)) { reminders += '- No analytics data imported yet. Run /linkedin:import to start tracking performance.\\n'; } + // Trend-store staleness (B-S3): warn-only, ≥7 days. Fires ONLY when the store + // already holds captures (newest === null ⇒ no nudge), so a never-scanned user + // is never nagged. Neutral wording — "scan for trends" hits trend-spotter's own + // trigger; no hardcoded beat (de-niche invariant, §17 guard). + const newestTrendCapture = trendsNewestCapture(join(getDataRoot('trends'), 'trends.json')); + const daysSinceTrend = daysSince(newestTrendCapture); + if (daysSinceTrend !== null && daysSinceTrend >= 7) { + reminders += `- Trend signals are ${daysSinceTrend} days old. Scan for trends to refresh the digest before planning content.\\n`; + } + + // Brain consolidation-due (SB-S2): fires only when published gold records exist + // and the loop is stale/never-run (a never-used brain never nags). Counts files + // + reads the sidecar last_run — no profile.md parse. Zero-dep. + const publishedCount = countPublished(join(getDataRoot('ingest'), 'published')); + const brainLastRunDate = brainLastRun(join(getDataRoot('brain'), 'consolidation-state.json')); + const daysSinceConsolidation = daysSince(brainLastRunDate); + if (publishedCount > 0 && (brainLastRunDate === null || (daysSinceConsolidation !== null && daysSinceConsolidation >= 7))) { + const since = brainLastRunDate === null ? 'never run' : `${daysSinceConsolidation}d ago`; + reminders += `- ${publishedCount} published post(s) captured, last brain consolidation ${since}. Run \`brain consolidate\` to evolve your profile.\\n`; + } + // Milestone reminders if (milestonePhase && followerCount > 0) { if (milestoneStatus === 'SIGNIFICANTLY BEHIND') { @@ -404,11 +522,26 @@ if (existsSync(STATE_FILE)) { } } +// SB-S2: nudge to seed the brain if it isn't initialised (after the if/else, so +// the fresh-install branch's `context = …` reassignment can't clobber it). +if (brainProfileMissing) { + context += '\\n## Brain\\n- Brain not initialised. Run `brain init` to seed your evolving profile.\\n'; +} + +// RE-R2b: surface the latest dated morning brief (zero-tsx; summary is a +// pre-rendered single line from the brief's frontmatter). Unconditional on a brief +// existing, like the brain nudge above (so the fresh-install branch surfaces it too). +const latestBrief = latestMorningBrief(join(getDataRoot('trends'), 'morning-brief')); +if (latestBrief && latestBrief.summary) { + context += `\\n## Morning Brief (${latestBrief.date})\\n${latestBrief.summary}\\n→ Full brief: ${latestBrief.file}\\n`; +} + // Read REMEMBER.md for user session context -const rememberFile = join(PLUGIN_ROOT, 'REMEMBER.md'); +const rememberFile = join(getDataRoot(), 'REMEMBER.md'); const rememberTemplate = join(PLUGIN_ROOT, 'config', 'REMEMBER.template.md'); if (!existsSync(rememberFile) && existsSync(rememberTemplate)) { + mkdirSync(dirname(rememberFile), { recursive: true }); copyFileSync(rememberTemplate, rememberFile); let rememberContent = readFileSync(rememberFile, 'utf-8'); const today = new Date().toISOString().slice(0, 10); @@ -421,6 +554,11 @@ if (!existsSync(rememberFile) && existsSync(rememberTemplate)) { context += `\\n## Session Context (from REMEMBER.md)\\n${rememberSummary.replace(/\n/g, '\\n')}\\n`; } +// M0: log one line when the migration actually relocated data (D5). +if (m0Migration.status === 'migrated' && (m0Migration.moved.length || m0Migration.copied.length)) { + context += `\\nLinkedIn Studio: migrated ${m0Migration.moved.length} file(s) + ${m0Migration.copied.length} scaffold(s) to the external data root.\\n`; +} + // Output JSON for Claude Code const output = { continue: true, diff --git a/hooks/scripts/user-prompt-context.mjs b/hooks/scripts/user-prompt-context.mjs index 66938a4..b47d8d7 100644 --- a/hooks/scripts/user-prompt-context.mjs +++ b/hooks/scripts/user-prompt-context.mjs @@ -13,13 +13,10 @@ // 0 - Always allow (informational hook) import { readFileSync, existsSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { getDataRoot, getStateFile } from './data-root.mjs'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const pluginRoot = join(__dirname, '..', '..'); -const home = process.env.HOME || process.env.USERPROFILE || ''; -const stateFile = join(home, '.claude', 'linkedin-studio.local.md'); +const stateFile = getStateFile(); // Read stdin JSON let input; @@ -98,10 +95,10 @@ if (!isLinkedin) { // === Build context enrichment === let context = '**LinkedIn Context Enrichment (auto-injected):**\n\n'; -// 1. Voice profile reference -const voiceFile = join(pluginRoot, 'assets', 'voice-samples', 'authentic-voice-samples.md'); +// 1. Voice profile reference — the REAL artefact in the external data root (SC7). +const voiceFile = join(getDataRoot('voice-samples'), 'authentic-voice-samples.md'); if (existsSync(voiceFile)) { - context += '**Voice Profile:** Read `assets/voice-samples/authentic-voice-samples.md` for tone matching.\n\n'; + context += `**Voice Profile:** Read \`${voiceFile}\` for tone matching.\n\n`; } // 2-4. State file data diff --git a/references/ab-testing-framework.md b/references/ab-testing-framework.md index 955650f..d30a4e6 100644 --- a/references/ab-testing-framework.md +++ b/references/ab-testing-framework.md @@ -6,7 +6,7 @@ description: Methodology for systematic content experimentation on LinkedIn, inc # A/B Testing Framework for LinkedIn Content -Systematic experimentation methodology for LinkedIn thought leadership. Since LinkedIn provides no native A/B testing, this framework uses sequential posting with controlled variables to generate actionable content insights. +Systematic experimentation methodology for LinkedIn authority building. Since LinkedIn provides no native A/B testing, this framework uses sequential posting with controlled variables to generate actionable content insights. ## Why A/B Test on LinkedIn? @@ -46,7 +46,7 @@ Organized by impact level. Always start with high-impact variables. | # | Variable | What to Test | Why It Matters | |---|----------|-------------|----------------| | 1 | **Hook/Opening line** | Question vs. statement, personal vs. universal, short vs. long (within 110-140 char limit) | Determines whether anyone clicks "see more." Single biggest driver of impressions. | -| 2 | **Post format** | Text-only vs. carousel vs. poll vs. video vs. document | Format multipliers range from 1.17x (text) to 1.6x (carousel). Audience preference varies. | +| 2 | **Post format** | Text-only vs. carousel vs. poll vs. video vs. document | Format shifts reach (documents/carousels rank highest) but no reliable per-format multiplier holds — see `references/algorithm-signals-reference.md`. Audience preference varies. | | 3 | **Content angle** | Story-based vs. tactical vs. contrarian vs. curation | Angle determines comment quality and engagement depth. | | 4 | **Call-to-action** | Question vs. invitation vs. challenge vs. none | CTA drives comments (strongest algorithm signal after saves). | @@ -63,7 +63,7 @@ Organized by impact level. Always start with high-impact variables. | # | Variable | What to Test | Why It Matters | |---|----------|-------------|----------------| -| 9 | **Hashtag count** | 0 vs. 3 vs. 5 | Diminishing returns; 5+ triggers -68% penalty. | +| 9 | **Hashtag count** | 0 vs. 3 vs. 5 | Diminishing returns; no primary source for a discrete figure. | | 10 | **First comment** | With vs. without, link vs. context vs. question | First comment strategy can boost or confuse engagement. | | 11 | **Emoji usage** | None vs. minimal vs. heavy | Audience-dependent; professional audiences may penalize heavy use. | | 12 | **Line spacing** | Dense vs. airy | Readability matters on mobile but effect is subtle. | diff --git a/references/ai-content-framework.md b/references/ai-content-framework.md deleted file mode 100644 index 68e5883..0000000 --- a/references/ai-content-framework.md +++ /dev/null @@ -1,387 +0,0 @@ -# AI Content Framework - -Specialized framework for creating LinkedIn content about AI topics. Designed for AI advisors, implementers, and strategists who want to build thought leadership in the AI space. - -## The 4 AI Content Pillars - -Structure your AI content around these four pillars for comprehensive coverage: - -### Pillar 1: AI News & Commentary (30-40% of content) - -**Purpose:** Establish yourself as someone who understands what's happening in AI - -**Content types:** -- New model releases and capabilities -- Company announcements (OpenAI, Anthropic, Microsoft, Google) -- Regulatory developments -- Industry trends and shifts -- Research paper summaries - -**Your angle matters:** -- Don't just report news - add perspective -- Connect to your expertise area -- Explain implications for your audience -- Predict what comes next - -**Example transformations:** - -| News Item | Weak Post | Strong Post | -|-----------|-----------|-------------| -| "GPT-5 released" | "GPT-5 is here! Amazing capabilities!" | "GPT-5 changes the game for enterprise AI. Here's what actually matters for implementation teams..." | -| "EU AI Act passed" | "New AI regulations coming" | "The EU AI Act just passed. After reviewing the 200+ pages, here are the 5 requirements that will hit AI projects hardest..." | -| "OpenAI acquires company" | "Big acquisition in AI!" | "OpenAI's acquisition of X signals a shift in strategy. Here's what this means for anyone building on their platform..." | - -### Pillar 2: Practical AI Implementation (30-40% of content) - -**Purpose:** Demonstrate that you've actually done the work - -**Content types:** -- How-to guides and tutorials -- Implementation patterns and anti-patterns -- Tool comparisons and recommendations -- Architecture decisions and trade-offs -- Troubleshooting and problem-solving - -**Key principles:** -- Be specific (exact steps, real examples) -- Share failures as much as successes -- Explain the "why" behind decisions -- Make it actionable - -**Example topics:** - -| Category | Example Topics | -|----------|----------------| -| Implementation | "How we reduced hallucinations by 60% in our RAG system" | -| Patterns | "The 3 architecture patterns I use for every AI project" | -| Tools | "Copilot Studio vs Power Automate: When to use each" | -| Troubleshooting | "Why your AI pilot succeeded but production failed" | -| Process | "Our 5-step AI vendor evaluation process" | - -### Pillar 3: AI Strategy & Leadership (20-30% of content) - -**Purpose:** Speak to decision-makers and establish strategic credibility - -**Content types:** -- ROI and business case frameworks -- Organizational readiness assessments -- Change management for AI -- Governance and ethics considerations -- Leadership perspectives and decisions - -**Target audience:** C-suite, department heads, IT leadership - -**Example topics:** - -| Focus Area | Example Topics | -|------------|----------------| -| ROI | "How to calculate AI ROI (the honest way)" | -| Readiness | "The 5 questions I ask before any AI project" | -| Change | "Why your AI project failed (it wasn't the technology)" | -| Governance | "Building an AI governance framework that actually works" | -| Leadership | "What I tell CEOs who ask 'Should we invest in AI?'" | - -### Pillar 4: AI Tools & Resources (10-20% of content) - -**Purpose:** Provide tangible value and establish generosity - -**Content types:** -- Free templates and frameworks -- Tool recommendations and reviews -- Resource roundups and guides -- Skills and capabilities shares -- Checklists and cheat sheets - -**Key principles:** -- Give away genuinely useful things -- Don't gate everything behind email capture -- Update regularly as tools change -- Focus on tools you actually use - -**Example shares:** - -| Type | Examples | -|------|----------| -| Templates | "AI project kickoff template (the one I actually use)" | -| Checklists | "Pre-deployment AI checklist (20 items)" | -| Frameworks | "My vendor evaluation scorecard" | -| Guides | "2026 AI tool landscape for enterprise" | -| Skills | "Custom Claude Code skill for AI documentation" | - -## AI News Monitoring Routine - -Stay current without drowning in information. - -### Daily Routine (10 minutes) - -**Morning scan:** -1. Check top 3 AI news sources (see list below) -2. Note 1-2 stories relevant to your expertise -3. Add to content ideas if commentary-worthy - -**Key sources for daily scan:** -- The Batch (Andrew Ng's newsletter) -- AI News (VentureBeat) -- Anthropic/OpenAI/Microsoft announcements -- r/MachineLearning (top posts) - -### Weekly Routine (30 minutes) - -**Dedicated AI research block:** - -1. **Research papers** (10 min) - - ArXiv AI papers (top cited) - - Google Research blog - - Microsoft Research blog - -2. **Industry analysis** (10 min) - - AI-focused podcasts - - YouTube channels (AI Explained, Two Minute Papers) - - LinkedIn content from top AI voices - -3. **Content planning** (10 min) - - Which news items merit posts? - - What patterns are emerging? - - What's my audience asking about? - -### Sources by Priority - -**Tier 1: Must follow (daily)** -- OpenAI blog/announcements -- Anthropic blog/announcements -- Microsoft AI blog -- Google AI blog - -**Tier 2: High value (2-3x/week)** -- MIT Technology Review -- The Verge AI section -- Ars Technica AI -- Stratechery (Ben Thompson) - -**Tier 3: Deep dives (weekly)** -- ArXiv (cs.AI, cs.CL, cs.LG) -- Distill.pub -- Papers With Code - -**Tier 4: Community (as needed)** -- r/MachineLearning -- r/LocalLLaMA -- Hacker News AI discussions -- AI Twitter/X threads - -## Content Trigger Framework - -Know when AI news warrants a post. - -### High-Priority Triggers (post within 24-48 hours) - -**Always post about:** -- Major model releases (GPT-X, Claude X, Gemini X) -- Significant capability breakthroughs -- Regulatory decisions affecting AI use -- Major acquisitions/partnerships -- Security vulnerabilities in AI systems - -**Why timing matters:** -- First-mover advantage in commentary -- Algorithm favors timely content -- Establishes you as "in the know" - -### Medium-Priority Triggers (post within week) - -**Consider posting about:** -- Research papers with practical implications -- Industry reports with notable findings -- Tool updates and feature releases -- Conference announcements -- Company strategy shifts - -### Low-Priority Triggers (optional) - -**Skip or brief mention:** -- Incremental updates -- Minor funding rounds -- Personnel changes (unless significant) -- Speculation and rumors -- Vendor marketing announcements - -### The Relevance Filter - -**Before posting, ask:** - -1. **Is this relevant to my expertise areas?** - - Yes = proceed - - No = skip (unless huge news) - -2. **Does my audience care?** - - Public sector leaders? Check. - - Enterprise AI implementers? Check. - - General tech enthusiasts? Maybe skip. - -3. **Can I add unique perspective?** - - Have implementation experience? Post. - - Just repeating news? Skip or brief. - -4. **Is there urgency?** - - Time-sensitive = prioritize - - Evergreen = can wait - -## AI-Specific Hook Templates - -Templates optimized for AI content. - -### News Commentary Hooks - -``` -"[Company] just announced [thing]. Here's what most commentators are missing..." - -"Everyone's talking about [AI development]. After [X] implementations, here's what actually matters..." - -"The [AI announcement] headlines are wrong. The real story is..." - -"[Number] hours after [AI release], here's my first assessment..." - -"While everyone focuses on [obvious thing], the real implication of [news] is..." -``` - -### Implementation Insight Hooks - -``` -"We just deployed [AI system] for [use case]. The hardest part wasn't what you'd expect..." - -"After [X] AI projects, I've seen the same pattern [Y]% of the time..." - -"Everyone says [common AI advice]. In practice, the opposite is true..." - -"The difference between AI projects that succeed and fail? It's not the technology..." - -"I just reviewed [X] failed AI projects. They all made this mistake..." -``` - -### Strategy/Leadership Hooks - -``` -"Our CEO asked me: 'Should we invest in AI?' Here's what I told her..." - -"Most AI strategies fail for the same reason. Here's the fix..." - -"Before any AI project, I ask these 5 questions. #3 is the killer..." - -"The uncomfortable truth about AI ROI that vendors won't tell you..." - -"What separates AI-ready organizations from the rest? It's not budget..." -``` - -### Tool/Resource Hooks - -``` -"I've tested [X] AI tools for [use case]. Here's the winner (and why)..." - -"Free resource: The [framework/template] I use for every [AI task]..." - -"[Tool] vs [Tool]: After using both for [time], here's my verdict..." - -"This [free tool] changed how I approach [AI task]..." - -"I built this [skill/template/framework] for my own use. Now it's yours..." -``` - -## AI Topic Calendar - -Structure your AI content across the month. - -### Weekly AI Topic Rotation - -| Week | Primary Focus | Secondary Focus | -|------|---------------|-----------------| -| 1 | News & Commentary | Strategy insight | -| 2 | Implementation how-to | Tool/resource | -| 3 | News & Commentary | Case study | -| 4 | Strategy deep-dive | Tool/resource | - -### Monthly AI Content Mix - -**For 8-12 posts per month:** - -| Pillar | Posts | Examples | -|--------|-------|----------| -| News & Commentary | 3-4 | News reactions, trend analysis | -| Implementation | 3-4 | How-tos, patterns, lessons | -| Strategy | 1-2 | Leadership posts, frameworks | -| Tools & Resources | 1-2 | Shares, comparisons, giveaways | - -### Seasonal AI Topics - -**Q1 (Jan-Mar):** -- Predictions and trends -- Budget planning for AI -- New year AI resolutions/strategies - -**Q2 (Apr-Jun):** -- Conference season coverage -- Mid-year assessments -- Implementation case studies - -**Q3 (Jul-Sep):** -- Summer project retrospectives -- H2 planning -- Back-to-school AI skills - -**Q4 (Oct-Dec):** -- Year-end reflections -- Predictions for next year -- Budget justification content - -## AI Content Quality Checklist - -Before posting AI content: - -### Accuracy Check -- [ ] Claims are factually accurate -- [ ] Statistics are sourced and current -- [ ] Technical details are correct -- [ ] No AI hype or fear-mongering - -### Expertise Signal -- [ ] Post demonstrates real experience -- [ ] Specific examples included -- [ ] Avoids generic AI cliches -- [ ] Shows nuanced understanding - -### Audience Value -- [ ] Relevant to target audience -- [ ] Actionable where appropriate -- [ ] Not just information, but insight -- [ ] Answers "so what?" - -### Differentiation -- [ ] Adds perspective beyond news -- [ ] Shows unique angle/experience -- [ ] Not duplicating what everyone else says -- [ ] Reflects my expertise areas - -## AI Content Anti-Patterns - -**Avoid these common AI content mistakes:** - -| Anti-Pattern | Why It's Bad | Better Approach | -|--------------|--------------|-----------------| -| "AI will change everything!" | Vague hype | Specific, grounded claims | -| "AI is dangerous/scary" | Fear-mongering | Balanced assessment | -| Just sharing announcements | No added value | Add your perspective | -| "10 AI tools you need" | Generic listicle | Curated with experience | -| Jargon-heavy technical posts | Alienates audience | Accessible explanations | -| "AI will replace [job]" | Tired take | Nuanced workforce analysis | -| Vendor press releases | Looks like promotion | Independent perspective | -| Repeating common advice | No differentiation | Counter-conventional takes | - -## Integration with Main Skill - -This framework integrates with the main LinkedIn thought leadership skill: - -- **Angles:** AI content uses same 8 angles (thought-leadership-angles.md) -- **Formats:** Follow format guidelines in linkedin-formats.md -- **Engagement:** Apply same engagement frameworks -- **Growth:** Contributes to overall authority building - -The difference: AI content requires staying current with fast-moving developments and maintaining technical credibility while remaining accessible to non-technical audiences. diff --git a/references/algorithm-signals-reference.md b/references/algorithm-signals-reference.md index 7a53adc..3d21c35 100644 --- a/references/algorithm-signals-reference.md +++ b/references/algorithm-signals-reference.md @@ -155,7 +155,7 @@ hypotheses, not targets. Confidence: **low / directional** for every row. | Linking? | First comment, lead with value either way | | Multiple ideas? | Split into separate posts | | Off your usual topic? | Topic alignment is a real input — stay on-domain or accept lower reach | -| Video or text? | Text for thought leadership, video (captioned, 4:5/1:1) for connection | +| Video or text? | Text for authority, video (captioned, 4:5/1:1) for connection | | Carousel or text? | Documents for frameworks/guides, text for stories/opinions | | Comment or like first? | Comment (higher in the engagement order) | diff --git a/references/articles-strategy-guide.md b/references/articles-strategy-guide.md index b7724be..58a7bde 100644 --- a/references/articles-strategy-guide.md +++ b/references/articles-strategy-guide.md @@ -25,8 +25,8 @@ LinkedIn Articles are the platform's native long-form content format - distinct ### The Trade-off -- Articles get 2-3x LESS initial reach than posts -- BUT they have 10x longer lifespan (found via search for months/years) +- Articles tend to get less initial reach than posts (no reliable multiplier) +- BUT they have a much longer lifespan (found via search for months/years) - Articles build authority profile; posts build engagement metrics ### When Articles Make Sense diff --git a/references/collaborations-guide.md b/references/collaborations-guide.md index fe66f5a..5ed2b43 100644 --- a/references/collaborations-guide.md +++ b/references/collaborations-guide.md @@ -1,6 +1,6 @@ # Strategic Collaborations Guide -Collaboration is one of the most underutilized growth accelerators on LinkedIn. Strategic partnerships can 10x your reach and credibility by tapping into complementary audiences. +Collaboration is one of the most underutilized growth accelerators on LinkedIn. Strategic partnerships can substantially expand your reach and credibility by tapping into complementary audiences. --- @@ -29,7 +29,7 @@ Collaboration is one of the most underutilized growth accelerators on LinkedIn. ### Growth Acceleration Data -- Collaborations generate 2-3x normal reach +- Collaborations tend to generate above-baseline reach (no reliable multiplier) - 40-60% of collaborator's engaged audience visits your profile - 10-15% conversion to new followers - Higher quality followers (already interested in your topics) @@ -162,7 +162,7 @@ After establishing rapport: - **Their post:** "Great conversation with [You] about [topic]. Here's what I shared: [key points]. Their follow-up questions revealed [additional insight]." **Time investment:** 30-45 minutes total -**Reach multiplier:** 2-3x +**Reach:** Above baseline — expands to your collaborator's audience (no reliable multiplier) **Topic examples:** - "How do you approach [common challenge]?" @@ -184,7 +184,7 @@ After establishing rapport: - Both posts link to each other: "My colleague [Name] addresses the technical side brilliantly. Check their perspective + mine for complete picture." **Time investment:** 1 hour (including coordination) -**Reach multiplier:** 2-3x +**Reach:** Above baseline — expands to your collaborator's audience (no reliable multiplier) **Benefit:** Shows diverse perspectives, positions you as collaborative thinker **Format variations:** @@ -207,7 +207,7 @@ After establishing rapport: - Both create content explaining framework from different angles **Time investment:** 3-5 hours (including creation and coordination) -**Reach multiplier:** 3-5x +**Reach:** Above baseline; deeper integration than a single co-post (no reliable multiplier) **Benefit:** Creates reusable asset, positions both as thought leaders, deeper integration **Execution:** @@ -232,7 +232,7 @@ After establishing rapport: - Caption explains collaboration **Time investment:** 2-3 hours -**Reach multiplier:** 4-6x (carousels perform well) +**Reach:** Above baseline (carousels perform well; no reliable multiplier) **Benefit:** High-value format, shareable, clear co-creation ### Format 5: Live Conversation/LinkedIn Live @@ -249,7 +249,7 @@ After establishing rapport: - Promote 3-5 days advance **Time investment:** 1 hour live + 1 hour prep and promotion -**Reach multiplier:** 12-24x (LinkedIn Live favored by algorithm) +**Reach:** Tends to be the strongest of these formats — LinkedIn Live is favored by the algorithm (no reliable multiplier) **Benefit:** Real-time engagement, authenticity, captures both audiences simultaneously **Topics that work:** @@ -274,7 +274,7 @@ After establishing rapport: - All post same week, tag each other, use consistent hashtag **Time investment:** 2-3 hours (coordination + content creation) -**Reach multiplier:** 3-4x per collaborator +**Reach:** Scales with each collaborator (no reliable multiplier) **Benefit:** Positions you within community of experts, major visibility spike ### Format 7: Takeovers @@ -289,7 +289,7 @@ After establishing rapport: "Today [Your Name] is taking over with their perspective on [topic]. [Your bio]. Take it away, [Name]:" **Time investment:** 1-2 hours -**Reach multiplier:** Direct exposure to their entire audience +**Reach:** Direct exposure to their entire audience **Benefit:** Credibility transfer, audience introduction, variety for both audiences --- @@ -346,28 +346,22 @@ Include collaborator content in Featured: --- -## Engagement Pods: March 2025 Crackdown +## Engagement Pods: An Active Crackdown -LinkedIn's March 2025 update dramatically increased pod detection capabilities. +LinkedIn runs an active engagement-pod crackdown and keeps expanding how it +detects them (see `algorithm-signals-reference.md` — the algorithm SSOT). -### Detection Methods Now in Use +### How Pods Get Detected -- Browser extension tracking (LinkedIn detects pod-organizing extensions) -- Pattern analysis on comment timing (simultaneous engagement = red flag) +- Comment-timing analysis (simultaneous engagement = red flag) - Cross-account engagement correlation (same people always first to engage) -- Linguistic fingerprinting (similar comment patterns across accounts) +- Reciprocal engagement loops (the same accounts engaging each other) -### Consequences +### What LinkedIn Does -- Shadow banning affects ALL future content (not just the flagged post) -- Reach penalties persist for 90+ days -- Can take 6+ months to recover algorithmic trust -- Some accounts never fully recover - -### The Math Has Changed - -- **Old:** Pod engagement boosted first-hour metrics → more reach -- **New:** Pod engagement triggers detection → permanent reach penalty +- Pod-style auto-comments are demoted out of "Most Relevant" +- Their reach is scoped to your own network — it stops spreading beyond it +- Repeat offenders get restricted **Pods now hurt more than help.** Build authentic communities instead: - Genuine relationships with 3-5 aligned creators @@ -479,4 +473,4 @@ High-performing creators often form informal masterminds: ## Bottom Line -Strategic collaborations provide 10x more growth acceleration than equivalent time spent creating solo content. Start building genuine relationships with complementary creators now, even if collaboration is months away. +Strategic collaborations can accelerate growth well beyond equivalent time spent creating solo content (no reliable multiplier). Start building genuine relationships with complementary creators now, even if collaboration is months away. diff --git a/references/thought-leadership-angles.md b/references/content-angles.md similarity index 96% rename from references/thought-leadership-angles.md rename to references/content-angles.md index ac238fd..08c976e 100644 --- a/references/thought-leadership-angles.md +++ b/references/content-angles.md @@ -1,10 +1,10 @@ -# Thought Leadership Angles +# Content Angles -This document provides frameworks for identifying thought leadership angles from any type of content or context. +This document provides frameworks for identifying content angles from any type of content or context. ## Core Principle -Thought leadership isn't about what you know—it's about **how you help others see differently**. Any content can become thought leadership by finding the right angle. +Authority isn't about what you know—it's about **how you help others see differently**. Any content can build authority by finding the right angle. ## 8 Universal Angles @@ -89,7 +89,7 @@ What do you have? - How should we think about this differently? (Reframe) - What does this mean for the future? (Future Implication) -### Step 3: Test For Thought Leadership Value +### Step 3: Test For Authority Value A good angle must pass at least two of these tests: - **Perspective shift:** Does it make people see things differently? @@ -124,7 +124,7 @@ These angles work across all industries because they're about **types of thinkin ## Industry Angle Variants -Concrete starter questions and example hooks per industry. When the user's industry is known (from `config/user-profile.local.md`), surface the relevant table during angle selection. +Concrete starter questions and example hooks per industry. When the user's industry is known (from `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md`), surface the relevant table during angle selection. ### Tech / Software / AI @@ -209,10 +209,10 @@ Concrete starter questions and example hooks per industry. When the user's indus - **Echo chamber:** Repeating what everyone already says - **Humble brag:** Disguised self-promotion without insight - **Vague wisdom:** Platitudes without specifics -- **Pure promotion:** Marketing disguised as thought leadership +- **Pure promotion:** Marketing disguised as expertise - **Borrowed authority:** Citing research without adding perspective -## The Thought Leadership Test +## The Authority Value Test Before posting, ask: 1. Does this help someone make a better decision? diff --git a/references/content-framework.md b/references/content-framework.md new file mode 100644 index 0000000..1f89f4a --- /dev/null +++ b/references/content-framework.md @@ -0,0 +1,351 @@ +# Content Framework + +A framework for creating LinkedIn content in **your** field — whatever that field is. The structure (pillars, triggers, source tiers, calendar) is domain-general; the subject matter comes from **your content pillars and expertise areas** (loaded from your profile), never baked into this file. The worked examples below deliberately span different domains so you can see the pattern, not inherit someone else's beat. + +> **How to read the examples:** placeholders in `[brackets]` are filled from your own domain. Where a concrete illustration is given, it is an *example from one field* — substitute the equivalent from yours. + +## The 4 Content Pillars + +Structure your content around these four pillars for comprehensive coverage. The percentages are a starting balance, not a rule. + +### Pillar 1: News & Commentary (30-40% of content) + +**Purpose:** Establish yourself as someone who understands what's happening in your field + +**Content types:** +- New releases, products, or capabilities in your domain +- Notable announcements from the players that matter to your audience +- Regulatory or policy developments +- Industry trends and shifts +- Summaries of new research or reports + +**Your angle matters:** +- Don't just report news — add perspective +- Connect it to your expertise area +- Explain implications for your audience +- Predict what comes next + +**Example transformations (different fields, same move):** + +| News Item | Weak Post | Strong Post | +|-----------|-----------|-------------| +| A major product release in your field | "It's here! Amazing!" | "This changes the calculus for [your audience]. Here's what actually matters when you go to implement it..." | +| A new regulation passes | "New rules coming" | "After reading the 200+ pages, here are the 5 requirements that will hit [audience]'s projects hardest..." | +| A large acquisition in your sector | "Big deal in [sector]!" | "This acquisition signals a strategy shift. Here's what it means for anyone building on their platform..." | + +### Pillar 2: Practical Implementation (30-40% of content) + +**Purpose:** Demonstrate that you've actually done the work + +**Content types:** +- How-to guides and tutorials +- Implementation patterns and anti-patterns +- Tool comparisons and recommendations +- Decisions and trade-offs you actually made +- Troubleshooting and problem-solving + +**Key principles:** +- Be specific (exact steps, real numbers, real examples) +- Share failures as much as successes +- Explain the "why" behind decisions +- Make it actionable + +**Example topics (spanning fields):** + +| Category | Example Topics | +|----------|----------------| +| Implementation | "How we cut [a costly metric] by 60% in our [system]" | +| Patterns | "The 3 patterns I reach for on every [type of] project" | +| Tools | "[Tool A] vs [Tool B]: when to use each" | +| Troubleshooting | "Why our pilot succeeded but production failed" | +| Process | "Our 5-step vendor evaluation process" | + +### Pillar 3: Strategy & Leadership (20-30% of content) + +**Purpose:** Speak to decision-makers and establish strategic credibility + +**Content types:** +- ROI and business-case frameworks +- Organizational readiness assessments +- Change management +- Governance and ethics considerations +- Leadership perspectives and decisions + +**Target audience:** C-suite, department heads, the leaders in your space + +**Example topics:** + +| Focus Area | Example Topics | +|------------|----------------| +| ROI | "How to calculate the ROI of [your initiative] (the honest way)" | +| Readiness | "The 5 questions I ask before any [type of] project" | +| Change | "Why your [initiative] failed (it wasn't the technology)" | +| Governance | "Building a governance framework that actually works" | +| Leadership | "What I tell executives who ask 'Should we invest in [X]?'" | + +### Pillar 4: Tools & Resources (10-20% of content) + +**Purpose:** Provide tangible value and establish generosity + +**Content types:** +- Free templates and frameworks +- Tool recommendations and reviews +- Resource roundups and guides +- Skills and capabilities you share +- Checklists and cheat sheets + +**Key principles:** +- Give away genuinely useful things +- Don't gate everything behind email capture +- Update regularly as the field changes +- Focus on tools you actually use + +**Example shares:** + +| Type | Examples | +|------|----------| +| Templates | "The kickoff template I actually use" | +| Checklists | "Pre-deployment checklist (20 items)" | +| Frameworks | "My vendor evaluation scorecard" | +| Guides | "The [current-year] tool landscape for [your audience]" | +| Skills | "A custom tool I built for my own [task]" | + +## Content Monitoring Routine + +Stay current in your field without drowning in information. + +> **Where you look is config, not baked into this file.** The specific sources to monitor — your vendors, regulators, outlets, communities — live in a source list the trend engine loads at runtime: `config/trends-sources.template.md` (shipped generic categories) with a user override at `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/sources.md` (your own niche list, which survives upgrades/reinstalls). Populate the tiers there with **your** domain's sources. The *rhythm* below is what generalizes; the *sources* are yours. + +### Daily Routine (10 minutes) + +**Morning scan:** +1. Check your top 3 field sources (your Tier 1 list) +2. Note 1-2 stories relevant to your expertise +3. Add to content ideas if commentary-worthy + +### Weekly Routine (30 minutes) + +**Dedicated research block:** + +1. **Deep sources** (10 min) — research, primary reports, authoritative analysis in your field +2. **Industry analysis** (10 min) — podcasts, channels, and the voices your audience follows +3. **Content planning** (10 min) + - Which items merit posts? + - What patterns are emerging? + - What is my audience asking about? + +### Source Tiers (cadence, not a fixed list) + +Group your own sources into four tiers by how fast they move, then poll on that cadence: + +| Tier | What lives here | Cadence | +|------|-----------------|---------| +| **Tier 1 — Primary / breaking** | first-party announcements, authoritative decisions | daily | +| **Tier 2 — Analysis & research** | where developments get interpreted, not just reported | 2-3×/week | +| **Tier 3 — Community signals** | where practitioners surface what matters before the press | weekly | +| **Tier 4 — Niche & seasonal** | slower sources with predictable cadence | monthly | + +## Content Trigger Framework + +Know when news in your field warrants a post. + +### High-Priority Triggers (post within 24-48 hours) + +**Always consider posting about:** +- Major releases or capability breakthroughs in your domain +- Regulatory decisions affecting how your audience works +- Major acquisitions or partnerships among the players that matter +- Security or safety issues in systems your audience relies on + +**Why timing matters:** +- First-mover advantage in commentary +- The algorithm favors timely content +- Establishes you as "in the know" + +### Medium-Priority Triggers (post within a week) + +**Consider posting about:** +- Research or reports with practical implications +- Tool updates and feature releases +- Conference takeaways +- Strategy shifts among notable players + +### Low-Priority Triggers (optional) + +**Skip or brief mention:** +- Incremental updates +- Minor funding rounds +- Personnel changes (unless significant) +- Speculation and rumors +- Vendor marketing announcements + +### The Relevance Filter + +**Before posting, ask:** + +1. **Is this relevant to my expertise areas?** Yes = proceed · No = skip (unless huge news) +2. **Does my audience care?** Check it against the audience in your profile — the people you actually write for. If it's adjacent-but-off, maybe skip. +3. **Can I add unique perspective?** Direct experience = post · Just repeating news = skip or brief +4. **Is there urgency?** Time-sensitive = prioritize · Evergreen = can wait + +## Hook Templates + +Templates for content built on news and expertise. Fill the `[brackets]` from your domain. + +### News Commentary Hooks + +``` +"[Player] just announced [thing]. Here's what most commentators are missing..." + +"Everyone's talking about [development]. After [X] implementations, here's what actually matters..." + +"The [announcement] headlines are wrong. The real story is..." + +"[Number] hours after [release], here's my first assessment..." + +"While everyone focuses on [obvious thing], the real implication of [news] is..." +``` + +### Implementation Insight Hooks + +``` +"We just deployed [system] for [use case]. The hardest part wasn't what you'd expect..." + +"After [X] projects, I've seen the same pattern [Y]% of the time..." + +"Everyone says [common advice]. In practice, the opposite is true..." + +"The difference between projects that succeed and fail? It's not the technology..." + +"I just reviewed [X] failed projects. They all made this mistake..." +``` + +### Strategy/Leadership Hooks + +``` +"Our CEO asked me: 'Should we invest in [X]?' Here's what I told her..." + +"Most [domain] strategies fail for the same reason. Here's the fix..." + +"Before any [type of] project, I ask these 5 questions. #3 is the killer..." + +"The uncomfortable truth about [X] ROI that vendors won't tell you..." + +"What separates [X]-ready organizations from the rest? It's not budget..." +``` + +### Tool/Resource Hooks + +``` +"I've tested [X] tools for [use case]. Here's the winner (and why)..." + +"Free resource: the [framework/template] I use for every [task]..." + +"[Tool] vs [Tool]: after using both for [time], here's my verdict..." + +"This [free tool] changed how I approach [task]..." + +"I built this [skill/template/framework] for my own use. Now it's yours..." +``` + +## Topic Calendar + +Structure your content across the month. + +### Weekly Topic Rotation + +| Week | Primary Focus | Secondary Focus | +|------|---------------|-----------------| +| 1 | News & Commentary | Strategy insight | +| 2 | Implementation how-to | Tool/resource | +| 3 | News & Commentary | Case study | +| 4 | Strategy deep-dive | Tool/resource | + +### Monthly Content Mix + +**For 8-12 posts per month:** + +| Pillar | Posts | Examples | +|--------|-------|----------| +| News & Commentary | 3-4 | News reactions, trend analysis | +| Implementation | 3-4 | How-tos, patterns, lessons | +| Strategy | 1-2 | Leadership posts, frameworks | +| Tools & Resources | 1-2 | Shares, comparisons, giveaways | + +### Seasonal Topics (rhythm, adapt to your field) + +The calendar rhythm is general; fill it with your domain's events and cycles. + +**Q1 (Jan-Mar):** +- Predictions and trends for the year +- Budget planning +- New-year resolutions/strategies + +**Q2 (Apr-Jun):** +- Conference season coverage (your field's events) +- Mid-year assessments +- Implementation case studies + +**Q3 (Jul-Sep):** +- Summer project retrospectives +- H2 planning +- Skills and fundamentals content + +**Q4 (Oct-Dec):** +- Year-end reflections +- Predictions for next year +- Budget-justification content + +## Content Quality Checklist + +Before posting: + +### Accuracy Check +- [ ] Claims are factually accurate +- [ ] Statistics are sourced and current +- [ ] Technical details are correct +- [ ] No hype or fear-mongering + +### Expertise Signal +- [ ] Post demonstrates real experience +- [ ] Specific examples included +- [ ] Avoids generic cliches +- [ ] Shows nuanced understanding + +### Audience Value +- [ ] Relevant to target audience +- [ ] Actionable where appropriate +- [ ] Not just information, but insight +- [ ] Answers "so what?" + +### Differentiation +- [ ] Adds perspective beyond the news +- [ ] Shows unique angle/experience +- [ ] Not duplicating what everyone else says +- [ ] Reflects my expertise areas + +## Content Anti-Patterns + +**Avoid these common mistakes:** + +| Anti-Pattern | Why It's Bad | Better Approach | +|--------------|--------------|-----------------| +| "[Field] will change everything!" | Vague hype | Specific, grounded claims | +| "[Field] is dangerous/scary" | Fear-mongering | Balanced assessment | +| Just sharing announcements | No added value | Add your perspective | +| "10 [tools] you need" | Generic listicle | Curated with experience | +| Jargon-heavy technical posts | Alienates audience | Accessible explanations | +| "[X] will replace [job]" | Tired take | Nuanced workforce analysis | +| Vendor press releases | Looks like promotion | Independent perspective | +| Repeating common advice | No differentiation | Counter-conventional takes | + +## Integration with Main Skill + +This framework integrates with the main LinkedIn content skill: + +- **Angles:** content uses the same 8 angles (content-angles.md) +- **Formats:** follow format guidelines in linkedin-formats.md +- **Engagement:** apply the same engagement frameworks +- **Growth:** contributes to overall authority building + +The difference for fast-moving fields: they require staying current with rapid developments and maintaining technical credibility while remaining accessible to non-specialist audiences. diff --git a/references/data-path-convention.md b/references/data-path-convention.md new file mode 100644 index 0000000..c77c678 --- /dev/null +++ b/references/data-path-convention.md @@ -0,0 +1,70 @@ +# Data-Path Convention + +How command, agent, skill, and hook-prompt prose should refer to **user data** vs +**plugin-shipped assets** after the M0 migration. One token, resolved inline, so +the prose Claude reads is directly actionable — no indirection, no stale in-plugin +paths. + +## The two roots + +| Root | Token in prose | Holds | Moves on migration? | +|------|----------------|-------|---------------------| +| **Data root** | `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}` | the user's real data (voice profile, analytics, drafts, plans, …) | yes — auto-migrated out of the plugin on session-start | +| **Plugin root** | `${CLAUDE_PLUGIN_ROOT}` | read-only ships (templates, checklists, references, fonts, framework scaffolds) | no — these stay in the plugin | + +The data root **mirrors** the state file (`~/.claude/linkedin-studio.local.md`). The +default is overridable by the single env var `LINKEDIN_STUDIO_DATA`; the deprecated +aliases `ANALYTICS_ROOT` / `STATE_FILE` / `PLUGIN_ROOT` are honored for one minor +version. This generalizes the proven newsletter pattern +`${LTL_SERIES_ROOT:-$HOME/linkedin-series}` (`commands/newsletter.md`) to all data. + +## Data-root layout (mirrors brief §7.1) + +``` +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/ + voice-samples/ authentic-voice-samples.md chronicle-voice-drift-log.md + analytics/ exports/ posts/ weekly-reports/ monthly-reports/ ab-tests/ + drafts/ queue.json week-*/ carousel/ multiplatform/ repurposed/ + profile/ user-profile.md + frameworks/ <slug>.md + audience-insights/ demographics.md engagement-patterns.md + examples/ high-engagement-posts.md + templates/ my-post-templates.md + plans/ <weekly|monthly>-plan-*.md +``` + +## The prose rule + +1. **A path to user data → the data-root token.** Write the full inline form + `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/<subpath>` — never a bare + `assets/<x>/` and never `${CLAUDE_PLUGIN_ROOT}/assets/<x>/` for data. The inline + `:-default` expansion means the prose is self-resolving; do **not** write "the data + dir's `<x>`" and force a lookup into this doc. +2. **A path to a plugin-shipped read-only asset → keep `${CLAUDE_PLUGIN_ROOT}`.** + Templates (`config/*.template.*`), checklists, `references/`, fonts, and framework + scaffolds ship with the plugin and stay there. +3. **Preserve existing graceful-degradation guards.** "if it exists", placeholder / + sentinel checks, and `<5`-sample silent-skips stay as written — only the path token + changes. + +## Scaffold fallback (the COPY classes + the voice placeholder) + +Four data files ship a **seed/scaffold in-plugin** and a **canonical instance external**: +`examples/high-engagement-posts.md`, `audience-insights/{demographics,engagement-patterns}.md`, +`templates/my-post-templates.md`. The voice profile is similar — the plugin ships a +placeholder at `assets/voice-samples/authentic-voice-samples.md`; the user's real profile +lives external. **Readers prefer the external instance and fall back to the in-plugin seed +when external is absent** (the code does this in `personalization-score.mjs`; prose keeps +its "if it exists" guard). The external instance is canonical — migration never clobbers it. + +## Why inline, not indirection (the Step-14 GATE finding) + +The prototype on the voice-readers family (19 files, 38 refs) measured the open question +from brief D3: *can a convention reduce edits, or do commands need literal paths for Claude +to act on?* Outcome: command prose that tells Claude to **read** a file needs a resolvable +path on the line. A "the data dir's `voice-samples/` (see this doc)" reference adds a lookup +hop and is not directly actionable. The inline `${LINKEDIN_STUDIO_DATA:-…}/…` token is both +self-resolving **and** points here. So the convention does **not** cut the edit count below +~1 per reference — it makes every edit a **uniform, mechanical** token swap instead of a +bespoke per-line decision, with this doc as the single source of truth. Step 15 applies the +same uniform token to the remaining families (D3 realized as one token — not a re-decision). diff --git a/references/engagement-frameworks.md b/references/engagement-frameworks.md index 20a0c05..8124382 100644 --- a/references/engagement-frameworks.md +++ b/references/engagement-frameworks.md @@ -64,7 +64,7 @@ Pattern: Set a scene that resonates **10. The Direct Address** Pattern: Speak directly to a specific audience -- "If you're an AI leader in the public sector, we need to talk." +- "If you run RevOps at a B2B SaaS company, we need to talk." - "To everyone implementing AI right now: Pause and read this." - "Fellow AI advisors: Are we being honest about timelines?" @@ -81,7 +81,7 @@ Pattern: Speak directly to a specific audience Analysis of 9,000+ viral posts reveals the science behind what works: **Pattern Interrupts:** -- Viral posts contain **2.7x more pattern interrupts** in first two lines +- Viral posts tend to open with **more pattern interrupts** in the first two lines (multiplier unverified) - Pattern interrupts create information gaps that psychologically demand closure - Trigger dopamine release and heightened attention - Brain's prediction error system activates when expectations disrupted @@ -134,7 +134,7 @@ This structure creates positive response by opposing forces and compels the "see ## Story Structure Frameworks -### The Standard Thought Leadership Structure (1,200-1,800 chars) +### The Standard Post Structure (1,200-1,800 chars) **Hook (110-140 chars)** → Grab attention, create curiosity @@ -347,7 +347,7 @@ Not all engagement is equal. The defensible spine is the **order**, not a fixed ### First Hour Critical - Aim for 15+ engagements in first 60 minutes -- Respond quickly to early comments (30-minute response = 64% more follow-up comments) +- Respond quickly to early comments (a 30-minute response tends to earn more follow-up comments — figure unverified) - Seed engagement by notifying key connections ### Comment Strategy diff --git a/references/first-comment-strategy.md b/references/first-comment-strategy.md index 26b58c4..ddee9a9 100644 --- a/references/first-comment-strategy.md +++ b/references/first-comment-strategy.md @@ -1,13 +1,13 @@ # First Comment Strategy -Your first comment is a strategic tool, not an afterthought. Used correctly, it extends your post's value without triggering algorithm penalties. Used poorly, it looks like spam. +Your first comment is a strategic tool, not an afterthought. Used correctly, it extends your post's value without the lower-reach correlation of body links. Used poorly, it looks like spam. ## Why First Comments Matter External links in the post body correlate with lower reach (correlational, ~38% in 2026; LinkedIn denies an intentional penalty — see `references/algorithm-signals-reference.md`). A first comment is a common hedge — but lead with standalone value either way; it's much more than a link dump. **First comment benefits:** -- Avoids link penalty while still providing resources +- Avoids the body-link reach correlation while still providing resources - Adds a second engagement surface (people reply to comments) - Signals to the algorithm that the post is generating conversation - Lets you add context that didn't fit the post's character limit @@ -43,7 +43,7 @@ Key takeaway: [1-sentence summary of why it's worth clicking] **Example:** ``` -Here's the Microsoft research paper I mentioned: +Here's the research paper I mentioned: [URL] Key takeaway: They found that AI assistants improve developer productivity by 26% — but only when the developer already understands the fundamentals. @@ -144,7 +144,7 @@ Commenting on your own post is not just for adding links. Strategic self-comment | Time | Action | Purpose | |------|--------|---------| | 0 min | Post goes live | — | -| 0-1 min | First comment (if link/resource) | Avoid link penalty | +| 0-1 min | First comment (if link/resource) | Avoid body-link reach hit | | 15-30 min | Reply to first 3-5 commenters | Build early engagement momentum | | 1-2 hours | Add additional perspective or data | Re-ignite algorithm distribution | | 4-6 hours | Respond to remaining comments | Maintain conversation signal | diff --git a/references/glossary.md b/references/glossary.md index 6949fa4..49b24b9 100644 --- a/references/glossary.md +++ b/references/glossary.md @@ -26,9 +26,9 @@ Signal triggers that correlate with lower reach: excessive hashtags, external li **Used in:** `references/algorithm-signals-reference.md`, `references/linkedin-formats.md` ### Angle Rotation -Systematic application of 8 universal thought leadership angles across the same topic to create distinct post variations without repeating yourself. The 8 angles: Contrarian Take, Pattern Recognition, Uncomfortable Truth, Future Implication, Personal Lesson, Reframe, Practical Breakdown, Human Story. +Systematic application of 8 universal content angles across the same topic to create distinct post variations without repeating yourself. The 8 angles: Contrarian Take, Pattern Recognition, Uncomfortable Truth, Future Implication, Personal Lesson, Reframe, Practical Breakdown, Human Story. -**Used in:** `references/thought-leadership-angles.md`, `agents/content-planner.md`, `agents/trend-spotter.md` +**Used in:** `references/content-angles.md`, `agents/content-planner.md`, `agents/trend-spotter.md` ### Authority Score Composite metric measuring a creator's established expertise on a topic, derived from posting consistency, engagement quality, profile alignment (topic-relevance), and network validation. Higher authority unlocks broader distribution. @@ -65,9 +65,9 @@ Optimal content type distribution: 70% Educational (teach, frameworks, how-to), **Used in:** `skills/linkedin-studio/SKILL.md`, `agents/content-planner.md`, `references/linkedin-growth-playbook-2025-2026.md` ### Content Pillars -3-5 core expertise areas that define your LinkedIn focus. Used for topic consistency validation, gap analysis in content planning, and topic-relevance alignment checks. Example for AI content: News, Implementation, Strategy, Tools. +3-5 core expertise areas that define your LinkedIn focus. Used for topic consistency validation, gap analysis in content planning, and topic-relevance alignment checks. Example pillar set: News, Implementation, Strategy, Tools. -**Used in:** `agents/content-planner.md`, `references/ai-content-framework.md`, `skills/linkedin-studio/SKILL.md` +**Used in:** `agents/content-planner.md`, `references/content-framework.md`, `skills/linkedin-studio/SKILL.md` ### CTA (Call-to-Action) Specific, genuine engagement prompt at the end of a post. Must feel natural and offer optionality ("Which strategy has worked for your team?" > "What do you think?"). Creates invitation for the engagement that drives distribution. @@ -88,7 +88,7 @@ Duration a user spends viewing content with ≥50% visible on screen. Posts with ## E ### Engagement Bait -Prohibited engagement tactics ("Comment YES if...", "Tag someone who...", "Type 1 for...") that trigger -30-50% reach penalty. The algorithm actively detects and penalizes these patterns. +Prohibited engagement tactics ("Comment YES if...", "Tag someone who...", "Type 1 for...") that correlate with lower reach — actively detected and suppressed (directional; no primary source for a discrete figure). **Used in:** `references/algorithm-signals-reference.md` @@ -105,7 +105,7 @@ The defensible **ordering** of engagement signals — **saves > shares > quality ### Engagement Velocity Speed of engagement accumulation in the first hour after posting. 15+ engagements in the first hour unlocks Stage 3 distribution. Monitored at 5/15/30/60/90-minute intervals. -**Used in:** `references/algorithm-signals-reference.md`, `assets/audience-insights/engagement-patterns.md` +**Used in:** `references/algorithm-signals-reference.md`, `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/audience-insights/engagement-patterns.md` ### Evergreen Content Posts maintaining relevance and engagement potential beyond the initial publication window. Identified through scoring (topical relevance, performance, refresh potential). Suitable for repurposing over 12+ months. @@ -122,12 +122,12 @@ LinkedIn's mechanism for validating creator authority: professional history, pos ## F ### First-Hour Engagement -Critical window (0-60 minutes post-publication) determining ~70% of a post's total reach. Requires: 5x5x5 pre-posting engagement, immediate response to first comments (within 5 minutes), and continued engagement through 90 minutes. +Critical window — the first 15–30 minutes decide ~70% of a post's total reach (golden window 60–90 min). Requires: 5x5x5 pre-posting engagement, immediate response to first comments (within 5 minutes), and continued engagement through 90 minutes. **Used in:** `skills/linkedin-studio/SKILL.md`, `references/linkedin-formats.md`, `agents/engagement-coach.md` ### Four-Stage Distribution Model -Sequential post distribution: Stage 1 (0-30s: Quality classifier + profile/topic-relevance validation) → Stage 2 (0-90min: Test to 6-10% of connections) → Stage 3 (1-24h: Extended if velocity good) → Stage 4 (24-72h+: Evergreen circulation). +Sequential post distribution: Stage 1 (0-30s: Quality classifier + profile/topic-relevance validation) → Stage 2 (0-90min: Test to a small slice of connections) → Stage 3 (1-24h: Extended if velocity good) → Stage 4 (24-72h+: Evergreen circulation). **Used in:** `references/algorithm-signals-reference.md` @@ -150,7 +150,7 @@ Opening 110-140 characters of a post that must work standalone on mobile (before **Used in:** `references/engagement-frameworks.md`, `skills/linkedin-studio/SKILL.md`, `agents/content-optimizer.md` ### Hook Psychology -Neuroscience-backed engagement: Pattern interrupts trigger prediction error → dopamine release → information gap demanding cognitive closure. Pattern interrupts are 2.7x more common in viral posts. Optimal first line: ~49 characters. +Neuroscience-backed engagement: Pattern interrupts trigger prediction error → dopamine release → information gap demanding cognitive closure. Pattern interrupts are markedly more common in viral posts (multiplier unverified). Optimal first line: ~49 characters. **Used in:** `references/engagement-frameworks.md` @@ -195,7 +195,7 @@ Three-level connection classification: **Tier 1** (Inner Circle, 5-10 people, da ## P ### Pattern Interrupt -Unexpected statement or data point that breaks normal thought patterns and captures attention. 2.7x more common in viral posts. Examples: contrarian claims, surprising statistics, provocative questions. +Unexpected statement or data point that breaks normal thought patterns and captures attention. Markedly more common in viral posts (multiplier unverified). Examples: contrarian claims, surprising statistics, provocative questions. **Used in:** `references/engagement-frameworks.md` @@ -213,7 +213,7 @@ Unexpected statement or data point that breaks normal thought patterns and captu ## S ### Save Signal -Highest-value engagement signal — top of the engagement order. A save ≈ 5x a like (≈ 2x a comment) in single-vendor data — directional, not a fixed weight. Saves indicate content worth returning to; posts with saves get 130% higher follow probability. Only ~3% of posts reach save-worthy status. +Highest-value engagement signal — top of the engagement order. A save ≈ 5x a like (≈ 2x a comment) in single-vendor data — directional, not a fixed weight. Saves indicate content worth returning to; posts with saves raise follow probability (saves are a follow-graph signal; figure unverified). Only a small fraction of posts reach save-worthy status. **Used in:** `references/algorithm-signals-reference.md`, `references/linkedin-growth-playbook-2025-2026.md` @@ -226,13 +226,13 @@ Penalty state where posts reach only immediate connections without algorithmic a ## T -### Thought Leadership Value Test +### Authority Value Test Three-question quality gate before publishing: (1) Does this help someone make a better decision? (2) Does this change how someone thinks? (3) Would I find this valuable if someone else wrote it? Must pass all three. -**Used in:** `references/thought-leadership-angles.md`, `agents/differentiation-checker.md`, `agents/trend-spotter.md` +**Used in:** `references/content-angles.md`, `agents/differentiation-checker.md`, `agents/trend-spotter.md` ### Topical Consistency -Posting about consistent topics within demonstrated expertise areas. The algorithm learns your domain expertise over 30+ days. Gaps >5 days trigger -15-25% reach penalty on return. +Posting about consistent topics within demonstrated expertise areas. The algorithm learns your domain expertise over 30+ days. Consistency is a ranking input; gaps correlate with lower reach on return (directional; no primary source for a discrete figure). **Used in:** `references/linkedin-growth-playbook-2025-2026.md`, `references/algorithm-signals-reference.md` @@ -248,5 +248,5 @@ Deviation from established personal voice profile. Measured across 6 dimensions: ### Voice Profile Quantified signature of a creator's unique writing style across sentence structure, vocabulary, hook preferences, storytelling approach, tone, and formatting. Updated quarterly. Identity-level traits (avoided words, tone, humor) are protected from automatic modification. -**Used in:** `agents/voice-trainer.md`, `assets/voice-samples/authentic-voice-samples.md` +**Used in:** `agents/voice-trainer.md`, `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/authentic-voice-samples.md` diff --git a/references/growth-roadmaps.md b/references/growth-roadmaps.md index 9b491f5..9663508 100644 --- a/references/growth-roadmaps.md +++ b/references/growth-roadmaps.md @@ -219,7 +219,7 @@ The 90-day system covers 0-2K followers. This section provides the roadmap from | Activity | Frequency | Purpose | |----------|-----------|---------| | Core expertise posts | 3-5x/week | Maintain authority | -| Thought leadership pieces | 2-3x/month | Differentiation | +| Authority pieces | 2-3x/month | Differentiation | | Speaking/podcasts | Monthly | Off-platform visibility | | Collaboration amplification | 2x/month | Network leverage | | Lead magnets | Create 1-2 | Funnel building | diff --git a/references/linkedin-formats.md b/references/linkedin-formats.md index d50f6bf..4df2e87 100644 --- a/references/linkedin-formats.md +++ b/references/linkedin-formats.md @@ -3,8 +3,8 @@ ## Critical Context: The Algorithm Revolution **The 2025-2026 Shift:** -- Organic reach declined 47-50% for average users -- Top 1% of creators: content rose from 15% to 31% of all feeds +- Organic reach declined sharply platform-wide (directional; exact YoY varies by source — see `references/algorithm-signals-reference.md`) +- Feed distribution concentrated further toward top creators (directional; no single sourced figure) - Algorithm now prioritizes topical authority over everything else - Dwell time became the golden metric - External links in the body correlate with lower reach (see `references/algorithm-signals-reference.md`) @@ -21,7 +21,7 @@ Choosing the right format isn't just about engagement rates—it's about underst - **"See more" threshold:** 110-140 characters (mobile) / 140 characters (desktop) - **Short posts:** 150-300 characters (quick insights, questions) - **Medium posts:** 700-1,000 characters (balanced engagement and substance) -- **Long posts:** 1,300-1,800 characters (storytelling, thought leadership) +- **Long posts:** 1,300-1,800 characters (storytelling, authority content) ### Comments - **Limit:** 1,500 characters @@ -49,14 +49,14 @@ Choosing the right format isn't just about engagement rates—it's about underst **2. Native documents (PDFs): High engagement (historically 24.42%, likely inflated)** - Note: documents/carousels are the top organic format (~7%; see `references/algorithm-signals-reference.md`). Cross-study rates (7% / 21.8% / 49.5%) differ by denominator/methodology, not by which format wins — do not treat any single figure as the carousel rate. - Great for frameworks, step-by-step content, detailed insights - - Keeps users on platform (no external link penalty) + - Keeps users on platform (no external link, which would correlate with lower reach) - Downloadable = high perceived value - Best for: Comprehensive guides, templates, detailed analyses -**3. Video posts: 5.60% engagement rate** +**3. Video posts: engagement varies (no reliable single rate)** - Optimal length: 60 seconds (2026 sweet spot, down from 90s) - - **Critical:** 30% minimum completion rate or video gets zero distribution - - LinkedIn Live: 12-24x engagement vs standard posts + - **Completion matters:** shorter videos complete better; completion/dwell is a ranking input (see `references/algorithm-signals-reference.md`) — no hard "completion or zero distribution" gate + - LinkedIn Live: strong engagement vs standard posts (multiplier unverified) - 85% watch without sound (captions essential) - **4:5 (1080×1350) or 1:1 (1080×1080) preferred** for broad feed distribution on a desktop-heavy professional audience. 9:16 is delivered mobile-only and crops to 1:1 on desktop; its "distribution boost" is an uncorroborated heuristic — no official 4:5-vs-9:16 engagement study exists. Reserve 9:16 for the opt-in vertical video tab - Front-load value for muted autoplay — ~85% watch without sound. The "three-second hook" is cross-platform folklore, not a LinkedIn-named signal; LinkedIn's only official "3 seconds" is the minimum video length @@ -76,9 +76,9 @@ Choosing the right format isn't just about engagement rates—it's about underst - Need bold opinions, emotional moments, or surprising insights - Sweet spot: 1,200-1,800 character range consistently outperforms - Very short (150-300 chars) can perform well with concentrated insights - - Best for: Thought leadership, contrarian takes, personal stories + - Best for: Authority content, contrarian takes, personal stories -**6. Polls: 1.64x reach multiplier (declining due to overuse)** +**6. Polls: declining effectiveness; audience research only (no reliable reach multiplier — see `references/algorithm-signals-reference.md`)** - Still generates high impressions but effectiveness declining - Strategic use for industry trends, controversial opinions - Use caption (300-400 chars) to provide context and insights @@ -88,7 +88,7 @@ Choosing the right format isn't just about engagement rates—it's about underst **7. Link posts: AVOID or use strategically** - External links in the body correlate with lower reach (see `references/algorithm-signals-reference.md`) - - 4.9% more impressions than no-link posts (OLD DATA - now penalized) + - 4.9% more impressions than no-link posts (OLD DATA — body links now correlate with lower reach; see `references/algorithm-signals-reference.md`) - Platform wants to keep users on LinkedIn - If must link: Use native LinkedIn article or wait until second-tier comment - Best for: Rare occasions when external resource is essential @@ -144,7 +144,7 @@ Most creators quit before day 90. Algorithm needs 30+ days of consistent posting ## Mobile Optimization (Critical) **The mobile reality:** -- 70% of LinkedIn users access via mobile +- The majority of LinkedIn users access via mobile - First 110-140 characters visible before "see more" - Design for mobile-first with short paragraphs - Vertical visuals when possible @@ -161,7 +161,7 @@ Most creators quit before day 90. Algorithm needs 30+ days of consistent posting ### The Golden Hour (First 60-90 Minutes) -The first hour after posting determines 70% of your post's total reach. See the comprehensive Golden Hour monitoring guide in linkedin-growth-playbook-2025-2026.md for detailed velocity targets and real-time signals. +The first 15–30 minutes decide ~70% of your post's total reach (golden window 60–90 min). See the comprehensive Golden Hour monitoring guide in linkedin-growth-playbook-2025-2026.md for detailed velocity targets and real-time signals. ### Engagement Quality Hierarchy @@ -173,7 +173,7 @@ The first hour after posting determines 70% of your post's total reach. See the 4. **Comments <15 words** (Medium signal) 5. **Reactions** (Lower signal - baseline engagement unit; see `references/algorithm-signals-reference.md`) -**AI-generated generic comments reduce reach by 30% and engagement by 55%** +**AI-generated generic comments correlate with ~45% less engagement (correlational, medium confidence) and are actively suppressed; no separate sourced reach figure — see `references/algorithm-signals-reference.md`** ### Dwell Time: The Golden Metric @@ -191,13 +191,13 @@ Algorithm prioritizes content that keeps users on platform longer. - Very short posts (quick reaction, quick scroll) - Polls (interaction but low time investment) -### The External Link Penalty +### External Links and Lower Reach **Critical reality:** - External links in the body correlate with lower reach (see `references/algorithm-signals-reference.md`) -- Links in post body get penalized most heavily +- Links in the post body correlate with the largest reach reduction - First comment links are tracked but acceptable as workaround when necessary -- Old strategy of "drive traffic to website" is now algorithmically punished +- Old strategy of "drive traffic to website" now correlates with lower reach (LinkedIn denies an intentional penalty; lead with value) **What to do instead:** - Use LinkedIn native formats (Articles, Documents, Newsletters) @@ -228,7 +228,7 @@ Algorithm prioritizes content that keeps users on platform longer. **Current function:** - Metadata only, not discovery -- Use 3-4 relevant hashtags (5+ hashtags = -68% reach) +- Use 3-4 relevant hashtags (more shows diminishing returns; no primary source for a discrete figure) - Focus on keyword-driven SEO throughout content - Actual words in your post matter MORE than hashtags @@ -269,14 +269,14 @@ Immediate engagement in first hour is critical for triggering subsequent waves. - Topic inconsistency (confuses your expertise) - Long gaps between posts (breaks consistency signal) -**Thought leadership posts:** -- Get 3x more shares than average content +**Authority posts:** +- Tend to earn more shares than average content - Generate deeper conversations (15+ word comments) - Position you as subject matter expert - Compound authority over time **Hashtag guidance (updated for 2026):** -- Use 3-4 relevant hashtags (5+ hashtags = -68% reach) +- Use 3-4 relevant hashtags (more shows diminishing returns; no primary source for a discrete figure) - Focus on keyword-driven SEO throughout post - Actual words matter more than hashtags - Don't rely on hashtags for discovery @@ -288,11 +288,11 @@ Immediate engagement in first hour is critical for triggering subsequent waves. **The Data Reality:** - Video posts get high impression counts - BUT: Engagement rates are often lower than text posts -- Videos under 60 seconds optimal for balancing engagement and completion rate (30% minimum completion gate) +- Videos under 60 seconds optimal for balancing engagement and completion rate (higher completion = stronger dwell signal; no hard completion gate) - Algorithm prioritizes dwell time over impressions **What This Means:** -Video isn't the silver bullet many creators think it is. Text-based thought leadership often outperforms video for building authority and generating meaningful engagement. However, video DOES have specific use cases where it excels. +Video isn't the silver bullet many creators think it is. Text-based content often outperforms video for building authority and generating meaningful engagement. However, video DOES have specific use cases where it excels. **Lara Acosta's Position:** "Video is overrated on LinkedIn" @@ -306,7 +306,7 @@ Video isn't the silver bullet many creators think it is. Text-based thought lead **When Text Outperforms Video:** - Complex frameworks requiring reflection -- Thought leadership requiring contemplation +- Authority content requiring contemplation - Contrarian perspectives needing careful articulation - Data-heavy insights - Long-form storytelling @@ -353,7 +353,7 @@ Video isn't the silver bullet many creators think it is. Text-based thought lead #### Hook (3-5 seconds) - CRITICAL -**Your first 3 seconds determine 70% of retention.** +**Your opening seconds carry the most weight — front-load value.** (The "three-second hook" is cross-platform folklore, not a LinkedIn-named signal; see the format notes above.) **Hook types:** - **Pattern interrupt:** "This will sound counterintuitive..." @@ -429,7 +429,7 @@ Video isn't the silver bullet many creators think it is. Text-based thought lead **Avoid:** - "Like and share if you agree" (engagement bait) -- External link CTAs (algorithm penalty) +- External link CTAs (correlate with lower reach) - Asking for too many actions ### Video Editing Guidelines @@ -475,7 +475,7 @@ Video isn't the silver bullet many creators think it is. Text-based thought lead **5. Length Optimization** - Ideal: 60 seconds (2026 sweet spot — maximizes completion rate) - Acceptable: 30-90 seconds -- Avoid: >90 seconds (completion rate drops, 30% minimum required for any distribution) +- Avoid: >90 seconds (completion rate drops — completion/dwell is a ranking input; no hard minimum, but shorter completes better) **Editing tools by skill level:** @@ -540,7 +540,7 @@ Video isn't the silver bullet many creators think it is. Text-based thought lead - If using 16:9: 1920x1080px minimum (letterboxed in the feed) - **File format:** MP4 (H.264) — the safe default. LinkedIn's own sources conflict on MOV/AVI (member troubleshooting lists them supported; the Pages spec says no longer supported), so treat MOV/AVI as warn-only, not blocking — re-encode to MP4 when in doubt - **Maximum file size:** 5GB -- **Maximum length:** 10 minutes (but aim for 30-60 seconds. 30% completion rate minimum or zero distribution) +- **Maximum length:** 10 minutes (but aim for 30-60 seconds — shorter lifts completion/dwell, a ranking input; no hard completion gate) - **Frame rate:** 30fps standard, 60fps for smooth motion **Lighting:** @@ -591,7 +591,7 @@ Video isn't the silver bullet many creators think it is. Text-based thought lead ### LinkedIn Live Considerations **The Live Advantage:** -- 12-24x engagement vs. standard posts +- Strong engagement vs. standard posts (multiplier unverified) - Algorithm massively prioritizes live content - Real-time interaction builds community - Saved as regular video post after @@ -641,7 +641,7 @@ Before posting any video, verify: - [ ] Hook grabs attention in 3 seconds - [ ] Clear value delivered (lesson/insight) - [ ] Tight editing (no unnecessary seconds) -- [ ] Length: 60 seconds optimal (30% completion rate minimum) +- [ ] Length: 60 seconds optimal (shorter lifts completion/dwell — no hard completion gate) - [ ] Ends with engagement-focused CTA **Technical:** @@ -659,7 +659,7 @@ Before posting any video, verify: - [ ] Complements overall content strategy - [ ] Doesn't include external links -**Bottom Line on Video:** Use strategically when it genuinely adds value beyond text. Prioritize authenticity over production quality. Focus on 60-second videos that deliver concentrated insights. LinkedIn now requires 30% minimum completion rate for any distribution — shorter is safer. Always optimize for mobile-first consumption: captions on (the one enforceable spec), 4:5/1:1 aspect for broad distribution, and a front-loaded opening for muted autoplay. +**Bottom Line on Video:** Use strategically when it genuinely adds value beyond text. Prioritize authenticity over production quality. Focus on 60-second videos that deliver concentrated insights. Completion/dwell is a ranking input — shorter videos complete better (no published hard completion gate). Always optimize for mobile-first consumption: captions on (the one enforceable spec), 4:5/1:1 aspect for broad distribution, and a front-loaded opening for muted autoplay. ## Creator Mode Features (Available to All Users) @@ -671,4 +671,4 @@ As of late 2024, Creator Mode features are available to all LinkedIn users witho - LinkedIn Live access - Creator analytics -**Note:** Profiles actively using these features see up to 35% more reach compared to inactive profiles. The key is not "enabling" Creator Mode (no longer exists as a toggle) but actively using the features. +**Note:** Profiles actively using these features tend to see more reach than inactive ones (exact figure unverified). The key is not "enabling" Creator Mode (no longer exists as a toggle) but actively using the features. diff --git a/references/linkedin-growth-playbook-2025-2026.md b/references/linkedin-growth-playbook-2025-2026.md index cbf4cfb..caafc69 100644 --- a/references/linkedin-growth-playbook-2025-2026.md +++ b/references/linkedin-growth-playbook-2025-2026.md @@ -54,9 +54,9 @@ Complete reference guide for growing from hundreds to thousands of engaged follo How long users spend viewing content with at least 50% visible on screen. LinkedIn found "skip threshold" exists—content viewed briefly is considered skipped. **Critical stats:** -- Posts that get saved: **3x faster audience growth** -- Users who save your content: **130% higher chance of following you** -- Only ~3% of posts reach save-worthy status +- Posts that get saved: **faster audience growth** (multiplier unverified — saves top the engagement order; see `references/algorithm-signals-reference.md`) +- Users who save your content: **more likely to follow you** (saves are a follow-graph signal; figure unverified — see `references/algorithm-signals-reference.md`) +- Only a small fraction of posts reach save-worthy status See linkedin-formats.md for detailed dwell time optimization strategies. @@ -129,7 +129,7 @@ The advantage isn't a formal "boost" with a cliff — it's a window where consis | Formats | Stick to text | Mix text + carousels + images | Algorithm maps format preferences faster | | Profile | Optimize gradually | Complete on day 1 | Every visit during high-distribution period should convert | | Engagement | 5-10 comments/day | 15-20 comments/day | Maximize visibility while algorithm surfaces you | -| Saves | Nice to have | Critical metric | Saves drive 3x faster audience growth — front-load save-worthy content | +| Saves | Nice to have | Critical metric | Saves drive faster audience growth (figure unverified) — front-load save-worthy content | **What Happens After 90 Days:** @@ -155,7 +155,7 @@ The difference is between riding a wave (window active) and swimming steadily (p **What this means:** - Platform deliberately designs against virality -- Organic reach declined 47-50% for most creators (2025-2026) +- Organic reach declined sharply for most creators (2025-2026; directional, exact YoY varies by source — see `references/algorithm-signals-reference.md`) - Success requires understanding how LinkedIn evaluates expertise - Focus on engagement quality over quantity @@ -163,7 +163,7 @@ The difference is between riding a wave (window active) and swimming steadily (p LinkedIn removed hashtag following, hashtag pages, and "Talks About" sections in late 2024. Hashtags are now metadata only. -**Best practice:** Use 3-4 relevant hashtags (5+ triggers -68% reach penalty). Focus on keyword-driven SEO throughout content. See linkedin-formats.md for detailed hashtag strategy. +**Best practice:** Use 3-4 relevant hashtags (more shows diminishing returns; no primary source for a discrete figure). Focus on keyword-driven SEO throughout content. See linkedin-formats.md for detailed hashtag strategy. --- @@ -172,7 +172,7 @@ LinkedIn removed hashtag following, hashtag pages, and "Talks About" sections in ### Carousel Posts (PDF Documents) **Performance:** -- **1.6x more reach** than average posts +- **More reach** than average posts (multiplier unverified — see `references/algorithm-signals-reference.md`) - Highest engagement ratios of any format - Buffer experiment: 14,001 impressions vs 5,033 for text posts (178% increase) - 381 engagements vs 110 for text (247% increase) @@ -202,7 +202,7 @@ LinkedIn removed hashtag following, hashtag pages, and "Talks About" sections in ### Video Content: The Paradox **LinkedIn's data:** -- 1.4x more engagement than other formats +- More engagement than other formats (multiplier unverified) - #2 format but **declining** in reach; quality of engagement is debated — add captions, most watch muted (see `references/algorithm-signals-reference.md`) **Successful creator perspective (Lara Acosta, #1 UK female creator):** @@ -218,11 +218,11 @@ LinkedIn removed hashtag following, hashtag pages, and "Talks About" sections in ### Text-Only Posts **Performance:** -- **1.17x average reach** for personal profiles +- Solid average reach for personal profiles (directional; no reliable multiplier) - Excel at generating thoughtful comments - Optimal length: **1,200-1,800 characters** -- Posts under 1,000 characters: **25% reach penalty** -- Posts exceeding 2,500 characters: **32% underperformance** +- Very short posts tend to underperform (directional; no primary source for a discrete figure) +- Very long posts tend to underperform (directional; no primary source for a discrete figure) **Mobile cutoff:** - ~140 characters displayed before "see more" @@ -231,7 +231,7 @@ LinkedIn removed hashtag following, hashtag pages, and "Talks About" sections in ### Polls **Performance:** -- **1.64x reach multiplier** (declining due to overuse) +- **Above-baseline reach, now declining** due to overuse (no reliable multiplier) - Still generates high impressions but effectiveness declining - Easy engagement mechanism @@ -243,12 +243,12 @@ LinkedIn removed hashtag following, hashtag pages, and "Talks About" sections in ### Format Performance Summary (2025-2026 Data) -**Reach multipliers:** -1. Polls: 1.64x (declining) -2. Carousels: 1.6x -3. Text: 1.17x -4. Video: 1.4x (but quality of engagement debated) -5. Multi-image: Highest engagement rate for mid-sized accounts +**Format reach (directional — no reliable multipliers; see `references/algorithm-signals-reference.md`):** +1. Documents / carousels — top organic format +2. Multi-image — strong, slightly below documents +3. Text-only — most resilient; best comment quality +4. Native video — #2 but declining +5. Polls — declining effectiveness; audience research only --- @@ -383,7 +383,7 @@ Never "pitch-slap" by sending walls of text promoting services immediately after - Profile viewers - Post engagers - Comment conversation participants -- These warm leads are **3.6x more likely to respond** than cold contacts +- These warm leads are **far more likely to respond** than cold contacts (figure unverified) **Conversation-starting framework:** 1. Observation about something you noticed @@ -419,7 +419,7 @@ Start professional dialogue, not sales pitch. Build relationships that convert o **11+ posts weekly:** - +16,946 more impressions per post -- 3x more engagements +- Markedly more engagements - +1.40 percentage point engagement rate jump ### Quality Must Remain High @@ -432,7 +432,7 @@ Start professional dialogue, not sales pitch. Build relationships that convert o - "Secret to growth hacking on LinkedIn" **Cautions:** -- Posting twice within 24 hours can reduce reach by 25% +- Posting twice within 24 hours tends to split your own audience (directional; no discrete figure) - Consecutive posts of same format show decreased performance - Algorithm learns your posting rhythm @@ -490,7 +490,7 @@ After 90 days consistent posting, analyze YOUR data to identify when YOUR specif ### Consistency: The Algorithmic Recognition Factor **Official LinkedIn data:** -Companies posting weekly see **5.6x more follower growth** than sporadic posters. +Companies posting weekly tend to see materially more follower growth than sporadic posters. **Why it works:** - Algorithm identifies consistent posters as "active users" @@ -633,7 +633,7 @@ Multiple creators generated **300-400 additional followers in two weeks** from t **Why it matters:** - Algorithm uses headline heavily in search rankings -- **Profiles with keyword-optimized headlines appear 40x more frequently** in search results when fully complete +- **Keyword-optimized headlines surface more often** in search results when fully complete (figure unverified) ### About Section: 2,600 Characters, First 3-4 Lines Critical @@ -819,13 +819,13 @@ Avoid weekends for B2B content. **Best for:** - Personal brand content - Behind-the-scenes insights -- Reflective thought leadership +- Reflective authority content - Not tactical business content ### Tactics That Now Hurt Performance **Excessive posting frequency:** -Multiple times within 3 hours can reduce reach by 25%. +Posting multiple times within 3 hours tends to split your own audience (directional; no discrete figure). **Link preview cards:** Significantly reduce distribution. Always remove if including external links. Consider adding links in comments or as text-only format instead. diff --git a/references/linkedin-monetization-strategies.md b/references/linkedin-monetization-strategies.md index 797336e..8ac64b3 100644 --- a/references/linkedin-monetization-strategies.md +++ b/references/linkedin-monetization-strategies.md @@ -282,7 +282,7 @@ Attempting to sell before building credibility damages both trust and algorithm - Connect via executive DMs, not public posts **Lead generation:** -- Inbound from thought leadership +- Inbound from authority building - Speaking at industry events - Referrals from existing clients - Strategic partnerships @@ -621,7 +621,7 @@ This isn't vanity metrics—it's market perception of your expertise. **Total monthly revenue:** $95,083 **Annual revenue:** ~$1,141,000 -**Content mix:** 80% thought leadership, 15% case studies, 5% strategic mentions +**Content mix:** 80% authority content, 15% case studies, 5% strategic mentions ## Common Monetization Mistakes to Avoid @@ -655,7 +655,7 @@ This isn't vanity metrics—it's market perception of your expertise. **Why it fails:** - Sounds like every other salesperson -- Breaks the thought leadership positioning +- Breaks the authority positioning - Triggers engagement bait detection - Reduces post reach diff --git a/references/linkedin-visual-style.md b/references/linkedin-visual-style.md index 7af4cec..91c3de7 100644 --- a/references/linkedin-visual-style.md +++ b/references/linkedin-visual-style.md @@ -1,10 +1,10 @@ # LinkedIn Visual Style Guide -Visual content on LinkedIn follows different rules than Instagram or Twitter. For thought leadership, text-first content consistently outperforms image-heavy posts. This guide defines when and how to use visuals strategically. +Visual content on LinkedIn follows different rules than Instagram or Twitter. For building authority, text-first content consistently outperforms image-heavy posts. This guide defines when and how to use visuals strategically. ## The Text-First Principle -LinkedIn rewards dwell time and conversation, not visual impressions. Pure text posts with strong hooks generate more comments and shares than image posts in the thought leadership niche. +LinkedIn rewards dwell time and conversation, not visual impressions. Pure text posts with strong hooks generate more comments and shares than image posts in the authority niche. **When text-only wins:** - Personal stories and lessons learned @@ -79,7 +79,7 @@ LinkedIn users scroll fast. Your visual has 1-2 seconds to communicate its value ## When to Use Each Visual Format ### No Image (Text-Only Post) -**Best for:** Thought leadership, stories, opinions, quick tips +**Best for:** Authority content, stories, opinions, quick tips **Engagement pattern:** Highest comment rates, strong for dwell time **Use when:** The value is in the words, not in showing something @@ -120,7 +120,7 @@ Before adding a visual, ask: 1. **Does this need to be seen, not just read?** If no → text-only 2. **Does the visual add information the text doesn't?** If no → text-only 3. **Would someone save this image for reference?** If yes → carousel or infographic -4. **Am I adding an image just because "posts with images get more engagement"?** → Stop. That's a myth for thought leadership content +4. **Am I adding an image just because "posts with images get more engagement"?** → Stop. That's a myth for authority content ## Tools by Skill Level diff --git a/references/opportunity-generation.md b/references/opportunity-generation.md index 2c93e49..d39a3d0 100644 --- a/references/opportunity-generation.md +++ b/references/opportunity-generation.md @@ -66,9 +66,9 @@ Your profile is your landing page. Optimize for the opportunities you want. **Structure:** [Identity] + [Value Proposition] + [Social Proof or Specificity] **Examples:** -- "AI Implementation Advisor | Helping public sector leaders deploy AI without the hype | 50+ projects delivered" -- "Low-Code AI Architect | Building practical AI solutions | Former Microsoft, now independent" -- "AI Strategy Consultant | Translating AI hype into business value | Speaker, Author" +- "Supply-Chain Analytics Lead | Helping retailers cut stockouts without bloating inventory | 50+ rollouts delivered" +- "Cloud Cost Architect | Building practical FinOps for scale-ups | Former Stripe, now independent" +- "B2B Growth Consultant | Translating marketing spend into qualified pipeline | Speaker, Author" **What to include:** - What you do (clearly) @@ -105,21 +105,21 @@ Your profile is your landing page. Optimize for the opportunities you want. **Example section:** ``` -I help public sector leaders implement AI that actually works. +I help operations leaders turn messy processes into measurable results. -After leading AI projects at [Organization] for 5 years, I saw the same pattern: -organizations spending millions on AI that never delivered value. Now I help +After leading improvement projects at [Organization] for 5 years, I saw the same pattern: +organizations spending heavily on tools that never delivered value. Now I help leaders avoid those expensive mistakes. What I do: -→ AI strategy development for public sector organizations +→ Process and workflow strategy for operations teams → Vendor-neutral technology advisory → Implementation oversight and quality assurance Who I help: -→ C-suite executives evaluating AI investments -→ Department heads responsible for AI projects -→ IT leaders managing AI implementations +→ C-suite executives evaluating major investments +→ Department heads responsible for transformation projects +→ Team leaders managing rollouts Track record: → 50+ AI projects delivered diff --git a/references/poll-strategy-guide.md b/references/poll-strategy-guide.md index ca93c36..b829c09 100644 --- a/references/poll-strategy-guide.md +++ b/references/poll-strategy-guide.md @@ -4,7 +4,7 @@ LinkedIn polls generate high impressions but their effectiveness is declining in ## Poll Effectiveness (2026 Status) -**Reach multiplier:** 1.64x average (down from 2.1x in 2024) +**Reach:** Historically above-baseline, now declining — no reliable multiplier (directional; see `references/algorithm-signals-reference.md`) **Trend:** Declining. LinkedIn is reducing poll distribution to combat low-quality engagement farming. **Verdict:** Use sparingly (1-2 per month maximum). Make every poll count. @@ -17,7 +17,7 @@ LinkedIn polls generate high impressions but their effectiveness is declining in **Why most polls fail:** - Generic questions that don't teach anything - No follow-up content using the results -- Overuse (more than 2 per month gets penalized) +- Overuse (declining effectiveness; use sparingly — directional) - Options that are obviously "right answer" bait ## When to Use Polls (and When Not To) @@ -84,7 +84,7 @@ Hot take: Most "AI strategies" are just PowerPoint decks. ``` If you could only invest in ONE AI capability this year: -○ Copilot for productivity +○ Off-the-shelf AI assistants ○ Custom AI agents ○ Data platform modernization ○ AI literacy training for all staff @@ -176,7 +176,7 @@ What surprised me: [unexpected finding] Here's what this means: [3-5 insights based on the results + your expertise] -The bigger lesson: [connect to your thought leadership angle] +The bigger lesson: [connect to your content angle] What do you think — did the results match your expectation? ``` @@ -202,7 +202,7 @@ What do you think — did the results match your expectation? |-----------|--------| | 1 per month | Optimal — each poll feels intentional | | 2 per month | Acceptable — space them 2+ weeks apart | -| 1 per week | Too much — reach penalty, audience fatigue | +| 1 per week | Too much — declining returns, audience fatigue | | Multiple per week | Algorithm suppression, looks like engagement farming | **Calendar rule:** Never post polls in consecutive weeks. Alternate with text, carousel, and story posts. diff --git a/references/scheduling-strategy.md b/references/scheduling-strategy.md index 8f5fac2..55c615e 100644 --- a/references/scheduling-strategy.md +++ b/references/scheduling-strategy.md @@ -32,7 +32,7 @@ Based on `weekly_goal` from state file: |------|-----|------------|-----------| | 1 | Monday | 09:00 | Week-start motivation content | | 2 | Tuesday | 08:30 | Peak B2B engagement window | -| 3 | Wednesday | 08:30 | Mid-week thought leadership | +| 3 | Wednesday | 08:30 | Mid-week authority content | | 4 | Thursday | 12:00 | Lunch-break engagement peak | | 5 | Saturday | 10:00 | Weekend catch-up readers | @@ -86,7 +86,7 @@ Ensure coverage across expertise areas: When posts are scheduled via `/linkedin:batch`: 1. Each post gets a `scheduled_date` and `scheduled_time` from this algorithm -2. Entry is added to `assets/drafts/queue.json` +2. Entry is added to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json` 3. Session-start hook shows today's scheduled posts 4. `/linkedin:calendar` (publish action) marks posts as published and updates state 5. `/linkedin:calendar` shows the full schedule view diff --git a/references/trajectory-strategy-adjustments.md b/references/trajectory-strategy-adjustments.md index 7bcc29f..4160512 100644 --- a/references/trajectory-strategy-adjustments.md +++ b/references/trajectory-strategy-adjustments.md @@ -36,8 +36,8 @@ Before adjusting tactics, identify root causes: | Dimension | Current (implied) | Adjustment | Rationale | |-----------|-------------------|------------|-----------| | **Posting frequency** | Below goal | Increase by 2x (e.g., 2/wk to 4/wk) | Volume is the #1 lever for algorithmic discovery | -| **Engagement intensity** | Passive or minimal | 5x5x5 at full intensity + 10 extra comments/day on larger creators | External engagement generates 30-40% of new follower growth | -| **Format mix** | Text-heavy | Add 2 carousels/week + 1 document post/month | Carousels get 2-3x saves; saves are the strongest growth signal | +| **Engagement intensity** | Passive or minimal | 5x5x5 at full intensity + 10 extra comments/day on larger creators | External engagement is a major driver of new follower growth (proportion unverified) | +| **Format mix** | Text-heavy | Add 2 carousels/week + 1 document post/month | Carousels tend to earn more saves (figure unverified); saves are the strongest growth signal | | **Collaboration pace** | Rare or none | 2 collaborations/month minimum | Cross-pollination is the fastest way to break out of a plateau | | **Content emphasis** | General expertise | Shift to 80% save-worthy (frameworks, templates, checklists) | Save-worthy content compounds; engagement-only content doesn't | | **Goal management** | Unchanged | Evaluate: extend target date by 3-6 months OR accept higher effort | Unrealistic targets cause burnout; recalibration preserves motivation | @@ -82,7 +82,7 @@ Before adjusting tactics, identify root causes: | **Engagement intensity** | Some but irregular | 5x5x5 daily without exception + focus on niche-relevant creators | Regularity of engagement signals reliability to algorithm | | **Format mix** | Mostly text | Add 1 carousel/week minimum | Single format change with highest ROI | | **Collaboration pace** | Occasional | Target 1 collaboration/month (tag, co-post, or comment thread) | Even small collaborations expand reach significantly | -| **Content emphasis** | Balanced | Increase save-worthy ratio to 60% (from ~40%) | Saves drive follower growth 3x more effectively than likes | +| **Content emphasis** | Balanced | Increase save-worthy ratio to 60% (from ~40%) | Saves drive follower growth more effectively than likes (figure unverified) | | **Goal management** | Keep current target | Review in 60 days; extend by 2 months if rate doesn't improve | Give adjustments time to compound | ### Quick Wins (First 14 Days) @@ -164,7 +164,7 @@ Confirm growth is sustainable, not a spike: | **Engagement intensity** | Active | Shift toward strategic relationship building with larger creators | Punch above your weight while momentum carries you | | **Format mix** | Working well | Invest in higher-production formats (video, long-form articles) | Higher-effort formats convert better when you have audience | | **Collaboration pace** | Opportunities appearing | Be selective; prioritize collaborations that unlock new audiences | Quality partnerships > quantity when ahead | -| **Content emphasis** | Shift to thought leadership | Develop signature frameworks, original research, contrarian takes | Build authority, not just audience | +| **Content emphasis** | Shift to authority content | Develop signature frameworks, original research, contrarian takes | Build authority, not just audience | | **Goal management** | Raise target or accelerate timeline | Consider: raise target to 15K, pull deadline forward, or add monetization goal | Capitalize on momentum | ### Strategic Opportunities @@ -199,7 +199,7 @@ Growth target is met. Strategy shifts from "grow" to "leverage." | **Engagement intensity** | Strategic only; prioritize high-value connections | Time is better spent on monetization and opportunities | | **Format mix** | Invest in premium formats (video, newsletters, articles) | Premium formats convert audience to revenue | | **Collaboration pace** | Highly selective; co-create with peers at your level | Collaborations should open doors, not just grow numbers | -| **Content emphasis** | 100% thought leadership and signature content | You've earned the audience; now lead them | +| **Content emphasis** | 100% authority and signature content | You've earned the audience; now lead them | | **Goal management** | Set new goal: revenue, influence, or impact metric | Follower count is a vanity metric past this point | ### New Metrics to Track @@ -214,7 +214,7 @@ Growth target is met. Strategy shifts from "grow" to "leverage." ### Related Commands - `/linkedin:monetize` -- revenue strategy -- `/linkedin:strategy` -- thought leadership deepening (authority building absorbed in v2.0.0) +- `/linkedin:strategy` -- authority deepening (authority building absorbed in v2.0.0) - `/linkedin:competitive` -- maintain positioning --- diff --git a/references/trend-scoring-modes.md b/references/trend-scoring-modes.md new file mode 100644 index 0000000..bbb6937 --- /dev/null +++ b/references/trend-scoring-modes.md @@ -0,0 +1,105 @@ +# Trend Scoring Modes Reference + +**Single source of truth** for how a discovered trend/topic candidate is scored. +There are **two modes** — they share the same 1–10 per-dimension scale and the same +composite formula, but they weight the dimensions differently because a feed post and +a long-form chronicle reward different things. Surfaces (the `trend-spotter` agent, any +research-engine pass) **select a mode and apply the matching rubric** — they do not +restate the weights inline. Cite this file; do not duplicate it. + +This file defines **methodology** (the weights), not user preference. The *source list* +a pass polls is separate and user-overridable — see +`config/trends-sources.template.md` → `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/sources.md`. + +## How to read this file + +- Score each dimension **1–10** (see the bands in the per-mode tables). +- Composite = the weighted sum of the five dimension scores. Each mode's weights sum to + **100 %**, so the composite stays on the same 0–10 scale across modes. +- The **ordering** of the weights is the signal; the exact percentages are a deliberate, + documented choice, not a measured coefficient. (Timing grounding — why a feed post + weights recency and a chronicle does not — traces to + `references/algorithm-signals-reference.md`; this file does not restate algorithm + magnitudes.) + +## Mode selection + +| Mode | Use for | Driver | +|------|---------|--------| +| **kortform** | feed posts (`/linkedin:post`, `:quick`, `:react`, `:carousel`, `:video`) | timing + audience pull — a good post published early beats a perfect post published late | +| **long-form** | chronicles / newsletters / series editions (`/linkedin:newsletter`) | depth + angle — a chronicle has a longer shelf life; whether there is *enough material for a full piece* matters more than a 24-hour window | + +If no mode is declared, default to **kortform** (the feed is the higher-cadence surface). +A caller may pass the mode explicitly (e.g. the long-form orchestrator requesting +`long-form`); a future slice may read a per-user default from the profile. + +## Mode: kortform (LinkedIn feed post) + +Tuned for the feed: timing and audience pull carry real weight because the first-mover +window is short. + +| Dimension | Weight | 1–2 (Low) | 3–5 (Medium) | 6–8 (High) | 9–10 (Exceptional) | +|-----------|--------|-----------|---------------|-------------|---------------------| +| **Pillar Fit** | 30 % | Outside all 5 pillars | Tangential to one pillar | Direct hit on one pillar | Intersects 2+ pillars | +| **Audience Relevance** | 25 % | Wrong audience entirely | Some audience overlap | Core audience cares | Audience actively asking about this | +| **Timing** | 20 % | >7 days old, saturated | 3–7 days, moderate coverage | 24–72h, early coverage | <24h, you would be among first | +| **Angle Potential** | 15 % | Only obvious take available | One good angle possible | 2–3 strong angles | Contrarian or unique angle clear | +| **Authority Match** | 10 % | No credibility on topic | Some related experience | Direct experience | Published authority on this | + +``` +Composite = (Pillar × 0.30) + (Audience × 0.25) + (Timing × 0.20) + (Angle × 0.15) + (Authority × 0.10) +``` + +## Mode: long-form (chronicle / newsletter / series) + +Reweighted for a longer piece: **depth potential** enters at 25 %, and **timing drops to +10 %** — a chronicle is not a 24-hour reaction, so recency matters less than whether the +topic has enough substance and a differentiated angle to carry a full edition. + +| Dimension | Weight | 1–2 (Low) | 3–5 (Medium) | 6–8 (High) | 9–10 (Exceptional) | +|-----------|--------|-----------|---------------|-------------|---------------------| +| **Pillar / thesis fit** | 30 % | Outside the active theses | Tangential to one thesis | Fits one thesis / series arc | Advances 2+ theses or a live series arc | +| **Depth potential** | 25 % | A single post at most | Enough for one solid post | Enough for a full chronicle | Enough for a multi-part series | +| **Angle / differentiation** | 20 % | Commodity take only | One non-obvious angle | 2–3 differentiated angles | Original thesis the field lacks | +| **Authority / experience** | 15 % | No credibility on topic | Some related experience | Direct lived experience | Published authority on this | +| **Currency** | 10 % | Stale / already resolved | Relevant this quarter | Relevant now, durable | Relevant now AND will compound | + +``` +Composite = (Pillar × 0.30) + (Depth × 0.25) + (Angle × 0.20) + (Authority × 0.15) + (Currency × 0.10) +``` + +## Why the two modes differ (the one delta) + +The two rubrics are the same five-dimension instrument with one deliberate swap: + +- **kortform:** Timing **20 %**, no depth dimension — the feed rewards being early. +- **long-form:** Timing → Currency at **10 %**, Depth potential added at **25 %** — a + chronicle rewards substance and a durable angle over speed. + +Pillar fit (30 %) and authority (kortform 10 % / long-form 15 %) anchor both: an off-pillar +or low-credibility topic scores low in either mode, because topic relevance and credibility +are non-negotiable regardless of format. + +## Composite → action + +The same priority bands apply to both modes (the composite is on the same 0–10 scale): + +| Composite | Priority | kortform action | long-form action | +|-----------|----------|-----------------|------------------| +| 8.0–10 | **Immediate** | Draft within 24h | Promote to the edition backlog now | +| 6.0–7.9 | **High** | Publish within 48–72h | Strong edition candidate — schedule it | +| 4.0–5.9 | **Medium** | Add to this week's calendar | Hold as a backlog candidate, revisit | +| 2.0–3.9 | **Low** | Note, skip for now | Park unless the angle sharpens | +| 0–1.9 | **Skip** | Off positioning | Off positioning | + +## Consumers + +- `agents/trend-spotter.md` — reads the requested mode and applies the matching rubric + instead of inlining a matrix (wired in research-engine slice 2b). +- Any future research-engine pass that scores candidates before writing them to the trend + store (`scripts/trends/`). + +**Note (RE-R3d):** the morning brief applies a *brief-time* **temporal overlay** (first-mover / +saturation, derived from the publish/capture dates + the seen-log) as a **within-composite-tier** +ranking refinement. It is a separate layer from this file — it does **not** change the capture-time +dimension weights, bands, or composite formula above. diff --git a/references/troubleshooting-guide.md b/references/troubleshooting-guide.md index 64b49de..36a6aa6 100644 --- a/references/troubleshooting-guide.md +++ b/references/troubleshooting-guide.md @@ -12,7 +12,7 @@ Understanding why content fails is as important as knowing what works. - Posted at wrong time for YOUR audience - No pre-posting engagement (cold start) - Topic drift confusing algorithm about your expertise -- External link penalizing reach +- External links correlate with lower reach - Inconsistent posting breaking topical authority signal **Solutions:** @@ -124,7 +124,7 @@ Understanding how to recover from algorithmic suppression is critical for long-t 1. **Profile-content mismatch (profile/topic mismatch)** - Algorithm validates profile before distributing content 2. **Topic inconsistency** - Confused algorithm about your expertise 3. **Engagement pod detection** - Artificial engagement patterns flagged -4. **External link overuse** - LinkedIn penalizes directing traffic away +4. **External link overuse** - body links correlate with lower reach (LinkedIn denies an intentional penalty; lead with value) 5. **Posting frequency gap** - More than 5 days without posting 6. **Shadow ban from policy violation** - Content flagged for misinformation, spam, or harassment @@ -177,7 +177,7 @@ Understanding how to recover from algorithmic suppression is critical for long-t - Recovery to baseline: 14-21 days - Full restoration with growth: 3-4 weeks -### Moderate Suppression (50-70% drop) +### Moderate Suppression (partial reach loss) - Initial improvement: 2-3 weeks - Recovery to baseline: 4-6 weeks diff --git a/references/url-processing-templates.md b/references/url-processing-templates.md index e3b4645..6976473 100644 --- a/references/url-processing-templates.md +++ b/references/url-processing-templates.md @@ -37,22 +37,22 @@ Comment #1: [Link to original article] **Example transformation:** -Source: "Microsoft announces new Copilot pricing tiers" +Source: "Figma announces new pricing tiers" ``` -The new Copilot pricing isn't about the money. It's about strategy. +The new Figma pricing isn't about the money. It's about strategy. -Microsoft just restructured their Copilot licensing. Most headlines focus on the $30/user price point. +Figma just restructured its licensing. Most headlines focus on the per-seat price point. Here's what they're missing: -The real story is differentiation. By splitting Copilot into tiers, Microsoft is: +The real story is differentiation. By splitting its plans into tiers, Figma is: 1. Creating an upgrade path (land and expand) 2. Protecting high-margin enterprise deals 3. Addressing the "too expensive for testing" problem -For organizations evaluating Copilot, this changes the conversation from "can we afford it?" to "which tier makes sense?" +For organizations evaluating Figma, this changes the conversation from "can we afford it?" to "which tier makes sense?" My prediction: Expect competitors to follow with similar tiered models within 6 months. @@ -393,7 +393,7 @@ Before publishing URL-based content: | Copying structure | Looks like plagiarism | Restructure for LinkedIn | | Burying the source | Appears deceptive | Credit early | | Over-quoting | Looks lazy | Paraphrase more | -| Link in post body | Reach penalty | Move to comment | +| Link in post body | Correlates with lower reach | Move to comment | | Missing CTA | Lower engagement | Add discussion question | | Wrong angle | Doesn't fit expertise | Choose relevant angle | | Too timely | Loses relevance fast | Add evergreen insight | diff --git a/references/video-strategy-guide.md b/references/video-strategy-guide.md index f8644d2..b9ba587 100644 --- a/references/video-strategy-guide.md +++ b/references/video-strategy-guide.md @@ -1,6 +1,6 @@ # Video Scripting & Production Strategy Guide -Comprehensive video scripting reference for LinkedIn thought leadership. This guide focuses on **scripting, pacing, and production workflow** — for general video format specs, algorithm data, technical requirements, editing tools, and thumbnail strategy, see `linkedin-formats.md` (Video Content Deep Dive section). +Comprehensive video scripting reference for LinkedIn authority content. This guide focuses on **scripting, pacing, and production workflow** — for general video format specs, algorithm data, technical requirements, editing tools, and thumbnail strategy, see `linkedin-formats.md` (Video Content Deep Dive section). --- @@ -77,7 +77,7 @@ Comprehensive video scripting reference for LinkedIn thought leadership. This gu ``` [0:00-0:03] HOOK (8 words) Pattern interrupt, bold claim, or stat shock. - Energy: HIGH — this determines 70% of retention. + Energy: HIGH — the opening seconds disproportionately determine retention. [0:03-0:12] CONTEXT (23 words) Why this matters. Brief personal connection. @@ -301,11 +301,11 @@ LinkedIn's algorithm weights **completion rate** above all other video metrics. ### Vertical Video Preference -LinkedIn now strongly prefers vertical (4:5 at 1080x1350) video on mobile feeds. Vertical videos get approximately 20-30% more feed real estate than landscape. +LinkedIn now strongly prefers vertical (4:5 at 1080x1350) video on mobile feeds. Vertical video occupies more feed real estate than landscape on mobile (proportion unverified). ### Native Upload Signals -- **Native upload** (direct to LinkedIn) vs link from YouTube/Vimeo: native gets 2-3x the distribution +- **Native upload** (direct to LinkedIn) vs link from YouTube/Vimeo: native tends to get materially more distribution (multiplier unverified) - LinkedIn cannot index or optimize external video players - Always upload the .mp4 file directly @@ -577,7 +577,7 @@ Type: [talking head / screen recording / slideshow] Length: [30s / 60s / 90s / 2min] Words: [count] (at 2.5 wps) Topic: [content pillar alignment] -Angle: [from 8 thought leadership angles] +Angle: [from 8 content angles] ━━━ SCRIPT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/scripts/analytics/src/utils/storage.ts b/scripts/analytics/src/utils/storage.ts index 33c7e80..d24dae5 100644 --- a/scripts/analytics/src/utils/storage.ts +++ b/scripts/analytics/src/utils/storage.ts @@ -1,19 +1,19 @@ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; import type { AnalyticsBatch, WeeklyReport, MonthlyReport, PostAnalytics } from "../models/types.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)); - /** * Walk up from `startDir` until a directory containing `.claude-plugin/plugin.json` * is found. Returns that directory, or null if no marker is found before the * filesystem root. * - * Anchoring on the plugin marker keeps the analytics root correct by + * Anchoring on the plugin marker locates the plugin tree correctly by * construction regardless of whether this module runs from `src/utils/` * (under tsx) or `build/utils/` (compiled) — and survives a future source - * move that a hardcoded "../../../../" count would silently break. + * move that a hardcoded "../../../../" count would silently break. Since M0 it + * locates bundled read-only assets only; the per-user data root is external + * (getDataRoot), no longer derived from the plugin tree. */ export function findPluginRoot(startDir: string): string | null { let dir = resolve(startDir); @@ -33,20 +33,42 @@ export function findPluginRoot(startDir: string): string | null { } /** - * Get the analytics root directory from environment or default location. - * Default is assets/analytics under the plugin root (the dir holding - * .claude-plugin/plugin.json). The ANALYTICS_ROOT env override is the test seam. + * Resolve HOME with the codebase idiom, but never fall through to "" — that would + * turn the absolute data path into a CWD-relative one. os.homedir() is the floor. + */ +function resolveHome(): string { + return process.env.HOME || process.env.USERPROFILE || homedir(); +} + +/** + * CANONICAL — twin of hooks/scripts/data-root.mjs:getDataRoot. MUST STAY IN SYNC: + * identical default (~/.claude/linkedin-studio) + identical LINKEDIN_STUDIO_DATA + * override semantics. A twin exists (not a shared import) because the runtimes + * differ: the hooks are plain .mjs (no tsx) and cannot import this TS module. The + * twin-consistency test (hooks/scripts/__tests__/data-root.test.mjs) guards the + * contract behaviorally. + * + * Per-user data root: ~/.claude/linkedin-studio/<subdir>, overridable via + * LINKEDIN_STUDIO_DATA (test + power-user seam, mirrors the state-file location). + */ +export function getDataRoot(subdir: string): string { + const dataBase = + process.env.LINKEDIN_STUDIO_DATA || + join(resolveHome(), ".claude", "linkedin-studio"); + return join(dataBase, subdir); +} + +/** + * Get the analytics root directory — since M0 the external per-user dir + * (getDataRoot("analytics")). The deprecated ANALYTICS_ROOT env override still + * wins for back-compat (D4) so cli.ts and existing prose keep working through the + * migration. findPluginRoot is preserved (above) for bundled read-only assets. */ export function getAnalyticsRoot(): string { if (process.env.ANALYTICS_ROOT) { return resolve(process.env.ANALYTICS_ROOT); } - - // Anchor on the .claude-plugin/plugin.json marker. Fall back to the legacy - // 4-levels-up count (scripts/analytics/{src,build}/utils -> plugin root) only - // if no marker is found (e.g. an unusual extraction without the manifest). - const pluginRoot = findPluginRoot(__dirname) ?? resolve(__dirname, "../../../../"); - return join(pluginRoot, "assets", "analytics"); + return getDataRoot("analytics"); } /** diff --git a/scripts/analytics/tests/storage-root.test.ts b/scripts/analytics/tests/storage-root.test.ts index c4ef29c..e3d6085 100644 --- a/scripts/analytics/tests/storage-root.test.ts +++ b/scripts/analytics/tests/storage-root.test.ts @@ -3,25 +3,34 @@ import assert from "node:assert/strict"; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs"; import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; -import { getAnalyticsRoot, findPluginRoot } from "../src/utils/storage.js"; +import { getAnalyticsRoot, getDataRoot, findPluginRoot } from "../src/utils/storage.js"; -// Regression lock for the fresh-clone / foreign-CWD analytics root resolution. -// The root must anchor on the .claude-plugin/plugin.json marker (correct by -// construction), NOT on a fragile count of "../" segments that silently breaks -// if the source layout is ever moved. -describe("analytics root resolution", () => { +// M0: the data root moved OUT of the plugin tree to the external per-user dir +// (~/.claude/linkedin-studio), behind getDataRoot(). getAnalyticsRoot() is now a +// thin alias = getDataRoot("analytics") that still honors the deprecated +// ANALYTICS_ROOT env for back-compat (D4). findPluginRoot stays — it now locates +// bundled read-only assets only (commit 798484b, the fresh-clone crash fix). +// +// The default must be stubbed via HOME + LINKEDIN_STUDIO_DATA save/restore so it +// never couples to the dev's real $HOME. Mirrors the .mjs twin test +// (hooks/scripts/__tests__/data-root.test.mjs) — keep them in sync. +describe("data root resolution", () => { let tempDir: string | undefined; - const savedEnv = process.env.ANALYTICS_ROOT; + const saved = { + ANALYTICS_ROOT: process.env.ANALYTICS_ROOT, + LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA, + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + }; afterEach(() => { if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true, force: true }); } tempDir = undefined; - if (savedEnv === undefined) { - delete process.env.ANALYTICS_ROOT; - } else { - process.env.ANALYTICS_ROOT = savedEnv; + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; } }); @@ -46,32 +55,50 @@ describe("analytics root resolution", () => { }); }); + describe("getDataRoot", () => { + test("external default (HOME-stubbed): getDataRoot('analytics') = HOME/.claude/linkedin-studio/analytics", () => { + process.env.HOME = "/home/tester"; + delete process.env.USERPROFILE; + delete process.env.LINKEDIN_STUDIO_DATA; + + assert.equal( + getDataRoot("analytics"), + join("/home/tester", ".claude", "linkedin-studio", "analytics"), + ); + }); + + test("LINKEDIN_STUDIO_DATA override wins", () => { + process.env.LINKEDIN_STUDIO_DATA = "/tmp/lis-data"; + + assert.equal(getDataRoot("analytics"), join("/tmp/lis-data", "analytics")); + }); + }); + describe("getAnalyticsRoot", () => { - test("honors ANALYTICS_ROOT override (resolved env path)", () => { + test("default (no env) now resolves to the EXTERNAL data dir, not in-plugin assets/analytics", () => { + process.env.HOME = "/home/tester"; + delete process.env.USERPROFILE; + delete process.env.LINKEDIN_STUDIO_DATA; + delete process.env.ANALYTICS_ROOT; + + assert.equal( + getAnalyticsRoot(), + join("/home/tester", ".claude", "linkedin-studio", "analytics"), + ); + }); + + test("honors deprecated ANALYTICS_ROOT alias (resolved env path) for back-compat", () => { tempDir = mkdtempSync(join(tmpdir(), "analytics-root-")); process.env.ANALYTICS_ROOT = tempDir; assert.equal(getAnalyticsRoot(), resolve(tempDir)); }); - test("default (no env) anchors on the plugin dir, not scripts/analytics/assets", () => { + test("LINKEDIN_STUDIO_DATA drives the analytics root when ANALYTICS_ROOT is unset", () => { delete process.env.ANALYTICS_ROOT; + process.env.LINKEDIN_STUDIO_DATA = "/tmp/lis-data"; - const root = getAnalyticsRoot(); - const suffix = join("assets", "analytics"); - assert.ok(root.endsWith(suffix), `expected to end with ${suffix}, got ${root}`); - - // The parent of assets/analytics must be the real plugin root (holds the marker), - // proving the root is NOT scripts/analytics/assets/analytics. - const pluginRoot = root.slice(0, root.length - (suffix.length + 1)); - assert.ok( - existsSync(join(pluginRoot, ".claude-plugin", "plugin.json")), - `plugin marker missing under resolved root parent: ${pluginRoot}`, - ); - assert.ok( - !pluginRoot.endsWith(join("scripts", "analytics")), - `root wrongly anchored under scripts/analytics: ${pluginRoot}`, - ); + assert.equal(getAnalyticsRoot(), join("/tmp/lis-data", "analytics")); }); }); }); diff --git a/scripts/brain/fixtures/user-profile.populated.md b/scripts/brain/fixtures/user-profile.populated.md new file mode 100644 index 0000000..cca62f1 --- /dev/null +++ b/scripts/brain/fixtures/user-profile.populated.md @@ -0,0 +1,59 @@ +# User Profile Configuration (populated test fixture) + +> Synthetic, domain-general fixture for SB-S0 fold tests. Mirrors the P1 labeled +> scalars + P2 expertise group of `config/user-profile.template.md`, with every +> placeholder replaced by a distinctive filled value. NOT a real person. + +--- + +## PERSONALIZATION SETTINGS + +### User Profile Context + +**Name:** Jordan Avery +**Current Role:** Senior Data Engineer +**Organization:** Northwind Analytics +**Industry/Domain:** Data infrastructure + +**Important Disclaimer:** All articles and posts are written as a private individual. Views expressed are personal and do not represent any employer. + +**Core Expertise Areas (5 topics):** +1. Streaming data pipelines +2. Data contracts +3. Cost-aware warehousing +4. Observability for ETL +5. Team data literacy + +**Target Audience:** +- **Primary:** Data engineers at scale-ups +- **Secondary:** Engineering managers +- **Geographic focus:** Nordics + +--- + +### Voice & Style Profile + +**Signature Elements:** +- **Key frameworks you've developed:** The Contract-First Pipeline +- **Recurring themes/angles:** Boring tech that ships +- **Phrases you commonly use:** "ship the boring thing" +- **Topics to AVOID:** Crypto + +**Writing Quirks & Preferences:** +- **Preferred post length:** Medium 1,200-1,800 +- **Emoji usage:** Minimal 1-2 +- **Question style CTAs:** Sometimes +- **Use of personal anecdotes:** Always +- **Technical depth:** Advanced + +**Language:** English + +--- + +### Strategic Context + +**Current LinkedIn Status:** +- **Follower count:** 2,400 +- **90-day growth goal:** 4,000 +- **Posting frequency:** 3x week +- **Optimal posting times:** Tue/Thu 08:00 CET diff --git a/scripts/brain/package-lock.json b/scripts/brain/package-lock.json new file mode 100644 index 0000000..f292c9b --- /dev/null +++ b/scripts/brain/package-lock.json @@ -0,0 +1,566 @@ +{ + "name": "linkedin-brain", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkedin-brain", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/scripts/brain/package.json b/scripts/brain/package.json new file mode 100644 index 0000000..c7ddd8c --- /dev/null +++ b/scripts/brain/package.json @@ -0,0 +1,16 @@ +{ + "name": "linkedin-brain", + "version": "0.1.0", + "type": "module", + "description": "Second-brain foundation (SB-S0) — the brain/ scaffold initialiser, the two-layer profile.md line-grammar (parse/serialize round-trip), the lossless+idempotent user-profile fold, and the canonical entity-id + provenance module. Deterministic, pure-where-it-matters, TDD. No loop, no ingest logic, no connector (later slices).", + "scripts": { + "build": "tsc", + "test": "node --import tsx --test tests/*.test.ts", + "start": "node --import tsx src/cli.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/scripts/brain/src/assemble.ts b/scripts/brain/src/assemble.ts new file mode 100644 index 0000000..05cd781 --- /dev/null +++ b/scripts/brain/src/assemble.ts @@ -0,0 +1,156 @@ +/** + * SB-S3c — the cross-silo assembler (the payoff). + * + * Answers the arc's north-star query (`architecture.md:17`): *which raw material + * actually performs?* — `specific → post → measured analytics`. The post record + * (`ingest/published/<contentId>.md`) already carries the `specifics`/`trends` ids + * it was built from (SB-S3c hub-side threading); this module joins each post to + * its measured analytics row and surfaces the whole graph. + * + * PURE core: `assemblePostGraph({records, analytics})` takes already-loaded inputs + * and returns the graph — no FS/clock/network. The analytics↔post join is an honest + * HEURISTIC, never a guaranteed key: analytics carries no body and no URN (only a + * title-prefix + date), so `matchRow` joins by normalized title-prefix + date with + * explicit confidence tiers (`high`/`low`/`none`) — a real-CSV `none` is a + * normalization-tightening signal, not a proof of no match. + * + * DECOUPLED: this module treats tributary ids as opaque 12-hex strings and takes a + * minimal `AnalyticsRowInput` shape — it never imports the analytics/trends/ + * specifics packages. The thin read-only `loadAnalyticsRows` IO inlines a raw-JSON + * read of the shared data-root (NOT the analytics package's `loadAllPosts`). + */ + +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +import { dataRoot } from "./dataRoot.js"; +import type { PublishedRecord } from "./ingest.js"; + +/** + * The minimal analytics-row shape the resolver needs, extracted from the raw + * `AnalyticsBatch.posts[]` JSON (`analytics/src/models/types.ts`). Note the field + * is `publishedDate` (analytics) vs `published_date` (the brain record). + */ +export interface AnalyticsRowInput { + title: string; + publishedDate: string; // YYYY-MM-DD + metrics?: { engagementRate?: number } & Record<string, unknown>; +} + +export type MatchConfidence = "high" | "low" | "none"; + +/** A post's matched analytics: the WHOLE row reference (FIX 4), or none. */ +export interface PostMatch { + confidence: MatchConfidence; + row?: AnalyticsRowInput; +} + +export interface PostGraphNode { + contentId: string; + published_date: string; + specifics: string[]; + trends: string[]; + match: PostMatch; +} + +/** + * Minimum normalized-title length to attempt a prefix match. The hook quality-rule + * floor is 110 chars; 24 normalized chars (~3–5 words) is the shortest opener + * specific enough that a prefix-match is not coincidental, while staying well under + * any real hook. Below floor → `none` (an operator can still eyeball). + */ +const PREFIX_FLOOR = 24; + +/** Brain-local copy of the specifics-bank `normalizeContent` idiom (NOT imported). */ +export function normalize(s: string): string { + return s.trim().toLowerCase().replace(/\s+/g, " "); +} + +/** Strip a trailing LinkedIn truncation marker (`…`/`...`) so a `…`-suffixed export title still prefix-matches. */ +function stripTrailingEllipsis(s: string): string { + return s.replace(/(?:…|\.{3})\s*$/, "").trimEnd(); +} + +/** + * Match one analytics row to one published record. Returns the tiered match, or + * `null` when the row does not qualify (no prefix / below floor) — STUB until S3c + * Step 3. + */ +export function matchRow(record: PublishedRecord, row: AnalyticsRowInput): PostMatch | null { + const nt = stripTrailingEllipsis(normalize(row.title)); + if (nt.length < PREFIX_FLOOR) return null; // too short to discriminate → none + if (!normalize(record.body).startsWith(nt)) return null; // no prefix → none + const confidence: MatchConfidence = record.published_date === row.publishedDate ? "high" : "low"; + return { confidence, row }; +} + +/** + * Assemble the post → raw-material → performance graph. Pure (no FS/clock/network). + * For each record, the BEST qualifying analytics row: `high` (same date) beats `low` + * (different date); within a tier, the longest matched title wins. The analytics rows + * are sorted once (publishedDate desc, title asc) so an exact-length tie is stable — + * never `readdirSync`-order-dependent. + */ +export function assemblePostGraph(args: { + records: PublishedRecord[]; + analytics: AnalyticsRowInput[]; +}): PostGraphNode[] { + const analytics = [...args.analytics].sort( + (a, b) => b.publishedDate.localeCompare(a.publishedDate) || a.title.localeCompare(b.title), + ); + return args.records.map((record) => { + let best: PostMatch | null = null; + let bestLen = -1; + for (const row of analytics) { + const m = matchRow(record, row); + if (!m) continue; + const len = stripTrailingEllipsis(normalize(row.title)).length; + const better = + best === null || + (m.confidence === "high" && best.confidence === "low") || + (m.confidence === best.confidence && len > bestLen); + if (better) { + best = m; + bestLen = len; + } + } + return { + contentId: record.id, + published_date: record.published_date, + specifics: record.specifics, + trends: record.trends, + match: best ?? { confidence: "none" }, + }; + }); +} + +/** + * Read-only loader: inline a raw-JSON read of the analytics batches under the shared + * data-root and extract the minimal row shape. STUB until S3c Step 3. + * + * NOTE (root-skew caveat): resolves via the brain `dataRoot` (`${LINKEDIN_STUDIO_DATA}/ + * analytics/posts`); the analytics package additionally honours the deprecated + * `ANALYTICS_ROOT` override, which this path does NOT — if set to a non-default + * path, the join degrades to every-post-`none` (accepted cost of the no-import + * decoupling; the M0 default leaves `ANALYTICS_ROOT` unset). + */ +export function loadAnalyticsRows(): AnalyticsRowInput[] { + const dir = dataRoot(join("analytics", "posts")); + if (!existsSync(dir)) return []; // fresh-clone / no imports yet → no rows + const rows: AnalyticsRowInput[] = []; + for (const name of readdirSync(dir)) { + if (!name.endsWith(".json") || name.startsWith(".")) continue; + try { + const batch = JSON.parse(readFileSync(join(dir, name), "utf8")) as { posts?: unknown[] }; + for (const p of batch?.posts ?? []) { + const row = p as Partial<AnalyticsRowInput>; + if (typeof row?.title === "string" && typeof row?.publishedDate === "string") { + rows.push({ title: row.title, publishedDate: row.publishedDate, metrics: row.metrics }); + } + } + } catch { + // skip a malformed/unreadable batch file — never crash (mirrors listPublished) + } + } + return rows; +} diff --git a/scripts/brain/src/cli.ts b/scripts/brain/src/cli.ts new file mode 100644 index 0000000..f8add96 --- /dev/null +++ b/scripts/brain/src/cli.ts @@ -0,0 +1,366 @@ +#!/usr/bin/env node +/** + * CLI for the second-brain foundation + ingest (SB-S0 / SB-S1). + * + * node --import tsx src/cli.ts init + * node --import tsx src/cli.ts ingest --file <path> [--source <s>] [--date <YYYY-MM-DD>] + * node --import tsx src/cli.ts ingest --scan-inbox [--source <s>] + * node --import tsx src/cli.ts published list [--json] + * + * `init` scaffolds the brain/ + ingest/ tree (idempotent). `ingest` captures the + * user's published posts into ingest/published/ tagged provenance=published — the + * gold signal the voice/profile-learning surface learns from (never ai-draft). + * + * Exit code: 0 on success, 2 on usage error. + */ + +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { + applyDiff, + proposeDiff, + readConsolidationState, + writeConsolidationState, + type Candidate, + type ProfileDiff, +} from "./consolidate.js"; +import { assemblePostGraph, loadAnalyticsRows } from "./assemble.js"; +import { loadRecentPosts, reconcileRecentPosts, summarizeReconcile } from "./reconcile.js"; +import { dataRoot } from "./dataRoot.js"; +import { ingestText, listPublished, parsePublishedRecord, scanInbox } from "./ingest.js"; +import { parseProfile, serializeProfile } from "./profile.js"; +import { initBrain } from "./scaffold.js"; +import { SCHEMA_VERSION } from "./types.js"; +import type { ProfileDoc } from "./types.js"; + +const PROVENANCES = ["human", "published", "ai-draft"]; +const EMPTY_PROFILE: ProfileDoc = { schemaVersion: SCHEMA_VERSION, static: [], dynamic: [] }; + +function loadProfile(): ProfileDoc { + const p = dataRoot(join("brain", "profile.md")); + return existsSync(p) ? parseProfile(readFileSync(p, "utf8")) : EMPTY_PROFILE; +} + +/** Parse `--key value` / boolean `--flag` args (the specifics-bank CLI idiom). */ +function parseFlags(args: string[]): Record<string, string> { + const out: Record<string, string> = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + out[key] = "true"; + } else { + out[key] = next; + i++; + } + } + } + return out; +} + +/** + * SB-S3c: collect EVERY value of a repeatable `--key <value>` flag into an array + * (the single-value `parseFlags` keeps only the last). Scans the raw args directly, + * leaving `parseFlags` untouched — so single-value flag behaviour is unchanged. + */ +function collectRepeated(args: string[], key: string): string[] { + const out: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === `--${key}`) { + const next = args[i + 1]; + if (next !== undefined && !next.startsWith("--")) { + out.push(next); + i++; + } + } + } + return out; +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function usage(msg: string): never { + console.error(`error: ${msg}`); + console.error( + "usage:\n" + + " init\n" + + " ingest --file <path> [--source <s>] [--date <YYYY-MM-DD>] [--specific <id>]… [--trend <id>]…\n" + + " ingest --scan-inbox [--source <s>]\n" + + " published list [--json]\n" + + " assemble\n" + + " reconcile", + ); + process.exit(2); +} + +function runInit(): void { + const { created, skipped } = initBrain(); + console.log(`Brain root: ${dataRoot("brain")}`); + if (created.length > 0) { + console.log(`Created (${created.length}):`); + for (const c of created) console.log(` + ${c}`); + } + if (skipped.length > 0) { + console.log(`Skipped — already present (${skipped.length}):`); + for (const s of skipped) console.log(` · ${s}`); + } + if (created.length === 0) console.log("Already initialised — nothing to do."); +} + +function runIngest(rest: string[], flags: Record<string, string>): void { + if (flags["scan-inbox"] === "true") { + const res = scanInbox({ captured_at: today(), source: flags.source }); + console.log( + `Scanned inbox → published: ${res.processed.length} new, ${res.skipped.length} already published`, + ); + return; + } + const file = flags.file; + if (!file || file === "true") usage("ingest needs --file <path> or --scan-inbox"); + const body = readFileSync(file, "utf8"); + // SB-S3c: --specific / --trend are repeatable — read ONLY via collectRepeated. + const res = ingestText({ + body, + captured_at: today(), + source: flags.source, + published_date: flags.date === "true" ? undefined : flags.date, + specifics: collectRepeated(rest, "specific"), + trends: collectRepeated(rest, "trend"), + }); + if (res.written && res.collision) { + console.log(`Collision (different body, same id) → wrote ${res.path}`); + } else if (res.written) { + console.log(`Wrote ingest/published/${res.record.id}.md`); + } else { + console.log(`Duplicate — already published: ${res.record.id}`); + } +} + +function runPublished(rest: string[], flags: Record<string, string>): void { + if (rest[0] !== "list") usage("published needs: list"); + const { records, skipped } = listPublished(); + if (flags.json === "true") { + console.log(JSON.stringify({ records, skipped }, null, 2)); + return; + } + const tail = skipped > 0 ? ` (${skipped} unreadable skipped)` : ""; + console.log(`Published gold corpus: ${records.length} record(s)${tail}`); + for (const r of records) { + console.log(` · ${r.id} · ${r.provenance} · ${r.published_date} · ${r.firstLine}`); + } +} + +/** + * SB-S3c: read-only cross-silo assembler view — post → raw-material → performance. + * Loads published records + analytics rows (inlined raw-JSON, no analytics import), + * prints the join newest-first. Writes NOTHING. + */ +function runAssemble(_flags: Record<string, string>): void { + const pubDir = dataRoot(join("ingest", "published")); + const records = existsSync(pubDir) + ? readdirSync(pubDir) + .filter((f) => f.endsWith(".md") && !f.startsWith(".")) + .map((f) => { + try { + return parsePublishedRecord(readFileSync(join(pubDir, f), "utf8")); + } catch { + return null; + } + }) + .filter((r): r is NonNullable<typeof r> => r !== null) + : []; + const analytics = loadAnalyticsRows(); + const bodyById = new Map(records.map((r) => [r.id, r.body])); + const graph = assemblePostGraph({ records, analytics }).sort((a, b) => + b.published_date.localeCompare(a.published_date), + ); + if (graph.length === 0) { + console.log( + "No published records to assemble. Ingest posts with `brain ingest --file <p> [--specific <id>] [--trend <id>]`.", + ); + return; + } + console.log(`Post graph — ${graph.length} record(s); ${analytics.length} analytics row(s):`); + for (const node of graph) { + const firstLine = (bodyById.get(node.contentId) ?? "").split("\n", 1)[0]; + console.log(`\n· ${node.contentId} · ${node.published_date} · ${firstLine}`); + console.log(` specifics: ${node.specifics.length ? node.specifics.join(", ") : "—"}`); + console.log(` trends: ${node.trends.length ? node.trends.join(", ") : "—"}`); + if (node.match.confidence === "none") { + console.log(" analytics: none (no title-prefix+date match)"); + } else { + const eng = node.match.row?.metrics?.engagementRate; + console.log( + ` analytics: ${node.match.confidence}${eng !== undefined ? ` [eng ${eng}%]` : ""}`, + ); + } + } +} + +/** + * SB-S3e: read-only triple-post reconciliation — silo 1 (`## Recent Posts`, + * auto-tracked creation) vs the silo 2↔3 graph. Surfaces the coverage gap + * (created posts never `brain ingest`-ed). Writes NOTHING. Loads real + * `PublishedRecord[]` (with `body`) via the same inline parse as `runAssemble` + * — NOT `listPublished` (body-less). Calls the core by its literal name. + */ +function runReconcile(_flags: Record<string, string>): void { + const pubDir = dataRoot(join("ingest", "published")); + const records = existsSync(pubDir) + ? readdirSync(pubDir) + .filter((f) => f.endsWith(".md") && !f.startsWith(".")) + .map((f) => { + try { + return parsePublishedRecord(readFileSync(join(pubDir, f), "utf8")); + } catch { + return null; + } + }) + .filter((r): r is NonNullable<typeof r> => r !== null) + : []; + const analytics = loadAnalyticsRows(); + const graph = assemblePostGraph({ records, analytics }); + const recentPosts = loadRecentPosts(); + const nodes = reconcileRecentPosts({ recentPosts, records, graph }); + const sum = summarizeReconcile(nodes); + if (nodes.length === 0) { + console.log( + "No tracked posts in `## Recent Posts` to reconcile (check that posts were created via the plugin, or that STATE_FILE points at your state file).", + ); + return; + } + console.log( + `Reconcile — ${sum.created} created · ${sum.inGraph} in-graph · ${sum.inBrainOnly} in-brain-only · ${sum.orphaned} orphaned:`, + ); + for (const n of nodes) { + const id = n.contentId ? ` · ${n.contentId}` : ""; + console.log(`\n· ${n.recentPost.date} · ${n.tier}${id}\n "${n.recentPost.hook}"`); + } + if (sum.orphaned > 0) { + console.log( + `\n${sum.orphaned} created post(s) are not in the brain graph — \`brain ingest --file <p> [--specific <id>] [--trend <id>]\` to feed them.`, + ); + } +} + +function renderDiffMd(diff: ProfileDiff): string { + const lines = ["---", "type: PendingDiff", "---", "", "# Pending profile diff", "", "> Operator-gated. Review, then `brain consolidate --apply --diff brain/pending-diff.json --confirm`.", ""]; + const section = (title: string, items: string[]) => { + lines.push(`## ${title} (${items.length})`, ""); + for (const i of items) lines.push(`- ${i}`); + lines.push(""); + }; + section("Additions", diff.additions.map((f) => `${f.value} [${f.provenance}, id ${f.id}]`)); + section("Evidence-bumps", diff.evidenceBumps.map((b) => `${b.id} → count ${b.newCount}`)); + section("Promotions (dynamic→static)", diff.promotions.map((p) => p.id)); + section("⚠ Conflicts (both kept)", diff.conflicts.map((c) => `"${c.primaryValue}" (${c.primaryId}) vs new (${c.altId})`)); + section("Stale (dynamic, >decay)", diff.staleFlags.map((s) => `${s.id} — last seen ${s.last_seen} (${s.daysStale}d)`)); + // SB-S3b: rendered LAST and only when present, so a zero-supersession diff stays byte-identical. + if ((diff.supersedes ?? []).length > 0) { + section("Supersessions (old → new)", diff.supersedes.map((s) => `\`${s.oldValue}\` → \`${s.winner.value}\` [${s.winner.provenance}, retired ${s.oldId}]`)); + } + return lines.join("\n") + "\n"; +} + +function validateCandidates(raw: unknown): Candidate[] { + if (!Array.isArray(raw)) usage("candidates file must be a JSON array"); + (raw as unknown[]).forEach((c: any, i) => { + for (const k of ["key", "value", "source", "observed_date"]) { + if (typeof c?.[k] !== "string" || c[k] === "") usage(`candidate ${i}: missing/empty "${k}"`); + } + if (!PROVENANCES.includes(c.provenance)) usage(`candidate ${i}: provenance must be one of ${PROVENANCES.join(", ")}`); + if (/[\n\r]/.test(c.key) || /[\n\r]/.test(c.value)) usage(`candidate ${i}: key/value must be single-line (no newline/CR)`); + // SB-S3b: the optional supersede signal, when present, is a non-empty single-line target key. + if (c.supersedes !== undefined && (typeof c.supersedes !== "string" || c.supersedes === "" || /[\n\r]/.test(c.supersedes))) { + usage(`candidate ${i}: "supersedes" must be a non-empty single-line string when present`); + } + }); + return raw as Candidate[]; +} + +function runConsolidate(flags: Record<string, string>): void { + if (flags.gather === "true") { + const { last_run } = readConsolidationState(); + const pubDir = dataRoot(join("ingest", "published")); + const records = existsSync(pubDir) + ? readdirSync(pubDir) + .filter((f) => f.endsWith(".md") && !f.startsWith(".")) + .map((f) => { + try { return parsePublishedRecord(readFileSync(join(pubDir, f), "utf8")); } catch { return null; } + }) + .filter((r): r is NonNullable<typeof r> => r !== null) + : []; + const fresh = records.filter((r) => last_run === null || r.published_date > last_run); + const profile = loadProfile(); + if (flags.json === "true") { + // SB-S3b: only ACTIVE facts are live context — superseded archival facts must not be re-presented to the extraction session. + console.log(JSON.stringify({ since: last_run, published: fresh.map((r) => ({ id: r.id, published_date: r.published_date, body: r.body })), profileFacts: [...profile.static, ...profile.dynamic].filter((f) => f.status === "active") }, null, 2)); + return; + } + console.log(`Consolidation gather — ${fresh.length} new published record(s) since ${last_run ?? "the beginning"}:`); + for (const r of fresh) { + console.log(`\n## ${r.id} (${r.published_date})`); + console.log(r.body); + } + console.log(`\nCurrent profile: ${profile.static.length} static + ${profile.dynamic.length} dynamic fact(s).`); + console.log(`\n→ Extract a Candidate[] from the above, then: brain consolidate --propose --candidates <file.json>`); + return; + } + + if (flags.propose === "true") { + const cf = flags.candidates; + if (!cf || cf === "true") usage("--propose needs --candidates <file.json>"); + let raw: unknown; + try { raw = JSON.parse(readFileSync(cf, "utf8")); } catch { usage("candidates file is not valid JSON"); } + const candidates = validateCandidates(raw); + const diff = proposeDiff({ current: loadProfile(), candidates, today: today() }); + mkdirSync(dataRoot("brain"), { recursive: true }); + const jsonPath = dataRoot(join("brain", "pending-diff.json")); + const mdPath = dataRoot(join("brain", "pending-diff.md")); + writeFileSync(jsonPath, JSON.stringify(diff, null, 2) + "\n", "utf8"); + writeFileSync(mdPath, renderDiffMd(diff), "utf8"); + console.log(`Wrote ${jsonPath}\nWrote ${mdPath}`); + console.log(`Review ${mdPath}, then: brain consolidate --apply --diff ${jsonPath} --confirm`); + return; + } + + if (flags.apply === "true") { + const df = flags.diff; + if (!df || df === "true") usage("--apply needs --diff <file.json>"); + if (flags.confirm !== "true") { + console.error("error: refusing to apply without --confirm (operator gate)"); + process.exit(1); + } + const diff = JSON.parse(readFileSync(df, "utf8")) as ProfileDiff; + const next = applyDiff(loadProfile(), diff); + const profilePath = dataRoot(join("brain", "profile.md")); + mkdirSync(dirname(profilePath), { recursive: true }); + writeFileSync(profilePath, serializeProfile(next), "utf8"); + writeConsolidationState(today()); + console.log(`Applied diff to ${profilePath}; consolidation recorded (last_run = ${today()}).`); + return; + } + + usage("consolidate needs --gather | --propose --candidates <f> | --apply --diff <f> --confirm"); +} + +function main(): void { + const [command, ...rest] = process.argv.slice(2); + const flags = parseFlags(rest); + + if (command === "init") return runInit(); + if (command === "ingest") return runIngest(rest, flags); + if (command === "published") return runPublished(rest, flags); + if (command === "consolidate") return runConsolidate(flags); + if (command === "assemble") return runAssemble(flags); + if (command === "reconcile") return runReconcile(flags); + + usage(command ? `unknown command: ${command}` : "no command given"); +} + +main(); diff --git a/scripts/brain/src/consolidate.ts b/scripts/brain/src/consolidate.ts new file mode 100644 index 0000000..44c152b --- /dev/null +++ b/scripts/brain/src/consolidate.ts @@ -0,0 +1,260 @@ +/** + * Consolidation engine (SB-S2) — the compounding mechanism. + * + * PURE core: `proposeDiff` reads candidate facts + the current two-layer profile + * and returns a typed DIFF (never mutates, never writes); `applyDiff` produces the + * next ProfileDoc. The operator-gated CLI is the only thing that writes profile.md. + * + * Invariants enforced IN CODE (not just docs): + * - provenance-gated: `ai-draft` candidates are rejected outright (model-collapse guard); + * - evidence-threshold promotion (dynamic→static at N observations); + * - contradiction → keep-both with DISTINCT ids (a genuine contradiction; both views coexist); + * - temporal-update → supersede (SB-S3b): an explicit `supersedes` signal retires the old fact + * (archival re-mint + status:superseded, replaced in place) and installs the winner at the key-id; + * - temporal decay flagging (dynamic ACTIVE facts only; static + superseded facts are decay-exempt). + * + * Id model (the no-duplicate-id guarantee): a concept's PRIMARY fact id is + * `mintEntityId({kind:'observed', key})` (key-only); a conflict ALT fact id is + * `mintContentId('observed-alt:'+key+'::'+value+'::'+date)` (byte-distinct). Folded + * `profile-field` static seeds use a different kind, so consolidation never collides + * with them (they stay immutable in S2). Matching always starts from the candidate's + * key, so an existing fact's key never needs to be recovered. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { dataRoot } from "./dataRoot.js"; +import { mintContentId, mintEntityId } from "./id.js"; +import { SCHEMA_VERSION } from "./types.js"; +import type { ProfileDoc, ProfileFact, Provenance } from "./types.js"; + +const OBSERVED_KIND = "observed"; + +/** A candidate fact the invoking session extracts from the gathered deltas. */ +export interface Candidate { + key: string; // the concept (keyed for the id; single-line) + value: string; // single-line (no newline/CR — profile grammar) + provenance: Provenance; + source: string; // e.g. "published:<id>" | "manual" + observed_date: string; // YYYY-MM-DD + // SB-S3b: explicit temporal-update signal — the key of the active prior fact this + // candidate retires. Absent for ordinary candidates (fully backward-compatible). + supersedes?: string; +} + +/** + * SB-S3b temporal-update retirement: the active fact at `oldId` (whose value was + * `oldValue` at propose time) is retired (re-minted to an archival id, status + * `superseded`) and replaced by `winner` (filed at the canonical key-id). The full + * winner fact is carried so `applyDiff` stays a pure projector (it has no clock). + */ +export interface SupersedeOp { + oldId: string; + oldValue: string; + winner: ProfileFact; +} + +export interface ProfileDiff { + additions: ProfileFact[]; // new dynamic facts (primary adds + conflict alts) + evidenceBumps: { id: string; newCount: number; last_seen: string }[]; + promotions: { id: string }[]; // dynamic→static (post-bump count ≥ N) + conflicts: { primaryId: string; primaryValue: string; altId: string }[]; + staleFlags: { id: string; last_seen: string; daysStale: number }[]; + supersedes: SupersedeOp[]; // SB-S3b temporal-update retirements +} + +export interface ConsolidateOpts { + promoteThreshold?: number; // default 3 + decayDays?: number; // default 90 +} + +function daysBetween(from: string, to: string): number { + return Math.floor((Date.parse(to) - Date.parse(from)) / 86400000); +} + +function altId(c: Candidate): string { + return mintContentId(`observed-alt:${c.key}::${c.value}::${c.observed_date}`); +} + +/** + * SB-S3b: archival id for a retired (superseded) fact. Seeded with the fact's + * PRE-archival id (unique among active facts by the no-duplicate-id invariant), so + * the re-mint is collision-free and never reuses the canonical key-id the winner takes. + */ +function archivalId(oldId: string, oldValue: string): string { + return mintContentId(`superseded:${oldId}::${oldValue}`); +} + +function newFact(id: string, c: Candidate, today: string): ProfileFact { + return { + id, + value: c.value, + first_seen: c.observed_date, + last_seen: today, + evidence_count: 1, + provenance: c.provenance, + status: "active", + }; +} + +/** + * Propose a diff over the current profile from a batch of candidates. Pure — never + * mutates `current`/`candidates`, never touches the filesystem. + */ +export function proposeDiff(args: { + current: ProfileDoc; + candidates: Candidate[]; + today: string; + opts?: ConsolidateOpts; +}): ProfileDiff { + const { current, candidates, today, opts } = args; + const N = opts?.promoteThreshold ?? 3; + const DECAY = opts?.decayDays ?? 90; + + const byId = new Map<string, ProfileFact>([...current.static, ...current.dynamic].map((f) => [f.id, f])); + const dynamicIds = new Set(current.dynamic.map((f) => f.id)); + + const additions: ProfileFact[] = []; + const evidenceBumps: ProfileDiff["evidenceBumps"] = []; + const promotions: ProfileDiff["promotions"] = []; + const conflicts: ProfileDiff["conflicts"] = []; + + // Track ids added/bumped/superseded this pass so a repeated candidate in one batch + // doesn't double-add, and a key retired this pass ignores later candidates for it. + const touched = new Set<string>(); + const supersededKeys = new Set<string>(); + const supersedes: SupersedeOp[] = []; + const bump = (id: string, prevCount: number) => { + const newCount = prevCount + 1; + evidenceBumps.push({ id, newCount, last_seen: today }); + if (dynamicIds.has(id) && newCount >= N) promotions.push({ id }); + }; + + for (const c of candidates) { + if (c.provenance === "ai-draft") continue; // model-collapse guard (SC1b) + const primaryId = mintEntityId({ kind: OBSERVED_KIND, key: c.key }); + + // A key retired earlier in this same batch ignores later candidates for it (first-supersede-wins, SC8). + if (supersededKeys.has(primaryId)) continue; + + // SB-S3b supersede fork (explicit signal): retire the active fact at the target key + // and install the winner at the candidate's key-id. Routed ONLY when the target + // exists, is active, and holds a DIFFERENT value — else a re-sent signal whose target + // already holds the new value would self-supersede every run (value-guard, SC3a). + if (c.supersedes) { + const targetId = mintEntityId({ kind: OBSERVED_KIND, key: c.supersedes }); + const target = byId.get(targetId); + if (target && target.status === "active" && target.value !== c.value && !touched.has(targetId) && !supersededKeys.has(targetId)) { + supersedes.push({ oldId: targetId, oldValue: target.value, winner: newFact(primaryId, c, today) }); + supersededKeys.add(targetId); + touched.add(targetId); + touched.add(primaryId); // reserve the winner's id + continue; + } + // else: no / inactive / value-equal target → fall through to ordinary add/bump/conflict (graceful, SC4) + } + + const prev = byId.get(primaryId); + if (!prev) { + if (!touched.has(primaryId)) { + additions.push(newFact(primaryId, c, today)); + touched.add(primaryId); + } + continue; + } + if (prev.value === c.value) { + bump(primaryId, prev.evidence_count); // SC1c/SC1d + continue; + } + // conflict — keep both with a distinct alt id (SC1e), old fact untouched + const aId = altId(c); + const altPrev = byId.get(aId); + if (altPrev && altPrev.value === c.value) { + bump(aId, altPrev.evidence_count); // idempotent re-conflict → bump the alt + } else if (!touched.has(aId)) { + additions.push(newFact(aId, c, today)); + conflicts.push({ primaryId, primaryValue: prev.value, altId: aId }); + touched.add(aId); + } + } + + // Decay flags exclude superseded facts (SB-S3b, SC11): a retained archival fact is + // audit, not a live signal, so it never throws perpetual staleFlags. + const staleFlags = current.dynamic + .filter((f) => f.status !== "superseded" && daysBetween(f.last_seen, today) > DECAY) + .map((f) => ({ id: f.id, last_seen: f.last_seen, daysStale: daysBetween(f.last_seen, today) })); + + return { additions, evidenceBumps, promotions, conflicts, staleFlags, supersedes }; +} + +/** + * Apply a proposed diff to produce the next ProfileDoc. Pure (returns a new doc). + * Because primary and alt ids are byte-distinct, no two facts ever share an id, so + * the bump/promote targets are unambiguous and the doc stays well-formed (SC3). + * + * SB-S3b supersede: a retired fact is REPLACED IN PLACE (re-minted to an archival id + + * `status: superseded`, kept in its layer, never bumped/promoted — supersede wins) and + * the winner is installed under the canonical key-id. A state-check (oldId present, still + * active, value still === oldValue) makes re-apply a no-op and a stale diff safe. + */ +export function applyDiff(current: ProfileDoc, diff: ProfileDiff): ProfileDoc { + // Resolve supersessions that still apply against THIS doc (idempotency + stale-diff guard). + const allById = new Map([...current.static, ...current.dynamic].map((f) => [f.id, f])); + const archive = new Map<string, ProfileFact>(); // oldId → archival replacement + const winners: ProfileFact[] = []; + for (const s of diff.supersedes ?? []) { + const f = allById.get(s.oldId); + if (!f || f.status === "superseded" || f.value !== s.oldValue) continue; // skip: re-apply / stale + archive.set(s.oldId, { ...f, id: archivalId(s.oldId, s.oldValue), status: "superseded" }); + winners.push({ ...s.winner }); + } + + const bumpMap = new Map(diff.evidenceBumps.map((b) => [b.id, b])); + // A superseded fact is replaced in place and NEVER bumped (supersede wins, SC12). + const transform = (f: ProfileFact): ProfileFact => { + const replaced = archive.get(f.id); + if (replaced) return replaced; + const b = bumpMap.get(f.id); + return b ? { ...f, evidence_count: b.newCount, last_seen: b.last_seen } : { ...f }; + }; + + let staticF = current.static.map(transform); + let dynamicF = current.dynamic.map(transform); + + // Promote active dynamic facts only — a just-superseded fact is never promoted (SC12). + const promoteIds = new Set(diff.promotions.map((p) => p.id)); + const promoting = (f: ProfileFact) => promoteIds.has(f.id) && f.status === "active"; + const promoted = dynamicF.filter(promoting); + dynamicF = dynamicF.filter((f) => !promoting(f)); + staticF = [...staticF, ...promoted]; + + // Additions + supersede winners land in the dynamic layer (winners re-earn promotion). + dynamicF = [...dynamicF, ...diff.additions.map((f) => ({ ...f })), ...winners]; + + return { schemaVersion: SCHEMA_VERSION, static: staticF, dynamic: dynamicF }; +} + +// ── consolidation-state sidecar (the only IO; brain data-root, reachable by both +// the CLI via dataRoot and the session-start hook via getDataRoot) ──────────── + +const STATE_SUB = "brain/consolidation-state.json"; + +/** Read the last-run date; absent/malformed → {last_run:null}. */ +export function readConsolidationState(): { last_run: string | null } { + const p = dataRoot(STATE_SUB); + if (!existsSync(p)) return { last_run: null }; + try { + const j = JSON.parse(readFileSync(p, "utf8")); + return { last_run: typeof j?.last_run === "string" ? j.last_run : null }; + } catch { + return { last_run: null }; + } +} + +/** Record the last-run date (called only by the gated --apply path). */ +export function writeConsolidationState(date: string): void { + const p = dataRoot(STATE_SUB); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, JSON.stringify({ last_run: date }, null, 2) + "\n", "utf8"); +} diff --git a/scripts/brain/src/dataRoot.ts b/scripts/brain/src/dataRoot.ts new file mode 100644 index 0000000..2438ba0 --- /dev/null +++ b/scripts/brain/src/dataRoot.ts @@ -0,0 +1,19 @@ +/** + * Per-package data-root resolver (M3: the repo idiom, NOT a new shared seam). + * + * The trends/specifics/analytics packages each inline this same resolver + * (`trends/src/store.ts:180`, `specifics-bank/src/bank.ts:150`, + * `analytics/src/utils/storage.ts:54`) rather than importing a shared util — + * there is no cross-package path map. SB-S0 matches that idiom, so it adds NO new + * seam function and the `data-root.mjs ⇄ storage.ts` twin-sync stays untouched + * (SC6). De-duplicating the now-four copies is an explicit out-of-scope refactor. + */ + +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** Resolve `sub` under the per-user data dir; `LINKEDIN_STUDIO_DATA` overrides the root. */ +export function dataRoot(sub: string): string { + const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); + return join(root, sub); +} diff --git a/scripts/brain/src/id.ts b/scripts/brain/src/id.ts new file mode 100644 index 0000000..3cbb4ce --- /dev/null +++ b/scripts/brain/src/id.ts @@ -0,0 +1,71 @@ +/** + * Canonical entity-id + provenance normalization (SB-S0 spine). + * + * Pure + deterministic — no filesystem, no clock, no network. The id is keyed on + * a STABLE SLUG of the label, not the raw label, so editing a fact's *value* (or + * the label's case/whitespace) never re-mints the id. SB-S3c threads this id + * through the tributaries (the published record carries the `specifics`/`trends` + * ids it was built from; analytics is joined by resolver — see `assemble.ts`); + * SB-S0 established mint + shape. + */ + +import { createHash } from "node:crypto"; + +import { PROVENANCE_VALUES } from "./types.js"; +import type { Provenance } from "./types.js"; + +/** + * Stable slug: lowercase, trim, every non-alphanumeric run → a single `-`, with + * leading/trailing dashes stripped. Stable across case + whitespace variation of + * the same label, so it is a safe id key. + */ +export function slugify(label: string): string { + return label + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** + * One canonical entity id = first 12 hex of `sha256(`${kind}:${slugify(key)}`)`. + * Matches the trends/specifics `sha256[:12]` idiom (`specifics-bank/src/bank.ts:58`). + * `kind` namespaces the id so the same key under different kinds never collides. + */ +export function mintEntityId(seed: { kind: string; key: string }): string { + return createHash("sha256") + .update(`${seed.kind}:${slugify(seed.key)}`) + .digest("hex") + .slice(0, 12); +} + +/** + * Content-hash id for an ingested published post = first 12 hex of + * `sha256(VERBATIM body)`. Mirrors the specifics-bank `specificId` hash idiom + * (`specifics-bank/src/bank.ts:57`) but deliberately hashes the body BYTE-EXACT — + * NOT through `normalizeContent` (which lowercases + collapses whitespace). For a + * full post body that normalization would map two structurally-different posts + * (same words, different line breaks/case) to the same id, and the second would be + * silently skipped on write — data loss of the exact gold signal SB-S1 captures. + * Byte-identity hashing makes the id true content identity: only an identical + * re-ingest collides; any real difference mints a distinct id (SB-S1). + */ +export function mintContentId(text: string): string { + return createHash("sha256").update(text).digest("hex").slice(0, 12); +} + +/** + * Normalize a raw provenance string to the single brain vocabulary. Trims + + * lowercases, then returns the match or THROWS — provenance is load-bearing for + * the model-collapse guard, so an unknown value is an error, never a silent pass. + */ +export function normalizeProvenance(raw: string): Provenance { + const candidate = raw.trim().toLowerCase(); + const match = PROVENANCE_VALUES.find((v) => v === candidate); + if (!match) { + throw new Error( + `invalid provenance: ${JSON.stringify(raw)} — expected one of ${PROVENANCE_VALUES.join(", ")}`, + ); + } + return match; +} diff --git a/scripts/brain/src/ingest.ts b/scripts/brain/src/ingest.ts new file mode 100644 index 0000000..2b31757 --- /dev/null +++ b/scripts/brain/src/ingest.ts @@ -0,0 +1,296 @@ +/** + * Ingest — the published-post gold signal (SB-S1). + * + * Captures the user's ACTUAL published posts into `ingest/published/` tagged + * `provenance=published`, so the voice/profile-learning surface can learn from + * human-published content ONLY (the model-collapse guard — never from ai-draft). + * + * This module has two halves: + * - PURE: `PublishedRecord` + `parsePublishedRecord` / `serializePublishedRecord` + * (a file-per-post constrained-header grammar; NO YAML dep, mirroring the brain + * profile.md line-grammar idiom). `parse ∘ serialize = identity` (SC2). + * - IO: `writePublished` / `ingestText` / `scanInbox` / `listPublished` — idempotent, + * collision-safe, create-on-demand under the brain `dataRoot` (SC1/SC3/SC4). + * + * The record id is `mintContentId(VERBATIM body)` (id.ts) — byte-identity, so two + * structurally-different posts never collide and the write path never silently + * drops a differing body. + */ + +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { dataRoot } from "./dataRoot.js"; +import { mintContentId, normalizeProvenance } from "./id.js"; + +/** One ingested published post. `provenance` is always `published` in SB-S1. */ +export interface PublishedRecord { + /** mintContentId(body) — 12 hex, the dedupe key + filename stem. */ + id: string; + /** Always `published` — the type pins it; parse rejects any other value. */ + provenance: "published"; + /** YYYY-MM-DD — when the post was published (operator-supplied or = captured_at). */ + published_date: string; + /** YYYY-MM-DD — when this record was ingested. */ + captured_at: string; + /** `manual` (default) | a future connector token. */ + source: string; + /** + * SB-S3c — the raw material this post was built from: specifics-bank + trends + * ids (12-hex), threaded hub-side so the post↔specific↔trend↔analytics graph is + * assemblable. Default `[]`; serialized only when non-empty (omit-empty keeps a + * pre-S3c record byte-identical). Validated to 12-hex on parse + at the producer. + */ + specifics: string[]; + trends: string[]; + /** The verbatim post body — byte-exact, may contain anything (`]`/`|`/newlines/`---`). */ + body: string; +} + +/** The one separator string, used identically by serialize and parse. */ +const SENTINEL = "\n---\n"; + +/** + * Serialize a record to the file grammar: a fixed 5-line header, the `---` + * sentinel, then the verbatim body. No title, no blank-line padding (the body is + * captured byte-exact, so any padding would break the round-trip identity). + */ +export function serializePublishedRecord(rec: PublishedRecord): string { + const header = [ + `id: ${rec.id}`, + `provenance: ${rec.provenance}`, + `published_date: ${rec.published_date}`, + `captured_at: ${rec.captured_at}`, + `source: ${rec.source}`, + ]; + // SB-S3c: the raw-material id lines are appended AFTER `source:` and emitted ONLY + // when non-empty — so a pre-S3c record (empty arrays) serializes to the unchanged + // 5-line header, byte-identical (SC2). Body-only `mintContentId` is unaffected. + if (rec.specifics.length > 0) header.push(`specifics: ${rec.specifics.join(",")}`); + if (rec.trends.length > 0) header.push(`trends: ${rec.trends.join(",")}`); + return header.join("\n") + SENTINEL + rec.body; +} + +const ID_RE = /^[0-9a-f]{12}$/; + +/** + * SB-S3c: read an optional comma-separated 12-hex id list from the header slice. + * Absent key → `[]` (a NON-throwing reader — NOT `headerScalar`, which throws on a + * missing key and would break every pre-S3c record). A present-but-malformed id + * throws (never silently dropped). + */ +function headerIdList(header: string, key: string): string[] { + const m = header.match(new RegExp(`^${key}:\\s*(.*?)\\s*$`, "m")); + if (!m) return []; + return m[1] + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== "") + .map((id) => { + if (!ID_RE.test(id)) { + throw new Error(`malformed published record: bad ${key} id ${JSON.stringify(id)}`); + } + return id; + }); +} + +function headerScalar(header: string, key: string): string { + const m = header.match(new RegExp(`^${key}:\\s*(.*?)\\s*$`, "m")); + if (!m) throw new Error(`malformed published record: missing "${key}:" header`); + return m[1]; +} + +/** + * Parse a record back from the file grammar. Splits on the FIRST `---` sentinel + * (the header lines are constrained `key: value` pairs that never contain one), so + * a body that itself contains `\n---\n` is preserved verbatim. The header scalars + * are read only from the header slice — a header-shaped body line cannot leak in. + * `provenance` is asserted to be `published`: a `published/` record carrying any + * other provenance is a corruption signal and throws (never silently accepted). + */ +export function parsePublishedRecord(text: string): PublishedRecord { + const idx = text.indexOf(SENTINEL); + if (idx === -1) throw new Error("malformed published record: no `---` separator"); + const header = text.slice(0, idx); + const body = text.slice(idx + SENTINEL.length); + + const id = headerScalar(header, "id"); + if (!/^[0-9a-f]{12}$/.test(id)) { + throw new Error(`malformed published record: bad id ${JSON.stringify(id)}`); + } + const provenance = normalizeProvenance(headerScalar(header, "provenance")); + if (provenance !== "published") { + throw new Error( + `corrupt published record: provenance is "${provenance}", expected "published"`, + ); + } + return { + id, + provenance, + published_date: headerScalar(header, "published_date"), + captured_at: headerScalar(header, "captured_at"), + source: headerScalar(header, "source"), + specifics: headerIdList(header, "specifics"), + trends: headerIdList(header, "trends"), + body, + }; +} + +// ── IO ─────────────────────────────────────────────────────────────────────── +// +// All IO resolves through the brain package's own `dataRoot` (LINKEDIN_STUDIO_DATA +// overrides the root for tests + installs). Create-on-demand: `ingest/published/` +// is mkdir'd as needed, so ingest does NOT depend on a prior `brain init`. + +const PUBLISHED_SUB = join("ingest", "published"); +const INBOX_SUB = join("ingest", "inbox"); + +/** The body stored at `path`, or `null` if it can't be read/parsed (treat as differing). */ +function storedBody(path: string): string | null { + try { + return parsePublishedRecord(readFileSync(path, "utf8")).body; + } catch { + return null; + } +} + +export interface WriteResult { + /** true iff a file was written (false = identical record already present). */ + written: boolean; + /** The path written or matched. */ + path: string; + /** true iff an id-collision with a DIFFERING body forced a disambiguated name. */ + collision?: boolean; +} + +/** + * Write a record idempotently + collision-safely. If `<id>.md` already holds the + * SAME body → no-op (`written:false`). If it holds a DIFFERENT body (a 48-bit hash + * collision or an unreadable file) → write the next free `<id>-N.md` rather than + * clobber or silently skip — so a differing gold record is never lost (B2). + */ +export function writePublished(rec: PublishedRecord): WriteResult { + const dir = dataRoot(PUBLISHED_SUB); + mkdirSync(dir, { recursive: true }); + const text = serializePublishedRecord(rec); + + const primary = join(dir, `${rec.id}.md`); + if (!existsSync(primary)) { + writeFileSync(primary, text, "utf8"); + return { written: true, path: primary }; + } + if (storedBody(primary) === rec.body) return { written: false, path: primary }; + + for (let n = 2; ; n++) { + const cand = join(dir, `${rec.id}-${n}.md`); + if (!existsSync(cand)) { + writeFileSync(cand, text, "utf8"); + return { written: true, path: cand, collision: true }; + } + if (storedBody(cand) === rec.body) return { written: false, path: cand, collision: true }; + } +} + +export interface IngestResult extends WriteResult { + record: PublishedRecord; +} + +/** + * Mint a record from a verbatim post body and write it. `provenance` is always + * `published`; `published_date` defaults to `captured_at` when not supplied. Pure + * inputs (the caller supplies `captured_at`), idempotent + collision-safe write. + */ +export function ingestText(opts: { + body: string; + captured_at: string; + source?: string; + published_date?: string; + specifics?: string[]; + trends?: string[]; +}): IngestResult { + // SB-S3c producer guard: a malformed raw-material id fails fast HERE (not only on + // a later re-parse). Empty/absent arrays skip validation, so every pre-S3c caller + // (incl. scanInbox) is unaffected. + const validateIds = (ids: string[] | undefined, kind: string): string[] => { + const list = ids ?? []; + for (const id of list) { + if (!ID_RE.test(id)) throw new Error(`ingest: bad ${kind} id ${JSON.stringify(id)}`); + } + return list; + }; + const record: PublishedRecord = { + id: mintContentId(opts.body), + provenance: "published", + published_date: opts.published_date ?? opts.captured_at, + captured_at: opts.captured_at, + source: opts.source ?? "manual", + specifics: validateIds(opts.specifics, "specifics"), + trends: validateIds(opts.trends, "trends"), + body: opts.body, + }; + return { record, ...writePublished(record) }; +} + +export interface ScanResult { + /** ids of records written this scan. */ + processed: string[]; + /** ids of records already present (skipped as duplicates). */ + skipped: string[]; +} + +/** + * Process the inbox drop-zone into `published/`. Reads TOP-LEVEL `*.md` files only, + * skipping dotfiles (`.DS_Store` etc.) and non-markdown — no recursion. An empty or + * absent inbox is a clean no-op. Non-destructive in SB-S1: processed inbox files are + * left in place (a re-scan re-skips via the published dedup). + */ +export function scanInbox(opts: { captured_at: string; source?: string }): ScanResult { + const dir = dataRoot(INBOX_SUB); + const processed: string[] = []; + const skipped: string[] = []; + if (!existsSync(dir)) return { processed, skipped }; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isFile() || entry.name.startsWith(".") || !entry.name.endsWith(".md")) continue; + const body = readFileSync(join(dir, entry.name), "utf8"); + const res = ingestText({ body, captured_at: opts.captured_at, source: opts.source }); + (res.written ? processed : skipped).push(res.record.id); + } + return { processed, skipped }; +} + +export interface PublishedSummary { + id: string; + provenance: string; + published_date: string; + firstLine: string; +} + +/** + * List the published gold corpus. Each file is parsed inside a try/catch — a + * malformed/hand-dropped file is counted as `skipped`, never crashing the command. + * Surfaces `provenance` so the operator can eyeball that nothing `ai-draft` leaked. + */ +export function listPublished(): { records: PublishedSummary[]; skipped: number } { + const dir = dataRoot(PUBLISHED_SUB); + const records: PublishedSummary[] = []; + let skipped = 0; + if (!existsSync(dir)) return { records, skipped }; + + for (const name of readdirSync(dir)) { + if (name.startsWith(".") || !name.endsWith(".md")) continue; + try { + const rec = parsePublishedRecord(readFileSync(join(dir, name), "utf8")); + records.push({ + id: rec.id, + provenance: rec.provenance, + published_date: rec.published_date, + firstLine: rec.body.split("\n", 1)[0], + }); + } catch { + skipped++; + } + } + records.sort((a, b) => a.published_date.localeCompare(b.published_date)); + return { records, skipped }; +} diff --git a/scripts/brain/src/profile.ts b/scripts/brain/src/profile.ts new file mode 100644 index 0000000..ae6346e --- /dev/null +++ b/scripts/brain/src/profile.ts @@ -0,0 +1,235 @@ +/** + * The two-layer `brain/profile.md` line-grammar + the user-profile fold (SB-S0). + * + * NO YAML dependency (M2): the repo has no YAML parser, so the profile uses a + * defined line-grammar that round-trips with the regex idiom already in-tree + * (`state-updater.mjs` scalar regex). One fact = one line: + * + * - [<provenance>|<status>|<first_seen>|<last_seen>|<evidence_count>|<id>] <value> + * + * The six bracket tokens are all CONSTRAINED (two enums, two ISO dates, an int, a + * 12-hex id) — none can contain `]` or `|`, so the first `]` unambiguously closes + * the bracket and `<value>` (free single-line text, may contain `]`/`|`/quotes) is + * everything after `] `. `parseProfile(serializeProfile(doc)) === doc` (SC2). + */ + +import { mintEntityId } from "./id.js"; +import { SCHEMA_VERSION } from "./types.js"; +import type { FactStatus, ProfileDoc, ProfileFact, Provenance } from "./types.js"; + +const STATIC_HEADER = "## Static"; +const DYNAMIC_HEADER = "## Dynamic"; + +/** + * One fact line. Tokens are pipe-joined in a fixed order; an empty value emits no + * trailing space (so the common source-absent fact is a clean `- [tokens]`). + */ +function serializeFact(f: ProfileFact): string { + const bracket = [ + f.provenance, + f.status, + f.first_seen, + f.last_seen, + String(f.evidence_count), + f.id, + ].join("|"); + return f.value === "" ? `- [${bracket}]` : `- [${bracket}] ${f.value}`; +} + +/** + * Deterministic full-document serialization; both sections always present. A + * constant OKF frontmatter block (`type: Profile`) leads the document so the brain + * bundle is OKF-compatible form (docs/okf-convergence-brief.md); it carries no + * typed-doc data, so `parseProfile` skips it and `parse ∘ serialize` stays identity. + */ +export function serializeProfile(doc: ProfileDoc): string { + const lines: string[] = [ + "---", + "type: Profile", + "title: Profile", + "description: Two-layer semantic profile (static and dynamic) - the distilled who-you-are.", + "---", + "", + "# Profile", + "", + `schemaVersion: ${doc.schemaVersion}`, + "", + STATIC_HEADER, + "", + ...doc.static.map(serializeFact), + "", + DYNAMIC_HEADER, + "", + ...doc.dynamic.map(serializeFact), + ]; + return lines.join("\n") + "\n"; +} + +const FACT_RE = + /^- \[(human|published|ai-draft)\|(active|superseded)\|(\d{4}-\d{2}-\d{2})\|(\d{4}-\d{2}-\d{2})\|(\d+)\|([0-9a-f]{12})\](?: (.*))?$/; + +/** + * Parse a profile document back into the typed two-layer doc. Lines that are not + * fact lines or section headers (blank lines, the title) carry no data and are + * skipped — the fact lines ARE the authoritative representation (C1), so nothing + * data-bearing is lost, and `parse ∘ serialize` is a true identity. + */ +export function parseProfile(text: string): ProfileDoc { + const schemaMatch = text.match(/^schemaVersion:\s*(\d+)\s*$/m); + const version = schemaMatch ? Number(schemaMatch[1]) : SCHEMA_VERSION; + if (version !== SCHEMA_VERSION) { + throw new Error(`unsupported profile schemaVersion: ${version} (expected ${SCHEMA_VERSION})`); + } + + const staticFacts: ProfileFact[] = []; + const dynamicFacts: ProfileFact[] = []; + let section: "static" | "dynamic" | null = null; + + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed === STATIC_HEADER) { + section = "static"; + continue; + } + if (trimmed === DYNAMIC_HEADER) { + section = "dynamic"; + continue; + } + const m = line.match(FACT_RE); + if (!m) continue; + const fact: ProfileFact = { + provenance: m[1] as Provenance, + status: m[2] as FactStatus, + first_seen: m[3], + last_seen: m[4], + evidence_count: Number(m[5]), + id: m[6], + value: m[7] ?? "", + }; + if (section === "static") staticFacts.push(fact); + else if (section === "dynamic") dynamicFacts.push(fact); + } + + return { schemaVersion: SCHEMA_VERSION, static: staticFacts, dynamic: dynamicFacts }; +} + +// ── user-profile fold ────────────────────────────────────────────────────────── +// +// Folds the field-set of `config/user-profile.template.md` into the static layer +// (M4: extraction pinned against the real template). Exactly two productions, and +// a hard section boundary: +// +// P1 — labeled scalar: `**Label:** value` (optionally `- ` prefixed). A line +// whose value is EMPTY is a GROUP HEADER (`**Signature Elements:**`) and is +// skipped — only the scalar fields under it fold. A `[placeholder]` value +// is stored EMPTY (unfilled); a literal value is kept. +// P2 — expertise group: numbered lines under `**Core Expertise Areas …:**` +// become `expertise-area-N` fields. +// Boundary — extraction STOPS at the `### Research Tooling` section, so the +// deferred Research-MCP / Asset-Utilization prose (which uses the same +// `**Label:** value` syntax for explainer text) cannot leak in as fields. +// +// Deferred to a later slice when the profile schema grows (§8): the checkbox +// preference groups (Goals, Tone, MCPs, Assets), Content Style Mix, and the +// Voice-Profile-Summary qualities — none of which match P1/P2. + +const PROFILE_FIELD_KIND = "profile-field"; + +interface ExtractedField { + label: string; + value: string; +} + +/** A leading `[...]` bracketed placeholder means the field is unfilled → empty. */ +function placeholderToEmpty(raw: string): string { + return /^\[[^\]]*\]/.test(raw) ? "" : raw; +} + +/** Apply P1 + P2 over the template/instance text up to the Research-Tooling boundary. */ +function extractFields(rawText: string): ExtractedField[] { + const cutoff = rawText.search(/^###\s+Research Tooling\b/m); + const text = cutoff === -1 ? rawText : rawText.slice(0, cutoff); + + const fields: ExtractedField[] = []; + let inExpertise = false; + let expertiseIndex = 0; + + for (const line of text.split("\n")) { + // P2 anchor — the expertise group header (value-less by construction). + if (/^\s*\*\*Core Expertise Areas\b.*:\*\*\s*$/.test(line)) { + inExpertise = true; + expertiseIndex = 0; + continue; + } + if (inExpertise) { + const item = line.match(/^\s*(\d+)\.\s+(.*)$/); + if (item) { + expertiseIndex += 1; + fields.push({ label: `expertise-area-${expertiseIndex}`, value: placeholderToEmpty(item[2].trim()) }); + continue; + } + // A non-blank, non-numbered line closes the group; blanks keep it open. + if (line.trim() !== "") inExpertise = false; + } + // P1 — labeled scalar. + const m = line.match(/^\s*(?:- )?\*\*(.+?):\*\*\s*(.*)$/); + if (m) { + const value = m[2].trim(); + if (value === "") continue; // group header — owned by its sub-fields, not a fact + fields.push({ label: m[1].trim(), value: placeholderToEmpty(value) }); + } + } + return fields; +} + +/** + * Fold the user-profile field-set into a two-layer `ProfileDoc.static`. The + * TEMPLATE defines the canonical field-set; an optional runtime INSTANCE supplies + * filled values; an optional EXISTING doc makes the fold idempotent. + * + * Lossless + idempotent (SC3): + * - source-absent → every template field present, placeholder values empty; + * - populated → each filled instance value carried onto the matching field; + * - re-run → no duplication, a non-empty value is never overwritten, only + * `last_seen` is bumped to `today`. + * + * Pure — the caller supplies the file texts and the run date (`today`). + */ +export function foldUserProfile(opts: { + templateText: string; + instanceText?: string; + existing?: ProfileDoc; + today: string; +}): ProfileDoc { + const { templateText, instanceText, existing, today } = opts; + + const templateFields = extractFields(templateText); + const instanceByLabel = new Map( + (instanceText ? extractFields(instanceText) : []).map((f) => [f.label, f.value]), + ); + const existingById = new Map((existing?.static ?? []).map((f) => [f.id, f])); + + const staticFacts: ProfileFact[] = templateFields.map((tf) => { + const id = mintEntityId({ kind: PROFILE_FIELD_KIND, key: tf.label }); + const instanceValue = instanceByLabel.get(tf.label); + // Prefer a filled instance value over the template's (possibly empty) value. + const incomingValue = instanceValue !== undefined && instanceValue !== "" ? instanceValue : tf.value; + + const prev = existingById.get(id); + if (prev) { + // Idempotent merge: never overwrite a non-empty value; fill empties; bump last_seen. + return { ...prev, value: prev.value !== "" ? prev.value : incomingValue, last_seen: today }; + } + return { + id, + value: incomingValue, + first_seen: today, + last_seen: today, + evidence_count: 0, + provenance: "human", + status: "active", + }; + }); + + return { schemaVersion: SCHEMA_VERSION, static: staticFacts, dynamic: existing?.dynamic ?? [] }; +} diff --git a/scripts/brain/src/reconcile.ts b/scripts/brain/src/reconcile.ts new file mode 100644 index 0000000..08115be --- /dev/null +++ b/scripts/brain/src/reconcile.ts @@ -0,0 +1,164 @@ +/** + * SB-S3e — triple-post reconciliation (the LAST S3 slice, read-side). + * + * Silo 1 (`## Recent Posts` in the runtime state file) is the AUTO-tracked stream + * of posts created via the plugin (written by `updatePostTracking`, + * state-updater.mjs:116). Silo 2 (`ingest/published/<id>.md`) + silo 3 (analytics) + * are joined by SB-S3c's `assemblePostGraph`, but ONLY for posts the user MANUALLY + * ran `brain ingest` on. So the graph is blind to everything created-but-never- + * ingested. This module reconciles silo 1 against the graph and surfaces that + * COVERAGE GAP — read-only, never writing the state silo. + * + * Honest limit: silo 1 carries only a ≤60-char (possibly `…`-truncated) hook + * preview, a weaker signal than silo 2's full body; below a floor it cannot + * discriminate → `orphaned-in-state`. Read-side cannot reconstruct the + * specifics/trends a post was built from — that needs auto-capture (a follow-up). + * + * PURE core: `parseRecentPosts` / `reconcileRecentPosts` / `summarizeReconcile` + * take strings/objects and return data — no FS/clock/network. The only IO is + * `loadRecentPosts`, which reads the state file via the canonical `getStateFile()` + * precedence (`STATE_FILE` first) — a DIFFERENT root than the brain dataRoot. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { normalize, type PostGraphNode } from "./assemble.js"; +import type { PublishedRecord } from "./ingest.js"; + +export interface RecentPost { + date: string; + hook: string; + charCount: number; + topic: string; +} + +export type ReconcileTier = "in-graph" | "in-brain-only" | "orphaned-in-state"; + +export interface ReconcileNode { + recentPost: RecentPost; + tier: ReconcileTier; + /** The matched published record's id, when a record matched. */ + contentId?: string; +} + +export interface ReconcileSummary { + created: number; + inGraph: number; + inBrainOnly: number; + orphaned: number; +} + +/** + * Minimum normalized-hook length to attempt a prefix match. Mirrors + * `assemble.ts`'s `PREFIX_FLOOR` (24) — the brain-local-copy idiom (dataRoot.ts + * header): the hook preview floor below which a prefix match is coincidental. + */ +const HOOK_PREFIX_FLOOR = 24; + +/** Strip a trailing LinkedIn truncation marker so a `…`-suffixed hook still prefix-matches (assemble.ts idiom). */ +function stripTrailingEllipsis(s: string): string { + return s.replace(/(?:…|\.{3})\s*$/, "").trimEnd(); +} + +/** + * Parse the `## Recent Posts` section into structured entries. The format source + * of truth is the WRITER (`updatePostTracking`, state-updater.mjs:116): + * `- [YYYY-MM-DD] "hook" (chars) - topic`. (NOT `pruneContentHistory`'s :145 regex, + * which is date-only.) Capture/`match` only — no `String.replace` — so a `$`-bearing + * hook/topic round-trips verbatim with no injection. Pure. + */ +export function parseRecentPosts(stateText: string): RecentPost[] { + // Line-walk from the `## Recent Posts` heading to the next `## ` heading (or EOF). + // Line-based (not one lookahead regex) so a blank line right after the heading + // can't collapse the capture. + const lines = stateText.split("\n"); + const start = lines.findIndex((l) => /^## Recent Posts[ \t]*$/.test(l)); + if (start === -1) return []; + const entry = /^- \[(\d{4}-\d{2}-\d{2})\] "(.*)" \((\d+)\) - (.+)$/; + const out: RecentPost[] = []; + for (let i = start + 1; i < lines.length; i++) { + if (/^## /.test(lines[i])) break; // next section ends the block + const m = lines[i].match(entry); + if (m) out.push({ date: m[1], hook: m[2], charCount: Number(m[3]), topic: m[4] }); + } + return out; +} + +/** + * Find the published record a silo-1 hook corresponds to: the hook (ellipsis- + * stripped, normalized) must be a PREFIX of the record body, and long enough to + * discriminate. Among matches, prefer one whose `published_date` equals the + * entry's date (deterministic), else the first in input order. + */ +function matchRecord(post: RecentPost, records: PublishedRecord[]): PublishedRecord | undefined { + const nh = stripTrailingEllipsis(normalize(post.hook)); + if (nh.length < HOOK_PREFIX_FLOOR) return undefined; // too short → cannot discriminate + let first: PublishedRecord | undefined; + for (const r of records) { + if (!normalize(r.body).startsWith(nh)) continue; + if (r.published_date === post.date) return r; // exact date wins + if (!first) first = r; + } + return first; +} + +/** + * Reconcile each silo-1 entry against the graph. `records` carry `body` (the join + * key the body-less `PostGraphNode` lacks); `graph` carries each record's analytics + * match. Pure. Tiers: `in-graph` (matched record HAS analytics) · `in-brain-only` + * (matched record, no analytics) · `orphaned-in-state` (no matching record — the + * coverage gap: created but never ingested). + */ +export function reconcileRecentPosts(args: { + recentPosts: RecentPost[]; + records: PublishedRecord[]; + graph: PostGraphNode[]; +}): ReconcileNode[] { + const tierOf = new Map(args.graph.map((g) => [g.contentId, g.match.confidence])); + return args.recentPosts.map((post) => { + const rec = matchRecord(post, args.records); + if (!rec) return { recentPost: post, tier: "orphaned-in-state" as const }; + const hasAnalytics = (tierOf.get(rec.id) ?? "none") !== "none"; + return { + recentPost: post, + tier: hasAnalytics ? ("in-graph" as const) : ("in-brain-only" as const), + contentId: rec.id, + }; + }); +} + +/** Count the coverage tiers. Pure. */ +export function summarizeReconcile(nodes: ReconcileNode[]): ReconcileSummary { + const sum: ReconcileSummary = { created: nodes.length, inGraph: 0, inBrainOnly: 0, orphaned: 0 }; + for (const n of nodes) { + if (n.tier === "in-graph") sum.inGraph++; + else if (n.tier === "in-brain-only") sum.inBrainOnly++; + else sum.orphaned++; + } + return sum; +} + +/** + * Resolve the runtime state file with the canonical precedence (the brain-side + * copy of `hooks/scripts/data-root.mjs: getStateFile()` — `STATE_FILE` first, else + * `~/.claude/linkedin-studio.local.md` with `HOME||USERPROFILE||homedir()`). This + * is a NEW root-skew seam: the state file lives OUTSIDE the brain dataRoot + * (`LINKEDIN_STUDIO_DATA`); if `STATE_FILE` is repointed the read follows it. + */ +function getStateFile(): string { + const home = process.env.HOME || process.env.USERPROFILE || homedir(); + return process.env.STATE_FILE || join(home, ".claude", "linkedin-studio.local.md"); +} + +/** Read-only: load + parse silo 1 from the state file. Absent/unreadable → [] (fresh-clone safe). */ +export function loadRecentPosts(): RecentPost[] { + const file = getStateFile(); + if (!existsSync(file)) return []; + try { + return parseRecentPosts(readFileSync(file, "utf8")); + } catch { + return []; // never crash on a malformed/locked state file + } +} diff --git a/scripts/brain/src/scaffold.ts b/scripts/brain/src/scaffold.ts new file mode 100644 index 0000000..1c27b72 --- /dev/null +++ b/scripts/brain/src/scaffold.ts @@ -0,0 +1,145 @@ +/** + * `initBrain` — the idempotent runtime scaffold for the second brain (SB-S0 D1/D5). + * + * Creates the `brain/` + `ingest/` tree under the per-user data dir (via the + * `dataRoot` resolver, so `LINKEDIN_STUDIO_DATA` relocates it for tests + real + * installs). Idempotent + migration-safe: a directory that exists is left alone, + * a file that exists is NEVER rewritten (compare-then-skip, never blind-write) — + * so a second run is a no-op and a user-edited file is preserved. + * + * NO session-start wiring (SB-S2 owns that) — this only ships the invokable init. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { dataRoot } from "./dataRoot.js"; +import { foldUserProfile, serializeProfile } from "./profile.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +/** The shipped template that defines the canonical profile field-set. */ +const TEMPLATE_PATH = join(HERE, "..", "..", "..", "config", "user-profile.template.md"); +/** The runtime instance (if the user copied + filled the template). */ +const INSTANCE_SUB = join("profile", "user-profile.md"); + +const SCAFFOLD_DIRS = ["brain/journal", "ingest/inbox", "ingest/published"]; + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function indexSeed(): string { + return `# Brain — Index (MOC) + +> Map of Content — one screen pointing at every tributary, with a freshness flag. +> Generated by \`brain init\`; safe to hand-edit (a re-run never clobbers it). + +okf_version: 0.1 + +| Tributary | What it holds | Freshness | +|-----------|---------------|-----------| +| voice-samples | writing style | — | +| specifics-bank | lived raw material | — | +| trends | external signal | — | +| analytics | performance | — | +| ingest | inbox + published (the gold signal) | — | + +- [profile.md](profile.md) — semantic, two-layer (static + dynamic) profile +- [operations.md](operations.md) — plans · ideas · the "who I am now" anchor +- journal/ — episodic, append-only session log (the consolidation loop's source) +`; +} + +function operationsSeed(): string { + return `--- +type: Operations +title: Operations +description: Plans, ideas, and the current-direction anchor - the operations centre. +--- +# Operations + +> The operations centre — where you're headed now, what you're working on, what you might +> do next. User-authored: the brain motor never writes here (a re-run of init never +> re-touches an existing file, so your edits are safe). + +## Who I am now (anchor) + +<!-- Frozen-past-self guard: a DATED, user-authored "where I'm headed now" statement. + It deprecates older inferences — when a profile.md fact predates or contradicts this + anchor, the anchor wins. Re-date it when your direction shifts. --> + +_As of YYYY-MM-DD:_ <one or two sentences on your current direction — replace this line> + +## Plans + +<!-- Active commitments — what you're working toward now. One per line. --> + +## Ideas + +<!-- Parking lot — possible content/strategy ideas, not yet commitments. One per line. --> +`; +} + +function journalIndexSeed(): string { + return `# Journal — episodic log + +> Append-only session entries (\`YYYY-MM-session.md\`). Raw, never edited — the +> source the consolidation loop distils from. One concept per file. A reserved +> OKF index (no frontmatter); the entries carry the \`type\`. +`; +} + +function profileSeed(): string { + const templateText = readFileSync(TEMPLATE_PATH, "utf8"); + const instancePath = dataRoot(INSTANCE_SUB); + const instanceText = existsSync(instancePath) ? readFileSync(instancePath, "utf8") : undefined; + return serializeProfile(foldUserProfile({ templateText, instanceText, today: today() })); +} + +export interface InitResult { + /** Data-relative paths created this run. */ + created: string[]; + /** Data-relative paths that already existed and were left untouched. */ + skipped: string[]; +} + +/** + * Create the brain scaffold idempotently. Returns the created/skipped split. + * File content seeds are computed lazily, so an already-present file costs no + * template read or fold. + */ +export function initBrain(): InitResult { + const created: string[] = []; + const skipped: string[] = []; + + for (const sub of SCAFFOLD_DIRS) { + const abs = dataRoot(sub); + if (existsSync(abs)) { + skipped.push(sub); + } else { + mkdirSync(abs, { recursive: true }); + created.push(sub); + } + } + + const files: Array<{ sub: string; seed: () => string }> = [ + { sub: "brain/profile.md", seed: profileSeed }, + { sub: "brain/index.md", seed: indexSeed }, + { sub: "brain/operations.md", seed: operationsSeed }, + { sub: "brain/journal/index.md", seed: journalIndexSeed }, + ]; + + for (const { sub, seed } of files) { + const abs = dataRoot(sub); + if (existsSync(abs)) { + skipped.push(sub); + continue; + } + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, seed(), "utf8"); + created.push(sub); + } + + return { created, skipped }; +} diff --git a/scripts/brain/src/types.ts b/scripts/brain/src/types.ts new file mode 100644 index 0000000..341be1d --- /dev/null +++ b/scripts/brain/src/types.ts @@ -0,0 +1,55 @@ +/** + * Shared types + constants for the second-brain foundation (SB-S0). + * + * The two invariants this module pins (architecture.md §"Invariants"): + * - ONE provenance shape across the whole brain: `human | published | ai-draft`. + * The model-collapse guard (profile/voice learn from `published` only) hangs on + * this being the single source of truth, not reinvented per silo. + * - ONE canonical entity-id + one fact record shape (the six fields below), so a + * later slice can thread the id through tributaries (SB-S3) without re-deciding + * the record schema. + */ + +/** The single provenance vocabulary for the whole brain. */ +export type Provenance = "human" | "published" | "ai-draft"; + +/** A fact is `active` until a later, superseding fact demotes it (SB-S2). */ +export type FactStatus = "active" | "superseded"; + +/** The accepted provenance values, as a runtime list (used by normalizeProvenance). */ +export const PROVENANCE_VALUES: readonly Provenance[] = ["human", "published", "ai-draft"] as const; + +/** The accepted fact-status values, as a runtime list. */ +export const FACT_STATUS_VALUES: readonly FactStatus[] = ["active", "superseded"] as const; + +/** The profile schema version — bumped only on a breaking grammar change. */ +export const SCHEMA_VERSION = 1 as const; + +/** + * One distilled fact in `brain/profile.md`. Every fact carries all six fields so + * the consolidation loop (SB-S2) has evidence/provenance/temporal context to + * promote, decay, and reconcile against. + */ +export interface ProfileFact { + /** Canonical entity id (id.ts), keyed on a stable slug — stable across value edits. */ + id: string; + /** Single-line value (no embedded newline — enforced by the line-grammar). */ + value: string; + /** YYYY-MM-DD — first observation. */ + first_seen: string; + /** YYYY-MM-DD — most recent observation. */ + last_seen: string; + /** How many independent observations back this fact (threshold-promotion, SB-S2). */ + evidence_count: number; + /** Where the fact came from — drives provenance-weighted learning. */ + provenance: Provenance; + /** `active` or `superseded`. */ + status: FactStatus; +} + +/** The two-layer profile document: stable `static` facts + evolving `dynamic` facts. */ +export interface ProfileDoc { + schemaVersion: typeof SCHEMA_VERSION; + static: ProfileFact[]; + dynamic: ProfileFact[]; +} diff --git a/scripts/brain/tests/assemble.test.ts b/scripts/brain/tests/assemble.test.ts new file mode 100644 index 0000000..a8f7439 --- /dev/null +++ b/scripts/brain/tests/assemble.test.ts @@ -0,0 +1,95 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { mintContentId } from "../src/id.js"; +import type { PublishedRecord } from "../src/ingest.js"; +import { assemblePostGraph, type AnalyticsRowInput } from "../src/assemble.js"; + +const DATE = "2026-05-26"; + +function rec(body: string, over: Partial<PublishedRecord> = {}): PublishedRecord { + return { + id: mintContentId(body), + provenance: "published", + published_date: DATE, + captured_at: "2026-06-23", + source: "manual", + specifics: [], + trends: [], + body, + ...over, + }; +} + +function row(title: string, over: Partial<AnalyticsRowInput> = {}): AnalyticsRowInput { + return { title, publishedDate: DATE, metrics: { engagementRate: 4.2 }, ...over }; +} + +// A body whose normalized opener is ≥ PREFIX_FLOOR (24) chars. +const BODY = "Jeg lærte noe viktig om dømmekraft i dag.\n\nDel 1 av serien."; +const FULL_TITLE = "Jeg lærte noe viktig om dømmekraft i dag."; + +describe("SB-S3c assembler — assemblePostGraph (SC6/SC7/SC8)", () => { + // SC6 — high confidence: body begins with the row title (≥floor) + same date. + test("SC6: prefix-match + same date → high, whole row attached, specifics/trends surfaced", () => { + const r = rec(BODY, { specifics: ["aaaaaaaaaaaa"], trends: ["bbbbbbbbbbbb"] }); + const graph = assemblePostGraph({ records: [r], analytics: [row(FULL_TITLE)] }); + assert.equal(graph.length, 1); + assert.equal(graph[0].contentId, r.id); + assert.equal(graph[0].match.confidence, "high"); + assert.deepEqual(graph[0].match.row, row(FULL_TITLE)); // the whole row reference (FIX 4) + assert.deepEqual(graph[0].specifics, ["aaaaaaaaaaaa"]); + assert.deepEqual(graph[0].trends, ["bbbbbbbbbbbb"]); + }); + + // SC7 — low / none / below-floor / ellipsis near-miss. + test("SC7a: no prefix match → none", () => { + const graph = assemblePostGraph({ + records: [rec(BODY)], + analytics: [row("Completely unrelated opening sentence here")], + }); + assert.equal(graph[0].match.confidence, "none"); + assert.equal(graph[0].match.row, undefined); + }); + + test("SC7b: a too-short (< floor) title that is a literal prefix → none (floor guards false high)", () => { + const graph = assemblePostGraph({ records: [rec(BODY)], analytics: [row("Jeg lærte")] }); + assert.equal(graph[0].match.confidence, "none"); + }); + + test("SC7c: a LinkedIn-truncated '…' title + same date → high (ellipsis stripped)", () => { + const graph = assemblePostGraph({ + records: [rec(BODY)], + analytics: [row("Jeg lærte noe viktig om dømmekraft i…")], + }); + assert.equal(graph[0].match.confidence, "high"); + }); + + test("SC7d: prefix match but a different date → low (surfaced, not hidden)", () => { + const graph = assemblePostGraph({ + records: [rec(BODY)], + analytics: [row(FULL_TITLE, { publishedDate: "2026-05-20" })], + }); + assert.equal(graph[0].match.confidence, "low"); + assert.ok(graph[0].match.row, "row still attached on a low match"); + }); + + // SC8 — pure + total. + test("SC8: empty records → empty graph", () => { + assert.deepEqual(assemblePostGraph({ records: [], analytics: [row(FULL_TITLE)] }), []); + }); + + test("SC8: empty analytics → every post present with match none, no throw", () => { + const graph = assemblePostGraph({ records: [rec(BODY), rec("another post body here")], analytics: [] }); + assert.equal(graph.length, 2); + assert.ok(graph.every((g) => g.match.confidence === "none")); + }); + + test("SC8: best-of prefers high over low and is deterministic", () => { + const r = rec(BODY); + const high = row(FULL_TITLE); // same date + const low = row(FULL_TITLE, { publishedDate: "2026-01-01" }); // different date + const graph = assemblePostGraph({ records: [r], analytics: [low, high] }); + assert.equal(graph[0].match.confidence, "high", "high beats low regardless of input order"); + }); +}); diff --git a/scripts/brain/tests/cli.test.ts b/scripts/brain/tests/cli.test.ts new file mode 100644 index 0000000..1c7060e --- /dev/null +++ b/scripts/brain/tests/cli.test.ts @@ -0,0 +1,132 @@ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI = join(HERE, "..", "src", "cli.ts"); + +/** Run the brain CLI in a child process with LINKEDIN_STUDIO_DATA pointed at `root`. */ +function runCli(root: string, args: string[]): { stdout: string; code: number } { + try { + const stdout = execFileSync("node", ["--import", "tsx", CLI, ...args], { + env: { ...process.env, LINKEDIN_STUDIO_DATA: root }, + encoding: "utf8", + }); + return { stdout, code: 0 }; + } catch (e: any) { + return { stdout: (e.stdout ?? "") + (e.stderr ?? ""), code: e.status ?? 1 }; + } +} + +describe("brain CLI dispatch (SB-S1)", () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "brain-cli-")); + }); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + test("`init` still works after the CLI refactor (regression)", () => { + const { stdout, code } = runCli(root, ["init"]); + assert.equal(code, 0); + assert.ok(existsSync(join(root, "brain", "profile.md")), "init scaffolded brain/"); + assert.match(stdout, /Created|Brain root/); + }); + + test("`ingest --file` writes a published record", () => { + const f = join(root, "post.md"); + writeFileSync(f, "A published post body.", "utf8"); + const { stdout, code } = runCli(root, ["ingest", "--file", f]); + assert.equal(code, 0); + assert.match(stdout, /Wrote ingest\/published\/[0-9a-f]{12}\.md/); + }); + + test("`ingest --file` twice on the same body reports a duplicate", () => { + const f = join(root, "post.md"); + writeFileSync(f, "dup body", "utf8"); + runCli(root, ["ingest", "--file", f]); + const { stdout } = runCli(root, ["ingest", "--file", f]); + assert.match(stdout, /Duplicate/); + }); + + test("`ingest --scan-inbox` processes the inbox", () => { + runCli(root, ["init"]); + writeFileSync(join(root, "ingest", "inbox", "a.md"), "inbox post", "utf8"); + const { stdout, code } = runCli(root, ["ingest", "--scan-inbox"]); + assert.equal(code, 0); + assert.match(stdout, /1 new/); + }); + + test("`published list` lists ingested records with provenance", () => { + const f = join(root, "post.md"); + writeFileSync(f, "listed post", "utf8"); + runCli(root, ["ingest", "--file", f]); + const { stdout, code } = runCli(root, ["published", "list"]); + assert.equal(code, 0); + assert.match(stdout, /published/); + }); + + test("unknown command exits 2 (usage error)", () => { + const { code } = runCli(root, ["bogus"]); + assert.equal(code, 2); + }); + + // SC5 — repeatable producer flags collect into arrays. + test("SC5: `ingest --specific a --specific c --trend b` tags the record [a,c]/[b]", () => { + const f = join(root, "post.md"); + writeFileSync(f, "A tagged published post body.", "utf8"); + const { stdout, code } = runCli(root, [ + "ingest", "--file", f, + "--specific", "aaaaaaaaaaaa", "--specific", "cccccccccccc", "--trend", "bbbbbbbbbbbb", + ]); + assert.equal(code, 0); + const idMatch = stdout.match(/published\/([0-9a-f]{12})\.md/); + assert.ok(idMatch, "wrote a record"); + const recText = readFileSync(join(root, "ingest", "published", `${idMatch![1]}.md`), "utf8"); + assert.match(recText, /specifics: aaaaaaaaaaaa,cccccccccccc/); + assert.match(recText, /trends: bbbbbbbbbbbb/); + }); + + // SC12 — single-value flags unregressed by the repeatable-flag change. + test("SC12: single-value `--source` + boolean `--scan-inbox` still parse as today", () => { + const f = join(root, "p.md"); + writeFileSync(f, "single-flag body", "utf8"); + runCli(root, ["ingest", "--file", f, "--source", "connector-x"]); + const recDir = join(root, "ingest", "published"); + const recFile = readFileSync(join(recDir, readdirSync(recDir)[0]), "utf8"); + assert.match(recFile, /source: connector-x/); + const { code } = runCli(root, ["ingest", "--scan-inbox"]); + assert.equal(code, 0); + }); + + // SC9 — read-only `assemble` prints the join and writes nothing. + test("SC9: `assemble` joins post↔analytics and writes nothing", () => { + const f = join(root, "post.md"); + const body = "Jeg lærte noe viktig om dømmekraft i dag. Del 1."; + writeFileSync(f, body, "utf8"); + runCli(root, ["ingest", "--file", f, "--date", "2026-05-26", "--specific", "aaaaaaaaaaaa"]); + // Seed an analytics batch JSON (raw shape; assemble inlines the read). + const postsDir = join(root, "analytics", "posts"); + mkdirSync(postsDir, { recursive: true }); + writeFileSync( + join(postsDir, "2026-05-26-batch.json"), + JSON.stringify({ posts: [{ title: "Jeg lærte noe viktig om dømmekraft i dag.", publishedDate: "2026-05-26", metrics: { engagementRate: 5.1 } }] }), + "utf8", + ); + const { stdout, code } = runCli(root, ["assemble"]); + assert.equal(code, 0); + assert.match(stdout, /aaaaaaaaaaaa/, "surfaces the specific id"); + assert.match(stdout, /high/i, "shows the high-confidence analytics match"); + assert.ok(!existsSync(join(root, "brain", "profile.md")), "assemble wrote no profile.md"); + }); + + test("SC9: `assemble` on an empty root degrades cleanly (no crash)", () => { + const { code } = runCli(root, ["assemble"]); + assert.equal(code, 0); + }); +}); diff --git a/scripts/brain/tests/consolidate-cli.test.ts b/scripts/brain/tests/consolidate-cli.test.ts new file mode 100644 index 0000000..d2744f1 --- /dev/null +++ b/scripts/brain/tests/consolidate-cli.test.ts @@ -0,0 +1,139 @@ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI = join(HERE, "..", "src", "cli.ts"); + +function runCli(root: string, args: string[]): { stdout: string; code: number } { + try { + const stdout = execFileSync("node", ["--import", "tsx", CLI, ...args], { + env: { ...process.env, LINKEDIN_STUDIO_DATA: root }, + encoding: "utf8", + }); + return { stdout, code: 0 }; + } catch (e: any) { + return { stdout: (e.stdout ?? "") + (e.stderr ?? ""), code: e.status ?? 1 }; + } +} + +const candidatesFile = (root: string, items: unknown): string => { + const p = join(root, "candidates.json"); + writeFileSync(p, JSON.stringify(items), "utf8"); + return p; +}; +const profilePath = (root: string) => join(root, "brain", "profile.md"); +const pendingJson = (root: string) => join(root, "brain", "pending-diff.json"); +const pendingMd = (root: string) => join(root, "brain", "pending-diff.md"); +const validCand = { key: "topic", value: "AI safety", provenance: "published", source: "manual", observed_date: "2026-06-23" }; + +describe("brain consolidate CLI (SC5)", () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "brain-consol-")); + runCli(root, ["init"]); // seed brain/profile.md + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + test("--gather reads published bodies (filtered by last_run) and writes nothing", () => { + runCli(root, ["ingest", "--file", (() => { const f = join(root, "p.md"); writeFileSync(f, "A post about AI safety governance.", "utf8"); return f; })()]); + const before = readFileSync(profilePath(root), "utf8"); + const { stdout, code } = runCli(root, ["consolidate", "--gather"]); + assert.equal(code, 0); + assert.match(stdout, /AI safety governance/, "gather surfaces the published body"); + assert.equal(readFileSync(profilePath(root), "utf8"), before, "gather leaves profile.md unchanged"); + }); + + test("--propose writes pending-diff.{md,json}, leaves profile.md unchanged", () => { + const before = readFileSync(profilePath(root), "utf8"); + const { code } = runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + assert.equal(code, 0); + assert.ok(existsSync(pendingJson(root)), "pending-diff.json written"); + assert.ok(existsSync(pendingMd(root)), "pending-diff.md written"); + assert.equal(readFileSync(profilePath(root), "utf8"), before, "propose leaves profile.md unchanged"); + const diff = JSON.parse(readFileSync(pendingJson(root), "utf8")); + assert.equal(diff.additions.length, 1); + }); + + test("--propose writes pending-diff.md in OKF-compatible form (type: PendingDiff)", () => { + // The pending-diff is a transient operator-review artifact that lives in the + // brain/ bundle, so it must carry a type or okf-check fails the whole bundle + // mid-propose. (docs/okf-convergence-brief.md Stage-1 deferred item.) + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + const block = readFileSync(pendingMd(root), "utf8").match(/^---\n([\s\S]*?)\n---\n/); + assert.ok(block, "pending-diff.md leads with a frontmatter block"); + assert.match(block![1], /^type:\s*PendingDiff\s*$/m, "pending-diff.md is type PendingDiff"); + }); + + test("--propose REJECTS a malformed candidate (missing field), no write", () => { + const { code } = runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [{ key: "x", value: "y" }])]); + assert.notEqual(code, 0, "non-zero exit on malformed candidate"); + assert.ok(!existsSync(pendingJson(root)), "no diff written"); + }); + + test("--propose REJECTS a candidate whose value contains a newline, no write", () => { + const bad = { ...validCand, value: "line one\nline two" }; + const { code } = runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [bad])]); + assert.notEqual(code, 0, "non-zero exit on multiline value"); + assert.ok(!existsSync(pendingJson(root)), "no diff written"); + }); + + test("--apply --confirm writes profile.md + consolidation-state.json", () => { + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + const { code } = runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root), "--confirm"]); + assert.equal(code, 0); + assert.match(readFileSync(profilePath(root), "utf8"), /AI safety/, "fact applied to profile.md"); + assert.ok(existsSync(join(root, "brain", "consolidation-state.json")), "last-run sidecar written"); + }); + + test("--apply WITHOUT --confirm refuses and writes nothing", () => { + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + const before = readFileSync(profilePath(root), "utf8"); + const { code } = runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root)]); + assert.notEqual(code, 0, "refuses without --confirm"); + assert.equal(readFileSync(profilePath(root), "utf8"), before, "profile.md untouched"); + }); + + test("init/ingest/published still work (regression)", () => { + assert.equal(runCli(root, ["init"]).code, 0); + const f = join(root, "q.md"); writeFileSync(f, "another post", "utf8"); + assert.equal(runCli(root, ["ingest", "--file", f]).code, 0); + assert.equal(runCli(root, ["published", "list"]).code, 0); + }); + + const supCand = { key: "topic", value: "AI governance", provenance: "published", source: "manual", observed_date: "2026-06-24", supersedes: "topic" }; + + test("S3b-SC6 — Supersessions section renders one line per entry; a plain diff renders none; gated apply writes superseded", () => { + // plain propose → NO Supersessions section (byte-identity decoy) + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + assert.ok(!readFileSync(pendingMd(root), "utf8").includes("## Supersessions"), "no section when 0 supersessions"); + // apply to seed the active fact, then propose a supersede of it + runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root), "--confirm"]); + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [supCand])]); + const md = readFileSync(pendingMd(root), "utf8"); + assert.match(md, /## Supersessions/, "section rendered when a supersession exists"); + assert.match(md, /AI safety.*→.*AI governance/, "old → new line rendered"); + const diff = JSON.parse(readFileSync(pendingJson(root), "utf8")); + assert.equal(diff.supersedes.length, 1, "one supersede op in the JSON"); + // the gated apply writes the superseded status to profile.md + runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root), "--confirm"]); + assert.match(readFileSync(profilePath(root), "utf8"), /superseded/, "apply writes the superseded status"); + }); + + test("S3b-SC10 — --gather excludes superseded facts from profileFacts", () => { + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [validCand])]); + runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root), "--confirm"]); + runCli(root, ["consolidate", "--propose", "--candidates", candidatesFile(root, [supCand])]); + runCli(root, ["consolidate", "--apply", "--diff", pendingJson(root), "--confirm"]); + // profile now holds an archival "AI safety" (superseded) + winner "AI governance" (active) + const { stdout } = runCli(root, ["consolidate", "--gather", "--json"]); + const out = JSON.parse(stdout); + const values = out.profileFacts.map((f: any) => f.value); + assert.ok(values.includes("AI governance"), "active winner present in gather"); + assert.ok(!out.profileFacts.some((f: any) => f.status === "superseded"), "no superseded fact leaks into gather"); + }); +}); diff --git a/scripts/brain/tests/consolidate.test.ts b/scripts/brain/tests/consolidate.test.ts new file mode 100644 index 0000000..39560a6 --- /dev/null +++ b/scripts/brain/tests/consolidate.test.ts @@ -0,0 +1,287 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { proposeDiff, applyDiff, type Candidate, type ProfileDiff } from "../src/consolidate.js"; +import { mintEntityId } from "../src/id.js"; +import { parseProfile, serializeProfile } from "../src/profile.js"; +import type { ProfileDoc, ProfileFact } from "../src/types.js"; + +const TODAY = "2026-06-23"; +const observedId = (key: string) => mintEntityId({ kind: "observed", key }); + +const fact = (over: Partial<ProfileFact>): ProfileFact => ({ + id: "000000000000", + value: "v", + first_seen: "2026-01-01", + last_seen: TODAY, + evidence_count: 1, + provenance: "published", + status: "active", + ...over, +}); +const doc = (staticF: ProfileFact[], dynamicF: ProfileFact[]): ProfileDoc => ({ + schemaVersion: 1, + static: staticF, + dynamic: dynamicF, +}); +const cand = (over: Partial<Candidate>): Candidate => ({ + key: "k", + value: "v", + provenance: "published", + source: "manual", + observed_date: TODAY, + ...over, +}); + +describe("proposeDiff classification (SC1a–g)", () => { + test("SC1a — add: a published candidate with no match becomes a new dynamic fact", () => { + const diff = proposeDiff({ current: doc([], []), candidates: [cand({ key: "topic", value: "AI safety" })], today: TODAY }); + assert.equal(diff.additions.length, 1); + assert.equal(diff.additions[0].value, "AI safety"); + assert.equal(diff.additions[0].id, observedId("topic")); + assert.equal(diff.additions[0].evidence_count, 1); + assert.equal(diff.additions[0].status, "active"); + }); + + test("SC1b — reject: an ai-draft candidate produces an empty diff", () => { + const diff = proposeDiff({ current: doc([], []), candidates: [cand({ provenance: "ai-draft" })], today: TODAY }); + assert.deepEqual(diff.additions, []); + assert.deepEqual(diff.evidenceBumps, []); + assert.deepEqual(diff.promotions, []); + assert.deepEqual(diff.conflicts, []); + }); + + test("SC1c — evidence-bump: same key+value bumps the existing fact, no addition", () => { + const id = observedId("topic"); + const cur = doc([], [fact({ id, value: "AI safety", evidence_count: 1 })]); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "topic", value: "AI safety" })], today: TODAY }); + assert.equal(diff.additions.length, 0); + assert.equal(diff.evidenceBumps.length, 1); + assert.deepEqual(diff.evidenceBumps[0], { id, newCount: 2, last_seen: TODAY }); + }); + + test("SC1d — promote: a dynamic fact reaching N=3 is promoted", () => { + const id = observedId("topic"); + const cur = doc([], [fact({ id, value: "AI safety", evidence_count: 2 })]); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "topic", value: "AI safety" })], today: TODAY }); + assert.equal(diff.evidenceBumps[0].newCount, 3); + assert.deepEqual(diff.promotions, [{ id }]); + }); + + test("SC1d — no promote below N", () => { + const id = observedId("topic"); + const cur = doc([], [fact({ id, value: "x", evidence_count: 1 })]); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "topic", value: "x" })], today: TODAY }); + assert.deepEqual(diff.promotions, []); + }); + + test("SC1e — conflict: different value keeps BOTH with DISTINCT ids, old fact untouched", () => { + const primaryId = observedId("role"); + const cur = doc([], [fact({ id: primaryId, value: "advisor", evidence_count: 2 })]); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect" })], today: TODAY }); + assert.equal(diff.conflicts.length, 1); + assert.equal(diff.conflicts[0].primaryId, primaryId); + assert.equal(diff.conflicts[0].primaryValue, "advisor"); + assert.notEqual(diff.conflicts[0].altId, primaryId, "alt id must differ from primary id (no duplicate-id corruption)"); + // the alt fact is added; the old fact is NOT bumped + assert.equal(diff.additions.length, 1); + assert.equal(diff.additions[0].value, "architect"); + assert.equal(diff.additions[0].id, diff.conflicts[0].altId); + assert.deepEqual(diff.evidenceBumps, []); + }); + + test("SC1f — decay: a dynamic fact older than 90d is flagged; a static fact is decay-exempt", () => { + const old = "2026-03-01"; // ~114 days before TODAY + const dyn = fact({ id: observedId("stale-topic"), value: "old", last_seen: old }); + const stat = fact({ id: observedId("settled"), value: "stable", last_seen: old }); + const cur = doc([stat], [dyn]); + const diff = proposeDiff({ current: cur, candidates: [], today: TODAY }); + assert.equal(diff.staleFlags.length, 1, "only the dynamic stale fact is flagged"); + assert.equal(diff.staleFlags[0].id, dyn.id); + assert.ok(diff.staleFlags[0].daysStale > 90); + }); + + test("SC1f — a fresh dynamic fact is not flagged", () => { + const cur = doc([], [fact({ id: observedId("fresh"), last_seen: "2026-06-20" })]); + const diff = proposeDiff({ current: cur, candidates: [], today: TODAY }); + assert.deepEqual(diff.staleFlags, []); + }); + + test("SC1g — folded immutable: a candidate overlapping a profile-field seed adds a new observed fact, seed untouched", () => { + const foldedId = mintEntityId({ kind: "profile-field", key: "role" }); + const folded = fact({ id: foldedId, value: "advisor", provenance: "human" }); + const cur = doc([folded], []); + const before = serializeProfile(cur); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect" })], today: TODAY }); + // candidate keys to observed:role (≠ profile-field:role) → no match → ADD, no conflict on the folded seed + assert.equal(diff.additions.length, 1); + assert.equal(diff.additions[0].id, observedId("role")); + assert.equal(diff.conflicts.length, 0); + assert.equal(serializeProfile(cur), before, "input doc not mutated; folded seed untouched"); + }); +}); + +describe("proposeDiff purity + applyDiff round-trip (SC2, SC3, SC4)", () => { + test("SC2 — proposeDiff does not mutate its inputs", () => { + const cur = doc([], [fact({ id: observedId("topic"), value: "x" })]); + const candidates = [cand({ key: "topic", value: "x" }), cand({ key: "new", value: "y" })]; + const snapCur = JSON.stringify(cur); + const snapCand = JSON.stringify(candidates); + proposeDiff({ current: cur, candidates, today: TODAY }); + assert.equal(JSON.stringify(cur), snapCur, "current unchanged"); + assert.equal(JSON.stringify(candidates), snapCand, "candidates unchanged"); + }); + + test("SC3 — applyDiff(current, proposeDiff(...)) round-trips through parse/serialize", () => { + const cur = doc([fact({ id: mintEntityId({ kind: "profile-field", key: "name" }), value: "KTG", provenance: "human" })], [fact({ id: observedId("topic"), value: "AI safety", evidence_count: 2 })]); + const candidates = [ + cand({ key: "topic", value: "AI safety" }), // bump→promote + cand({ key: "new-topic", value: "governance" }), // add + cand({ key: "topic", value: "alignment" }), // conflict on topic → alt + ]; + const diff = proposeDiff({ current: cur, candidates, today: TODAY }); + const next = applyDiff(cur, diff); + const round = parseProfile(serializeProfile(next)); + assert.deepEqual(round, next, "applied doc round-trips exactly"); + // every fact id is unique (no duplicate-id corruption) + const ids = [...round.static, ...round.dynamic].map((f) => f.id); + assert.equal(new Set(ids).size, ids.length, "all fact ids unique"); + }); + + test("SC4 — idempotent: re-running propose→apply with the same candidates adds no duplicate facts", () => { + const cur = doc([], []); + const candidates = [cand({ key: "topic", value: "AI safety" })]; + const once = applyDiff(cur, proposeDiff({ current: cur, candidates, today: TODAY })); + const twice = applyDiff(once, proposeDiff({ current: once, candidates, today: TODAY })); + const factsOnce = [...once.static, ...once.dynamic].length; + const factsTwice = [...twice.static, ...twice.dynamic].length; + assert.equal(factsTwice, factsOnce, "no duplicate fact on re-run (bump only)"); + }); +}); + +describe("supersede arm (SB-S3b)", () => { + test("S3b-SC1 — routing: a supersede candidate retires the active prior, no keep-both", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }); + assert.equal(diff.supersedes.length, 1, "one supersede op"); + assert.equal(diff.conflicts.length, 0, "supersede routes away from keep-both"); + assert.equal(diff.supersedes[0].oldId, id); + assert.equal(diff.supersedes[0].oldValue, "advisor"); + assert.equal(diff.supersedes[0].winner.value, "architect"); + assert.equal(diff.supersedes[0].winner.id, observedId("role"), "winner takes the canonical key-id"); + assert.equal(diff.additions.length, 0, "winner is carried in the op, not additions"); + }); + + test("S3b-SC2 — apply: retired fact superseded+re-minted, winner at key-id, both retained", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2, first_seen: "2026-01-01" })]); + const next = applyDiff(cur, proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY })); + const all = [...next.static, ...next.dynamic]; + const winner = all.find((f) => f.value === "architect")!; + const retired = all.find((f) => f.value === "advisor")!; + assert.ok(winner && retired, "both winner + retired present"); + assert.equal(winner.id, id, "winner takes the canonical key-id"); + assert.equal(winner.status, "active"); + assert.equal(winner.evidence_count, 1, "winner re-earns evidence"); + assert.equal(retired.status, "superseded"); + assert.notEqual(retired.id, id, "retired fact re-minted off the key-id (archival)"); + assert.equal(retired.first_seen, "2026-01-01", "retired fact keeps its OWN first_seen (audit)"); + const ids = all.map((f) => f.id); + assert.equal(new Set(ids).size, ids.length, "no duplicate id"); + }); + + test("S3b-SC3a — propose-idempotent: a re-sent supersede whose target already holds the new value bumps, no new supersession", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const once = applyDiff(cur, proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY })); + // re-extract the same candidate (value now matches the winner) WITH the stale signal still set + const diff2 = proposeDiff({ current: once, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }); + assert.equal(diff2.supersedes.length, 0, "no self-supersede when the target already holds the value"); + assert.equal(diff2.evidenceBumps.length, 1, "it bumps the winner instead"); + }); + + test("S3b-SC3b — apply-idempotent: re-applying the same supersede diff is a no-op (value-matched skip)", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }); + const once = applyDiff(cur, diff); + const twice = applyDiff(once, diff); + assert.deepEqual(twice, once, "re-applying the same supersede diff changes nothing"); + }); + + test("S3b-SC4 — graceful no-target: a supersede signal with no active prior degrades to a plain add", () => { + const cur = doc([], []); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY }); + assert.equal(diff.supersedes.length, 0); + assert.equal(diff.additions.length, 1, "degrades to a plain add"); + assert.equal(diff.additions[0].value, "architect"); + assert.equal(diff.additions[0].id, observedId("role")); + }); + + test("S3b-SC5 — round-trip: a superseded fact produced by applyDiff parses/serializes identically", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const next = applyDiff(cur, proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect", supersedes: "role" })], today: TODAY })); + const round = parseProfile(serializeProfile(next)); + assert.deepEqual(round, next, "superseded fact round-trips through the grammar"); + const ids = [...round.static, ...round.dynamic].map((f) => f.id); + assert.equal(new Set(ids).size, ids.length, "all ids unique"); + }); + + test("S3b-SC7 — no regression: a candidate WITHOUT the signal still keeps-both (no supersede)", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const diff = proposeDiff({ current: cur, candidates: [cand({ key: "role", value: "architect" })], today: TODAY }); + assert.equal(diff.supersedes.length, 0); + assert.equal(diff.conflicts.length, 1, "keep-both unchanged when no signal"); + }); + + test("S3b-SC8 — intra-batch: two supersede candidates for the same key resolve deterministically (first wins)", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const diff = proposeDiff({ + current: cur, + candidates: [ + cand({ key: "role", value: "architect", supersedes: "role" }), + cand({ key: "role", value: "principal", supersedes: "role" }), + ], + today: TODAY, + }); + assert.equal(diff.supersedes.length, 1, "only the first supersede for the key is taken"); + assert.equal(diff.supersedes[0].winner.value, "architect"); + const next = applyDiff(cur, diff); + const ids = [...next.static, ...next.dynamic].map((f) => f.id); + assert.equal(new Set(ids).size, ids.length, "no duplicate id after intra-batch supersede"); + }); + + test("S3b-SC11 — decay excludes superseded: a retained superseded dynamic fact is not stale-flagged", () => { + const old = "2026-03-01"; // ~114 days before TODAY + const superseded = fact({ id: observedId("old-role"), value: "advisor", last_seen: old, status: "superseded" }); + const active = fact({ id: observedId("topic"), value: "x", last_seen: old, status: "active" }); + const cur = doc([], [superseded, active]); + const diff = proposeDiff({ current: cur, candidates: [], today: TODAY }); + assert.equal(diff.staleFlags.length, 1, "only the active stale fact is flagged"); + assert.equal(diff.staleFlags[0].id, active.id); + }); + + test("S3b-SC12 — supersede wins over a same-id bump in one diff", () => { + const id = observedId("role"); + const cur = doc([], [fact({ id, value: "advisor", evidence_count: 2 })]); + const winner = fact({ id, value: "architect", evidence_count: 1, status: "active" }); + const diff: ProfileDiff = { + additions: [], + promotions: [], + conflicts: [], + staleFlags: [], + evidenceBumps: [{ id, newCount: 3, last_seen: TODAY }], // a stale bump targeting the same id + supersedes: [{ oldId: id, oldValue: "advisor", winner }], + }; + const next = applyDiff(cur, diff); + const all = [...next.static, ...next.dynamic]; + const w = all.find((f) => f.value === "architect")!; + const r = all.find((f) => f.value === "advisor")!; + assert.equal(r.status, "superseded", "the old fact is superseded, not bumped"); + assert.equal(w.evidence_count, 1, "winner unaffected by the stale bump"); + }); +}); diff --git a/scripts/brain/tests/fold.test.ts b/scripts/brain/tests/fold.test.ts new file mode 100644 index 0000000..c29432b --- /dev/null +++ b/scripts/brain/tests/fold.test.ts @@ -0,0 +1,113 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { foldUserProfile } from "../src/profile.js"; +import { mintEntityId } from "../src/id.js"; +import type { ProfileDoc } from "../src/types.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const TEMPLATE = readFileSync(join(here, "../../../config/user-profile.template.md"), "utf8"); +const POPULATED = readFileSync(join(here, "../fixtures/user-profile.populated.md"), "utf8"); + +const D1 = "2026-06-23"; +const D2 = "2026-07-01"; + +/** Look a fact up by its source label via the canonical id. */ +const byLabel = (doc: ProfileDoc, label: string) => + doc.static.find((f) => f.id === mintEntityId({ kind: "profile-field", key: label })); + +const FIELD_COUNT = 27; // 22 P1 labeled scalars + 5 P2 expertise areas + +describe("foldUserProfile (SC3)", () => { + describe("SC3a — source-absent", () => { + const doc = foldUserProfile({ templateText: TEMPLATE, today: D1 }); + + test("lists every template field (22 P1 + 5 P2), no dynamic facts", () => { + assert.equal(doc.static.length, FIELD_COUNT); + assert.equal(doc.dynamic.length, 0); + }); + + test("placeholder fields fold to an empty value", () => { + assert.equal(byLabel(doc, "Name")?.value, ""); + assert.equal(byLabel(doc, "Current Role")?.value, ""); + assert.equal(byLabel(doc, "expertise-area-1")?.value, ""); + assert.equal(byLabel(doc, "Follower count")?.value, ""); + assert.equal(byLabel(doc, "Topics to AVOID")?.value, ""); + }); + + test("the lone literal template field (Disclaimer) keeps its boilerplate", () => { + assert.equal(byLabel(doc, "Important Disclaimer")?.value.startsWith("All articles"), true); + }); + + test("deferred Research-Tooling prose does not leak in as fields", () => { + assert.equal(byLabel(doc, "Always-available floor (no MCP needed)"), undefined); + assert.equal(byLabel(doc, "Preferred order (optional)"), undefined); + }); + + test("every folded fact has the canonical shape", () => { + for (const f of doc.static) { + assert.match(f.id, /^[0-9a-f]{12}$/); + assert.equal(f.provenance, "human"); + assert.equal(f.status, "active"); + assert.equal(f.evidence_count, 0); + assert.equal(f.first_seen, D1); + assert.equal(f.last_seen, D1); + } + }); + }); + + describe("SC3b — populated (lossless)", () => { + const doc = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, today: D1 }); + + test("same field-set size — fold neither drops nor invents fields", () => { + assert.equal(doc.static.length, FIELD_COUNT); + }); + + test("every filled value is carried (none dropped)", () => { + assert.equal(doc.static.every((f) => f.value !== ""), true); + }); + + test("specific filled values land on the right fields", () => { + assert.equal(byLabel(doc, "Name")?.value, "Jordan Avery"); + assert.equal(byLabel(doc, "Current Role")?.value, "Senior Data Engineer"); + assert.equal(byLabel(doc, "expertise-area-1")?.value, "Streaming data pipelines"); + assert.equal(byLabel(doc, "expertise-area-5")?.value, "Team data literacy"); + assert.equal(byLabel(doc, "Topics to AVOID")?.value, "Crypto"); + assert.equal(byLabel(doc, "Follower count")?.value, "2,400"); + assert.equal(byLabel(doc, "Phrases you commonly use")?.value, '"ship the boring thing"'); + }); + }); + + describe("SC3c — idempotent re-run", () => { + test("re-running source-absent over its own output changes nothing", () => { + const a = foldUserProfile({ templateText: TEMPLATE, today: D1 }); + const a2 = foldUserProfile({ templateText: TEMPLATE, existing: a, today: D1 }); + assert.deepEqual(a2, a); + }); + + test("re-running populated over its own output changes nothing (no dup)", () => { + const b = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, today: D1 }); + const b2 = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, existing: b, today: D1 }); + assert.deepEqual(b2, b); + assert.equal(b2.static.length, FIELD_COUNT); + }); + + test("a later run bumps last_seen but preserves first_seen and value", () => { + const b = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, today: D1 }); + const later = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, existing: b, today: D2 }); + const name = byLabel(later, "Name"); + assert.equal(name?.first_seen, D1); + assert.equal(name?.last_seen, D2); + assert.equal(name?.value, "Jordan Avery"); + }); + + test("re-folding source-absent over a populated existing never wipes a filled value", () => { + const b = foldUserProfile({ templateText: TEMPLATE, instanceText: POPULATED, today: D1 }); + const over = foldUserProfile({ templateText: TEMPLATE, existing: b, today: D2 }); + assert.equal(byLabel(over, "Name")?.value, "Jordan Avery"); + }); + }); +}); diff --git a/scripts/brain/tests/id.test.ts b/scripts/brain/tests/id.test.ts new file mode 100644 index 0000000..53b69dd --- /dev/null +++ b/scripts/brain/tests/id.test.ts @@ -0,0 +1,73 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { slugify, mintEntityId, normalizeProvenance } from "../src/id.js"; + +describe("id module (SC4)", () => { + describe("slugify", () => { + test("lowercases, trims, and dashes non-alphanumeric runs", () => { + assert.equal(slugify("Current Role"), "current-role"); + }); + + test("collapses repeated separators and strips edge dashes", () => { + assert.equal(slugify(" Core Expertise Areas (5 topics) "), "core-expertise-areas-5-topics"); + }); + + test("is stable across case + whitespace variation of the same label", () => { + assert.equal(slugify("Current Role"), slugify(" current ROLE ")); + }); + }); + + describe("mintEntityId", () => { + test("is deterministic — same seed yields the same id", () => { + assert.equal( + mintEntityId({ kind: "profile-field", key: "Current Role" }), + mintEntityId({ kind: "profile-field", key: "Current Role" }), + ); + }); + + test("is stable across label case/whitespace (id keyed on the slug)", () => { + assert.equal( + mintEntityId({ kind: "profile-field", key: "Current Role" }), + mintEntityId({ kind: "profile-field", key: " current ROLE " }), + ); + }); + + test("different key yields a different id", () => { + assert.notEqual( + mintEntityId({ kind: "profile-field", key: "Current Role" }), + mintEntityId({ kind: "profile-field", key: "Organization" }), + ); + }); + + test("different kind yields a different id (kind is part of the seed)", () => { + assert.notEqual( + mintEntityId({ kind: "profile-field", key: "role" }), + mintEntityId({ kind: "expertise-area", key: "role" }), + ); + }); + + test("id is 12 lowercase hex characters", () => { + assert.match(mintEntityId({ kind: "profile-field", key: "Name" }), /^[0-9a-f]{12}$/); + }); + }); + + describe("normalizeProvenance", () => { + test("returns each accepted value unchanged", () => { + assert.equal(normalizeProvenance("human"), "human"); + assert.equal(normalizeProvenance("published"), "published"); + assert.equal(normalizeProvenance("ai-draft"), "ai-draft"); + }); + + test("trims and lowercases before matching", () => { + assert.equal(normalizeProvenance(" Human "), "human"); + assert.equal(normalizeProvenance("AI-DRAFT"), "ai-draft"); + }); + + test("throws on anything outside the provenance vocabulary", () => { + assert.throws(() => normalizeProvenance("robot")); + assert.throws(() => normalizeProvenance("")); + assert.throws(() => normalizeProvenance("humanoid")); + }); + }); +}); diff --git a/scripts/brain/tests/ingest.test.ts b/scripts/brain/tests/ingest.test.ts new file mode 100644 index 0000000..2a00026 --- /dev/null +++ b/scripts/brain/tests/ingest.test.ts @@ -0,0 +1,124 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { mintContentId } from "../src/id.js"; +import { + parsePublishedRecord, + serializePublishedRecord, + type PublishedRecord, +} from "../src/ingest.js"; + +const baseRec = (body: string): PublishedRecord => ({ + id: mintContentId(body), + provenance: "published", + published_date: "2026-05-26", + captured_at: "2026-06-23", + source: "manual", + specifics: [], + trends: [], + body, +}); + +/** Round-trip helper: parse∘serialize must equal the original record. */ +function roundTrip(rec: PublishedRecord): PublishedRecord { + return parsePublishedRecord(serializePublishedRecord(rec)); +} + +describe("mintContentId — verbatim-body content hash (SC3)", () => { + test("deterministic: same body → same id", () => { + const body = "Jeg lærte noe om dømmekraft i dag.\n\nDel 1 av serien."; + assert.equal(mintContentId(body), mintContentId(body)); + }); + + test("returns a 12-hex id", () => { + assert.match(mintContentId("any body"), /^[0-9a-f]{12}$/); + }); + + test("different bodies → different ids", () => { + assert.notEqual(mintContentId("post A"), mintContentId("post B")); + }); + + test("whitespace/case differences mint DIFFERENT ids (no normalize-collision, B2 guard)", () => { + // Under specifics-bank normalizeContent these would collapse to the SAME id — + // proving the post-body hash is byte-identity, not normalized. + assert.notEqual(mintContentId("Hello World"), mintContentId("hello world")); + assert.notEqual( + mintContentId("Line one\nLine two"), + mintContentId("Line one Line two"), + ); + }); +}); + +describe("PublishedRecord grammar — parse∘serialize identity (SC2, B1 edge battery)", () => { + const bodies: Record<string, string> = { + "plain multi-paragraph": "Hook line.\n\nBody paragraph one.\n\nBody paragraph two.", + "empty body": "", + "single newline": "\n", + "trailing newlines": "post text\n\n", + "body starting with ---": "---\nlooks like a separator but is body", + "body containing a \\n---\\n sentinel mid-text": "before\n---\nafter the fake separator", + "header-shaped first line": "id: 0123456789ab\nprovenance: published", + "special chars ] | quotes": 'value with ] and | and "quotes" inside', + }; + + for (const [label, body] of Object.entries(bodies)) { + test(`round-trips: ${label}`, () => { + const rec = baseRec(body); + assert.deepEqual(roundTrip(rec), rec); + }); + } + + test("serialized form has the fixed 5-line header then the --- sentinel", () => { + const text = serializePublishedRecord(baseRec("hi")); + const [header, ...rest] = text.split("\n---\n"); + assert.equal(header.split("\n").length, 5, "exactly 5 header lines before the sentinel"); + assert.equal(rest.join("\n---\n"), "hi", "body is everything after the FIRST sentinel"); + assert.match(text, /^id: [0-9a-f]{12}\nprovenance: published\n/); + }); + + test("a published/ record with non-published provenance is rejected (corruption signal)", () => { + const corrupt = serializePublishedRecord(baseRec("x")).replace( + "provenance: published", + "provenance: ai-draft", + ); + assert.throws(() => parsePublishedRecord(corrupt), /provenance/i); + }); +}); + +describe("SB-S3c — raw-material ids on the published record", () => { + const ids2 = ["aaaaaaaaaaaa", "bbbbbbbbbbbb"]; + + // SC1 — round-trip with non-empty specifics/trends, order preserved. + test("SC1: parse∘serialize round-trips a record carrying specifics/trends (order preserved)", () => { + const rec: PublishedRecord = { ...baseRec("a tagged post"), specifics: ids2, trends: ["cccccccccccc"] }; + assert.deepEqual(roundTrip(rec), rec); + }); + + // SC2 — empty arrays → unchanged 5-line header; a pre-S3c fixture round-trips byte-identically. + test("SC2: empty specifics/trends serialize byte-identically to a pre-S3c record", () => { + const oldText = + "id: 0123456789ab\nprovenance: published\npublished_date: 2026-05-26\n" + + "captured_at: 2026-06-23\nsource: manual\n---\nthe body text"; + assert.equal(serializePublishedRecord(parsePublishedRecord(oldText)), oldText); + // and the live empty-array record still emits exactly 5 header lines. + const text = serializePublishedRecord(baseRec("hi")); + assert.equal(text.split("\n---\n")[0].split("\n").length, 5); + }); + + // SC2b — when present, the new lines are appended AFTER source: and before the sentinel. + test("SC2b: non-empty specifics/trends are appended after source:, before the --- sentinel", () => { + const text = serializePublishedRecord({ ...baseRec("x"), specifics: [ids2[0]], trends: [ids2[1]] }); + const header = text.split("\n---\n")[0]; + assert.match(header, /source: manual\nspecifics: aaaaaaaaaaaa\ntrends: bbbbbbbbbbbb$/); + }); + + // SC3 (parse side) — a non-12-hex id in the header throws, never silently dropped. + test("SC3: a malformed specifics/trends id throws on parse", () => { + const bad = + serializePublishedRecord(baseRec("x")).replace( + "source: manual", + "source: manual\nspecifics: NOTAHEXID", + ); + assert.throws(() => parsePublishedRecord(bad), /bad specifics id/i); + }); +}); diff --git a/scripts/brain/tests/okf-conform.test.ts b/scripts/brain/tests/okf-conform.test.ts new file mode 100644 index 0000000..8a68220 --- /dev/null +++ b/scripts/brain/tests/okf-conform.test.ts @@ -0,0 +1,116 @@ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { initBrain } from "../src/scaffold.js"; +import { parseProfile } from "../src/profile.js"; + +/** + * OKF-compatible-form conformance for the `brain/` knowledge bundle. + * + * Stage 1 of the cross-plugin OKF convergence (docs/okf-convergence-brief.md): the + * brain is the REFERENCE design, OKF is a thin interop veneer. Contract — mirrors + * `okr/scripts/okf-check.mjs`, the reference checker: + * - every CONCEPT file (`*.md` except `index.md`) carries a non-empty `type` in a + * leading YAML frontmatter block; + * - the bundle-root `index.md` carries an `okf_version` marker as markdown TEXT + * (index files carry no frontmatter per the OKF spec); + * - each directory level has its own `index.md` (progressive disclosure). + * + * EXCLUDED by design (brief §6): the `ingest/` tributary. `ingest/published/*.md` + * is the byte-exact gold store with a hard round-trip invariant (SC2) that a YAML + * frontmatter block would break — it is a raw tributary, not a navigable concept, + * and the hub `index.md` points to it rather than folding it in. This test walks + * `brain/` ONLY; it never asserts frontmatter on the tributary. + */ + +/** + * Minimal frontmatter `type` reader. We only ever EMIT OKF frontmatter; we never add + * a YAML-parser dependency (the brain is deliberately YAML-free), so a constrained + * regex over the leading `---` block is the right reader here. + */ +function frontmatterType(text: string): string | null { + const block = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!block) return null; + const t = block[1].match(/^type:\s*(.+?)\s*$/m); + return t ? t[1].trim() : null; +} + +/** All concept files (`*.md` except `index.md`) under `root`, recursive. */ +function walkConceptMd(root: string): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.isFile() && e.name.endsWith(".md") && e.name !== "index.md") out.push(p); + } + }; + walk(root); + return out; +} + +describe("brain/ bundle is OKF-compatible form (Stage 1)", () => { + let root: string; + const prevEnv = process.env.LINKEDIN_STUDIO_DATA; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "brain-okf-")); + process.env.LINKEDIN_STUDIO_DATA = root; + initBrain(); + }); + + afterEach(() => { + if (prevEnv === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = prevEnv; + rmSync(root, { recursive: true, force: true }); + }); + + test("bundle-root index.md carries an okf_version marker (markdown text, no frontmatter)", () => { + const index = readFileSync(join(root, "brain/index.md"), "utf8"); + assert.match(index, /^okf_version:\s*\S+/m, "root index.md declares okf_version"); + assert.equal(frontmatterType(index), null, "index.md carries NO frontmatter (OKF reserved file)"); + }); + + test("every concept file under brain/ carries a non-empty frontmatter type", () => { + const concepts = walkConceptMd(join(root, "brain")); + assert.ok(concepts.length > 0, "the bundle has concept files to check"); + for (const f of concepts) { + const type = frontmatterType(readFileSync(f, "utf8")); + assert.ok(type && type.length > 0, `concept ${f} is missing a frontmatter type:`); + } + }); + + test("profile.md is type: Profile AND still parses (rich fields preserved — round-trip intact)", () => { + const text = readFileSync(join(root, "brain/profile.md"), "utf8"); + assert.equal(frontmatterType(text), "Profile"); + const doc = parseProfile(text); + assert.equal(doc.schemaVersion, 1, "line-grammar still parses through the frontmatter"); + assert.ok(doc.static.length > 0, "the seeded static facts survive conformance"); + }); + + test("operations.md is type: Operations", () => { + const text = readFileSync(join(root, "brain/operations.md"), "utf8"); + assert.equal(frontmatterType(text), "Operations"); + }); + + test("concept files carry the cheap recommended fields (title + description)", () => { + // OKF recommends title/description; they are free (constant) here and give a + // foreign agent a human label without a clock (timestamp/resource stay out — + // a timestamp would break the pure serializer; resource is N/A for an internal + // concept). okf-check still only REQUIRES type; these clear its warnings. + for (const sub of ["brain/profile.md", "brain/operations.md"]) { + const block = readFileSync(join(root, sub), "utf8").match(/^---\n([\s\S]*?)\n---\n/); + assert.ok(block, `${sub} has a frontmatter block`); + assert.match(block![1], /^title:\s*\S/m, `${sub} has a title`); + assert.match(block![1], /^description:\s*\S/m, `${sub} has a description`); + } + }); + + test("each directory level under brain/ has its own index.md (progressive disclosure)", () => { + assert.ok(existsSync(join(root, "brain/index.md")), "brain/index.md"); + assert.ok(existsSync(join(root, "brain/journal/index.md")), "brain/journal/index.md"); + }); +}); diff --git a/scripts/brain/tests/profile.test.ts b/scripts/brain/tests/profile.test.ts new file mode 100644 index 0000000..43b9eeb --- /dev/null +++ b/scripts/brain/tests/profile.test.ts @@ -0,0 +1,80 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { parseProfile, serializeProfile } from "../src/profile.js"; +import { SCHEMA_VERSION } from "../src/types.js"; +import type { ProfileDoc, ProfileFact } from "../src/types.js"; + +const fact = (over: Partial<ProfileFact> = {}): ProfileFact => ({ + id: "abc123def456", + value: "a value", + first_seen: "2026-06-23", + last_seen: "2026-06-23", + evidence_count: 0, + provenance: "human", + status: "active", + ...over, +}); + +describe("profile line-grammar (SC2)", () => { + test("serialized text exposes both section headers", () => { + const text = serializeProfile({ schemaVersion: SCHEMA_VERSION, static: [], dynamic: [] }); + assert.match(text, /^## Static$/m); + assert.match(text, /^## Dynamic$/m); + assert.match(text, /^schemaVersion: 1$/m); + }); + + test("parse ∘ serialize is identity over the whole doc", () => { + const doc: ProfileDoc = { + schemaVersion: SCHEMA_VERSION, + static: [ + fact({ id: "0123456789ab", value: "Kjell Tore", provenance: "human" }), + fact({ id: "fedcba987654", value: "", evidence_count: 3, provenance: "published" }), + ], + dynamic: [ + fact({ id: "aaaabbbbcccc", value: "leans contrarian", status: "superseded", last_seen: "2026-06-20" }), + ], + }; + assert.deepEqual(parseProfile(serializeProfile(doc)), doc); + }); + + test("a value containing ], | and quotes round-trips intact", () => { + const tricky = 'uses "scare quotes" | pipes | and a ] bracket'; + const doc: ProfileDoc = { + schemaVersion: SCHEMA_VERSION, + static: [fact({ value: tricky })], + dynamic: [], + }; + const round = parseProfile(serializeProfile(doc)); + assert.equal(round.static[0].value, tricky); + assert.deepEqual(round, doc); + }); + + test("an empty-value fact round-trips to an empty string (the source-absent case)", () => { + const doc: ProfileDoc = { + schemaVersion: SCHEMA_VERSION, + static: [fact({ value: "" })], + dynamic: [], + }; + const round = parseProfile(serializeProfile(doc)); + assert.equal(round.static[0].value, ""); + assert.deepEqual(round, doc); + }); + + test("section attribution is preserved (static vs dynamic do not bleed)", () => { + const doc: ProfileDoc = { + schemaVersion: SCHEMA_VERSION, + static: [fact({ id: "111111111111", value: "static one" })], + dynamic: [fact({ id: "222222222222", value: "dynamic one" })], + }; + const round = parseProfile(serializeProfile(doc)); + assert.equal(round.static.length, 1); + assert.equal(round.dynamic.length, 1); + assert.equal(round.static[0].value, "static one"); + assert.equal(round.dynamic[0].value, "dynamic one"); + }); + + test("an unsupported schemaVersion throws", () => { + assert.throws(() => parseProfile("# Profile\n\nschemaVersion: 99\n\n## Static\n\n## Dynamic\n")); + }); +}); diff --git a/scripts/brain/tests/publish.test.ts b/scripts/brain/tests/publish.test.ts new file mode 100644 index 0000000..c62d2f8 --- /dev/null +++ b/scripts/brain/tests/publish.test.ts @@ -0,0 +1,158 @@ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + writePublished, + ingestText, + scanInbox, + listPublished, + serializePublishedRecord, + parsePublishedRecord, + type PublishedRecord, +} from "../src/ingest.js"; +import { mintContentId } from "../src/id.js"; + +const CAPTURED = "2026-06-23"; + +function publishedDir(root: string): string { + return join(root, "ingest", "published"); +} +function inboxDir(root: string): string { + return join(root, "ingest", "inbox"); +} +function mdFiles(dir: string): string[] { + return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".md")) : []; +} + +describe("ingest IO — writePublished / ingestText / scanInbox / listPublished (SC1/SC3/SC4)", () => { + let root: string; + const prevEnv = process.env.LINKEDIN_STUDIO_DATA; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "brain-ingest-")); + process.env.LINKEDIN_STUDIO_DATA = root; + }); + afterEach(() => { + if (prevEnv === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = prevEnv; + rmSync(root, { recursive: true, force: true }); + }); + + test("create-on-demand: ingestText writes ingest/published/<id>.md with no prior init (SC1)", () => { + const body = "Min første publiserte post.\n\nMed avsnitt."; + const res = ingestText({ body, captured_at: CAPTURED }); + assert.equal(res.written, true); + const path = join(publishedDir(root), `${mintContentId(body)}.md`); + assert.ok(existsSync(path), "record file created at runtime data-path"); + const rec = parsePublishedRecord(readFileSync(path, "utf8")); + assert.equal(rec.provenance, "published"); + assert.equal(rec.body, body, "verbatim body preserved"); + }); + + test("published_date defaults to captured_at when not supplied", () => { + const res = ingestText({ body: "no date given", captured_at: CAPTURED }); + assert.equal(res.record.published_date, CAPTURED); + }); + + test("published_date is kept when supplied", () => { + const res = ingestText({ body: "dated", captured_at: CAPTURED, published_date: "2026-05-26" }); + assert.equal(res.record.published_date, "2026-05-26"); + }); + + test("re-ingesting an identical body is a no-op (SC3 dedup): {written:false}, no duplicate file", () => { + const body = "same exact text"; + ingestText({ body, captured_at: CAPTURED }); + const res2 = ingestText({ body, captured_at: "2026-07-01" }); // different day, same body + assert.equal(res2.written, false); + assert.equal(mdFiles(publishedDir(root)).length, 1, "still exactly one record"); + }); + + test("id-collision with a DIFFERING body disambiguates to <id>-2.md — never silent data loss (B2)", () => { + const body = "the real body"; + const id = mintContentId(body); + // Pre-plant a file at <id>.md whose body is DIFFERENT (simulates a hash collision). + mkdirSync(publishedDir(root), { recursive: true }); + const squatter: PublishedRecord = { + id, provenance: "published", published_date: CAPTURED, captured_at: CAPTURED, + source: "manual", specifics: [], trends: [], body: "a DIFFERENT body that happens to share the id", + }; + writeFileSync(join(publishedDir(root), `${id}.md`), serializePublishedRecord(squatter), "utf8"); + + const res = writePublished({ ...squatter, body }); + assert.equal(res.written, true); + assert.equal(res.collision, true); + assert.match(res.path, /-2\.md$/); + assert.equal(mdFiles(publishedDir(root)).length, 2, "both records kept — no data loss"); + }); + + test("scanInbox processes top-level *.md only, skipping dotfiles like .DS_Store (SC4)", () => { + mkdirSync(inboxDir(root), { recursive: true }); + writeFileSync(join(inboxDir(root), "post-a.md"), "Post A body", "utf8"); + writeFileSync(join(inboxDir(root), "post-b.md"), "Post B body", "utf8"); + writeFileSync(join(inboxDir(root), ".DS_Store"), "junk", "utf8"); + writeFileSync(join(inboxDir(root), "notes.txt"), "not markdown", "utf8"); + + const res = scanInbox({ captured_at: CAPTURED }); + assert.equal(res.processed.length, 2, "only the two .md files processed"); + assert.equal(mdFiles(publishedDir(root)).length, 2); + }); + + test("scanInbox on an empty/absent inbox is a clean no-op (SC4)", () => { + const res = scanInbox({ captured_at: CAPTURED }); + assert.deepEqual(res, { processed: [], skipped: [] }); + }); + + test("re-running scanInbox re-skips already-published records (idempotent)", () => { + mkdirSync(inboxDir(root), { recursive: true }); + writeFileSync(join(inboxDir(root), "post.md"), "once", "utf8"); + scanInbox({ captured_at: CAPTURED }); + const res2 = scanInbox({ captured_at: CAPTURED }); + assert.equal(res2.processed.length, 0); + assert.equal(res2.skipped.length, 1); + assert.equal(mdFiles(publishedDir(root)).length, 1, "no duplicate"); + }); + + test("listPublished surfaces provenance and skips a malformed file without throwing", () => { + ingestText({ body: "good record", captured_at: CAPTURED }); + mkdirSync(publishedDir(root), { recursive: true }); + writeFileSync(join(publishedDir(root), "broken.md"), "not a valid record at all", "utf8"); + + const res = listPublished(); + assert.equal(res.records.length, 1, "the one valid record listed"); + assert.equal(res.records[0].provenance, "published"); + assert.equal(res.skipped, 1, "the malformed file counted as skipped, not a crash"); + }); + + // SC4 — ingestText threads specifics/trends onto the written record; absent → []. + test("SC4: ingestText threads specifics/trends; round-trips off disk", () => { + const spec = "aaaaaaaaaaaa"; + const trend = "bbbbbbbbbbbb"; + const res = ingestText({ body: "tagged post body", captured_at: CAPTURED, specifics: [spec], trends: [trend] }); + assert.deepEqual(res.record.specifics, [spec]); + assert.deepEqual(res.record.trends, [trend]); + const onDisk = parsePublishedRecord(readFileSync(res.path, "utf8")); + assert.deepEqual(onDisk.specifics, [spec]); + assert.deepEqual(onDisk.trends, [trend]); + }); + + test("SC4: ingestText with no specifics/trends keeps them empty (regression)", () => { + const res = ingestText({ body: "plain post", captured_at: CAPTURED }); + assert.deepEqual(res.record.specifics, []); + assert.deepEqual(res.record.trends, []); + }); + + // SC3 (producer side) — a malformed id thrown fast at the producer boundary. + test("SC3: ingestText with a non-12-hex specifics id throws at the producer", () => { + assert.throws( + () => ingestText({ body: "x", captured_at: CAPTURED, specifics: ["NOTAHEX"] }), + /bad specifics id/i, + ); + assert.throws( + () => ingestText({ body: "y", captured_at: CAPTURED, trends: ["zzz"] }), + /bad trends id/i, + ); + }); +}); diff --git a/scripts/brain/tests/reconcile.test.ts b/scripts/brain/tests/reconcile.test.ts new file mode 100644 index 0000000..bb999e6 --- /dev/null +++ b/scripts/brain/tests/reconcile.test.ts @@ -0,0 +1,210 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { mintContentId } from "../src/id.js"; +import type { PublishedRecord } from "../src/ingest.js"; +import { assemblePostGraph, type AnalyticsRowInput } from "../src/assemble.js"; +import { + parseRecentPosts, + reconcileRecentPosts, + summarizeReconcile, + loadRecentPosts, +} from "../src/reconcile.js"; + +const DATE = "2026-05-26"; + +function rec(body: string, over: Partial<PublishedRecord> = {}): PublishedRecord { + return { + id: mintContentId(body), + provenance: "published", + published_date: DATE, + captured_at: "2026-06-23", + source: "manual", + specifics: [], + trends: [], + body, + ...over, + }; +} + +function row(title: string, over: Partial<AnalyticsRowInput> = {}): AnalyticsRowInput { + return { title, publishedDate: DATE, metrics: { engagementRate: 4.2 }, ...over }; +} + +// Bodies whose normalized opener is ≥ HOOK_PREFIX_FLOOR (24) chars. +const BODY_A = "Jeg lærte noe viktig om dømmekraft i dag.\n\nDel 1 av serien."; +const HOOK_A = "Jeg lærte noe viktig om dømmekraft i dag."; +const BODY_B = "En helt annen tekst om automatisering og styring.\n\nDel 2."; +const HOOK_B = "En helt annen tekst om automatisering og styring."; + +// The writer's exact entry format (state-updater.mjs:116): - [date] "hook" (chars) - topic +function entry(date: string, hook: string, chars: number, topic: string): string { + return `- [${date}] "${hook}" (${chars}) - ${topic}`; +} + +function stateWith(...entries: string[]): string { + return [ + "---", + "posts_this_week: 3", + "---", + "", + "## Recent Posts", + "", + ...entries, + "", + "## Milestone Log", + "", + "- [2026-05] 1048 (+10)", + "", + ].join("\n"); +} + +describe("SB-S3e — parseRecentPosts (writer-format reader)", () => { + test("extracts {date, hook, charCount, topic} from the ## Recent Posts section", () => { + const text = stateWith( + entry("2026-05-26", HOOK_A, 1400, "dømmekraft"), + entry("2026-05-20", HOOK_B, 900, "automatisering"), + ); + const posts = parseRecentPosts(text); + assert.equal(posts.length, 2); + assert.deepEqual(posts[0], { date: "2026-05-26", hook: HOOK_A, charCount: 1400, topic: "dømmekraft" }); + assert.deepEqual(posts[1], { date: "2026-05-20", hook: HOOK_B, charCount: 900, topic: "automatisering" }); + }); + + test("golden-string: a literal writer-produced entry round-trips verbatim", () => { + // The byte-exact string updatePostTracking emits (state-updater.mjs:116). + const writerLine = `- [2026-06-01] "${HOOK_A}" (1234) - strategi`; + const posts = parseRecentPosts(["## Recent Posts", "", writerLine, ""].join("\n")); + assert.equal(posts.length, 1); + assert.equal(posts[0].hook, HOOK_A); + assert.equal(posts[0].charCount, 1234); + assert.equal(posts[0].topic, "strategi"); + }); + + test("stops at the next section (## Milestone Log is not parsed as an entry)", () => { + const text = stateWith(entry("2026-05-26", HOOK_A, 1400, "dømmekraft")); + const posts = parseRecentPosts(text); + assert.equal(posts.length, 1); + assert.ok(posts.every((p) => /^\d{4}-\d{2}-\d{2}$/.test(p.date))); + }); + + test("a doc with no ## Recent Posts section → []", () => { + assert.deepEqual(parseRecentPosts("# Some other doc\n\n## Notes\n\n- a line"), []); + }); + + test("$-bearing hook/topic round-trips verbatim (capture on read — no replace, no injection)", () => { + const dollarHook = "Hvorfor $100 i budsjettkutt endrer alt for $deg"; + const text = ["## Recent Posts", "", entry("2026-05-26", dollarHook, 800, "$penger$"), ""].join("\n"); + const posts = parseRecentPosts(text); + assert.equal(posts.length, 1); + assert.equal(posts[0].hook, dollarHook); + assert.equal(posts[0].topic, "$penger$"); + }); +}); + +describe("SB-S3e — reconcileRecentPosts (coverage tiers)", () => { + test("in-graph: created post matches a published record that HAS analytics", () => { + const r = rec(BODY_A); + const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); + const recentPosts = parseRecentPosts( + ["## Recent Posts", "", entry("2026-05-26", HOOK_A, 1400, "dømmekraft"), ""].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); + assert.equal(nodes.length, 1); + assert.equal(nodes[0].tier, "in-graph"); + assert.equal(nodes[0].contentId, r.id); + }); + + test("in-brain-only: created post matches a record but the record has NO analytics", () => { + const r = rec(BODY_B); + const graph = assemblePostGraph({ records: [r], analytics: [] }); // no analytics → match none + const recentPosts = parseRecentPosts( + ["## Recent Posts", "", entry("2026-05-26", HOOK_B, 900, "automatisering"), ""].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); + assert.equal(nodes[0].tier, "in-brain-only"); + assert.equal(nodes[0].contentId, r.id); + }); + + test("orphaned-in-state: created post matches NO published record (never ingested)", () => { + const r = rec(BODY_A); + const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); + const recentPosts = parseRecentPosts( + ["## Recent Posts", "", entry("2026-05-19", "En post som aldri ble ingestet i hjernen", 700, "annet"), ""].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); + assert.equal(nodes[0].tier, "orphaned-in-state"); + assert.equal(nodes[0].contentId, undefined); + }); + + test("truncated-hook (…) degradation: a '…'-suffixed preview still prefix-matches", () => { + const r = rec(BODY_A); + const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); + const recentPosts = parseRecentPosts( + ["## Recent Posts", "", entry("2026-05-26", "Jeg lærte noe viktig om dømmekraft i…", 1400, "dømmekraft"), ""].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); + assert.equal(nodes[0].tier, "in-graph"); + }); + + test("below-floor hook → orphaned (too short to discriminate, never a false match)", () => { + const r = rec(BODY_A); + const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); + const recentPosts = parseRecentPosts( + ["## Recent Posts", "", entry("2026-05-26", "Jeg lærte", 1400, "dømmekraft"), ""].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); + assert.equal(nodes[0].tier, "orphaned-in-state"); + }); + + test("summary counts created / in-graph / in-brain-only / orphaned", () => { + const rA = rec(BODY_A); + const rB = rec(BODY_B); + const graph = assemblePostGraph({ records: [rA, rB], analytics: [row(HOOK_A)] }); + const recentPosts = parseRecentPosts( + [ + "## Recent Posts", + "", + entry("2026-05-26", HOOK_A, 1400, "a"), // in-graph (rA has analytics) + entry("2026-05-26", HOOK_B, 900, "b"), // in-brain-only (rB no analytics) + entry("2026-05-10", "Helt ukjent post som ingen record dekker", 600, "c"), // orphaned + "", + ].join("\n"), + ); + const nodes = reconcileRecentPosts({ recentPosts, records: [rA, rB], graph }); + const sum = summarizeReconcile(nodes); + assert.deepEqual(sum, { created: 3, inGraph: 1, inBrainOnly: 1, orphaned: 1 }); + }); + + test("empty ## Recent Posts → empty report, no throw", () => { + const nodes = reconcileRecentPosts({ recentPosts: [], records: [rec(BODY_A)], graph: [] }); + assert.deepEqual(nodes, []); + assert.deepEqual(summarizeReconcile(nodes), { created: 0, inGraph: 0, inBrainOnly: 0, orphaned: 0 }); + }); +}); + +describe("SB-S3e — loadRecentPosts (state-file IO seam, STATE_FILE precedence)", () => { + test("reads the state file pointed at by STATE_FILE; unset/absent → []", () => { + const dir = mkdtempSync(join(tmpdir(), "ls-recon-")); + const file = join(dir, "linkedin-studio.local.md"); + writeFileSync(file, stateWith(entry("2026-05-26", HOOK_A, 1400, "dømmekraft"))); + const prev = process.env.STATE_FILE; + try { + process.env.STATE_FILE = file; + const posts = loadRecentPosts(); + assert.equal(posts.length, 1); + assert.equal(posts[0].hook, HOOK_A); + + // Absent file via STATE_FILE → [] (fresh-clone safe, no throw). + process.env.STATE_FILE = join(dir, "does-not-exist.md"); + assert.deepEqual(loadRecentPosts(), []); + } finally { + if (prev === undefined) delete process.env.STATE_FILE; + else process.env.STATE_FILE = prev; + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/brain/tests/scaffold.test.ts b/scripts/brain/tests/scaffold.test.ts new file mode 100644 index 0000000..d09bebe --- /dev/null +++ b/scripts/brain/tests/scaffold.test.ts @@ -0,0 +1,90 @@ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { initBrain } from "../src/scaffold.js"; +import { parseProfile } from "../src/profile.js"; + +const EXPECTED_DIRS = ["brain", "brain/journal", "ingest", "ingest/inbox", "ingest/published"]; +const EXPECTED_FILES = ["brain/profile.md", "brain/index.md", "brain/operations.md"]; + +describe("initBrain scaffold (SC1)", () => { + let root: string; + const prevEnv = process.env.LINKEDIN_STUDIO_DATA; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "brain-scaffold-")); + process.env.LINKEDIN_STUDIO_DATA = root; + }); + + afterEach(() => { + if (prevEnv === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = prevEnv; + rmSync(root, { recursive: true, force: true }); + }); + + test("creates the full brain/ + ingest/ tree at the runtime data-path", () => { + const res = initBrain(); + for (const d of EXPECTED_DIRS) { + assert.ok(existsSync(join(root, d)) && statSync(join(root, d)).isDirectory(), `dir ${d}`); + } + for (const f of EXPECTED_FILES) { + assert.ok(existsSync(join(root, f)), `file ${f}`); + } + // first run reports everything as created, nothing skipped + assert.equal(res.skipped.length, 0); + assert.ok(res.created.length >= EXPECTED_FILES.length); + }); + + test("profile.md is a parseable two-layer doc seeded from the template", () => { + initBrain(); + const doc = parseProfile(readFileSync(join(root, "brain/profile.md"), "utf8")); + assert.equal(doc.schemaVersion, 1); + assert.ok(doc.static.length > 0, "static layer seeded from the template field-set"); + }); + + test("index.md and operations.md carry their real seed anchors", () => { + initBrain(); + const index = readFileSync(join(root, "brain/index.md"), "utf8"); + const ops = readFileSync(join(root, "brain/operations.md"), "utf8"); + for (const trib of ["voice-samples", "specifics-bank", "trends", "analytics", "ingest"]) { + assert.match(index, new RegExp(trib), `index mentions ${trib}`); + } + assert.match(ops, /Who I am now/); + assert.match(ops, /## Plans/); + assert.match(ops, /## Ideas/); + }); + + test("operations.md ships a dated frozen-past-self anchor convention (SB-S3d)", () => { + initBrain(); + const ops = readFileSync(join(root, "brain/operations.md"), "utf8"); + // RED-bearing literal: the dated-anchor convention is the genuinely-new SB-S3d seed + // content (absent pre-S3d). The frozen-past-self guard is temporal — the anchor must + // carry a date the reader can compare against a profile fact's last_seen. + assert.match(ops, /_As of YYYY-MM-DD:_/, "anchor ships the dated convention"); + // Non-RED companion (already shipped pre-S3d, asserted only to lock the guard wording + // the reader keys on — NOT the failing literal). + assert.match(ops, /deprecates older inferences/); + }); + + test("a second invocation is a no-op — every target skipped, content unchanged", () => { + initBrain(); + const before = EXPECTED_FILES.map((f) => readFileSync(join(root, f), "utf8")); + const res2 = initBrain(); + assert.equal(res2.created.length, 0, "nothing re-created"); + assert.ok(res2.skipped.length >= EXPECTED_FILES.length, "everything skipped"); + const after = EXPECTED_FILES.map((f) => readFileSync(join(root, f), "utf8")); + assert.deepEqual(after, before, "no clobber of existing content"); + }); + + test("a second invocation never clobbers a user-edited file", () => { + initBrain(); + const opsPath = join(root, "brain/operations.md"); + const edited = readFileSync(opsPath, "utf8") + "\n## Plans\n- ship SB-S1\n"; + writeFileSync(opsPath, edited, "utf8"); + initBrain(); + assert.equal(readFileSync(opsPath, "utf8"), edited, "user edit preserved"); + }); +}); diff --git a/scripts/brain/tsconfig.json b/scripts/brain/tsconfig.json new file mode 100644 index 0000000..eaa1e8e --- /dev/null +++ b/scripts/brain/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "tests"] +} diff --git a/scripts/contract-gate/README.md b/scripts/contract-gate/README.md new file mode 100644 index 0000000..59c024d --- /dev/null +++ b/scripts/contract-gate/README.md @@ -0,0 +1,104 @@ +# Contract-gate — §B/§C1 mekanisk binding-gate + +Deterministisk gate som håndhever den **mekaniske** halvdelen av Maskinrommet-skrivekontrakten +(`maskinrommet/docs/skrivekontrakt.md`: §B de ti reglene, §C1 regelsjekk-gaten, §C2 prosa-håndverk) +**før et utkast leveres til operatøren** — så de samme mekaniske korreksjonene aldri når KTG to ganger. + +Dette er fix #1 (§C2 bindende + akkumulerende). **Slice 1** = denne gaten: den binder *utkast* til +reglene — bevisst _bindende_, ren string/regex-evaluering, ingen AI, ingen nettverk, så den ikke kan +hoppes over eller snakkes rundt, og kjører på millisekunder. **Slice 2** = `ratify` + §E: den binder +*reglene* (`rules.ts`) til den menneskelige sannhetskilden (kontrakten). Se [Akkumulering](#akkumulering-slice-2). + +## Hva gaten gjør (og ikke gjør) + +Tre-nivå-modellen fra diagnosen — denne gaten er **nivå 1**: + +| Nivå | Hva | Hvem | Hvor | +|------|-----|------|------| +| **1 — rent mekanisk** | §B/§C1-grep + tellinger | **denne gaten** (BLOCK/WARN) | her | +| 2 — AI-mekanisk | §C1 pkt 1/8/9 | `voice-scrubber` (fikser før handoff) | Step 4 | +| 3 — ekte dømmekraft | §C2 narrativ-arkitektur | `editorial-reviewer` (flagg til KTG) | Step 5.5 | + +**Klassifiseringsprinsipp:** BLOCK bare det gaten kan _bevise_ med ~null false positives. +Alt som krever dom → WARN (blokkerer ikke). Ekte dømmekraft / ekstern data er ikke her i det +hele tatt (blir hos nivå 2/3). + +### Regler (seed) + +| Regel | Ref | Severity | +|-------|-----|----------| +| Modell-stråmann (4 eksakte fraser) | §B-3 | **BLOCK** | +| Serie-tese «er ikke modellen» | §B-10 | **BLOCK** | +| `_understrek_`-emfase | §C1 | **BLOCK** | +| Metafor-ord (tak/lodd/reise/landskap) | §B-2 | WARN | +| Bar «modell» (alle treff) | §B-3 | WARN | +| «KI-en» som subjekt (>3×) | hygiene | WARN | +| PS-blokk | §B-9 | WARN | +| Tankestrek-tetthet (>1/50 ord) | §C2 | WARN | +| Versal-tic (emfase-ord, ikke akronymer) | §C2 | WARN | +| AI-tells (navngitte fraser) | §B-8 | WARN | + +Reglene er **data** (`src/rules.ts`), ikke hardkodet logikk — det er det som gjør +akkumuleringen (slice 2) ren: en ny regel = én rad i `skrivekontrakt.md` + én post her. + +### Bevisst utsatt (dokumenterte gap, ikke stille utelatelser) + +- **§C2 «postulerte tall»** (tall uten hedge/kilde) — krever hedge-nærhet-heuristikk med + ikke-triviell false-positive-risiko. +- **§C2 «ordrette gjentakelser»** (n-gram gjentatt >2×) — krever n-gram-analyse; kontraktens + navngitte fraser er utgave-spesifikke. + +Begge hører hjemme i en senere slice når heuristikken kan gjøres tillitsverdig. + +## Kjøre + +```bash +cd scripts/contract-gate +npm install # engangs (tsx + typescript) +node --import tsx src/cli.ts <utkast.md> # kvittering + exit 0/1 +node --import tsx src/cli.ts <utkast.md> --json # maskinlesbart +npm run ratify # slice 2: rules.ts ↔ §E-manifest i sync? +npm test # 33 tester (gate + ratify) +``` + +Exit: `0` = passert (ingen BLOCK, handoff tillatt), `1` = blokkert, `2` = bruksfeil. +WARN feiler aldri gaten — de surfaces for KTGs dom. + +## Validering (6 ekte utgaver, juni 2026) + +- **0 BLOCK** på publiserte Del 2–5 → false-blokkerer ikke ekte arbeid. +- **Del 6 (WIP):** fanget et ekte Regel-10-brudd («Den knappe ressursen er ikke modellen» i + brødteksten) automatisk — slog-drepingen i praksis. +- **Del 1:** blokkerer på samme frase, men ble publisert **før** Regel 10 ble kodifisert (01.06) — + historisk artefakt, ikke gate-feil. +- Versal-tic-redesignet (denylist av emfase-ord, ikke allowlist av akronymer) fjernet + NAV/DFØ/NPM-false-positivene som ekte data avslørte. + +## Akkumulering (slice 2) + +Slice 1 binder *utkast* til reglene. Slice 2 binder *reglene* til kontrakten — så «rettelser fester +seg» blir mekanisk, ikke håpet-på. Diagnosens gap 2 («rettelser fester seg ikke») lukkes ved at hver +korreksjon KTG gjør får et varig, håndhevet hjem. + +**Ruta** (full prosedyre i `maskinrommet/docs/skrivekontrakt.md` §E): + +1. **Fang** korreksjonen i utgave-staten (`articles.NN.foldIns[]`) — ingenting mistes. +2. **Klassifiser:** mekanisk (eksakt frase / ord / markdown / telling) → `rules.ts`-regel + §B/§C1/§C2-rad; + dømmekraft → kun §C2-boks (blir hos `editorial-reviewer`, ingen gate-regel). +3. **JA-promoter:** KTG svarer JA/NEI. NEI → `rejected` (beholdes). JA → promotér. +4. **Promotér atomisk:** `rules.ts`-entry + kontrakt-rad + §E-manifest-rad (id·ref·severity·proveniens) + + merk fold-in `promoted`. +5. **Ratify:** `npm run ratify` MÅ være grønn før promoteringen teller. + +**Bindingen** (`src/ratify.ts`): importerer `RULES` direkte (ingen skjør tekst-parsing) og krever en +**bijeksjon** mot gate-bindings-manifestet i §E — hver regel ↔ nøyaktig én manifest-rad, med `ref` + +`severity` identisk, og hver `§`-anker en ekte seksjon. En regel i `rules.ts` uten manifest-rad (gaten +håndhever noe udokumentert), eller en manifest-rad uten regel (en «regel» ingen håndhever), feiler: + +``` +$ npm run ratify +✓ I sync — 10 regler bundet 1:1 til §E-manifestet (ref + severity stemmer). +``` + +Kontrakt-sti: argument > `MASKINROMMET_CONTRACT` > default (relativt til modulen). Mangler kontrakten +(fersk klon uten maskinrommet) → exit 2 med klar beskjed; den ekte-kontrakt-testen skippes da. diff --git a/scripts/contract-gate/package-lock.json b/scripts/contract-gate/package-lock.json new file mode 100644 index 0000000..b02ea9d --- /dev/null +++ b/scripts/contract-gate/package-lock.json @@ -0,0 +1,566 @@ +{ + "name": "linkedin-contract-gate", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkedin-contract-gate", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/scripts/contract-gate/package.json b/scripts/contract-gate/package.json new file mode 100644 index 0000000..6a20af9 --- /dev/null +++ b/scripts/contract-gate/package.json @@ -0,0 +1,17 @@ +{ + "name": "linkedin-contract-gate", + "version": "0.1.0", + "type": "module", + "description": "Deterministic §B/§C1 mechanical binding-gate for the Maskinrommet writing contract — runs BEFORE operator handoff so mechanical corrections never reach KTG twice", + "scripts": { + "build": "tsc", + "test": "node --import tsx --test tests/*.test.ts", + "start": "node --import tsx src/cli.ts", + "ratify": "node --import tsx src/cli.ts --ratify" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/scripts/contract-gate/src/cli.ts b/scripts/contract-gate/src/cli.ts new file mode 100644 index 0000000..cd31063 --- /dev/null +++ b/scripts/contract-gate/src/cli.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/** + * CLI for the §B/§C1 mechanical binding-gate. + * + * node --import tsx src/cli.ts <utkast.md> [--json] + * + * Exit code: 0 if the gate passes (no BLOCK violations — handoff permitted), + * 1 if BLOCKED, 2 on usage error. WARN violations never fail the gate; they are + * surfaced for KTG's judgment. The text output is the "kvittering" the writing + * contract asks for when a draft is delivered. + */ + +import { runGateFile } from "./gate.js"; +import { ratifyFile, defaultContractPath } from "./ratify.js"; +import type { RatifyResult } from "./ratify.js"; +import type { GateResult, Violation } from "./types.js"; + +function printViolation(v: Violation): void { + console.log(` [${v.ref}] ${v.ruleId}`); + if (v.observed !== undefined) { + const t = v.threshold !== undefined ? ` (terskel: ${v.threshold})` : ""; + console.log(` observert: ${v.observed}${t}`); + } + console.log(` → ${v.message}`); + const shown = v.matches.slice(0, 5); + for (const m of shown) console.log(` · L${m.line}: ${m.excerpt}`); + if (v.matches.length > shown.length) { + console.log(` · … +${v.matches.length - shown.length} til`); + } +} + +function report(r: GateResult): void { + console.log("§B/§C1 regelsjekk-gate"); + console.log(`Utkast: ${r.draftPath ?? "(stdin)"} · ${r.wordCount} ord\n`); + if (r.blocks.length) { + console.log(`BLOCK (${r.blocks.length}) — må fikses før handoff:`); + r.blocks.forEach(printViolation); + console.log(""); + } + if (r.warns.length) { + console.log(`WARN (${r.warns.length}) — krever din dom (blokkerer ikke):`); + r.warns.forEach(printViolation); + console.log(""); + } + if (r.passed) { + console.log(`✓ Gate passert — 0 block, ${r.warns.length} warn til vurdering.`); + } else { + console.log( + `✗ BLOKKERT — ${r.blocks.length} block, ${r.warns.length} warn. Fiks block-ene og kjør på nytt.`, + ); + } +} + +function reportRatify(r: RatifyResult): void { + console.log("Gate-bindings-ratify (rules.ts ↔ §E-manifest)"); + console.log( + `Kontrakt: ${r.contractPath ?? "(ukjent)"} · ${r.ruleCount} regler / ${r.manifestCount} manifest-rader\n`, + ); + if (r.issues.length) { + console.log(`DRIFT (${r.issues.length}):`); + for (const i of r.issues) console.log(` [${i.kind}] ${i.id}\n → ${i.detail}`); + console.log(""); + console.log("✗ Ute av sync — rules.ts og §E-manifestet stemmer ikke. Rett før promotering teller."); + } else { + console.log(`✓ I sync — ${r.ruleCount} regler bundet 1:1 til §E-manifestet (ref + severity stemmer).`); + } +} + +function runRatify(args: string[], jsonOut: boolean): never { + const argPath = args.find((a) => !a.startsWith("--")); + const path = argPath ?? process.env.MASKINROMMET_CONTRACT ?? defaultContractPath(); + const result = ratifyFile(path); + if (jsonOut) console.log(JSON.stringify(result, null, 2)); + else reportRatify(result); + process.exit(result.ok ? 0 : 1); +} + +function main(): void { + const args = process.argv.slice(2); + const jsonOut = args.includes("--json"); + if (args.includes("--ratify")) { + runRatify(args, jsonOut); + } + const path = args.find((a) => !a.startsWith("--")); + if (!path) { + console.error("Bruk: contract-gate <utkast.md> [--json] | contract-gate --ratify [kontrakt.md] [--json]"); + process.exit(2); + } + const result = runGateFile(path); + if (jsonOut) console.log(JSON.stringify(result, null, 2)); + else report(result); + process.exit(result.passed ? 0 : 1); +} + +main(); diff --git a/scripts/contract-gate/src/gate.ts b/scripts/contract-gate/src/gate.ts new file mode 100644 index 0000000..1c7f241 --- /dev/null +++ b/scripts/contract-gate/src/gate.ts @@ -0,0 +1,207 @@ +/** + * Deterministic engine for the §B/§C1 mechanical binding-gate. + * + * Pure string/regex evaluation of a draft against a structured ruleset. No AI, + * no network, no external state — so it is genuinely BINDING: it cannot be + * skipped or talked around, and it runs in milliseconds. Mechanical violations + * are caught here BEFORE the draft reaches KTG, so the same correction never + * reaches him twice. + * + * Matching runs on a MASKED copy of the draft (frontmatter + code stripped to + * spaces, line positions preserved) so identifiers/code never false-positive. + */ + +import { readFileSync } from "node:fs"; +import type { GateResult, Match, Rule, Violation } from "./types.js"; +import { RULES } from "./rules.js"; + +export { RULES } from "./rules.js"; + +/** + * Replace YAML frontmatter, fenced code blocks, and inline code spans with + * spaces — preserving newlines, length, and therefore every offset/line number. + */ +export function mask(text: string): string { + const chars = text.split(""); + const blank = (start: number, end: number) => { + for (let i = start; i < end && i < chars.length; i++) { + if (chars[i] !== "\n") chars[i] = " "; + } + }; + + // YAML frontmatter at the very top: ---\n … \n--- + if (text.startsWith("---\n")) { + const close = text.indexOf("\n---", 3); + if (close !== -1) { + const lineEnd = text.indexOf("\n", close + 1); + blank(0, lineEnd === -1 ? text.length : lineEnd); + } + } + + let m: RegExpExecArray | null; + const fence = /```[\s\S]*?```/g; + while ((m = fence.exec(text)) !== null) blank(m.index, m.index + m[0].length); + + const inline = /`[^`\n]*`/g; + while ((m = inline.exec(text)) !== null) blank(m.index, m.index + m[0].length); + + return chars.join(""); +} + +function lineStarts(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") starts.push(i + 1); + } + return starts; +} + +/** 1-based line of a character index. */ +function lineOf(starts: number[], index: number): number { + let lo = 0; + let hi = starts.length - 1; + let ans = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (starts[mid] <= index) { + ans = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return ans + 1; +} + +function excerptAt(original: string, index: number, len: number): string { + const start = Math.max(0, index - 20); + const end = Math.min(original.length, index + len + 20); + return original.slice(start, end).replace(/\s+/g, " ").trim(); +} + +function countWords(masked: string): number { + const m = masked.match(/[A-Za-zÆØÅæøå0-9]+/g); + return m ? m.length : 0; +} + +interface Ctx { + masked: string; + original: string; + starts: number[]; +} + +function literalMatches(rule: Rule, ctx: Ctx): Match[] { + const hay = ctx.masked.toLowerCase(); + const out: Match[] = []; + for (const pat of rule.patterns) { + const needle = pat.toLowerCase(); + if (!needle) continue; + let from = 0; + let idx: number; + while ((idx = hay.indexOf(needle, from)) !== -1) { + out.push({ + line: lineOf(ctx.starts, idx), + excerpt: excerptAt(ctx.original, idx, needle.length), + }); + from = idx + needle.length; + } + } + return out; +} + +/** Run a rule's regex patterns; returns ALL matches (after the allow-filter). */ +function regexMatches(rule: Rule, ctx: Ctx, cap = Infinity): { matches: Match[]; count: number } { + const baseFlags = rule.flags ?? "gi"; + const flags = baseFlags.includes("g") ? baseFlags : baseFlags + "g"; + const matches: Match[] = []; + let count = 0; + for (const pat of rule.patterns) { + const re = new RegExp(pat, flags); + let m: RegExpExecArray | null; + while ((m = re.exec(ctx.masked)) !== null) { + if (m[0].length === 0) { + re.lastIndex++; + continue; + } + const trimmed = m[0].trim(); + count++; + if (matches.length < cap) { + const lead = m[0].length - m[0].trimStart().length; + const at = m.index + lead; + matches.push({ + line: lineOf(ctx.starts, at), + excerpt: excerptAt(ctx.original, at, trimmed.length), + }); + } + } + } + return { matches, count }; +} + +function evalRule(rule: Rule, ctx: Ctx, wordCount: number): Violation | null { + let matches: Match[] = []; + let observed: number | undefined; + let threshold: number | undefined; + + switch (rule.kind) { + case "literal": + matches = literalMatches(rule, ctx); + break; + case "regex": + matches = regexMatches(rule, ctx).matches; + break; + case "count": { + const { matches: ms, count } = regexMatches(rule, ctx, 8); + observed = count; + threshold = rule.threshold ?? 0; + if (count > threshold) matches = ms; + break; + } + case "density": { + const { matches: ms, count } = regexMatches(rule, ctx, 8); + const maxAllowed = Math.floor((wordCount / (rule.per ?? 50)) * (rule.threshold ?? 1)); + observed = count; + threshold = maxAllowed; + if (count > maxAllowed) matches = ms; + break; + } + } + + if (matches.length === 0) return null; + return { + ruleId: rule.id, + ref: rule.ref, + severity: rule.severity, + message: rule.message, + matches, + observed, + threshold, + }; +} + +/** Evaluate raw draft text against a ruleset. */ +export function evaluate( + text: string, + rules: Rule[] = RULES, + draftPath: string | null = null, +): GateResult { + const masked = mask(text); + const ctx: Ctx = { masked, original: text, starts: lineStarts(text) }; + const wordCount = countWords(masked); + + const blocks: Violation[] = []; + const warns: Violation[] = []; + for (const rule of rules) { + const v = evalRule(rule, ctx, wordCount); + if (!v) continue; + (v.severity === "block" ? blocks : warns).push(v); + } + + return { draftPath, wordCount, blocks, warns, passed: blocks.length === 0 }; +} + +/** Read a draft file from disk and evaluate it. */ +export function runGateFile(path: string, rules: Rule[] = RULES): GateResult { + const text = readFileSync(path, "utf8"); + return evaluate(text, rules, path); +} diff --git a/scripts/contract-gate/src/ratify.ts b/scripts/contract-gate/src/ratify.ts new file mode 100644 index 0000000..c56cbb3 --- /dev/null +++ b/scripts/contract-gate/src/ratify.ts @@ -0,0 +1,229 @@ +/** + * Ratify — the binding half of accumulation (slice 2 of fix #1). + * + * Slice 1 binds *drafts* to the rules. Ratify binds the *rules* (`rules.ts`) to + * the human source of truth (`maskinrommet/docs/skrivekontrakt.md`). Without it, + * accumulation rots silently: add a rule to `rules.ts` and forget the contract + * row (the gate enforces something undocumented), or codify a contract rule and + * forget the gate entry (a "rule" nobody enforces). Either way the two drift. + * + * The contract carries a machine-readable "gate-bindings-manifest" — one row per + * mechanical rule (id · ref · severity · provenance) — fenced between + * `<!-- ratify:begin -->` and `<!-- ratify:end -->`. Ratify asserts a BIJECTION + * between that manifest and `RULES`, matching ref + severity exactly, and that + * every cited §-anchor resolves to a real section. It imports RULES directly + * (not a text-parse of rules.ts) so the gate's own loader is the source. + * + * Pure + deterministic: no AI, no network. Exit 0 = in sync, 1 = drift. + */ + +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import type { Rule, Severity } from "./types.js"; +import { RULES } from "./rules.js"; + +export interface ManifestRow { + id: string; + ref: string; + severity: string; + provenance: string; +} + +export type RatifyIssueKind = + | "missing-from-manifest" // rule in RULES, no manifest row + | "extra-in-manifest" // manifest row, no rule in RULES + | "ref-mismatch" // shared id, ref differs + | "severity-mismatch" // shared id, severity differs + | "dangling-ref"; // manifest/rule ref cites a §-anchor the contract lacks + +export interface RatifyIssue { + kind: RatifyIssueKind; + id: string; + detail: string; +} + +export interface RatifyResult { + contractPath: string | null; + ruleCount: number; + manifestCount: number; + issues: RatifyIssue[]; + ok: boolean; +} + +/** Default contract path, resolved relative to THIS module (cwd-independent). */ +export function defaultContractPath(): string { + const here = dirname(fileURLToPath(import.meta.url)); + // src → contract-gate → scripts → linkedin-studio → ktg-plugin-marketplace → repos + return resolve(here, "../../../../../maskinrommet/docs/skrivekontrakt.md"); +} + +/** + * Extract the manifest rows fenced by `<!-- ratify:begin/end -->`. + * Returns null if the fence is missing (a hard error — the contract must carry + * the manifest for the binding to exist at all). + */ +export function parseManifest(contractText: string): ManifestRow[] | null { + const begin = contractText.indexOf("<!-- ratify:begin -->"); + const end = contractText.indexOf("<!-- ratify:end -->"); + if (begin === -1 || end === -1 || end < begin) return null; + + const block = contractText.slice(begin, end); + const rows: ManifestRow[] = []; + for (const raw of block.split("\n")) { + const line = raw.trim(); + if (!line.startsWith("|")) continue; + // cells between the outer pipes + const cells = line.split("|").slice(1, -1).map((c) => c.trim()); + if (cells.length < 4) continue; + const [id, ref, severity] = cells; + // skip the header row and the |---|---| separator + if (id === "rule-id" || /^:?-{2,}:?$/.test(id)) continue; + if (!id) continue; + rows.push({ id, ref, severity, provenance: cells[3] }); + } + return rows; +} + +/** Section anchors the contract actually defines, for dangling-ref checks. */ +interface Anchors { + hasC1: boolean; + hasC2: boolean; + hasB: boolean; + bRowCount: number; +} + +function contractAnchors(contractText: string): Anchors { + const hasC1 = /^##\s+C1\./m.test(contractText); + const hasC2 = /^##\s+C2\./m.test(contractText); + const hasB = /^##\s+B\./m.test(contractText); + // §B rule rows look like: | 7 | **Regel** | … | + let bRowCount = 0; + for (const line of contractText.split("\n")) { + if (/^\|\s*\d+\s*\|/.test(line)) bRowCount++; + } + return { hasC1, hasC2, hasB, bRowCount }; +} + +/** Validate that every §-anchor in a ref resolves to a real contract section. */ +function danglingRefIssues(id: string, ref: string, anchors: Anchors): RatifyIssue[] { + const out: RatifyIssue[] = []; + // §B-3, §C1, §C2 — ignore non-§ tokens like "hygiene" / "★" + const tokens = ref.match(/§[A-Za-z]+\d*(?:-\d+)?/g) ?? []; + for (const tok of tokens) { + const bRule = tok.match(/^§B-(\d+)$/); + if (tok === "§C1") { + if (!anchors.hasC1) out.push({ kind: "dangling-ref", id, detail: `${ref}: §C1 mangler i kontrakten` }); + } else if (tok === "§C2") { + if (!anchors.hasC2) out.push({ kind: "dangling-ref", id, detail: `${ref}: §C2 mangler i kontrakten` }); + } else if (bRule) { + const n = Number(bRule[1]); + if (!anchors.hasB) out.push({ kind: "dangling-ref", id, detail: `${ref}: §B mangler i kontrakten` }); + else if (n > anchors.bRowCount) + out.push({ kind: "dangling-ref", id, detail: `${ref}: §B-${n} finnes ikke (§B har ${anchors.bRowCount} regler)` }); + } else if (tok === "§B" && !anchors.hasB) { + out.push({ kind: "dangling-ref", id, detail: `${ref}: §B mangler i kontrakten` }); + } + } + return out; +} + +/** Core: compare RULES against a parsed manifest + contract anchors. */ +export function ratify(contractText: string, rules: Rule[] = RULES): RatifyResult { + const issues: RatifyIssue[] = []; + const manifest = parseManifest(contractText); + + if (manifest === null) { + return { + contractPath: null, + ruleCount: rules.length, + manifestCount: 0, + issues: [ + { + kind: "missing-from-manifest", + id: "(manifest)", + detail: + "Fant ikke gate-bindings-manifestet (<!-- ratify:begin/end -->) i §E. Kontrakten må bære manifestet for at bindingen skal eksistere.", + }, + ], + ok: false, + }; + } + + const anchors = contractAnchors(contractText); + const ruleById = new Map<string, Rule>(rules.map((r) => [r.id, r])); + const manById = new Map<string, ManifestRow>(); + for (const row of manifest) { + if (manById.has(row.id)) { + issues.push({ kind: "extra-in-manifest", id: row.id, detail: "duplisert manifest-rad" }); + continue; + } + manById.set(row.id, row); + } + + // RULES → manifest (every enforced rule must be documented) + for (const rule of rules) { + const row = manById.get(rule.id); + if (!row) { + issues.push({ + kind: "missing-from-manifest", + id: rule.id, + detail: `rules.ts håndhever «${rule.id}» (${rule.ref}, ${rule.severity}) uten en §E-manifest-rad`, + }); + continue; + } + if (row.ref !== rule.ref) { + issues.push({ kind: "ref-mismatch", id: rule.id, detail: `manifest «${row.ref}» ≠ rules.ts «${rule.ref}»` }); + } + if (row.severity !== (rule.severity as Severity)) { + issues.push({ + kind: "severity-mismatch", + id: rule.id, + detail: `manifest «${row.severity}» ≠ rules.ts «${rule.severity}»`, + }); + } + issues.push(...danglingRefIssues(rule.id, rule.ref, anchors)); + } + + // manifest → RULES (every documented rule must be enforced) + for (const row of manifest) { + if (!ruleById.has(row.id)) { + issues.push({ + kind: "extra-in-manifest", + id: row.id, + detail: `§E dokumenterer «${row.id}» men rules.ts håndhever den ikke (regel fjernet uten å rydde manifestet?)`, + }); + } + } + + return { + contractPath: null, + ruleCount: rules.length, + manifestCount: manifest.length, + issues, + ok: issues.length === 0, + }; +} + +/** Read a contract file from disk and ratify it against RULES. */ +export function ratifyFile(path: string, rules: Rule[] = RULES): RatifyResult { + if (!existsSync(path)) { + return { + contractPath: path, + ruleCount: rules.length, + manifestCount: 0, + issues: [ + { + kind: "missing-from-manifest", + id: "(contract)", + detail: `Fant ikke kontrakten: ${path}. Sett MASKINROMMET_CONTRACT eller gi stien som argument.`, + }, + ], + ok: false, + }; + } + const text = readFileSync(path, "utf8"); + const r = ratify(text, rules); + r.contractPath = path; + return r; +} diff --git a/scripts/contract-gate/src/rules.ts b/scripts/contract-gate/src/rules.ts new file mode 100644 index 0000000..7754df3 --- /dev/null +++ b/scripts/contract-gate/src/rules.ts @@ -0,0 +1,141 @@ +/** + * Seed ruleset for the §B/§C1 mechanical binding-gate. + * + * Each rule transcribes a mechanically-checkable item from the Maskinrommet + * writing contract (maskinrommet/docs/skrivekontrakt.md). The classification + * principle (slice 1): BLOCK only what the gate can PROVE with ~zero false + * positives (exact forbidden phrases, markdown syntax); WARN on grep-flags that + * still need human/AI judgment (a word that *might* be a metaphor, a bare + * "modell" that *might* be neutral). Judgment-only / external-data rules + * (research/ traceability, term definitions, narrative architecture, the full + * AI-tell list) are intentionally NOT here — they stay with voice-scrubber + * (tier 2) and editorial-reviewer (tier 3). + * + * Deferred to a later slice (documented gaps, not silent omissions): + * - §C2 "postulerte tall" (numbers without hedge/source) — needs a + * hedge-proximity heuristic with non-trivial false-positive risk. + * - §C2 "ordrette gjentakelser" (any n-gram repeated >2×) — needs n-gram + * analysis; the contract's named phrases are edition-specific. + */ + +import type { Rule } from "./types.js"; + +export const RULES: Rule[] = [ + // ── BLOCK: provable, ~zero false positives ───────────────────────────── + { + id: "B3-modell-referansepunkt", + ref: "§B-3 / §C1", + severity: "block", + kind: "literal", + patterns: [ + "det er ikke modellen som", + "ikke en bedre modell", + "modellen er god nok", + "uten å bytte modell", + ], + message: + "Modell-stråmann (regel 3, KTGs mest gjentatte korreksjon): ingen påstand om at modellen er problemet/for svak. Skriv om til vår evne, vilje og kompetanse til å hente ut potensialet.", + }, + { + id: "B10-serie-tese", + ref: "§B-10 / §C1", + severity: "block", + kind: "literal", + patterns: ["er ikke modellen"], + message: + "Serie-tesen / meta-tese (regel 10, korollar til regel 3): «… er ikke modellen» er den tause linsen, aldri en setning vi leverer. Positiv konkret landing («menneskene … er den knappe ressursen») er OK — den abstrakte meta-tesen er ikke.", + }, + { + id: "understrek-emfase", + ref: "§C1 / hygiene", + severity: "block", + kind: "regex", + // Markdown underscore-emphasis: opening _ at a boundary, closing _ before + // whitespace/punctuation. snake_case (underscores between word chars) is + // NOT matched, so identifiers in prose don't false-positive. + patterns: ["(^|[\\s(])_[^_\\n]+_(?=[\\s).,!?:;]|$)"], + message: + "Emfase med _understrek_ rendrer rått (hygiene). Bruk **fet** (eller *kursiv* med stjerne).", + }, + + // ── WARN: grep-flag, needs human/AI judgment ─────────────────────────── + { + id: "B2-metafor", + ref: "§B-2", + severity: "warn", + kind: "regex", + patterns: ["\\b(tak|lodd|reise|landskap)\\b"], + message: + "Mulig metafor som bærer argumentet (regel 2). Bekreft at bruken er konkret/nøytral, ikke utvidet bildespråk («et usynlig tak» → «KI-en når ikke dataene»).", + }, + { + id: "B3-modell-bar", + ref: "§B-3", + severity: "warn", + kind: "count", + threshold: 0, + patterns: ["\\bmodell"], + message: + "«modell» nevnt — sjekk at HVERT treff er nøytral faktabruk («en KI-modell er trent på …»), ikke modellen brukt som referansepunkt/stråmann (regel 3).", + }, + { + id: "KI-en-subjekt", + ref: "hygiene ★", + severity: "warn", + kind: "count", + threshold: 3, + patterns: ["\\bKI-en\\b"], + message: + "«KI-en» gjentatt som handlende subjekt er dårlig norsk (språk-hygiene). Varier: «KI», «verktøyet», eller omskriv («data KI har tilgang til»).", + }, + { + id: "B9-PS", + ref: "§B-9 / §C1", + severity: "warn", + kind: "regex", + patterns: ["(^|\\n)\\s*P\\.?\\s?S\\.?\\b"], + message: + "PS/etter-closing-blokk (regel 9). Sjekk at den ikke åpner nytt begrep/rammeverk etter handlingskallet — leseren skal lukke på ett konkret handlingsvalg.", + }, + { + id: "C2-tankestrek-tetthet", + ref: "§C2", + severity: "warn", + kind: "density", + threshold: 1, + per: 50, + patterns: ["\\u2014"], // em dash — + message: + "Tankestrek-tetthet over ~1 per 50 ord (§C2). Bytt halvparten med komma eller punktum; innskudd-formelen «X — Y — Z» bør være sjelden.", + }, + { + id: "C2-versal-tic", + ref: "§C2", + severity: "warn", + kind: "regex", + // Case-sensitive, Unicode-aware (handles MÅ/NÅ/SÅ). DENYLIST, not allowlist: + // flag only UPPERCASE forms of known Norwegian emphasis words — NOT every + // 2+-uppercase run. Acronyms (NPM, DFØ, NAV) are not emphasis words, so they + // never flag. (Real-data finding on Del 4/5: an acronym allowlist can't keep + // up; the emphasis-word set is small and stable.) + flags: "gu", + patterns: [ + "(?<!\\p{L})(OG|IKKE|IKKJE|MEN|MÅ|SKAL|ALDRI|ALLTID|ALLE|INGEN|BARE|KUN|NÅ|SÅ|HELE|ENHVER)(?!\\p{L})", + ], + message: + "Versal-tic (§C2): emfase-ord i VERSALER midt i prosa («OG», «IKKE», «MEN») bryter kronikk-stilen. Bruk *kursiv* eller omformuler.", + }, + { + id: "B8-ai-tells", + ref: "§B-8", + severity: "warn", + kind: "regex", + patterns: [ + "la meg være ærlig", + "i en verden der", + "det handler ikke om .{1,40}? det handler om", + ], + message: + "AI-tell (regel 8). Fjern/omformuler. Full liste: auto-memory `no-ai-tell-phrases`.", + }, +]; diff --git a/scripts/contract-gate/src/types.ts b/scripts/contract-gate/src/types.ts new file mode 100644 index 0000000..c4ac303 --- /dev/null +++ b/scripts/contract-gate/src/types.ts @@ -0,0 +1,64 @@ +/** + * Types for the deterministic §B/§C1 mechanical binding-gate. + * + * The gate consumes a *structured* ruleset (data, not hardcoded logic) so that + * the accumulation half (slice 2) can add a rule by appending one entry here + + * one row to the live skrivekontrakt.md — the gate then enforces it next run. + * + * Source of truth for the seed rules: maskinrommet/docs/skrivekontrakt.md + * (§B De ti reglene, §C1 Regelsjekk-gate, §C2 prosa-håndverk). + */ + +export type Severity = "block" | "warn"; + +export type RuleKind = + | "literal" // case-insensitive substring(s) — robust for æøå phrases + | "regex" // forbidden regex pattern + | "count" // count regex occurrences; flag when total > threshold (default 0) + | "density"; // count occurrences; flag when rate exceeds threshold per `per` words + +export interface Rule { + /** Stable id, e.g. "B3-modell-referansepunkt". */ + id: string; + /** Contract reference for the report, e.g. "§B-3 / §C1". */ + ref: string; + severity: Severity; + kind: RuleKind; + /** Literal substrings (kind=literal) or regex sources (regex/count/density). */ + patterns: string[]; + /** Operator-facing direction: what the violation is + what to do. */ + message: string; + /** count: max allowed before flagging (default 0). density: max per `per` words. */ + threshold?: number; + /** density window size in words (default 50). */ + per?: number; + /** Regex flags for regex/count/density (default "gi"). */ + flags?: string; +} + +export interface Match { + /** 1-based line in the ORIGINAL draft. */ + line: number; + /** Short single-line excerpt around the match (from the original text). */ + excerpt: string; +} + +export interface Violation { + ruleId: string; + ref: string; + severity: Severity; + message: string; + matches: Match[]; + /** Observed count/rate for count|density rules. */ + observed?: number; + threshold?: number; +} + +export interface GateResult { + draftPath: string | null; + wordCount: number; + blocks: Violation[]; + warns: Violation[]; + /** true iff there are no BLOCK violations — i.e. handoff is permitted. */ + passed: boolean; +} diff --git a/scripts/contract-gate/tests/gate.test.ts b/scripts/contract-gate/tests/gate.test.ts new file mode 100644 index 0000000..286a208 --- /dev/null +++ b/scripts/contract-gate/tests/gate.test.ts @@ -0,0 +1,178 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { evaluate, runGateFile, mask } from "../src/gate.js"; +import { RULES } from "../src/rules.js"; + +const ids = (vs: { ruleId: string }[]) => vs.map((v) => v.ruleId); + +describe("contract-gate engine", () => { + describe("clean draft", () => { + test("a contract-compliant snippet passes with no blocks and no warns", () => { + const text = + "Jeg bruker KI hver dag og ser hvordan saksbehandlere henter ut potensialet.\n" + + "En kontraktsjurist sparte to dager. Verktøyet leverer det vi ber om, og " + + "ledelsen tar valget."; + const r = evaluate(text, RULES); + assert.equal(r.passed, true); + assert.equal(r.blocks.length, 0, `unexpected blocks: ${ids(r.blocks)}`); + assert.equal(r.warns.length, 0, `unexpected warns: ${ids(r.warns)}`); + }); + }); + + describe("BLOCK: modell-stråmann (§B-3)", () => { + test("an exact forbidden phrase blocks handoff", () => { + const text = "Poenget er at det er ikke modellen som mangler, men kompetansen."; + const r = evaluate(text, RULES); + assert.equal(r.passed, false); + assert.ok(ids(r.blocks).includes("B3-modell-referansepunkt")); + }); + + test("'modellen er god nok' blocks", () => { + const r = evaluate("Mange tror modellen er god nok allerede.", RULES); + assert.equal(r.passed, false); + assert.ok(ids(r.blocks).includes("B3-modell-referansepunkt")); + }); + }); + + describe("BLOCK: serie-tese (§B-10)", () => { + test("the meta-thesis 'er ikke modellen' blocks", () => { + const text = "Tesen er enkel: den knappe ressursen er ikke modellen."; + const r = evaluate(text, RULES); + assert.equal(r.passed, false); + assert.ok(ids(r.blocks).includes("B10-serie-tese")); + }); + + test("REGRESSION: positive concrete landing 'er den knappe ressursen' does NOT block", () => { + const text = + "Menneskene i organisasjonen er den knappe ressursen, ikke verktøyet."; + const r = evaluate(text, RULES); + assert.equal(r.passed, true, `unexpected blocks: ${ids(r.blocks)}`); + assert.ok(!ids(r.blocks).includes("B10-serie-tese")); + }); + }); + + describe("BLOCK: _understrek_ emphasis (§C1)", () => { + test("underscore emphasis blocks", () => { + const r = evaluate("Dette er _viktig_ å forstå.", RULES); + assert.equal(r.passed, false); + assert.ok(ids(r.blocks).includes("understrek-emfase")); + }); + + test("snake_case identifiers in prose do NOT block", () => { + const r = evaluate("Variabelen LINKEDIN_STUDIO_DATA styrer roten.", RULES); + assert.ok(!ids(r.blocks).includes("understrek-emfase")); + }); + }); + + describe("code masking", () => { + test("a forbidden phrase inside a fenced code block is NOT flagged", () => { + const text = + "Her er konfigen:\n```\nmodellen er god nok\n```\nEllers ren prosa."; + const r = evaluate(text, RULES); + assert.equal(r.passed, true, `unexpected blocks: ${ids(r.blocks)}`); + }); + + test("a forbidden phrase inside an inline code span is NOT flagged", () => { + const r = evaluate("Flagget `er ikke modellen` i koden.", RULES); + assert.ok(!ids(r.blocks).includes("B10-serie-tese")); + }); + + test("mask preserves line count and positions", () => { + const text = "linje1\n```\nkode\n```\nlinje5"; + const masked = mask(text); + assert.equal(masked.split("\n").length, text.split("\n").length); + assert.equal(masked.length, text.length); + }); + }); + + describe("WARN: non-blocking flags", () => { + test("a metaphor word warns but does not block", () => { + const text = "Det er et usynlig tak over hele organisasjonen."; + const r = evaluate(text, RULES); + assert.equal(r.passed, true); + assert.ok(ids(r.warns).includes("B2-metafor")); + assert.ok(!ids(r.blocks).includes("B2-metafor")); + }); + + test("high em-dash density warns", () => { + const text = "Ord — ord — ord — ord — ord."; + const r = evaluate(text, RULES); + assert.ok(ids(r.warns).includes("C2-tankestrek-tetthet")); + }); + + test("uppercase emphasis word warns", () => { + const dirty = evaluate("Dette er IKKE bra, selv om KI og IT er OK.", RULES); + assert.ok(ids(dirty.warns).includes("C2-versal-tic")); + }); + + test("REGRESSION: acronyms (NPM/DFØ/NAV) are NOT versal-tics", () => { + // Denylist semantics: only UPPERCASE emphasis words flag, never acronyms. + const r = evaluate("Verktøyet NPM og CLI, og DFØ og NAV, er nyttige.", RULES); + assert.ok(!ids(r.warns).includes("C2-versal-tic"), `flagged: ${ids(r.warns)}`); + }); + + test("uppercase 'MÅ' (with Å) is caught by the Unicode-aware pattern", () => { + const r = evaluate("Du MÅ gjøre dette nå.", RULES); + assert.ok(ids(r.warns).includes("C2-versal-tic")); + }); + + test("a plain acronym sentence raises no versal warn", () => { + const clean = evaluate("KI og IT samarbeider tett med NSM og EU.", RULES); + assert.ok(!ids(clean.warns).includes("C2-versal-tic")); + }); + + test("'KI-en' as subject warns only past the repetition threshold", () => { + const few = evaluate("KI-en gjør jobben.", RULES); + assert.ok(!ids(few.warns).includes("KI-en-subjekt")); + + const many = evaluate( + "KI-en gjør X. KI-en gjør Y. KI-en gjør Z. KI-en gjør W.", + RULES, + ); + assert.ok(ids(many.warns).includes("KI-en-subjekt")); + }); + + test("bare 'modell' is surfaced as a warn for per-occurrence review", () => { + const r = evaluate("En KI-modell er trent på data.", RULES); + assert.ok(ids(r.warns).includes("B3-modell-bar")); + assert.equal(r.passed, true); + }); + }); + + describe("reporting", () => { + test("line numbers point at the original draft line", () => { + const text = "linje1\nlinje2\nDette er _viktig_ her.\nlinje4"; + const r = evaluate(text, RULES); + const v = r.blocks.find((b) => b.ruleId === "understrek-emfase"); + assert.ok(v); + assert.equal(v!.matches[0].line, 3); + }); + + test("count rules report observed and threshold", () => { + const r = evaluate("En modell, to modeller, tre modell-typer.", RULES); + const v = r.warns.find((w) => w.ruleId === "B3-modell-bar"); + assert.ok(v); + assert.ok((v!.observed ?? 0) >= 3); + }); + }); + + describe("runGateFile", () => { + test("reads a draft file and evaluates it", () => { + const dir = mkdtempSync(join(tmpdir(), "contract-gate-")); + try { + const p = join(dir, "draft.md"); + writeFileSync(p, "Tesen: den knappe ressursen er ikke modellen."); + const r = runGateFile(p); + assert.equal(r.draftPath, p); + assert.equal(r.passed, false); + assert.ok(ids(r.blocks).includes("B10-serie-tese")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/scripts/contract-gate/tests/ratify.test.ts b/scripts/contract-gate/tests/ratify.test.ts new file mode 100644 index 0000000..8c3ecbd --- /dev/null +++ b/scripts/contract-gate/tests/ratify.test.ts @@ -0,0 +1,136 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; + +import { RULES } from "../src/rules.js"; +import { + ratify, + ratifyFile, + parseManifest, + defaultContractPath, +} from "../src/ratify.js"; + +const kinds = (r: { issues: { kind: string }[] }) => r.issues.map((i) => i.kind); + +/** A minimal contract carrying the required section headings + a manifest fence. */ +function contractWith(manifestRows: string[], opts: { bRows?: number } = {}): string { + const bRows = opts.bRows ?? 10; + const bTable = Array.from({ length: bRows }, (_, i) => `| ${i + 1} | regel ${i + 1} | hvorfor | flagg |`).join("\n"); + return [ + "## B. De ti reglene", + "| # | Regel | Hvorfor | Flagg |", + "|---|---|---|---|", + bTable, + "", + "## C1. Regelsjekk-gate", + "## C2. Redaktør-pass", + "## E. Akkumulering", + "<!-- ratify:begin -->", + "| rule-id | ref | severity | proveniens |", + "|---------|-----|----------|------------|", + ...manifestRows, + "<!-- ratify:end -->", + ].join("\n"); +} + +/** One manifest row per rule, transcribing id/ref/severity exactly. */ +const validRows = RULES.map((r) => `| ${r.id} | ${r.ref} | ${r.severity} | prov |`); + +describe("ratify — rules.ts ↔ §E-manifest binding", () => { + test("a manifest that mirrors RULES exactly is in sync", () => { + const r = ratify(contractWith(validRows)); + assert.equal(r.ok, true, `unexpected issues: ${JSON.stringify(r.issues)}`); + assert.equal(r.issues.length, 0); + assert.equal(r.ruleCount, RULES.length); + assert.equal(r.manifestCount, RULES.length); + }); + + test("a missing manifest fence is a hard failure", () => { + const r = ratify("## B. regler\nno manifest here at all\n"); + assert.equal(r.ok, false); + assert.ok(kinds(r).includes("missing-from-manifest")); + assert.equal(r.manifestCount, 0); + }); + + test("a rule with no manifest row → missing-from-manifest", () => { + // drop the first rule's row + const rows = validRows.slice(1); + const r = ratify(contractWith(rows)); + assert.equal(r.ok, false); + const missing = r.issues.filter((i) => i.kind === "missing-from-manifest"); + assert.equal(missing.length, 1); + assert.equal(missing[0].id, RULES[0].id); + }); + + test("a manifest row with no rule → extra-in-manifest", () => { + const rows = [...validRows, "| ghost-rule | §B-2 | warn | spøkelse |"]; + const r = ratify(contractWith(rows)); + assert.equal(r.ok, false); + const extra = r.issues.filter((i) => i.kind === "extra-in-manifest"); + assert.equal(extra.length, 1); + assert.equal(extra[0].id, "ghost-rule"); + }); + + test("a ref that differs from rules.ts → ref-mismatch", () => { + const rows = validRows.map((row, i) => + i === 0 ? `| ${RULES[0].id} | §B-99 | ${RULES[0].severity} | x |` : row, + ); + const r = ratify(contractWith(rows)); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.kind === "ref-mismatch" && i.id === RULES[0].id)); + }); + + test("a severity that differs from rules.ts → severity-mismatch", () => { + const flip = RULES[0].severity === "block" ? "warn" : "block"; + const rows = validRows.map((row, i) => + i === 0 ? `| ${RULES[0].id} | ${RULES[0].ref} | ${flip} | x |` : row, + ); + const r = ratify(contractWith(rows)); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.kind === "severity-mismatch" && i.id === RULES[0].id)); + }); + + test("a §B-N ref pointing past the table → dangling-ref", () => { + // §B only has 5 rows here, but RULES cites §B-10 (B10-serie-tese) + const r = ratify(contractWith(validRows, { bRows: 5 })); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.kind === "dangling-ref")); + }); + + test("a duplicate manifest id is flagged", () => { + const rows = [...validRows, validRows[0]]; + const r = ratify(contractWith(rows)); + assert.equal(r.ok, false); + assert.ok(r.issues.some((i) => i.kind === "extra-in-manifest" && i.detail.includes("duplisert"))); + }); +}); + +describe("parseManifest", () => { + test("returns null when the fence is absent", () => { + assert.equal(parseManifest("no markers"), null); + }); + + test("skips header + separator, keeps data rows", () => { + const rows = parseManifest(contractWith(validRows)); + assert.ok(rows); + assert.equal(rows!.length, RULES.length); + assert.equal(rows![0].id, RULES[0].id); + assert.equal(rows![0].severity, RULES[0].severity); + }); +}); + +describe("ratifyFile — real contract integration", () => { + const real = defaultContractPath(); + // Skips on a fresh clone / CI where maskinrommet is absent; runs on KTG's machine. + test("the live skrivekontrakt.md §E-manifest ratifies clean", { skip: !existsSync(real) }, () => { + const r = ratifyFile(real); + assert.equal(r.ok, true, `drift vs live contract: ${JSON.stringify(r.issues, null, 2)}`); + assert.equal(r.ruleCount, RULES.length); + }); + + test("a non-existent contract path fails cleanly (no throw)", () => { + const r = ratifyFile("/nonexistent/skrivekontrakt.md"); + assert.equal(r.ok, false); + assert.equal(r.contractPath, "/nonexistent/skrivekontrakt.md"); + }); +}); diff --git a/scripts/contract-gate/tsconfig.json b/scripts/contract-gate/tsconfig.json new file mode 100644 index 0000000..eaa1e8e --- /dev/null +++ b/scripts/contract-gate/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "tests"] +} diff --git a/scripts/specifics-bank/README.md b/scripts/specifics-bank/README.md new file mode 100644 index 0000000..d4225da --- /dev/null +++ b/scripts/specifics-bank/README.md @@ -0,0 +1,87 @@ +# specifics-bank — the lived-specifics inventory (Fix #2, kilde-så-draft) + +A deterministic, topic-tagged store of the operator's **real, un-generatable raw +material** — the specific measured number, the named (anonymized-but-real) case, the +thing that actually broke, the unfashionable opinion privately held, the moment they +changed their mind. The point (retning §3, dream-spec «Lived-Specifics Extraction»): +**sourcing, not styling, is where authenticity is won.** Drafts must draw from real +inventory instead of inventing plausible filler, so every downstream voice gate polishes +grounded content, not hollow content. + +This is the **store half** (deterministic + tested). The **elicitation half** — a guided +interview that refuses vague answers (escape hatches: mark a slot `abstrakt` or `ekstern`) +— lives in the command layer (`/linkedin:newsletter` Step 1.5, slice 3). The bank never +holds AI-generated lived experience; every `content` is the operator's own words. + +## Where the bank lives + +`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/specifics-bank/specifics-bank.json` + +It sits in the per-user data dir (M0 data-path convention), **outside** the plugin +install, so it **survives plugin upgrades and reinstalls**. It is the first concrete +tributary of the broader profile-evolution / "second brain" architecture. + +## Setup + +```bash +cd scripts/specifics-bank && npm install +``` + +## Use + +```bash +# Surface inventory the operator already has for an edition's topics +node --import tsx src/cli.ts query --tags "saksbehandling,produktivitet" + +# Fold newly-elicited material back into the bank +node --import tsx src/cli.ts add --type number \ + --content "Saksbehandleren sparte to dager i uka." \ + --tags "saksbehandling,produktivitet" --source "seres/05" + +# Inspect the whole bank +node --import tsx src/cli.ts list +``` + +Flags: `--bank <path>` overrides the default location; `--json` emits machine output for +`query`/`list`. `add` defaults `--verification` to `unverified` (numbers stay +guilty-until-checked — regel 6/7) and `--source` to `manual`. + +`--type` ∈ `number | named-case | what-broke | contrarian | mind-change | other`. + +## Model + +- **Dedupe key** = `sha256(normalizeContent(content)).slice(0,12)` (lowercase + trimmed + + whitespace-collapsed). Re-capturing the same material under a new edition's topic does + **not** add a second entry — its `topicTags` are unioned in, enriching the record. +- **Query** returns active specifics whose tags overlap, ranked by overlap (desc) then + recency (`capturedAt` desc); archived material is kept but never offered. +- **Content is stored verbatim**; only the id is normalized. + +## Per-edition binding (slice 2) + +The bank is the *global* inventory; an edition *binds* its load-bearing claims to +real material before any prose exists. That bridge lives here too: + +- `binding.ts` — the per-edition slot-map model + `validateBinding`, the + «vaghet avvises» gate (a slot must be `specific` / `abstrakt` / `ekstern`; an + `unresolved` slot, a dangling specific id, or an unjustified `abstrakt` → BLOCK; + an unverified-number-backed slot → WARN per regel 6/7). +- `kilder.ts` — `renderKilder`, the read-only `NN-kilder.md` sources artifact, + rendered deterministically from the slot-map + the bank. + +```bash +# Gate one edition's binding (exit 1 = BLOCK) +node --import tsx src/cli.ts validate-binding --edition "<abs>/linkedin/edition-state.json" +# Render the sources ledger +node --import tsx src/cli.ts render-kilder --edition "<abs>/linkedin/edition-state.json" --out "<abs>/linkedin/NN-kilder.md" +``` + +Design record + the Step 2 research re-scope spec: `docs/fix2/slice2-binding.md`. +The elicitation interview that POPULATES the slot-map (Step 1.5) is slice 3. + +## Test + +```bash +npm test # node:test, deterministic (callers supply capturedAt) +npm run build # tsc — must stay clean +``` diff --git a/scripts/specifics-bank/package-lock.json b/scripts/specifics-bank/package-lock.json new file mode 100644 index 0000000..0199d5c --- /dev/null +++ b/scripts/specifics-bank/package-lock.json @@ -0,0 +1,566 @@ +{ + "name": "linkedin-specifics-bank", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkedin-specifics-bank", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/scripts/specifics-bank/package.json b/scripts/specifics-bank/package.json new file mode 100644 index 0000000..fb72f07 --- /dev/null +++ b/scripts/specifics-bank/package.json @@ -0,0 +1,16 @@ +{ + "name": "linkedin-specifics-bank", + "version": "0.1.0", + "type": "module", + "description": "Lived-specifics bank (Fix #2, kilde-så-draft) — a topic-tagged, provenance-bearing store of the operator's real, un-generatable raw material, so drafts start grounded instead of hollow. Deterministic store + query; the elicitation interview lives in the command layer.", + "scripts": { + "build": "tsc", + "test": "node --import tsx --test tests/*.test.ts", + "start": "node --import tsx src/cli.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/scripts/specifics-bank/src/bank.ts b/scripts/specifics-bank/src/bank.ts new file mode 100644 index 0000000..5163a34 --- /dev/null +++ b/scripts/specifics-bank/src/bank.ts @@ -0,0 +1,153 @@ +/** + * Deterministic store + query for the lived-specifics bank (Fix #2). + * + * Pure where it matters: id derivation, dedupe, and topic query are + * side-effect-free and fully testable. Only loadBank/saveBank touch the + * filesystem. No AI, no network — the bank is reliable inventory, not a + * creatively-interpreted blob. The elicitation that POPULATES the bank lives in + * the command layer (a guided interview that refuses vague answers); this module + * only stores, dedupes, and serves what the operator actually supplied. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; + +import { SCHEMA_VERSION } from "./types.js"; +import type { + Bank, + Provenance, + QueryHit, + Specific, + SpecificStatus, + SpecificType, + Verification, +} from "./types.js"; + +export { SCHEMA_VERSION } from "./types.js"; + +/** What a caller supplies to addSpecific — the id is derived, never passed in. */ +export interface SpecificInput { + type: SpecificType; + /** The operator's raw material, verbatim. */ + content: string; + topicTags: string[]; + provenance: Provenance; + /** Defaults to "unverified" (numbers stay guilty-until-checked — regel 6/7). */ + verification?: Verification; + /** Defaults to "active". */ + status?: SpecificStatus; +} + +export interface AddResult { + bank: Bank; + /** true iff a new specific was appended (false = duplicate content). */ + added: boolean; + /** true iff an existing duplicate gained new topic tags via union. */ + merged: boolean; +} + +/** Lowercase + trim + collapse all whitespace runs to a single space. */ +export function normalizeContent(content: string): string { + return content.trim().toLowerCase().replace(/\s+/g, " "); +} + +/** Stable id = first 12 hex of sha256(normalizeContent) — also the dedupe key. */ +export function specificId(content: string): string { + return createHash("sha256").update(normalizeContent(content)).digest("hex").slice(0, 12); +} + +export function emptyBank(): Bank { + return { schemaVersion: SCHEMA_VERSION, specifics: [] }; +} + +/** Read the bank; a missing file is an empty bank (never throws on absence). */ +export function loadBank(path: string): Bank { + if (!existsSync(path)) return emptyBank(); + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<Bank>; + return { + schemaVersion: parsed.schemaVersion ?? SCHEMA_VERSION, + specifics: Array.isArray(parsed.specifics) ? parsed.specifics : [], + }; +} + +/** Write the bank as pretty JSON, creating the parent dir if needed. */ +export function saveBank(path: string, bank: Bank): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(bank, null, 2) + "\n", "utf8"); +} + +/** Union of two tag lists, order-stable on the first list, case-insensitive dedupe. */ +function unionTags(existing: string[], incoming: string[]): { tags: string[]; changed: boolean } { + const seen = new Set(existing.map((t) => t.toLowerCase())); + const tags = [...existing]; + let changed = false; + for (const t of incoming) { + if (!seen.has(t.toLowerCase())) { + seen.add(t.toLowerCase()); + tags.push(t); + changed = true; + } + } + return { tags, changed }; +} + +/** + * Add a specific, deduping on normalized content. A duplicate does not append a + * second entry — instead its topic tags are unioned in, so the same real + * material re-surfaced under a new edition's topic enriches the existing record. + * The operator's `content` is stored VERBATIM; only the id is normalized. + */ +export function addSpecific(bank: Bank, input: SpecificInput): AddResult { + const id = specificId(input.content); + const existing = bank.specifics.find((s) => s.id === id); + if (existing) { + const { tags, changed } = unionTags(existing.topicTags, input.topicTags); + existing.topicTags = tags; + return { bank, added: false, merged: changed }; + } + const specific: Specific = { + id, + type: input.type, + content: input.content, + topicTags: [...input.topicTags], + provenance: input.provenance, + verification: input.verification ?? "unverified", + status: input.status ?? "active", + }; + bank.specifics.push(specific); + return { bank, added: true, merged: false }; +} + +/** + * Active specifics whose tags overlap the query, ranked by overlap (desc) then + * recency (capturedAt desc). Tag matching is case-insensitive. Non-matches and + * archived material are excluded. + */ +export function queryByTopic(bank: Bank, tags: string[]): QueryHit[] { + const wanted = tags.map((t) => t.toLowerCase()); + const hits: QueryHit[] = []; + for (const specific of bank.specifics) { + if (specific.status !== "active") continue; + const have = new Set(specific.topicTags.map((t) => t.toLowerCase())); + const tagOverlap = wanted.reduce((n, t) => (have.has(t) ? n + 1 : n), 0); + if (tagOverlap > 0) hits.push({ specific, tagOverlap }); + } + hits.sort( + (a, b) => + b.tagOverlap - a.tagOverlap || + b.specific.provenance.capturedAt.localeCompare(a.specific.provenance.capturedAt), + ); + return hits; +} + +/** + * Default bank path under the per-user data dir (M0 data-path convention), so the + * bank survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` overrides the + * root; otherwise `~/.claude/linkedin-studio`. + */ +export function defaultBankPath(): string { + const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); + return join(root, "specifics-bank", "specifics-bank.json"); +} diff --git a/scripts/specifics-bank/src/binding.ts b/scripts/specifics-bank/src/binding.ts new file mode 100644 index 0000000..f6d10dc --- /dev/null +++ b/scripts/specifics-bank/src/binding.ts @@ -0,0 +1,156 @@ +/** + * Per-edition lived-specifics BINDING (Fix #2, slice 2 — kilde-så-draft). + * + * The bank (`bank.ts`) is the operator's global inventory of real material. This + * module is the per-EDITION bridge: a slot-map that binds each load-bearing + * key-point (or, later, section) of one edition to either a real specific from + * the bank OR an explicit escape decision. It lives in the edition's + * `edition-state.json` under `articles.NN.livedSpecifics`; this module only + * validates and reports on it (pure, testable). The elicitation that POPULATES + * the slot-map — the guided interview that refuses vague answers — lives in the + * command layer (`/linkedin:newsletter` Step 1.5, slice 3). + * + * THE GATE (retning §3 + drømme-spec G4, «vaghet avvises»): a load-bearing claim + * must be grounded in real material (`specific`) or explicitly escape-hatched + * (`abstrakt` with a justification, or `ekstern` = backed by research, not lived). + * A slot left `unresolved` is the vagueness the gate rejects → BLOCK. Numbers + * stay guilty-until-checked: a slot backed by an UNVERIFIED number is allowed but + * WARNED (regel 6/7), so Step 2 research verifies it before a draft asserts it. + */ + +import type { Bank, Specific } from "./types.js"; + +/** A slot is a load-bearing key-point (Step 1.5) or a section (Step 2.5 refinement). */ +export type SlotKind = "key-point" | "section"; + +/** How a slot is grounded. `unresolved` is the vagueness the gate blocks on. */ +export type Binding = + | { type: "specific"; specificId: string } // grounded in real bank material (best) + | { type: "abstrakt"; rationale: string } // deliberately abstract here — must justify + | { type: "ekstern"; source?: string } // backed by external research, not lived material + | { type: "unresolved" }; // not yet decided → BLOCK + +export interface Slot { + slotId: string; + kind: SlotKind; + /** Human-readable claim/key-point text. */ + label: string; + binding: Binding; +} + +export type LivedSpecificsStatus = "pending" | "bound"; + +export interface LivedSpecifics { + slots: Slot[]; + status: LivedSpecificsStatus; +} + +export type BindingVerdict = "PASS" | "BLOCK"; + +export type SlotProblemReason = "unresolved" | "dangling-specific" | "unjustified-abstrakt"; + +export interface SlotProblem { + slotId: string; + reason: SlotProblemReason; + detail: string; +} + +export interface SlotWarning { + slotId: string; + reason: "unverified-number"; + detail: string; +} + +/** Coverage counts by binding TYPE (a dangling specific still counts under `specific`). */ +export interface BindingCoverage { + total: number; + specific: number; + abstrakt: number; + ekstern: number; + unresolved: number; +} + +export interface BindingResult { + verdict: BindingVerdict; + /** BLOCK reasons — every offending slot, not short-circuited. */ + problems: SlotProblem[]; + /** Non-blocking direction (regel 6/7 number-verification). */ + warnings: SlotWarning[]; + coverage: BindingCoverage; +} + +const isBlank = (s: string): boolean => s.trim().length === 0; + +/** + * Validate one edition's slot-map against the bank. Pure: no I/O, no clock. + * BLOCK iff any slot is `unresolved`, points at a `specificId` absent from the + * bank, or is an `abstrakt` escape with a blank rationale. Otherwise PASS. + * Warnings (do not block) flag slots backed by an unverified number (regel 6/7). + */ +export function validateBinding(ls: LivedSpecifics, bank: Bank): BindingResult { + const byId = new Map<string, Specific>(bank.specifics.map((s) => [s.id, s])); + const problems: SlotProblem[] = []; + const warnings: SlotWarning[] = []; + const coverage: BindingCoverage = { + total: ls.slots.length, + specific: 0, + abstrakt: 0, + ekstern: 0, + unresolved: 0, + }; + + for (const slot of ls.slots) { + const b = slot.binding; + switch (b.type) { + case "specific": { + coverage.specific++; + const spec = byId.get(b.specificId); + if (!spec) { + problems.push({ + slotId: slot.slotId, + reason: "dangling-specific", + detail: `specificId '${b.specificId}' is not in the bank`, + }); + } else if (spec.type === "number" && spec.verification === "unverified") { + warnings.push({ + slotId: slot.slotId, + reason: "unverified-number", + detail: `number '${b.specificId}' is unverified — verify before the draft asserts it (regel 6/7)`, + }); + } + break; + } + case "abstrakt": { + coverage.abstrakt++; + if (isBlank(b.rationale)) { + problems.push({ + slotId: slot.slotId, + reason: "unjustified-abstrakt", + detail: "an abstrakt escape needs a rationale — an unjustified abstraction is vaghet", + }); + } + break; + } + case "ekstern": { + coverage.ekstern++; // source optional — Step 2 research fills it + break; + } + case "unresolved": { + coverage.unresolved++; + problems.push({ + slotId: slot.slotId, + reason: "unresolved", + detail: "slot is unresolved — bind it to a specific, or escape-hatch as abstrakt/ekstern", + }); + break; + } + } + } + + return { + verdict: problems.length === 0 ? "PASS" : "BLOCK", + problems, + warnings, + coverage, + }; +} diff --git a/scripts/specifics-bank/src/cli.ts b/scripts/specifics-bank/src/cli.ts new file mode 100644 index 0000000..6ade4ca --- /dev/null +++ b/scripts/specifics-bank/src/cli.ts @@ -0,0 +1,219 @@ +#!/usr/bin/env node +/** + * CLI for the lived-specifics bank (Fix #2 — kilde-så-draft). + * + * node --import tsx src/cli.ts query --tags <a,b> [--bank <path>] [--json] + * node --import tsx src/cli.ts add --type <t> --content "<text>" --tags <a,b> + * [--source <s>] [--verification <v>] [--bank <path>] + * node --import tsx src/cli.ts list [--bank <path>] [--json] + * + * The command layer (Step 1.5) calls `query` to surface inventory the operator + * already has for an edition's topics, and `add` to fold newly-elicited material + * back in. Deterministic store; the elicitation interview itself lives upstream. + * + * Exit code: 0 on success, 2 on usage error. + */ + +import { readFileSync, writeFileSync } from "node:fs"; + +import { + addSpecific, + defaultBankPath, + loadBank, + queryByTopic, + saveBank, +} from "./bank.js"; +import { validateBinding } from "./binding.js"; +import { renderKilder } from "./kilder.js"; +import type { LivedSpecifics } from "./binding.js"; +import type { SpecificType, Verification } from "./types.js"; + +const VALID_TYPES: SpecificType[] = [ + "number", + "named-case", + "what-broke", + "contrarian", + "mind-change", + "other", +]; +const VALID_VERIFICATION: Verification[] = ["verified", "unverified", "n/a"]; + +function parseFlags(args: string[]): Record<string, string> { + const out: Record<string, string> = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + out[key] = "true"; + } else { + out[key] = next; + i++; + } + } + } + return out; +} + +function splitTags(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +function usage(msg: string): never { + console.error(`error: ${msg}`); + console.error( + "usage:\n" + + " query --tags <a,b> [--bank <path>] [--json]\n" + + ' add --type <t> --content "<text>" --tags <a,b> [--source <s>] [--verification <v>] [--bank <path>]\n' + + " list [--bank <path>] [--json]\n" + + " validate-binding --edition <edition-state.json> [--article <id>] [--bank <path>] [--json]\n" + + " render-kilder --edition <edition-state.json> [--article <id>] [--bank <path>] [--out <path>]", + ); + process.exit(2); +} + +/** Minimal shape of edition-state.json that the binding subcommands read. */ +interface EditionArticle { + title?: string; + livedSpecifics?: LivedSpecifics; +} +interface EditionState { + currentArticle?: string; + articles?: Record<string, EditionArticle>; +} + +const EMPTY_LS: LivedSpecifics = { slots: [], status: "pending" }; + +function loadEdition(path: string): EditionState { + return JSON.parse(readFileSync(path, "utf8")) as EditionState; +} + +/** Resolve the target article: explicit --article, else currentArticle, else "01". */ +function resolveArticle(edition: EditionState, flag?: string): { id: string; article: EditionArticle } { + const id = flag ?? edition.currentArticle ?? "01"; + return { id, article: edition.articles?.[id] ?? {} }; +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +function main(): void { + const [command, ...rest] = process.argv.slice(2); + const flags = parseFlags(rest); + const bankPath = flags.bank ?? defaultBankPath(); + const asJson = flags.json === "true"; + + if (command === "query") { + const tags = splitTags(flags.tags); + if (tags.length === 0) usage("query needs --tags <a,b>"); + const hits = queryByTopic(loadBank(bankPath), tags); + if (asJson) { + console.log(JSON.stringify(hits, null, 2)); + return; + } + if (hits.length === 0) { + console.log(`No lived specifics found for tags: ${tags.join(", ")}`); + console.log("→ elicit fresh material from the operator (Step 1.5), then `add` it."); + return; + } + console.log(`${hits.length} lived specific(s) for: ${tags.join(", ")}`); + for (const { specific, tagOverlap } of hits) { + const v = specific.verification === "verified" ? "" : ` [${specific.verification}]`; + console.log(`\n · (${specific.type}, overlap ${tagOverlap})${v} ${specific.content}`); + console.log(` tags: ${specific.topicTags.join(", ")} — from ${specific.provenance.source}`); + } + return; + } + + if (command === "add") { + const type = flags.type as SpecificType; + if (!VALID_TYPES.includes(type)) usage(`--type must be one of: ${VALID_TYPES.join(", ")}`); + const content = flags.content; + if (!content || content === "true") usage("add needs --content \"<text>\""); + const tags = splitTags(flags.tags); + if (tags.length === 0) usage("add needs --tags <a,b>"); + const verification = (flags.verification as Verification) ?? "unverified"; + if (!VALID_VERIFICATION.includes(verification)) { + usage(`--verification must be one of: ${VALID_VERIFICATION.join(", ")}`); + } + const bank = loadBank(bankPath); + const res = addSpecific(bank, { + type, + content, + topicTags: tags, + provenance: { capturedAt: today(), source: flags.source ?? "manual" }, + verification, + }); + saveBank(bankPath, res.bank); + if (res.added) { + console.log(`Added (${type}): ${content}`); + } else { + console.log(`Duplicate — already in bank${res.merged ? " (tags unioned)" : ""}: ${content}`); + } + console.log(`Bank: ${bankPath} (${res.bank.specifics.length} specifics)`); + return; + } + + if (command === "list") { + const bank = loadBank(bankPath); + if (asJson) { + console.log(JSON.stringify(bank, null, 2)); + return; + } + console.log(`Bank: ${bankPath} — ${bank.specifics.length} specific(s)`); + for (const s of bank.specifics) { + const flag = s.status === "active" ? "" : ` (${s.status})`; + console.log(` · [${s.type}]${flag} ${s.content} {${s.topicTags.join(", ")}}`); + } + return; + } + + if (command === "validate-binding") { + if (!flags.edition || flags.edition === "true") usage("validate-binding needs --edition <path>"); + const edition = loadEdition(flags.edition); + const { id, article } = resolveArticle(edition, flags.article); + const ls = article.livedSpecifics ?? EMPTY_LS; + const res = validateBinding(ls, loadBank(bankPath)); + if (asJson) { + console.log(JSON.stringify({ article: id, ...res }, null, 2)); + } else { + const c = res.coverage; + console.log(`Binding for article ${id}: ${res.verdict}`); + console.log( + ` slots ${c.total} — levd ${c.specific} · abstrakt ${c.abstrakt} · ekstern ${c.ekstern} · uavklart ${c.unresolved}`, + ); + for (const p of res.problems) console.log(` ⛔ ${p.slotId}: ${p.reason} — ${p.detail}`); + for (const w of res.warnings) console.log(` ⚠ ${w.slotId}: ${w.detail}`); + } + process.exit(res.verdict === "BLOCK" ? 1 : 0); + } + + if (command === "render-kilder") { + if (!flags.edition || flags.edition === "true") usage("render-kilder needs --edition <path>"); + const edition = loadEdition(flags.edition); + const { id, article } = resolveArticle(edition, flags.article); + const md = renderKilder({ + articleId: id, + title: article.title ?? id, + livedSpecifics: article.livedSpecifics ?? EMPTY_LS, + bank: loadBank(bankPath), + }); + if (flags.out && flags.out !== "true") { + writeFileSync(flags.out, md, "utf8"); + console.log(`Wrote ${flags.out}`); + } else { + process.stdout.write(md); + } + return; + } + + usage(command ? `unknown command: ${command}` : "no command given"); +} + +main(); diff --git a/scripts/specifics-bank/src/kilder.ts b/scripts/specifics-bank/src/kilder.ts new file mode 100644 index 0000000..a86d9c5 --- /dev/null +++ b/scripts/specifics-bank/src/kilder.ts @@ -0,0 +1,93 @@ +/** + * Render the per-edition `NN-kilder.md` artifact (Fix #2, slice 2). + * + * A read-only, deterministically-rendered sources ledger: for each load-bearing + * slot, what backs the claim. The single source of truth is the edition's + * `livedSpecifics` slot-map + the specifics-bank — `NN-kilder.md` is regenerated + * from them (like POST.html), never hand-edited. The coverage block and the + * regel-6/7 number list are computed by `validateBinding`, so the artifact and + * the skeleton-gate can never disagree. + * + * Pure + dateless on purpose (golden-testable). The CLI writes the string to + * `<serie>/linkedin/NN-kilder.md`. + */ + +import type { Bank, Specific } from "./types.js"; +import type { LivedSpecifics, Slot } from "./binding.js"; +import { validateBinding } from "./binding.js"; + +export interface RenderKilderInput { + articleId: string; + title: string; + livedSpecifics: LivedSpecifics; + bank: Bank; +} + +function verifMarker(spec: Specific): string { + if (spec.verification === "verified") return " · verifisert ✓"; + if (spec.verification === "unverified") return " · uverifisert ⚠"; + return ""; // n/a — no marker +} + +function bindingLines(slot: Slot, byId: Map<string, Specific>): string[] { + const b = slot.binding; + switch (b.type) { + case "specific": { + const spec = byId.get(b.specificId); + if (!spec) { + return [`- **Binding:** ⛔ levd materiale — id \`${b.specificId}\` mangler i banken (dangling)`]; + } + return [ + `- **Binding:** levd materiale (${spec.type}) · \`${spec.id}\`${verifMarker(spec)}`, + `- **Materiale:** «${spec.content}»`, + `- **Opphav:** ${spec.provenance.source}, fanget ${spec.provenance.capturedAt}`, + `- **Tema-tagger:** ${spec.topicTags.join(", ")}`, + ]; + } + case "abstrakt": + return b.rationale.trim().length === 0 + ? ["- **Binding:** ⛔ abstrakt uten begrunnelse (avvist — vaghet)"] + : [`- **Binding:** abstrakt (med vilje) — ${b.rationale}`]; + case "ekstern": + return [`- **Binding:** ekstern kilde — ${b.source ?? "(fylles av research, Step 2)"}`]; + case "unresolved": + return ["- **Binding:** ⛔ uavklart — må bindes før skjelett-gaten (vaghet avvises)"]; + } +} + +export function renderKilder(input: RenderKilderInput): string { + const { articleId, title, livedSpecifics, bank } = input; + const byId = new Map<string, Specific>(bank.specifics.map((s) => [s.id, s])); + const { coverage, warnings } = validateBinding(livedSpecifics, bank); + + const out: string[] = []; + out.push(`# Kilder — ${title} (${articleId})`, ""); + out.push( + "> Auto-generert fra edition-state `livedSpecifics` + specifics-bank. Per lastbærende påstand: hva backer den.", + "> Levd materiale er KTGs egne ord, aldri AI-oppdiktet (retning §3). Tall er uverifiserte til fakta-sjekket (regel 6/7).", + "", + ); + out.push("## Lastbærende påstander", ""); + if (livedSpecifics.slots.length === 0) { + out.push("_Ingen lastbærende slots bundet ennå._", ""); + } else { + for (const slot of livedSpecifics.slots) { + out.push(`### ${slot.slotId} · ${slot.label}`); + out.push(...bindingLines(slot, byId)); + out.push(""); + } + } + + out.push("## Dekning"); + out.push(`- Lastbærende slots: ${coverage.total}`); + out.push(`- Levd materiale: ${coverage.specific}`); + out.push(`- Abstrakt (escape): ${coverage.abstrakt}`); + out.push(`- Ekstern: ${coverage.ekstern}`); + out.push(`- Uavklart (BLOCK): ${coverage.unresolved}`); + out.push(""); + + const numSlots = warnings.filter((w) => w.reason === "unverified-number").map((w) => w.slotId); + out.push(`Tall som må verifiseres (regel 6/7): ${numSlots.length > 0 ? numSlots.join(", ") : "ingen"}`); + + return out.join("\n") + "\n"; +} diff --git a/scripts/specifics-bank/src/types.ts b/scripts/specifics-bank/src/types.ts new file mode 100644 index 0000000..15a5f40 --- /dev/null +++ b/scripts/specifics-bank/src/types.ts @@ -0,0 +1,72 @@ +/** + * Types for the lived-specifics bank (Fix #2 — kilde-så-draft). + * + * The bank is the operator's inventory of REAL, un-generatable raw material: + * the specific measured number, the named (anonymized-but-real) case, the thing + * that actually broke, the unfashionable opinion privately held, the moment they + * changed their mind. The taxonomy mirrors the dream-spec's "Lived-Specifics + * Extraction" engine (docs/expert-review/dream-spec.local.md): sourcing, not + * styling, is where authenticity is won — so drafts must draw from real + * inventory instead of inventing plausible filler. + * + * INVARIANT (retning §3 — «ditt råmateriale, verktøyets håndverk»): every + * `content` is the OPERATOR's own words, captured through elicitation. The bank + * never holds AI-generated lived experience. The store half lives here + * (deterministic, testable); the elicitation interview lives in the command. + * + * Forward-compatible with the profile-evolution / "second brain" architecture: + * a typed, tag- and provenance-bearing knowledge store in the per-user data dir + * (`${LINKEDIN_STUDIO_DATA}`), so it survives plugin upgrades/reinstalls via the + * existing M0 data-path seam. New `SpecificType`s or cross-references can be + * added without breaking the shape. + */ + +/** The un-generatable taxonomy (dream-spec line 56). */ +export type SpecificType = + | "number" // a real, measured number (must be fact-checked — regel 6/7) + | "named-case" // a named (anonymized-but-real) client / project / person + | "what-broke" // the thing that actually went wrong / broke + | "contrarian" // an unfashionable opinion the operator privately holds + | "mind-change" // a moment the operator changed their mind + | "other"; // real material that does not fit the above + +/** Whether a checkable claim (esp. a number) has been verified to a source. */ +export type Verification = "verified" | "unverified" | "n/a"; + +/** Active material is offered by query; archived material is kept but not offered. */ +export type SpecificStatus = "active" | "archived"; + +export interface Provenance { + /** ISO-8601 date the material was captured. Supplied by the caller (CLI edge). */ + capturedAt: string; + /** Where it was captured: an edition id (e.g. "seres/05"), a command, or "manual". */ + source: string; +} + +export interface Specific { + /** Stable id — a short hash of the normalized content; doubles as the dedupe key. */ + id: string; + type: SpecificType; + /** The operator's raw material, in their words. NEVER AI-generated. */ + content: string; + /** Topic tags for query-by-topic. Unioned across re-captures of the same content. */ + topicTags: string[]; + provenance: Provenance; + /** Numbers must be fact-checked before a draft asserts them as fact (regel 6/7). */ + verification: Verification; + status: SpecificStatus; +} + +export interface Bank { + schemaVersion: number; + specifics: Specific[]; +} + +/** A query hit: the specific plus how many of the queried tags it matched. */ +export interface QueryHit { + specific: Specific; + /** Number of queried tags present on the specific (≥1 for a hit). */ + tagOverlap: number; +} + +export const SCHEMA_VERSION = 1; diff --git a/scripts/specifics-bank/tests/bank.test.ts b/scripts/specifics-bank/tests/bank.test.ts new file mode 100644 index 0000000..deab502 --- /dev/null +++ b/scripts/specifics-bank/tests/bank.test.ts @@ -0,0 +1,198 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + normalizeContent, + specificId, + emptyBank, + loadBank, + saveBank, + addSpecific, + queryByTopic, +} from "../src/bank.js"; +import { SCHEMA_VERSION } from "../src/types.js"; +import type { Bank } from "../src/types.js"; + +const prov = (source = "test", capturedAt = "2026-06-20") => ({ source, capturedAt }); + +const tmp = () => mkdtempSync(join(tmpdir(), "specifics-bank-")); + +describe("specifics-bank store", () => { + describe("normalizeContent + specificId", () => { + test("id is deterministic and content-derived", () => { + assert.equal(specificId("Sparte to dager."), specificId("Sparte to dager.")); + }); + + test("id is stable across case + surrounding/collapsed whitespace", () => { + const a = specificId("Sparte to dager."); + const b = specificId(" sparte TO dager. "); + assert.equal(a, b, "normalization should collapse case + whitespace"); + }); + + test("different content yields different ids", () => { + assert.notEqual(specificId("Sparte to dager."), specificId("Sparte tre dager.")); + }); + + test("normalizeContent collapses whitespace + lowercases", () => { + assert.equal(normalizeContent(" Hei DU\n da "), "hei du da"); + }); + }); + + describe("emptyBank + load/save round-trip", () => { + test("emptyBank has the schema version and no specifics", () => { + const b = emptyBank(); + assert.equal(b.schemaVersion, SCHEMA_VERSION); + assert.deepEqual(b.specifics, []); + }); + + test("loadBank on a missing file returns an empty bank (never throws)", () => { + const dir = tmp(); + try { + const b = loadBank(join(dir, "does-not-exist.json")); + assert.equal(b.schemaVersion, SCHEMA_VERSION); + assert.deepEqual(b.specifics, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("saveBank then loadBank round-trips", () => { + const dir = tmp(); + const path = join(dir, "bank.json"); + try { + let bank: Bank = emptyBank(); + bank = addSpecific(bank, { + type: "number", + content: "Saksbehandleren sparte to dager i uka.", + topicTags: ["saksbehandling", "produktivitet"], + provenance: prov("seres/05"), + verification: "unverified", + }).bank; + saveBank(path, bank); + assert.ok(existsSync(path)); + const loaded = loadBank(path); + assert.equal(loaded.specifics.length, 1); + assert.equal(loaded.specifics[0].content, "Saksbehandleren sparte to dager i uka."); + assert.equal(loaded.specifics[0].type, "number"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); + + describe("addSpecific", () => { + test("adds a new specific with a computed id and reports added:true", () => { + const res = addSpecific(emptyBank(), { + type: "named-case", + content: "En kontraktsjurist hos en kunde automatiserte avtalegjennomgang.", + topicTags: ["juss", "automatisering"], + provenance: prov(), + verification: "n/a", + }); + assert.equal(res.added, true); + assert.equal(res.bank.specifics.length, 1); + assert.equal(res.bank.specifics[0].id, specificId(res.bank.specifics[0].content)); + assert.equal(res.bank.specifics[0].status, "active"); + }); + + test("preserves the operator's content verbatim (normalizes only for id)", () => { + const content = " KI-en nådde IKKE arkivdataene. "; + const res = addSpecific(emptyBank(), { + type: "what-broke", + content, + topicTags: ["data"], + provenance: prov(), + }); + assert.equal(res.bank.specifics[0].content, content, "content must not be mutated"); + }); + + test("dedupes on normalized content: same material twice → one entry, tags unioned", () => { + let bank = emptyBank(); + bank = addSpecific(bank, { + type: "number", + content: "Sparte to dager.", + topicTags: ["produktivitet"], + provenance: prov("seres/04"), + }).bank; + const res2 = addSpecific(bank, { + type: "number", + content: " sparte to dager. ", + topicTags: ["saksbehandling"], + provenance: prov("seres/05"), + }); + assert.equal(res2.added, false, "duplicate content must not add a second entry"); + assert.equal(res2.bank.specifics.length, 1); + assert.deepEqual( + [...res2.bank.specifics[0].topicTags].sort(), + ["produktivitet", "saksbehandling"], + "tags must be unioned on re-capture", + ); + }); + + test("defaults verification to unverified and status to active when omitted", () => { + const res = addSpecific(emptyBank(), { + type: "contrarian", + content: "De fleste KI-pilotene feiler på data, ikke på modellvalg.", + topicTags: ["data"], + provenance: prov(), + }); + assert.equal(res.bank.specifics[0].verification, "unverified"); + assert.equal(res.bank.specifics[0].status, "active"); + }); + }); + + describe("queryByTopic", () => { + const seed = (): Bank => { + let bank = emptyBank(); + bank = addSpecific(bank, { + type: "named-case", + content: "Saksbehandler-case A.", + topicTags: ["saksbehandling", "arkiv"], + provenance: prov("seres/04", "2026-05-01"), + }).bank; + bank = addSpecific(bank, { + type: "number", + content: "Tallet B.", + topicTags: ["saksbehandling", "produktivitet", "ledelse"], + provenance: prov("seres/05", "2026-06-01"), + }).bank; + bank = addSpecific(bank, { + type: "other", + content: "Irrelevant C.", + topicTags: ["privatliv"], + provenance: prov("manual", "2026-06-10"), + }).bank; + return bank; + }; + + test("ranks hits by tag overlap (desc)", () => { + const hits = queryByTopic(seed(), ["saksbehandling", "produktivitet"]); + assert.equal(hits.length, 2); + assert.equal(hits[0].specific.content, "Tallet B."); // overlap 2 + assert.equal(hits[0].tagOverlap, 2); + assert.equal(hits[1].specific.content, "Saksbehandler-case A."); // overlap 1 + assert.equal(hits[1].tagOverlap, 1); + }); + + test("returns [] when nothing matches", () => { + assert.deepEqual(queryByTopic(seed(), ["ingenting"]), []); + }); + + test("excludes archived specifics", () => { + const bank = seed(); + bank.specifics[1].status = "archived"; // archive "Tallet B." + const hits = queryByTopic(bank, ["saksbehandling"]); + assert.equal(hits.length, 1); + assert.equal(hits[0].specific.content, "Saksbehandler-case A."); + }); + + test("ties on overlap are broken by capturedAt (newest first)", () => { + const hits = queryByTopic(seed(), ["saksbehandling"]); // both A and B overlap 1 + assert.equal(hits[0].specific.content, "Tallet B."); // 2026-06-01 newer than 2026-05-01 + assert.equal(hits[1].specific.content, "Saksbehandler-case A."); + }); + }); +}); diff --git a/scripts/specifics-bank/tests/binding.test.ts b/scripts/specifics-bank/tests/binding.test.ts new file mode 100644 index 0000000..a1dae9d --- /dev/null +++ b/scripts/specifics-bank/tests/binding.test.ts @@ -0,0 +1,151 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { validateBinding } from "../src/binding.js"; +import type { LivedSpecifics, Slot } from "../src/binding.js"; +import type { Bank, Specific } from "../src/types.js"; + +// ---- fixtures --------------------------------------------------------------- + +function mkSpecific(over: Partial<Specific> & { id: string }): Specific { + return { + id: over.id, + type: over.type ?? "other", + content: over.content ?? "x", + topicTags: over.topicTags ?? ["t"], + provenance: over.provenance ?? { capturedAt: "2026-06-20", source: "test" }, + verification: over.verification ?? "n/a", + status: over.status ?? "active", + }; +} + +const mkBank = (specifics: Specific[]): Bank => ({ schemaVersion: 1, specifics }); + +const slot = (slotId: string, binding: Slot["binding"], over: Partial<Slot> = {}): Slot => ({ + slotId, + kind: over.kind ?? "key-point", + label: over.label ?? `label ${slotId}`, + binding, +}); + +const ls = (slots: Slot[]): LivedSpecifics => ({ slots, status: "pending" }); + +// ---- tests ------------------------------------------------------------------ + +describe("validateBinding — the «vaghet avvises» gate (G4)", () => { + test("every slot resolved (specific + abstrakt + ekstern) → PASS, coverage counted", () => { + const bank = mkBank([mkSpecific({ id: "aaa", type: "named-case" })]); + const res = validateBinding( + ls([ + slot("kp1", { type: "specific", specificId: "aaa" }), + slot("kp2", { type: "abstrakt", rationale: "bevisst abstrakt her" }), + slot("kp3", { type: "ekstern", source: "research note 2" }), + ]), + bank, + ); + assert.equal(res.verdict, "PASS"); + assert.equal(res.problems.length, 0); + assert.deepEqual(res.coverage, { + total: 3, + specific: 1, + abstrakt: 1, + ekstern: 1, + unresolved: 0, + }); + }); + + test("an unresolved slot → BLOCK with reason 'unresolved'", () => { + const res = validateBinding( + ls([ + slot("kp1", { type: "specific", specificId: "aaa" }), + slot("kp2", { type: "unresolved" }), + ]), + mkBank([mkSpecific({ id: "aaa" })]), + ); + assert.equal(res.verdict, "BLOCK"); + assert.ok( + res.problems.some((p) => p.slotId === "kp2" && p.reason === "unresolved"), + "kp2 should be flagged unresolved", + ); + assert.equal(res.coverage.unresolved, 1); + }); + + test("a specific slot whose id is not in the bank → BLOCK 'dangling-specific'", () => { + const res = validateBinding( + ls([slot("kp1", { type: "specific", specificId: "missing" })]), + mkBank([]), + ); + assert.equal(res.verdict, "BLOCK"); + assert.ok( + res.problems.some((p) => p.slotId === "kp1" && p.reason === "dangling-specific"), + "dangling id must be caught", + ); + // dangling still counts under `specific` in coverage (binding TYPE is specific) + assert.equal(res.coverage.specific, 1); + }); + + test("an abstrakt escape with a blank rationale is vaghet → BLOCK 'unjustified-abstrakt'", () => { + const res = validateBinding(ls([slot("kp1", { type: "abstrakt", rationale: " " })]), mkBank([])); + assert.equal(res.verdict, "BLOCK"); + assert.ok( + res.problems.some((p) => p.slotId === "kp1" && p.reason === "unjustified-abstrakt"), + "an unjustified abstraction must be rejected", + ); + }); + + test("ekstern needs no source (research fills it later) → PASS", () => { + const res = validateBinding( + ls([ + slot("a", { type: "ekstern" }), + slot("b", { type: "abstrakt", rationale: "x" }), + slot("c", { type: "abstrakt", rationale: "y" }), + ]), + mkBank([]), + ); + assert.equal(res.verdict, "PASS"); + assert.deepEqual(res.coverage, { + total: 3, + specific: 0, + abstrakt: 2, + ekstern: 1, + unresolved: 0, + }); + }); + + test("a slot backed by an UNVERIFIED number → PASS + warning (regel 6/7)", () => { + const bank = mkBank([mkSpecific({ id: "num1", type: "number", verification: "unverified" })]); + const res = validateBinding(ls([slot("kp1", { type: "specific", specificId: "num1" })]), bank); + assert.equal(res.verdict, "PASS", "an unverified number warns, it does not block"); + assert.ok( + res.warnings.some((w) => w.slotId === "kp1" && w.reason === "unverified-number"), + "unverified number must surface as a warning", + ); + }); + + test("a slot backed by a VERIFIED number → PASS, no warning", () => { + const bank = mkBank([mkSpecific({ id: "num1", type: "number", verification: "verified" })]); + const res = validateBinding(ls([slot("kp1", { type: "specific", specificId: "num1" })]), bank); + assert.equal(res.verdict, "PASS"); + assert.equal(res.warnings.length, 0); + }); + + test("empty slot-map → PASS, total 0 (the command decides whether ≥1 slot is required)", () => { + const res = validateBinding(ls([]), mkBank([])); + assert.equal(res.verdict, "PASS"); + assert.equal(res.coverage.total, 0); + assert.equal(res.problems.length, 0); + }); + + test("multiple problems are all reported (gate is not short-circuited)", () => { + const res = validateBinding( + ls([ + slot("a", { type: "unresolved" }), + slot("b", { type: "specific", specificId: "nope" }), + slot("c", { type: "abstrakt", rationale: "" }), + ]), + mkBank([]), + ); + assert.equal(res.verdict, "BLOCK"); + assert.equal(res.problems.length, 3); + }); +}); diff --git a/scripts/specifics-bank/tests/kilder.test.ts b/scripts/specifics-bank/tests/kilder.test.ts new file mode 100644 index 0000000..55515a8 --- /dev/null +++ b/scripts/specifics-bank/tests/kilder.test.ts @@ -0,0 +1,130 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { renderKilder } from "../src/kilder.js"; +import type { LivedSpecifics, Slot } from "../src/binding.js"; +import type { Bank, Specific } from "../src/types.js"; + +function mkSpecific(over: Partial<Specific> & { id: string }): Specific { + return { + id: over.id, + type: over.type ?? "other", + content: over.content ?? "x", + topicTags: over.topicTags ?? ["t"], + provenance: over.provenance ?? { capturedAt: "2026-06-20", source: "test" }, + verification: over.verification ?? "n/a", + status: over.status ?? "active", + }; +} + +const mkBank = (specifics: Specific[]): Bank => ({ schemaVersion: 1, specifics }); +const ls = (slots: Slot[]): LivedSpecifics => ({ slots, status: "pending" }); + +describe("renderKilder — the NN-kilder.md artifact", () => { + test("golden render of a mixed edition (specific-number-unverified + abstrakt + ekstern + unresolved)", () => { + const bank = mkBank([ + mkSpecific({ + id: "num1", + type: "number", + verification: "unverified", + content: "Sparte to dager i uka.", + topicTags: ["saksbehandling", "tid"], + provenance: { capturedAt: "2026-06-19", source: "seres/05" }, + }), + ]); + const livedSpecifics = ls([ + { + slotId: "kp1", + kind: "key-point", + label: "Saksbehandling ble raskere", + binding: { type: "specific", specificId: "num1" }, + }, + { + slotId: "kp2", + kind: "key-point", + label: "Verktøyet bærer faget", + binding: { type: "abstrakt", rationale: "Poenget er prinsippet, ikke et enkelttilfelle." }, + }, + { + slotId: "kp3", + kind: "key-point", + label: "LinkedIn ranker på relevans", + binding: { type: "ekstern" }, + }, + { + slotId: "kp4", + kind: "key-point", + label: "Noe uavklart", + binding: { type: "unresolved" }, + }, + ]); + + const expected = `# Kilder — Maskinrommet (05) + +> Auto-generert fra edition-state \`livedSpecifics\` + specifics-bank. Per lastbærende påstand: hva backer den. +> Levd materiale er KTGs egne ord, aldri AI-oppdiktet (retning §3). Tall er uverifiserte til fakta-sjekket (regel 6/7). + +## Lastbærende påstander + +### kp1 · Saksbehandling ble raskere +- **Binding:** levd materiale (number) · \`num1\` · uverifisert ⚠ +- **Materiale:** «Sparte to dager i uka.» +- **Opphav:** seres/05, fanget 2026-06-19 +- **Tema-tagger:** saksbehandling, tid + +### kp2 · Verktøyet bærer faget +- **Binding:** abstrakt (med vilje) — Poenget er prinsippet, ikke et enkelttilfelle. + +### kp3 · LinkedIn ranker på relevans +- **Binding:** ekstern kilde — (fylles av research, Step 2) + +### kp4 · Noe uavklart +- **Binding:** ⛔ uavklart — må bindes før skjelett-gaten (vaghet avvises) + +## Dekning +- Lastbærende slots: 4 +- Levd materiale: 1 +- Abstrakt (escape): 1 +- Ekstern: 1 +- Uavklart (BLOCK): 1 + +Tall som må verifiseres (regel 6/7): kp1 +`; + + assert.equal(renderKilder({ articleId: "05", title: "Maskinrommet", livedSpecifics, bank }), expected); + }); + + test("a verified named-case renders ✓ and no number-verification line", () => { + const bank = mkBank([ + mkSpecific({ id: "c1", type: "named-case", verification: "verified", content: "Etat X." }), + ]); + const out = renderKilder({ + articleId: "01", + title: "T", + livedSpecifics: ls([ + { slotId: "kp1", kind: "key-point", label: "L", binding: { type: "specific", specificId: "c1" } }, + ]), + bank, + }); + assert.match(out, /levd materiale \(named-case\) · `c1` · verifisert ✓/); + assert.match(out, /Tall som må verifiseres \(regel 6\/7\): ingen/); + }); + + test("a dangling specific id renders an explicit dangling note", () => { + const out = renderKilder({ + articleId: "01", + title: "T", + livedSpecifics: ls([ + { slotId: "kp1", kind: "key-point", label: "L", binding: { type: "specific", specificId: "ghost" } }, + ]), + bank: mkBank([]), + }); + assert.match(out, /⛔ levd materiale — id `ghost` mangler i banken \(dangling\)/); + }); + + test("an empty slot-map renders the 'ingen slots' placeholder, not an empty section", () => { + const out = renderKilder({ articleId: "01", title: "T", livedSpecifics: ls([]), bank: mkBank([]) }); + assert.match(out, /_Ingen lastbærende slots bundet ennå\._/); + assert.match(out, /- Lastbærende slots: 0/); + }); +}); diff --git a/scripts/specifics-bank/tsconfig.json b/scripts/specifics-bank/tsconfig.json new file mode 100644 index 0000000..eaa1e8e --- /dev/null +++ b/scripts/specifics-bank/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "tests"] +} diff --git a/scripts/test-runner.sh b/scripts/test-runner.sh index 3fd252d..febef8b 100755 --- a/scripts/test-runner.sh +++ b/scripts/test-runner.sh @@ -16,7 +16,56 @@ # command was cleaned of survives in the reference it renders from) in S12; the # `$`-safety guard (no untrusted value reaches a String.replace replacement STRING # in state-updater.mjs — proven behaviorally, coverage-complete, self-testing) in -# S13. All five are live below (Sections 8, 9, 10, 11 and 12). +# S13. The external data-dir convention guard (M0: no command/agent/skill/hook prose +# references a migrated user-data path in-plugin — bare or ${CLAUDE_PLUGIN_ROOT}-pinned; +# + no ANALYTICS_ROOT in-plugin pin; + an SC2 dry-run that the tree holds no in-plugin +# user data) in Section 13, each with a non-vacuity self-test; the contract-gate +# binding guard (Slice 3: the §B/§C1 gate's own suite stays green and rules.ts +# ratifies 1:1 against the maskinrommet §E-manifest — KTG-only, skipped for an adopter +# shipping no deps/contract) in Section 14; the specifics-bank binding guard (Fix #2 +# slice 3: the lived-specifics store/binding suite stays green and its case count +# never erodes — KTG-only, skipped for an adopter shipping no deps) in Section 15; +# the trends-store binding guard (research-engine slice 2b: the trend store's suite +# stays green and its case count never erodes, now that trend-spotter persists its +# findings through it — KTG-only, skipped for an adopter shipping no deps) in Section +# 16; the trend-spotter de-niche guard (B-S1: agents/trend-spotter.md names no +# hardcoded vendor/sector beat — the domain comes from the user's pillars at runtime, +# never baked into the agent — with a non-vacuity self-test) in Section 17; the brain +# profile-reader guard (SB-S3a: strategy-advisor names brain/profile.md AND carries the +# anti-sycophancy literal 'evidence to TEST', with a non-vacuity self-test) in Section +# 16d; the brain operations-reader guard (SB-S3d: strategy-advisor names +# brain/operations.md AND carries the frozen-past-self literal 'deprecates older +# inferences', with a non-vacuity self-test) in Section 16e; the brain reconcile-wiring +# guard (SB-S3e: scripts/brain/src/cli.ts dispatches `reconcile` AND calls the core +# reconcileRecentPosts by literal name, with a non-vacuity self-test) in Section 16f; +# the trends-scorer wiring guard (RE-R1: scripts/trends/src/score.ts encodes both mode +# weight-sets AND agents/trend-spotter.md references the scorer CLI 'src/cli.ts score', +# with a non-vacuity self-test) in Section 16g; the trends-capture wiring guard (RE-R2a: +# scripts/trends/src/cli.ts dispatches `capture` AND agents/trend-spotter.md references the +# capture CLI 'src/cli.ts capture', with a non-vacuity self-test) in Section 16h; the +# trends-brief wiring guard (RE-R2b: scripts/trends/src/cli.ts dispatches `brief`, +# agents/trend-spotter.md references the brief CLI 'src/cli.ts brief', AND +# hooks/scripts/session-start.mjs surfaces it via 'latestMorningBrief', with a non-vacuity +# self-test) in Section 16i; the trends-score wiring guard (RE-R3a: scripts/trends/src/score.ts +# exports the 'export interface TrendScore' persist envelope, scripts/trends/src/types.ts carries +# 'score?: TrendScore' on the record, agents/trend-spotter.md carries the judgment via '"dimensions"', +# AND scripts/trends/src/brief.ts ranks on 'score?.composite', with a non-vacuity self-test) in +# Section 16j; the trends-lifecycle wiring guard (RE-R3b: scripts/trends/src/types.ts declares +# 'export type TrendStatus' AND carries 'surfacedCount', scripts/trends/src/store.ts owns +# 'export function markSurfaced', scripts/trends/src/brief.ts excludes handled via 'effectiveStatus', +# AND scripts/trends/src/cli.ts exposes 'command === "act"', with a non-vacuity self-test) in +# Section 16k; the trends-scheduler/headless wiring guard (RE-R3c: scripts/trends/src/schedule.ts +# emits 'export function launchdPlist' AND 'export function crontabLine', scripts/trends/src/cli.ts +# exposes 'command === "schedule"', scripts/trends/run-daily.sh runs 'cli.ts" brief' AND uses +# 'LINKEDIN_STUDIO_DATA:-', with a non-vacuity self-test) in Section 16l; the trends-temporal-overlay +# wiring guard (RE-R3d: scripts/trends/src/brief.ts has 'export function temporalSignal' AND the cmp +# key 'b.temporal.rank' AND the '"first-mover"' tier, scripts/trends/src/cli.ts exposes the +# 'first-mover-days' AND 'saturation-at' brief flags, with a non-vacuity self-test) in Section 16m; +# the trends-brief-history/diff wiring guard (RE-R3e: scripts/trends/src/brief.ts has +# 'export function diffSurfaced' AND 'parseSurfacedFrontmatter' AND the 'Nytt siden sist' section AND +# the 'surfaced: ' frontmatter emit, scripts/trends/src/cli.ts wires 'selectPriorBriefFile', with a +# non-vacuity self-test) in Section 16n; the assertion-count anti-erosion floor (SC6) in Section 18. All +# are live below (Sections 8–18). # # Usage: bash scripts/test-runner.sh # bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`. @@ -43,8 +92,16 @@ warn() { echo -e "${YELLOW}⚠${NC} $1"; WARN=$((WARN + 1)); } # with the files when adding/removing an agent, command, reference, or skill. EXPECT_AGENTS=19 EXPECT_COMMANDS=29 -EXPECT_REFS=25 +EXPECT_REFS=27 EXPECT_SKILLS=6 +# Pre-M0 references/ baseline was 25. Every ref doc added since is NAMED below, so the +# count bump always maps to an intended, named addition — never an incidental doc masked +# by the bump (m3/m11). M0 added data-path-convention.md; each later slice appends its +# doc to POSTM0_REFS. The assert below proves EXPECT_REFS == 25 + 1 (M0) + |POSTM0_REFS| +# AND that every named doc actually exists. bash 3.2-safe: plain indexed array. +REFS_BASELINE_PRE_M0=25 +M0_REF="references/data-path-convention.md" +POSTM0_REFS=("references/trend-scoring-modes.md") # research-engine slice 2a (scoring SSOT) echo "================================================" echo "LinkedIn Studio Plugin — Structure Validator" @@ -87,6 +144,21 @@ assert_count "commands/*.md" "$COMMANDS" "$EXPECT_COMMANDS" assert_count "references/*.md" "$REFS" "$EXPECT_REFS" assert_count "skills/*/SKILL.md" "$SKILLS" "$EXPECT_SKILLS" +# references/ count must map 1:1 to the NAMED additions (M0 + every later slice's doc), +# and each named doc must exist. Guards against the bump silently absorbing an incidental +# extra ref doc (m3/m11). To add a ref doc: append it to POSTM0_REFS and bump EXPECT_REFS. +NAMED_REFS_OK=1 +[ -f "$M0_REF" ] || NAMED_REFS_OK=0 +for r in "${POSTM0_REFS[@]}"; do + [ -f "$r" ] || NAMED_REFS_OK=0 +done +EXPECT_NAMED=$((REFS_BASELINE_PRE_M0 + 1 + ${#POSTM0_REFS[@]})) +if [ "$EXPECT_REFS" -eq "$EXPECT_NAMED" ] && [ "$NAMED_REFS_OK" -eq 1 ]; then + pass "references/ count maps to named additions (${REFS_BASELINE_PRE_M0} baseline +1 M0 +${#POSTM0_REFS[@]} post-M0 = ${EXPECT_REFS}; all named docs exist)" +else + fail "references/ count != named additions — bump EXPECT_REFS and name the doc in POSTM0_REFS (expected ${EXPECT_NAMED}, have ${EXPECT_REFS}; named-docs-exist=${NAMED_REFS_OK})" +fi + # Cross-check the CLAUDE.md declared headers against the contract (doc-drift guard) DOC_AGENTS=$(grep -oE '^## Agents \([0-9]+\)' CLAUDE.md | grep -oE '[0-9]+' | head -1) DOC_COMMANDS=$(grep -oE '^## Commands \([0-9]+\)' CLAUDE.md | grep -oE '[0-9]+' | head -1) @@ -458,6 +530,1028 @@ fi echo "" +# --- Section 13: External Data-Dir Convention (R1 — M0) --- +echo "--- External Data-Dir Convention ---" + +# After the M0 migration (user data lives under ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/ +# linkedin-studio}/), no command/agent/skill/hook prose may reference a MIGRATED +# user-data path in-plugin — neither bare (assets/<x>/) nor ${CLAUDE_PLUGIN_ROOT}- +# prefixed — or a command writes the external dir while Claude follows stale in-plugin +# prose (R1, brief §12.6). Shipped read-only assets keep ${CLAUDE_PLUGIN_ROOT} and are +# exempt: the analytics README, every *-template.md seed, assets/checklists/, +# assets/quick-post-resources.md, the shipped assets/templates/*, config/*.template.*. +# config/personas.local.md is a deliberate in-plugin fallback library (un-migrated, out +# of M0 scope) and is NOT in the class. Scope = the actionable prose dirs (commands/ +# agents/skills/hooks-prompts); references/ is documentation (the convention doc +# legitimately names scaffold paths) and is excluded. +BARE_DATA='(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/(voice-samples|analytics|drafts|plans|audience-insights|examples|frameworks|case-studies|network)/|(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/repurposing-tracker\.md|(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/templates/my-post-templates\.md|(\$\{CLAUDE_PLUGIN_ROOT\}/)?config/user-profile\.local\.md' +BARE_EXEMPT='README\.md|-template\.md' + +# Non-vacuity self-test (mirrors Section 8): the full criterion is regex-match AND +# not-exempt. Positives MUST be violations; negatives (external token + every shipped +# exemption + the out-of-scope personas lib) MUST NOT be. +B13_OK=1 +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if echo "$probe" | grep -qE "$BARE_DATA" && ! echo "$probe" | grep -qE "$BARE_EXEMPT"; then :; else + B13_OK=0; echo " non-vacuity FAIL: in-plugin data path not flagged -> $probe" + fi +done <<'POSITIVE13' +assets/voice-samples/authentic-voice-samples.md +${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/ +assets/drafts/queue.json +assets/templates/my-post-templates.md +assets/frameworks/ai-maturity-model.md +assets/repurposing-tracker.md +config/user-profile.local.md +POSITIVE13 +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if echo "$probe" | grep -qE "$BARE_DATA" && ! echo "$probe" | grep -qE "$BARE_EXEMPT"; then + B13_OK=0; echo " false-positive FAIL: legitimate path flagged -> $probe" + fi +done <<'NEGATIVE13' +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/ +assets/analytics/README.md +assets/frameworks/framework-template.md +assets/case-studies/case-study-template.md +assets/checklists/quality-scorecard.md +assets/quick-post-resources.md +config/user-profile.template.md +config/personas.local.md +NEGATIVE13 +if [ "$B13_OK" -eq 1 ]; then + pass "no-bare-path self-test: 7 in-plugin data paths caught, 8 legitimate/exempt forms ignored" +else + fail "no-bare-path self-test failed — the R1 criterion no longer enforces in-plugin data-path detection" +fi + +BARE_HITS=$(grep -rnE "$BARE_DATA" commands/ agents/ skills/ hooks/prompts/ 2>/dev/null | grep -vE "$BARE_EXEMPT" || true) +if [ -z "$BARE_HITS" ]; then + pass "no command/agent/skill/hook prose references a migrated user-data path in-plugin (R1)" +else + fail "in-plugin user-data path(s) in prose — route via \${LINKEDIN_STUDIO_DATA:-\$HOME/.claude/linkedin-studio}/ (see references/data-path-convention.md):" + echo "$BARE_HITS" +fi + +# Sibling no-pin guard (M3): the no-bare-path grep cannot catch an ANALYTICS_ROOT alias +# pinned to the in-plugin analytics dir — Step 11 dropped these; forbid their return. +PIN='ANALYTICS_ROOT=.{0,2}\$\{CLAUDE_PLUGIN_ROOT\}/assets/analytics' +PIN_OK=1 +echo 'ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"' | grep -qE "$PIN" || { PIN_OK=0; echo " non-vacuity FAIL: in-plugin pin not caught"; } +if echo 'ANALYTICS_ROOT="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics"' | grep -qE "$PIN"; then PIN_OK=0; echo " false-positive FAIL: external alias caught"; fi +if [ "$PIN_OK" -eq 1 ]; then + pass "no-pin self-test: in-plugin ANALYTICS_ROOT pin caught, external alias ignored" +else + fail "no-pin self-test failed — the M3 pin criterion is not enforced" +fi +PIN_HITS=$(grep -rnE "$PIN" commands/ agents/ skills/ hooks/prompts/ 2>/dev/null || true) +if [ -z "$PIN_HITS" ]; then + pass "no prose pins ANALYTICS_ROOT to the in-plugin analytics dir (M3)" +else + fail "ANALYTICS_ROOT pinned to in-plugin analytics — drop the pin (external default applies):" + echo "$PIN_HITS" +fi + +# SC2 dry-run: the migrated in-plugin user-data classes (.gitignore) must hold NO data +# files — after M0 they live external. --ignored surfaces gitignored stragglers a plain +# porcelain would hide; filtered to the data classes, it must be empty. Catches a flow +# (or a stray file) that wrote user data back into the plugin tree (SC2). personas is +# excluded (un-migrated, out of M0 scope — consistent with Section 13's class). +SC2_CLASSES='assets/analytics/(exports|posts|weekly-reports|monthly-reports)/|assets/drafts/queue\.json|assets/drafts/week-|assets/voice-samples/[^ ]*\.local\.md|config/user-profile\.local\.md' +SC2_DIRT=$(git status --porcelain --ignored 2>/dev/null | grep -E "$SC2_CLASSES" || true) +if [ -z "$SC2_DIRT" ]; then + pass "SC2 dry-run: no in-plugin user-data files (analytics/drafts/voice/profile) in the tree" +else + fail "SC2 violation — migrated user data sits in the plugin tree (should be external):" + echo "$SC2_DIRT" +fi + +echo "" + +# --- Section 14: Contract-Gate Binding (Slice 3) --- +echo "--- Contract-Gate Binding ---" + +# The deterministic §B/§C1 contract-gate (scripts/contract-gate) is wired into +# /linkedin:newsletter as Step 4.5. Two invariants belong in CI, not per-edition: +# (14a) the gate's own test suite stays green and its case count never erodes; and +# (14b) rules.ts stays bound 1:1 to the maskinrommet skrivekontrakt §E-manifest +# (ratify). Both are KTG-internal: skipped (warn, never fail) for an adopter that +# ships no contract-gate deps or no contract — so the lint stays green everywhere. +# bash 3.2-safe; `set +e` inside the test command-substitution keeps a red npm test +# from aborting the whole runner (set -e is active), and ratify exit 1 is captured by +# the `if (subshell)` form (condition context is exempt from set -e). +CG_DIR="scripts/contract-gate" +if [ -x "$CG_DIR/node_modules/.bin/tsx" ]; then + # 14a: the gate suite is green (npm test exit 0) and holds its case-count floor. + CG_OUT=$( set +e; (cd "$CG_DIR" && npm test) 2>&1; echo "CG_EXIT:$?" ) + CG_EXIT=$(echo "$CG_OUT" | grep -oE 'CG_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) + CG_TESTS=$(echo "$CG_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) + CONTRACT_GATE_TESTS_FLOOR=33 + if [ "$CG_EXIT" = "0" ] && [ -n "$CG_TESTS" ] && [ "$CG_TESTS" -ge "$CONTRACT_GATE_TESTS_FLOOR" ]; then + pass "contract-gate suite green: $CG_TESTS tests pass (floor $CONTRACT_GATE_TESTS_FLOOR)" + else + fail "contract-gate suite NOT green (exit=${CG_EXIT:-?}, tests=${CG_TESTS:-?}, floor $CONTRACT_GATE_TESTS_FLOOR) — run: (cd $CG_DIR && npm test)" + fi + + # 14b: ratify — rules.ts <-> §E-manifest 1:1. Skipped when the contract is absent. + CONTRACT="${MASKINROMMET_CONTRACT:-$PLUGIN_ROOT/../../maskinrommet/docs/skrivekontrakt.md}" + if [ -f "$CONTRACT" ]; then + if (cd "$CG_DIR" && node --import tsx src/cli.ts --ratify) >/dev/null 2>&1; then + pass "contract-gate ratify: rules.ts bound 1:1 to the §E-manifest (in sync)" + else + fail "contract-gate ratify DRIFT — rules.ts != §E-manifest; run: (cd $CG_DIR && npm run ratify)" + fi + else + warn "contract-gate ratify skipped — contract absent ($CONTRACT); KTG-only invariant" + fi +else + warn "contract-gate skipped — deps absent ($CG_DIR/node_modules); run: (cd $CG_DIR && npm install)" +fi + +echo "" + +# --- Section 15: Specifics-Bank Binding (Fix #2 slice 3) --- +echo "--- Specifics-Bank Binding ---" + +# The lived-specifics store + per-edition binding (scripts/specifics-bank) is wired +# into /linkedin:newsletter as Step 1.5 (elicitation + slot-map) and its +# validate-binding gate into the Step 2.5 skeleton-gate. The seam is now load-bearing, +# so its suite belongs in CI: the bank/binding/kilder tests stay green and the case +# count never erodes. KTG-internal: skipped (warn, never fail) for an adopter that +# ships no specifics-bank deps — so the lint stays green everywhere. Same set +e / +# subshell discipline as Section 14 (bash 3.2-safe; keeps a red npm test from +# aborting the runner under set -e). +SB_DIR="scripts/specifics-bank" +if [ -x "$SB_DIR/node_modules/.bin/tsx" ]; then + SB_OUT=$( set +e; (cd "$SB_DIR" && npm test) 2>&1; echo "SB_EXIT:$?" ) + SB_EXIT=$(echo "$SB_OUT" | grep -oE 'SB_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) + SB_TESTS=$(echo "$SB_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) + SPECIFICS_BANK_TESTS_FLOOR=28 + if [ "$SB_EXIT" = "0" ] && [ -n "$SB_TESTS" ] && [ "$SB_TESTS" -ge "$SPECIFICS_BANK_TESTS_FLOOR" ]; then + pass "specifics-bank suite green: $SB_TESTS tests pass (floor $SPECIFICS_BANK_TESTS_FLOOR)" + else + fail "specifics-bank suite NOT green (exit=${SB_EXIT:-?}, tests=${SB_TESTS:-?}, floor $SPECIFICS_BANK_TESTS_FLOOR) — run: (cd $SB_DIR && npm test)" + fi +else + warn "specifics-bank skipped — deps absent ($SB_DIR/node_modules); run: (cd $SB_DIR && npm install)" +fi + +echo "" + +# --- Section 16: Trends-Store Binding (research-engine slice 2b) --- +echo "--- Trends-Store Binding ---" + +# The persistent trend store (scripts/trends) is wired into the trend-spotter agent +# (slice 2b): the agent queries prior history and persists each kept trend through the +# store's deterministic add (dedup/union), so the store is now load-bearing for the +# research engine's de-amnesia — not a standalone inventory. Its suite therefore belongs +# in CI: the store/dedup/query tests stay green and the case count never erodes. Mirrors +# the specifics-bank binding guard (Section 15) — the trend-side twin. KTG-internal: +# skipped (warn, never fail) for an adopter that ships no trends deps. Same set +e / +# subshell discipline as Sections 14–15 (bash 3.2-safe; keeps a red npm test from +# aborting the runner under set -e). +TR_DIR="scripts/trends" +if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then + TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" ) + TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) + TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) + TRENDS_TESTS_FLOOR=245 # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log) + RE-R3c: schedule +9, cli +8, run-daily +4 (scheduler + headless wrapper) + RE-R3d: brief +21, cli +3 (temporal overlay: first-mover + saturation) + RE-R3e: brief +27, cli +2 (brief history + diff) + if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then + pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)" + else + fail "trends-store suite NOT green (exit=${TR_EXIT:-?}, tests=${TR_TESTS:-?}, floor $TRENDS_TESTS_FLOOR) — run: (cd $TR_DIR && npm test)" + fi +else + warn "trends-store skipped — deps absent ($TR_DIR/node_modules); run: (cd $TR_DIR && npm install)" +fi + +echo "" + +# --- Section 16b: Brain Foundation (SB-S0) --- +echo "--- Brain Foundation ---" + +# The second-brain foundation (scripts/brain, SB-S0) ships the brain/ scaffold +# initialiser, the two-layer profile.md line-grammar, the lossless+idempotent +# user-profile fold, and the canonical entity-id + provenance module. This is the +# spine later slices hang on (the id threads through tributaries in SB-S3, the +# evolution loop consumes the profile in SB-S2), so the suite stays green and its +# case count never erodes. KTG-internal: skipped (warn, never fail) for an adopter +# that ships no brain deps. Same set +e / subshell discipline as Sections 14-16 +# (bash 3.2-safe; keeps a red npm test from aborting the runner under set -e). +BR_DIR="scripts/brain" +if [ -x "$BR_DIR/node_modules/.bin/tsx" ]; then + BR_OUT=$( set +e; (cd "$BR_DIR" && npm test) 2>&1; echo "BR_EXIT:$?" ) + BR_EXIT=$(echo "$BR_OUT" | grep -oE 'BR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) + BR_TESTS=$(echo "$BR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) + BRAIN_TESTS_FLOOR=127 # SB-S0 34 [id(11)+profile(6)+fold(12)+scaffold(5)] + SB-S1 29 [ingest(14)+publish(9)+cli(6)] + SB-S2 19 [consolidate(12)+consolidate-cli(7)] + SB-S3b 12 [consolidate(10)+consolidate-cli(2)] + SB-S3c 19 [ingest(4)+publish(3)+assemble(8)+cli(4)] + SB-S3d 1 [scaffold dated-anchor seed] + SB-S3e 13 [reconcile: parse(5)+tiers(7)+io(1)] + if [ "$BR_EXIT" = "0" ] && [ -n "$BR_TESTS" ] && [ "$BR_TESTS" -ge "$BRAIN_TESTS_FLOOR" ]; then + pass "brain suite green: $BR_TESTS tests pass (floor $BRAIN_TESTS_FLOOR)" + else + fail "brain suite NOT green (exit=${BR_EXIT:-?}, tests=${BR_TESTS:-?}, floor $BRAIN_TESTS_FLOOR) — run: (cd $BR_DIR && npm test)" + fi +else + warn "brain skipped — deps absent ($BR_DIR/node_modules); run: (cd $BR_DIR && npm install)" +fi + +echo "" + +# --- Section 16c: Brain Published-Only Invariant (SB-S1) --- +echo "--- Brain Published-Only Invariant ---" + +# SB-S1 wires voice-trainer to learn from the published gold signal ONLY: the agent +# must (a) name ingest/published/ as a source AND (b) carry the explicit exclusion of +# provenance=ai-draft (the model-collapse guard — never train voice on the engine's own +# drafts). Both literals are required. voice-trainer.md ALREADY contains "AI-generated" +# (a drift-cause description, line ~175), so a loose ai.?draft pattern would pass +# vacuously — we grep the EXACT literals with grep -F. The contract doc must also state +# the rule. Non-vacuity self-test mirrors Sections 8/13/17: a probe counts as "wired" +# iff it carries BOTH literals; probes missing either (incl. one with "AI-generated" but +# not "ai-draft") must NOT count as wired. The wiring is gate-enforced; the agent's +# runtime OBEDIENCE is not provable here (stated honestly in the contract doc). +PUB_LIT='ingest/published' +DRAFT_LIT='provenance=ai-draft' + +pub_wired() { # $1 = text; wired iff BOTH literals present (echo twice — grep consumes stdin) + echo "$1" | grep -qF "$PUB_LIT" && echo "$1" | grep -qF "$DRAFT_LIT" +} + +PUB_SELFTEST_OK=1 +if ! pub_wired "reads from ingest/published/ and excludes provenance=ai-draft"; then + PUB_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if pub_wired "$probe"; then + PUB_SELFTEST_OK=0; echo " false-positive FAIL: under-wired probe accepted -> $probe" + fi +done <<'NEGATIVE16C' +reads from ingest/published/ but never states the draft exclusion +excludes provenance=ai-draft but never names the published source +flags AI-generated drift but carries neither required literal +NEGATIVE16C +if [ "$PUB_SELFTEST_OK" -eq 1 ]; then + pass "published-only self-test: full wiring detected; 3 under-wired forms rejected (incl. 'AI-generated'-only)" +else + fail "published-only self-test failed — the invariant lint is vacuous or over-eager" +fi + +VT="agents/voice-trainer.md" +if grep -qF "$PUB_LIT" "$VT" && grep -qF "$DRAFT_LIT" "$VT"; then + pass "voice-trainer.md wired to published-only (names '$PUB_LIT', excludes '$DRAFT_LIT')" +else + fail "voice-trainer.md missing published-only wiring — needs both '$PUB_LIT' and '$DRAFT_LIT'" +fi + +PUB_DOC="docs/second-brain/ingest-manual-import.md" +if grep -qF "$DRAFT_LIT" "$PUB_DOC"; then + pass "ingest-manual-import.md documents the published-only rule ('$DRAFT_LIT')" +else + fail "ingest-manual-import.md missing the published-only rule literal '$DRAFT_LIT'" +fi + +echo "" + +# --- Section 16d: Brain Profile Reader (SB-S3a) --- +echo "--- Brain Profile Reader ---" + +# SB-S3a wires the FIRST reader of the evolving second-brain profile: strategy-advisor +# must (a) name brain/profile.md as a context source AND (b) carry the anti-sycophancy +# literal 'evidence to TEST' (architecture.md:58 — the profile informs, it never dictates +# or flatters; every fact is counter-pressured against analytics/state). Both literals +# are required, grepped EXACT with grep -F: a loose 'profile' pattern would match the +# legacy flat profile/user-profile.md vacuously, and a loose lowercase 'evidence' would +# match generic prose. Non-vacuity self-test mirrors Sections 8/13/16c/17: a probe counts +# as "wired" iff it carries BOTH exact literals; probes missing either (incl. a decoy +# naming the legacy path + lowercase "evidence") must NOT count as wired. The wiring is +# gate-enforced; the agent's runtime OBEDIENCE is verified manually (SC4/SC5 at land — +# agent-prompt behaviour is not unit-testable; the brief states this honestly). +READER_LIT='brain/profile.md' +ANTISYC_LIT='evidence to TEST' + +reader_wired() { # $1 = text; wired iff BOTH literals present (echo twice — grep consumes stdin) + echo "$1" | grep -qF "$READER_LIT" && echo "$1" | grep -qF "$ANTISYC_LIT" +} + +READER_SELFTEST_OK=1 +if ! reader_wired "reads brain/profile.md as evidence to TEST"; then + READER_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if reader_wired "$probe"; then + READER_SELFTEST_OK=0; echo " false-positive FAIL: under-wired probe accepted -> $probe" + fi +done <<'NEGATIVE16D' +reads brain/profile.md but never frames how to weigh it +treats facts as evidence to TEST but names no source file +reads profile/user-profile.md and tests the evidence +NEGATIVE16D +if [ "$READER_SELFTEST_OK" -eq 1 ]; then + pass "profile-reader self-test: full wiring detected; 3 under-wired forms rejected (incl. legacy-path + lowercase 'evidence' decoy)" +else + fail "profile-reader self-test failed — the reader-wiring lint is vacuous or over-eager" +fi + +SA="agents/strategy-advisor.md" +if grep -qF "$READER_LIT" "$SA" && grep -qF "$ANTISYC_LIT" "$SA"; then + pass "strategy-advisor.md wired to brain profile reader (names '$READER_LIT', frames '$ANTISYC_LIT')" +else + fail "strategy-advisor.md missing brain profile reader wiring — needs both '$READER_LIT' and '$ANTISYC_LIT'" +fi + +echo "" + +# --- Section 16e: Brain Operations Reader (SB-S3d) --- +echo "--- Brain Operations Reader ---" + +# SB-S3d wires strategy-advisor to read the second-brain OPERATIONS centre: it must +# (a) name brain/operations.md as a context source AND (b) carry the frozen-past-self +# literal 'deprecates older inferences' (architecture.md:59 — the dated "who I am now" +# anchor outranks/deprecates older profile inferences; advisory/reader-side). Both +# literals are required, grepped EXACT with grep -F: a loose 'operations' pattern would +# match generic prose, and naming the sibling brain/profile.md must NOT satisfy the +# operations literal. Non-vacuity self-test mirrors Sections 8/13/16c/16d: a probe counts +# as "wired" iff it carries BOTH exact literals; probes missing either (incl. a sibling- +# file decoy naming brain/profile.md) must NOT count as wired. The wiring is gate-enforced; +# the agent's runtime OBEDIENCE is verified manually (SC6/SC7 at land — agent-prompt +# behaviour is not unit-testable; the brief states this honestly). +OPS_LIT='brain/operations.md' +ANCHOR_LIT='deprecates older inferences' + +ops_reader_wired() { # $1 = text; wired iff BOTH literals present (echo twice — grep consumes stdin) + echo "$1" | grep -qF "$OPS_LIT" && echo "$1" | grep -qF "$ANCHOR_LIT" +} + +OPS_SELFTEST_OK=1 +if ! ops_reader_wired "reads brain/operations.md; the anchor deprecates older inferences"; then + OPS_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if ops_reader_wired "$probe"; then + OPS_SELFTEST_OK=0; echo " false-positive FAIL: under-wired probe accepted -> $probe" + fi +done <<'NEGATIVE16E' +reads brain/operations.md but never says the anchor outranks anything +the anchor deprecates older inferences but names no source file +reads brain/profile.md and tests the evidence +NEGATIVE16E +if [ "$OPS_SELFTEST_OK" -eq 1 ]; then + pass "ops-reader self-test: full wiring detected; 3 under-wired forms rejected (incl. sibling-file brain/profile.md decoy)" +else + fail "ops-reader self-test failed — the ops-reader-wiring lint is vacuous or over-eager" +fi + +SA_OPS="agents/strategy-advisor.md" +if grep -qF "$OPS_LIT" "$SA_OPS" && grep -qF "$ANCHOR_LIT" "$SA_OPS"; then + pass "strategy-advisor.md wired to brain operations reader (names '$OPS_LIT', frames '$ANCHOR_LIT')" +else + fail "strategy-advisor.md missing brain operations reader wiring — needs both '$OPS_LIT' and '$ANCHOR_LIT'" +fi + +echo "" + +# --- Section 16f: Brain Reconcile Wiring (SB-S3e) --- +echo "--- Brain Reconcile Wiring ---" + +# SB-S3e wires `brain reconcile` (read-side triple-post reconciliation: silo 1 +# `## Recent Posts` ↔ the silo 2↔3 graph). The CLI must (a) dispatch the reconcile +# command AND (b) call the pure core by its literal name. Both literals live in +# scripts/brain/src/cli.ts (the dispatch `if` + runReconcile's literal-name call), +# grepped EXACT with grep -F. Non-vacuity self-test mirrors Section 16e: a probe is +# "wired" iff it carries BOTH literals; D1/D2 miss EXACTLY ONE literal (the load- +# bearing discriminators), D3 is a fully-wired SIBLING command (assemble) carrying +# neither reconcile literal (specificity — the predicate is reconcile-specific, not +# "any command + any core"). Wiring is gate-enforced; the reconcile CORE correctness +# is covered by the brain suite (reconcile.test.ts) and the end-to-end run is verified +# manually (SC4/SC6 at land — CLI behaviour is not unit-tested here; brief says so). +RECON_CLI_LIT='command === "reconcile"' +RECON_CORE_LIT='reconcileRecentPosts' + +reconcile_wired() { # $1 = text; wired iff BOTH literals present (echo twice — grep consumes stdin) + echo "$1" | grep -qF "$RECON_CLI_LIT" && echo "$1" | grep -qF "$RECON_CORE_LIT" +} + +RECON_SELFTEST_OK=1 +if ! reconcile_wired 'if (command === "reconcile") return runReconcile via reconcileRecentPosts'; then + RECON_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if reconcile_wired "$probe"; then + RECON_SELFTEST_OK=0; echo " false-positive FAIL: under-wired probe accepted -> $probe" + fi +done <<'NEGATIVE16F' +if (command === "reconcile") return runReconcile(flags) but never calls the core +const nodes = reconcileRecentPosts({ recentPosts, records, graph }) with no command dispatch +if (command === "assemble") return runAssemble(flags) using assemblePostGraph +NEGATIVE16F +if [ "$RECON_SELFTEST_OK" -eq 1 ]; then + pass "reconcile self-test: full wiring detected; D1/D2 single-literal forms + D3 assemble specificity decoy rejected" +else + fail "reconcile self-test failed — the reconcile-wiring lint is vacuous or over-eager" +fi + +CLI_RECON="scripts/brain/src/cli.ts" +if grep -qF "$RECON_CLI_LIT" "$CLI_RECON" && grep -qF "$RECON_CORE_LIT" "$CLI_RECON"; then + pass "brain CLI wired to reconcile (dispatch + core call by literal name)" +else + fail "brain CLI missing reconcile wiring — needs both '$RECON_CLI_LIT' and '$RECON_CORE_LIT' in $CLI_RECON" +fi + +echo "" + +# --- Section 17: De-Niche Guard (B-S1 + B-S2) --- +echo "--- De-Niche Guard ---" + +# The de-niche sweep removed the hardcoded KTG beat (Microsoft / public-sector) from +# three surfaces so the domain comes from the user's profile/pillars at runtime, never +# baked in (plugin-is-domain-general): +# - agents/trend-spotter.md (B-S1: pillar-driven scanning; the agent's own +# contract says "the niche lives in the source +# list and the user's pillars, never in this agent") +# - agents/content-planner.md (B-S2a: planner calendar recast off the beat) +# - references/content-framework.md (B-S2a: recast + renamed from ai-content-framework.md) +# Each must name NO specific vendor or sector beat; this guard forbids those KTG-beat +# proper nouns from returning to any of them. Scoped per file BY DESIGN: the check runs +# against this explicit allowlist, NOT the whole tree, because references/content-angles.md +# legitimately lists "Public Sector" as one of six example industry tables (a KEEP surface, +# generic illustration — not a hardcoded beat), and "AI" is the plugin's own subject, not a +# niche token. Non-vacuity self-test mirrors Sections 8/13: the criterion must catch the beat +# tokens and ignore generic prose (incl. the content-framework.md reference filename — B-S2a +# recast + renamed it from ai-content-framework.md — and "AI"-flavoured planner prose, locking +# in that "AI" is never treated as a beat). Case-insensitive: "Public sector" and "public +# sector" name the same beat, and a future reintroduction could use either case — the +# positive set locks that in. +NICHE_TOKENS='Microsoft|Azure|Copilot|public sector|offentlig sektor' +TS_SELFTEST_OK=1 +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if ! echo "$probe" | grep -qiE "$NICHE_TOKENS"; then + TS_SELFTEST_OK=0; echo " non-vacuity FAIL: beat token not caught -> $probe" + fi +done <<'POSITIVE17' +Microsoft platform changes +Azure updates +Copilot rollout +Public sector leaders +trender i offentlig sektor +POSITIVE17 +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if echo "$probe" | grep -qiE "$NICHE_TOKENS"; then + TS_SELFTEST_OK=0; echo " false-positive FAIL: generic prose caught -> $probe" + fi +done <<'NEGATIVE17' +platform changes in the user's stack +sector milestones in the user's domain +the user's content pillars and expertise areas +references/content-framework.md +major product/model releases +AI-driven content planning across the user's pillars +NEGATIVE17 +if [ "$TS_SELFTEST_OK" -eq 1 ]; then + pass "de-niche self-test: 5 beat tokens caught (case-insensitive), 6 generic forms ignored (incl. 'AI' kept as the plugin's own subject)" +else + fail "de-niche self-test failed — the guard no longer enforces the no-beat criterion" +fi + +for guarded in agents/trend-spotter.md agents/content-planner.md references/content-framework.md; do + NICHE_HITS=$(grep -niE "$NICHE_TOKENS" "$guarded" 2>/dev/null || true) + if [ -z "$NICHE_HITS" ]; then + pass "$guarded names no hardcoded vendor/sector beat (domain comes from pillars)" + else + fail "$guarded hardcodes a vendor/sector beat — generalize to pillar-driven prose:" + echo "$NICHE_HITS" + fi +done + +echo "" + +# --- Section 16g: Trends Scorer Wiring (research-engine RE-R1 / B2) --- +echo "--- Trends Scorer Wiring ---" + +# RE-R1 lifts the composite/band/threshold arithmetic out of trend-spotter.md prose into +# tested code (scripts/trends/src/score.ts) behind a CLI seam. Two literals must hold, +# grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx): +# (1) score.ts encodes BOTH mode weight-sets (the 'kortform' + 'long-form' literals), so a +# silent collapse to one mode fails here (the per-mode arithmetic itself is unit-tested +# in score.test.ts, behind the deps guard / trends-suite floor); +# (2) agents/trend-spotter.md references the scorer CLI by the literal 'src/cli.ts score' — +# the lift is real and grep-able, not merely documented. +# Non-vacuity self-test mirrors Sections 16c-17: the weight-set predicate (AND of both mode +# literals) must accept a both-modes probe and reject single-mode probes; the wiring predicate +# must accept a probe carrying the scorer-pointer literal and reject one without it. Labelled +# 16g but placed after Section 17 / before Section 18 (anti-erosion must run last so it sees +# every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +WEIGHT_KORT_LIT='kortform' +WEIGHT_LONG_LIT='long-form' +SCORER_WIRE_LIT='src/cli.ts score' + +weights_both_modes() { # $1 = text; true iff BOTH mode literals present (echo twice — grep consumes stdin) + echo "$1" | grep -qF "$WEIGHT_KORT_LIT" && echo "$1" | grep -qF "$WEIGHT_LONG_LIT" +} + +G16_SELFTEST_OK=1 +if ! weights_both_modes 'the kortform weight-set and the long-form weight-set are both encoded'; then + G16_SELFTEST_OK=0; echo " non-vacuity FAIL: a both-modes weight probe was not detected" +fi +while IFS= read -r probe; do + [ -z "$probe" ] && continue + if weights_both_modes "$probe"; then + G16_SELFTEST_OK=0; echo " false-positive FAIL: single-mode weight probe accepted -> $probe" + fi +done <<'NEGATIVE16G' +only the kortform weight-set is present here +only the long-form weight-set is present here +NEGATIVE16G +if ! echo 'pipe the scores to src/cli.ts score for the composite' | grep -qF "$SCORER_WIRE_LIT"; then + G16_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired scorer-pointer probe was not detected" +fi +if echo 'the agent computes the composite itself' | grep -qF "$SCORER_WIRE_LIT"; then + G16_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the scorer pointer" +fi +if [ "$G16_SELFTEST_OK" -eq 1 ]; then + pass "trends-scorer self-test: both-modes weight predicate + scorer-pointer predicate detect wiring, reject the under-wired forms" +else + fail "trends-scorer self-test failed — the scorer-wiring lint is vacuous or over-eager" +fi + +SCORE_TS="scripts/trends/src/score.ts" +if grep -qF "$WEIGHT_KORT_LIT" "$SCORE_TS" 2>/dev/null && grep -qF "$WEIGHT_LONG_LIT" "$SCORE_TS" 2>/dev/null; then + pass "score.ts encodes both mode weight-sets ('$WEIGHT_KORT_LIT' + '$WEIGHT_LONG_LIT')" +else + fail "score.ts missing a mode weight-set — needs both '$WEIGHT_KORT_LIT' and '$WEIGHT_LONG_LIT' in $SCORE_TS" +fi + +if grep -qF "$SCORER_WIRE_LIT" agents/trend-spotter.md; then + pass "trend-spotter.md references the scorer CLI ('$SCORER_WIRE_LIT') as the deterministic-step owner" +else + fail "trend-spotter.md does not reference the scorer CLI — add a '$SCORER_WIRE_LIT' pointer (RE-R1 lift)" +fi + +echo "" + +# --- Section 16h: Trends Capture Wiring (research-engine RE-R2a) --- +echo "--- Trends Capture Wiring ---" + +# RE-R2a closes the capture loop: the item->store bridge (itemToInput) + a `capture` CLI that +# normalizes a raw batch from stdin and folds it into the store (persisting publishedAt). Two +# literals must hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx): +# (1) cli.ts dispatches the `capture` subcommand (the handler exists), by the literal +# 'command === "capture"' (the capture path is real, not merely documented); +# (2) agents/trend-spotter.md re-points Step 4.5 to the capture CLI by the literal +# 'src/cli.ts capture' (one normalizing batch call, replacing the N x `add` block). +# Non-vacuity self-test mirrors Sections 16c-16g: the wiring predicate must accept a probe +# carrying the capture-pointer literal and reject one without it. Labelled 16h but placed after +# Section 17 / before Section 18 (anti-erosion must run last so it sees every prior check). +# UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +CAPTURE_HANDLER_LIT='command === "capture"' +CAPTURE_WIRE_LIT='src/cli.ts capture' + +H16_SELFTEST_OK=1 +if ! echo 'pipe the raw batch to src/cli.ts capture for the store fold' | grep -qF "$CAPTURE_WIRE_LIT"; then + H16_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired capture-pointer probe was not detected" +fi +if echo 'the agent folds each trend into the store itself' | grep -qF "$CAPTURE_WIRE_LIT"; then + H16_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the capture pointer" +fi +if [ "$H16_SELFTEST_OK" -eq 1 ]; then + pass "trends-capture self-test: capture-pointer predicate detects wiring, rejects the under-wired form" +else + fail "trends-capture self-test failed — the capture-wiring lint is vacuous or over-eager" +fi + +if grep -qF "$CAPTURE_HANDLER_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts dispatches the capture subcommand ('$CAPTURE_HANDLER_LIT')" +else + fail "cli.ts has no capture handler — add a '$CAPTURE_HANDLER_LIT' branch (RE-R2a capture loop)" +fi + +if grep -qF "$CAPTURE_WIRE_LIT" agents/trend-spotter.md; then + pass "trend-spotter.md references the capture CLI ('$CAPTURE_WIRE_LIT') as the Step 4.5 store-fold owner" +else + fail "trend-spotter.md does not reference the capture CLI — re-point Step 4.5 to a '$CAPTURE_WIRE_LIT' batch call (RE-R2a wiring)" +fi + +echo "" + +# --- Section 16i: Trends Brief Wiring (research-engine RE-R2b) --- +echo "--- Trends Brief Wiring ---" + +# RE-R2b makes the store VISIBLE: a `brief` CLI ranks the store by pillar-overlap + recency and +# writes a dated Markdown file the SessionStart hook surfaces. Three literals must hold, grepped +# EXACT (grep -F), deps-absent-safe (pure grep, no tsx): +# (1) cli.ts dispatches the `brief` subcommand, by the literal 'command === "brief"'; +# (2) agents/trend-spotter.md generates the brief after capture, by the literal 'src/cli.ts brief'; +# (3) hooks/scripts/session-start.mjs surfaces it, by the literal 'latestMorningBrief' (the +# surfacing is wired, not merely documented). +# Non-vacuity self-test mirrors Section 16h: the wiring predicate must accept a probe carrying the +# brief-pointer literal and reject one without it. Placed after Section 16h / before Section 18 +# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts +# toward ASSERT_BASELINE_FLOOR. +BRIEF_HANDLER_LIT='command === "brief"' +BRIEF_WIRE_LIT='src/cli.ts brief' +BRIEF_SURFACE_LIT='latestMorningBrief' + +I16_SELFTEST_OK=1 +if ! echo 'after capture, run src/cli.ts brief --pillars to write the dated brief' | grep -qF "$BRIEF_WIRE_LIT"; then + I16_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired brief-pointer probe was not detected" +fi +if echo 'the agent renders the morning brief itself' | grep -qF "$BRIEF_WIRE_LIT"; then + I16_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the brief pointer" +fi +if [ "$I16_SELFTEST_OK" -eq 1 ]; then + pass "trends-brief self-test: brief-pointer predicate detects wiring, rejects the under-wired form" +else + fail "trends-brief self-test failed — the brief-wiring lint is vacuous or over-eager" +fi + +if grep -qF "$BRIEF_HANDLER_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts dispatches the brief subcommand ('$BRIEF_HANDLER_LIT')" +else + fail "cli.ts has no brief handler — add a '$BRIEF_HANDLER_LIT' branch (RE-R2b morning brief)" +fi + +if grep -qF "$BRIEF_WIRE_LIT" agents/trend-spotter.md; then + pass "trend-spotter.md references the brief CLI ('$BRIEF_WIRE_LIT') as the post-capture surfacing step" +else + fail "trend-spotter.md does not reference the brief CLI — wire Step 4.6 to a '$BRIEF_WIRE_LIT' call (RE-R2b)" +fi + +if grep -qF "$BRIEF_SURFACE_LIT" hooks/scripts/session-start.mjs; then + pass "session-start.mjs surfaces the morning brief ('$BRIEF_SURFACE_LIT')" +else + fail "session-start.mjs does not surface the brief — add the '$BRIEF_SURFACE_LIT' reader (RE-R2b hull 4)" +fi + +echo "" + +# --- Section 16j: Trends Score Wiring (research-engine RE-R3a) --- +echo "--- Trends Score Wiring ---" + +# RE-R3a persists the relevance score the trend-spotter agent already computes and ranks the +# morning brief on its composite. Four literals must hold, grepped EXACT (grep -F), +# deps-absent-safe (pure grep, no tsx): +# (1) score.ts exports the persist envelope, by the literal 'export interface TrendScore'; +# (2) types.ts carries it on the record, by the literal 'score?: TrendScore'; +# (3) agents/trend-spotter.md carries the judgment in the capture batch, by 'dimensions' +# (verified absent pre-R3a -> the grep is non-vacuous); +# (4) brief.ts ranks on the composite, by the literal 'score?.composite' (the payoff is wired, +# not merely doc'd). +# Non-vacuity self-test mirrors Section 16i: the rank predicate must accept a probe carrying the +# composite-rank literal and reject one without it. Placed after Section 16i / before Section 18 +# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts +# toward ASSERT_BASELINE_FLOOR. +SCORE_IFACE_LIT='export interface TrendScore' +SCORE_TYPE_LIT='score?: TrendScore' +SCORE_DIMS_LIT='"dimensions"' +SCORE_RANK_LIT='score?.composite' + +I16J_SELFTEST_OK=1 +if ! echo 'rankForBrief sorts on (b.trend.score?.composite ?? -1) first' | grep -qF "$SCORE_RANK_LIT"; then + I16J_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired composite-rank probe was not detected" +fi +if echo 'the brief ranks on pillar overlap only' | grep -qF "$SCORE_RANK_LIT"; then + I16J_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the composite-rank pointer" +fi +if [ "$I16J_SELFTEST_OK" -eq 1 ]; then + pass "trends-score self-test: composite-rank predicate detects wiring, rejects the under-wired form" +else + fail "trends-score self-test failed — the score-wiring lint is vacuous or over-eager" +fi + +if grep -qF "$SCORE_IFACE_LIT" scripts/trends/src/score.ts; then + pass "score.ts exports the persist envelope ('$SCORE_IFACE_LIT')" +else + fail "score.ts has no TrendScore envelope — add '$SCORE_IFACE_LIT' (RE-R3a persist)" +fi + +if grep -qF "$SCORE_TYPE_LIT" scripts/trends/src/types.ts; then + pass "types.ts carries the score on the record ('$SCORE_TYPE_LIT')" +else + fail "types.ts does not carry the score — add '$SCORE_TYPE_LIT' to TrendRecord (RE-R3a schema v3)" +fi + +if grep -qF "$SCORE_DIMS_LIT" agents/trend-spotter.md; then + pass "trend-spotter.md carries the judgment in the capture batch ($SCORE_DIMS_LIT)" +else + fail "trend-spotter.md does not carry the judgment — add the per-item $SCORE_DIMS_LIT to Step 4.5 (RE-R3a wiring)" +fi + +if grep -qF "$SCORE_RANK_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts ranks on the composite ('$SCORE_RANK_LIT')" +else + fail "brief.ts does not rank on the composite — add the '$SCORE_RANK_LIT' comparator term (RE-R3a payoff)" +fi + +echo "" + +# --- Section 16k: Trends Lifecycle Wiring (research-engine RE-R3b) --- +echo "--- Trends Lifecycle Wiring ---" + +# RE-R3b adds the trend lifecycle: re-score on re-capture, a status (new/acted/skipped) the brief +# EXCLUDES, and a seen-log (surfacedCount/lastSurfacedAt) the brief records. Five literals must hold, +# grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx): +# (1) types.ts declares the status type, by 'export type TrendStatus'; +# (2) types.ts carries the seen-log field, by 'surfacedCount'; +# (3) store.ts owns the seen-log writer, by 'export function markSurfaced'; +# (4) brief.ts excludes handled trends, by 'effectiveStatus' (the filter is wired, not doc'd); +# (5) cli.ts exposes the lifecycle verb, by 'command === "act"'. +# Non-vacuity self-test mirrors Section 16j: the brief-filter predicate must accept a probe carrying +# the effectiveStatus pointer and reject one without it. Placed after Section 16j / before Section 18 +# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward +# ASSERT_BASELINE_FLOOR. +LIFECYCLE_STATUS_LIT='export type TrendStatus' +LIFECYCLE_SEEN_LIT='surfacedCount' +LIFECYCLE_MARK_LIT='export function markSurfaced' +LIFECYCLE_FILTER_LIT='effectiveStatus' +LIFECYCLE_VERB_LIT='command === "act"' + +I16K_SELFTEST_OK=1 +if ! echo 'rankForBrief drops a trend when effectiveStatus(trend) !== "new"' | grep -qF "$LIFECYCLE_FILTER_LIT"; then + I16K_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired status-filter probe was not detected" +fi +if echo 'the brief shows every record regardless of status' | grep -qF "$LIFECYCLE_FILTER_LIT"; then + I16K_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the status-filter pointer" +fi +if [ "$I16K_SELFTEST_OK" -eq 1 ]; then + pass "trends-lifecycle self-test: status-filter predicate detects wiring, rejects the unfiltered form" +else + fail "trends-lifecycle self-test failed — the lifecycle-wiring lint is vacuous or over-eager" +fi + +if grep -qF "$LIFECYCLE_STATUS_LIT" scripts/trends/src/types.ts; then + pass "types.ts declares the lifecycle status type ('$LIFECYCLE_STATUS_LIT')" +else + fail "types.ts has no TrendStatus — add '$LIFECYCLE_STATUS_LIT' (RE-R3b lifecycle)" +fi + +if grep -qF "$LIFECYCLE_SEEN_LIT" scripts/trends/src/types.ts; then + pass "types.ts carries the seen-log field ('$LIFECYCLE_SEEN_LIT')" +else + fail "types.ts does not carry the seen-log — add '$LIFECYCLE_SEEN_LIT' to TrendRecord (RE-R3b schema v4)" +fi + +if grep -qF "$LIFECYCLE_MARK_LIT" scripts/trends/src/store.ts; then + pass "store.ts owns the seen-log writer ('$LIFECYCLE_MARK_LIT')" +else + fail "store.ts has no markSurfaced — add '$LIFECYCLE_MARK_LIT' (RE-R3b seen-log)" +fi + +if grep -qF "$LIFECYCLE_FILTER_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts excludes handled trends ('$LIFECYCLE_FILTER_LIT')" +else + fail "brief.ts does not exclude handled trends — wire '$LIFECYCLE_FILTER_LIT' into rankForBrief (RE-R3b)" +fi + +if grep -qF "$LIFECYCLE_VERB_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the lifecycle verb ('$LIFECYCLE_VERB_LIT')" +else + fail "cli.ts has no act/skip/reset — add '$LIFECYCLE_VERB_LIT' (RE-R3b lifecycle verbs)" +fi + +echo "" + +# --- Section 16l: Trends Scheduler / Headless Wiring (research-engine RE-R3c) --- +echo "--- Trends Scheduler / Headless Wiring ---" + +# RE-R3c adds the autonomous trigger + headless entry: a `schedule` CLI verb emitting a launchd plist / +# cron line (print-first), and a bash wrapper that runs the DETERMINISTIC brief headless. Five literals +# must hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx): +# (1) schedule.ts emits the launchd plist, by 'export function launchdPlist'; +# (2) schedule.ts emits the cron line, by 'export function crontabLine'; +# (3) cli.ts exposes the schedule verb, by 'command === "schedule"'; +# (4) run-daily.sh runs the deterministic brief, by 'cli.ts" brief' (the wrapper owns the subcommand); +# (5) run-daily.sh uses the data-path twin seam, by 'LINKEDIN_STUDIO_DATA:-'. +# Non-vacuity self-test mirrors Section 16k: a probe carrying the launchdPlist pointer is accepted and +# one without it rejected. Placed after Section 16k / before Section 18 (anti-erosion must run last so it +# sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +SCHED_PLIST_LIT='export function launchdPlist' +SCHED_CRON_LIT='export function crontabLine' +SCHED_VERB_LIT='command === "schedule"' +SCHED_BRIEF_LIT='cli.ts" brief' +SCHED_DATA_LIT='LINKEDIN_STUDIO_DATA:-' + +I16L_SELFTEST_OK=1 +if ! echo 'a wired scheduler declares: export function launchdPlist(spec)' | grep -qF "$SCHED_PLIST_LIT"; then + I16L_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired scheduler-emit probe was not detected" +fi +if echo 'an unwired module emits no artifact at all' | grep -qF "$SCHED_PLIST_LIT"; then + I16L_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the scheduler-emit pointer" +fi +if [ "$I16L_SELFTEST_OK" -eq 1 ]; then + pass "trends-scheduler self-test: the emit pointer is detected, the no-emit form rejected" +else + fail "trends-scheduler self-test failed — the scheduler-wiring lint is vacuous or over-eager" +fi + +if grep -qF "$SCHED_PLIST_LIT" scripts/trends/src/schedule.ts; then + pass "schedule.ts emits the launchd plist ('$SCHED_PLIST_LIT')" +else + fail "schedule.ts has no launchd emitter — add '$SCHED_PLIST_LIT' (RE-R3c scheduler)" +fi + +if grep -qF "$SCHED_CRON_LIT" scripts/trends/src/schedule.ts; then + pass "schedule.ts emits the cron line ('$SCHED_CRON_LIT')" +else + fail "schedule.ts has no cron-line emitter — add '$SCHED_CRON_LIT' (RE-R3c scheduler)" +fi + +if grep -qF "$SCHED_VERB_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the schedule verb ('$SCHED_VERB_LIT')" +else + fail "cli.ts has no schedule verb — add '$SCHED_VERB_LIT' (RE-R3c trigger)" +fi + +if grep -qF "$SCHED_BRIEF_LIT" scripts/trends/run-daily.sh; then + pass "run-daily.sh runs the deterministic brief ('$SCHED_BRIEF_LIT')" +else + fail "run-daily.sh does not run the brief — wire '$SCHED_BRIEF_LIT' (RE-R3c headless entry)" +fi + +if grep -qF "$SCHED_DATA_LIT" scripts/trends/run-daily.sh; then + pass "run-daily.sh uses the data-path twin seam ('$SCHED_DATA_LIT')" +else + fail "run-daily.sh has no data-path seam — add '$SCHED_DATA_LIT' (RE-R3c fourth twin)" +fi + +echo "" + +# --- Section 16m: Trends Temporal Overlay (research-engine RE-R3d) --- +echo "--- Trends Temporal Overlay ---" + +# RE-R3d adds the DERIVED temporal overlay (first-mover + saturation) to the morning-brief ranking: +# a pure temporalSignal in brief.ts, a new cmp key, and two tunable CLI flags. Five literals must +# hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx); ASCII-only (bash 3.2 set -u): +# (1) brief.ts exports the signal, by 'export function temporalSignal'; +# (2) brief.ts ranks on it, by 'b.temporal.rank' (the cmp key); +# (3) brief.ts declares the first-mover tier, by '"first-mover"'; +# (4) cli.ts exposes the first-mover threshold flag, by 'first-mover-days'; +# (5) cli.ts exposes the saturation threshold flag, by 'saturation-at'. +# Non-vacuity self-test mirrors Section 16l. Placed after Section 16l / before Section 18 (anti-erosion +# must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +TEMP_SIGNAL_LIT='export function temporalSignal' +TEMP_RANK_LIT='b.temporal.rank' +TEMP_TIER_LIT='"first-mover"' +TEMP_FMDAYS_LIT='first-mover-days' +TEMP_SATAT_LIT='saturation-at' + +I16M_SELFTEST_OK=1 +if ! echo 'a wired overlay declares: export function temporalSignal(ageDays)' | grep -qF "$TEMP_SIGNAL_LIT"; then + I16M_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired temporal-overlay probe was not detected" +fi +if echo 'an unwired module derives no temporal signal at all' | grep -qF "$TEMP_SIGNAL_LIT"; then + I16M_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the temporal-overlay pointer" +fi +if [ "$I16M_SELFTEST_OK" -eq 1 ]; then + pass "trends-temporal self-test: the signal pointer is detected, the no-signal form rejected" +else + fail "trends-temporal self-test failed — the temporal-overlay lint is vacuous or over-eager" +fi + +if grep -qF "$TEMP_SIGNAL_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts derives the temporal signal ('$TEMP_SIGNAL_LIT')" +else + fail "brief.ts has no temporal signal — add '$TEMP_SIGNAL_LIT' (RE-R3d overlay)" +fi + +if grep -qF "$TEMP_RANK_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts ranks on the temporal overlay ('$TEMP_RANK_LIT')" +else + fail "brief.ts cmp does not use the temporal rank — add '$TEMP_RANK_LIT' (RE-R3d ranking)" +fi + +if grep -qF "$TEMP_TIER_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts declares the first-mover tier ('$TEMP_TIER_LIT')" +else + fail "brief.ts has no first-mover tier — add '$TEMP_TIER_LIT' (RE-R3d tiers)" +fi + +if grep -qF "$TEMP_FMDAYS_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the first-mover-days flag ('$TEMP_FMDAYS_LIT')" +else + fail "cli.ts has no first-mover-days flag — add '$TEMP_FMDAYS_LIT' (RE-R3d brief flag)" +fi + +if grep -qF "$TEMP_SATAT_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts exposes the saturation-at flag ('$TEMP_SATAT_LIT')" +else + fail "cli.ts has no saturation-at flag — add '$TEMP_SATAT_LIT' (RE-R3d brief flag)" +fi + +echo "" + +# --- Section 16n: Trends Brief History / Diff (research-engine RE-R3e) --- +echo "--- Trends Brief History / Diff ---" + +# RE-R3e adds the day-over-day brief diff: a 'surfaced:' frontmatter record + three pure helpers +# (diffSurfaced / parseSurfacedFrontmatter / selectPriorBriefFile) in brief.ts, wired at the cli.ts +# edge, rendering a 'Nytt siden sist' section. Five literals must hold, grepped EXACT (grep -F), +# deps-absent-safe (pure grep, no tsx); ASCII-only (bash 3.2 set -u — the section's emoji is NEVER +# grepped, only its ASCII tail 'Nytt siden sist'): +# (1) brief.ts exports the diff, by 'export function diffSurfaced'; +# (2) brief.ts parses the prior record, by 'parseSurfacedFrontmatter'; +# (3) brief.ts renders the section, by 'Nytt siden sist' (the ASCII header tail); +# (4) cli.ts wires prior-discovery, by 'selectPriorBriefFile'; +# (5) brief.ts emits the membership record, by 'surfaced: ' (the frontmatter line). +# Non-vacuity self-test mirrors Section 16m. Placed after Section 16m / before Section 18 (anti-erosion +# must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR. +DIFF_FN_LIT='export function diffSurfaced' +DIFF_PARSE_LIT='parseSurfacedFrontmatter' +DIFF_SECTION_LIT='Nytt siden sist' +DIFF_SELECT_LIT='selectPriorBriefFile' +DIFF_SURFACED_LIT='surfaced: ' + +I16N_SELFTEST_OK=1 +if ! echo 'a wired diff declares: export function diffSurfaced(currentIds, priorIds)' | grep -qF "$DIFF_FN_LIT"; then + I16N_SELFTEST_OK=0; echo " non-vacuity FAIL: a wired brief-diff probe was not detected" +fi +if echo 'an unwired module computes no day-over-day diff at all' | grep -qF "$DIFF_FN_LIT"; then + I16N_SELFTEST_OK=0; echo " false-positive FAIL: an unwired probe matched the brief-diff pointer" +fi +if [ "$I16N_SELFTEST_OK" -eq 1 ]; then + pass "trends-brief-diff self-test: the diff pointer is detected, the no-diff form rejected" +else + fail "trends-brief-diff self-test failed — the brief-diff lint is vacuous or over-eager" +fi + +if grep -qF "$DIFF_FN_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts exports the day-over-day diff ('$DIFF_FN_LIT')" +else + fail "brief.ts has no diff — add '$DIFF_FN_LIT' (RE-R3e brief history)" +fi + +if grep -qF "$DIFF_PARSE_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts parses the prior surfaced record ('$DIFF_PARSE_LIT')" +else + fail "brief.ts cannot read a prior brief — add '$DIFF_PARSE_LIT' (RE-R3e)" +fi + +if grep -qF "$DIFF_SECTION_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts renders the diff section ('$DIFF_SECTION_LIT')" +else + fail "brief.ts has no diff section — add '$DIFF_SECTION_LIT' (RE-R3e section header)" +fi + +if grep -qF "$DIFF_SELECT_LIT" scripts/trends/src/cli.ts; then + pass "cli.ts wires prior-brief discovery ('$DIFF_SELECT_LIT')" +else + fail "cli.ts does not discover the prior brief — add '$DIFF_SELECT_LIT' (RE-R3e wiring)" +fi + +if grep -qF "$DIFF_SURFACED_LIT" scripts/trends/src/brief.ts; then + pass "brief.ts emits the surfaced: membership record ('$DIFF_SURFACED_LIT')" +else + fail "brief.ts records no membership — add the 'surfaced:' frontmatter line (RE-R3e)" +fi + +echo "" + +# --- Section 18: Assertion-Count Anti-Erosion (SC6) --- +# The lint self-modifies its own checks, so a green run could mask a silently dropped +# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count +# may only grow (brief-reviewer assumption 3). History: 74 pre-M0; +1 for the SB-S0 +# brain-suite floor (Section 16b) = 75; +3 for SB-S1's three UNCONDITIONAL Section-16c +# checks (published-only self-test + voice-trainer grep + contract-doc grep) = 78; +# +2 for SB-S3a's two UNCONDITIONAL Section-16d checks (profile-reader self-test + +# strategy-advisor wiring grep) = 80; +2 for SB-S3d's two UNCONDITIONAL Section-16e +# checks (ops-reader self-test + strategy-advisor ops-wiring grep) = 82; +2 for SB-S3e's +# two UNCONDITIONAL Section-16f checks (reconcile self-test + brain-CLI reconcile grep) = 84; +# +3 for RE-R1's three UNCONDITIONAL Section-16g checks (trends-scorer self-test + score.ts +# both-modes weight-set grep + trend-spotter scorer-pointer grep) = 87; +3 for RE-R2a's three +# UNCONDITIONAL Section-16h checks (trends-capture self-test + cli.ts capture-handler grep + +# trend-spotter capture-pointer grep) = 90; +4 for RE-R2b's four UNCONDITIONAL Section-16i checks +# (trends-brief self-test + cli.ts brief-handler grep + trend-spotter brief-pointer grep + +# session-start surfacing grep) = 94; +5 for RE-R3a's five UNCONDITIONAL Section-16j checks +# (trends-score self-test + score.ts TrendScore-iface grep + types.ts score-field grep + +# trend-spotter dimensions grep + brief.ts composite-rank grep) = 99; +6 for RE-R3b's six +# UNCONDITIONAL Section-16k checks (lifecycle self-test + types.ts TrendStatus grep + types.ts +# surfacedCount grep + store.ts markSurfaced grep + brief.ts effectiveStatus grep + cli.ts +# act-verb grep) = 105; +6 for RE-R3c's six UNCONDITIONAL Section-16l checks (scheduler self-test +# + schedule.ts launchdPlist grep + schedule.ts crontabLine grep + cli.ts schedule-verb grep + +# run-daily.sh brief-invocation grep + run-daily.sh data-twin grep) = 111; +6 for RE-R3d's six +# UNCONDITIONAL Section-16m checks (temporal self-test + brief.ts temporalSignal grep + brief.ts +# b.temporal.rank cmp grep + brief.ts "first-mover" tier grep + cli.ts first-mover-days flag grep + +# cli.ts saturation-at flag grep) = 117; +6 for RE-R3e's six UNCONDITIONAL Section-16n checks +# (brief-diff self-test + brief.ts diffSurfaced grep + brief.ts parseSurfacedFrontmatter grep + +# brief.ts 'Nytt siden sist' section grep + cli.ts selectPriorBriefFile wiring grep + brief.ts +# 'surfaced: ' frontmatter grep) = 123. +# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop +# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the +# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh +# clone). Runs last so TOTAL_CHECKS sees every prior check. +ASSERT_BASELINE_FLOOR=123 +TOTAL_CHECKS=$((PASS + FAIL)) +if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then + pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" +else + fail "assertion count $TOTAL_CHECKS < baseline floor $ASSERT_BASELINE_FLOOR — a check was silently removed" +fi + +echo "" + # --- Summary --- echo "================================================" echo "RESULTS" diff --git a/scripts/trends/README.md b/scripts/trends/README.md new file mode 100644 index 0000000..3b9fa30 --- /dev/null +++ b/scripts/trends/README.md @@ -0,0 +1,208 @@ +# linkedin-trends-store + +Persistent **trend store** — the foundation layer of the research engine +(retning §5). A topic-tagged, provenance-bearing inventory of trend signals +captured over time, so the engine accumulates **history** instead of starting +amnesiac each session. + +Twin of [`scripts/specifics-bank`](../specifics-bank): same deterministic +store / dedup / query discipline, different dedupe key — a trend is identified by +its **normalized title+URL**, not by free-text content. + +## Generic by architecture + +Nothing niche-specific lives here. A `TrendRecord` carries free-form `topics` +tags and a free-form `source` string; *which* topics matter and *which* sources +to poll are decided upstream (config/profile + the capture agent), never +hard-coded in this module. The same store serves any niche. + +## Data location + +The store lives under the per-user data dir (M0 data-path convention), so trend +history survives plugin upgrades/reinstalls: + +``` +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/trends.json +``` + +`LINKEDIN_STUDIO_DATA` overrides the root. No path is hard-coded in prose. + +## Record shape (minimal generic core) + +```ts +interface TrendRecord { + id: string; // sha256(normalized title+url).slice(0,12) — also the dedupe key + title: string; // headline, verbatim + url: string; // source URL, verbatim + source: string; // "tavily" | "websearch" | "manual" | <mcp-name> + capturedAt: string; // ISO-8601 date — when WE captured it + publishedAt?: string;// optional source publish date (ISO-8601); distinct from capturedAt, first-sight, never back-filled + topics: string[]; // query tags; unioned across re-captures + summary?: string; // optional, verbatim + score?: TrendScore; // persisted relevance (RE-R3a): { mode, dimensions, composite, priority } — REFRESHED on re-capture (RE-R3b, last-wins) + status?: TrendStatus; // lifecycle (RE-R3b): "new" | "acted" | "skipped"; absent ⇒ "new"; the brief excludes non-new + surfacedCount?: number; // seen-log (RE-R3b): distinct days surfaced in a brief; absent ⇒ 0; per-day idempotent + lastSurfacedAt?: string; // seen-log (RE-R3b): ISO date of the most recent surfacing +} +``` + +`score` is the persisted relevance envelope (RE-R3a): a capture **item** carries the +agent's **judgment** — `{ mode, dimensions }` (the five 1–10 dimension scores) — and the +store turns that into the persisted `TrendScore` `{ mode, dimensions, composite, priority }`, +computing the composite + band once via the single scorer owner (`src/score.ts`). It is +**refreshed on re-capture** (RE-R3b, last-wins — the timing dimension decays, so the newer +judgment supersedes the stored one; `score` is the one mutable field, provenance stays +first-sight); the score-free `add` manual path omits it. The morning brief ranks each bucket +on `composite` first (schema v4). + +The **lifecycle** fields (RE-R3b) are the trend's life after first capture: `status` is set by +the `act`/`skip`/`reset` verbs (a freshly-captured trend is implicitly `new`), and the seen-log +`surfacedCount`/`lastSurfacedAt` is recorded by `brief` (per-day idempotent) so the loop can avoid +re-surfacing handled work. + +## CLI + +```bash +# Capture freshly-polled trends — the NORMALIZING BATCH path (the research agent's path): +# raw items on stdin → validate+normalize each → dedupe on title+url → union topics on +# re-capture → persist the source's publishedAt → persist the relevance score (when carried). +# Content-invalid items (incl. a malformed/out-of-range score) are reported in the summary +# errors[], never fail the run; the summary is {added, duplicates, merged, errors}. +# An item's "score" carries the agent's judgment (mode + the five 1–10 dimensions); the store +# computes the composite + band and persists the full TrendScore first-sight. +echo '[{"source":"tavily","title":"Agentic workflows hit production", + "url":"https://example.com/agentic","topics":["agents","engineering"], + "publishedAt":"2026-06-20","summary":"Teams ship multi-step agents past the demo stage.", + "score":{"mode":"kortform","dimensions":{"pillar":9,"audience":8,"timing":9,"angle":7,"authority":6}}}]' \ + | node --import tsx src/cli.ts capture [--store <path>] [--json] + +# Add a SINGLE trend MANUALLY — raw flags, no normalization, publish-date-free: +node --import tsx src/cli.ts add \ + --title "Agentic workflows hit production" \ + --url "https://example.com/agentic" \ + --topics "agents,engineering" --source tavily \ + --summary "Teams ship multi-step agents past the demo stage." + +# Topic-scoped history — trends matching these topics, ranked by overlap then recency +node --import tsx src/cli.ts query --topics "agents,engineering" [--json] + +# Time-scoped history — newest first, optionally windowed/capped +node --import tsx src/cli.ts list [--since 2026-06-01] [--limit 10] [--json] + +# Dated morning brief — rank the store by composite then pillar-overlap then recency, write a +# dated Markdown file the SessionStart hook surfaces. Pillars come from the caller (user config). +# The brief EXCLUDES acted/skipped trends and RECORDS surfacing on the store (per-day idempotent) +# unless --no-mark. Pillars come from the caller (user config). +node --import tsx src/cli.ts brief --pillars "agents,engineering" \ + [--fresh-days 7] [--out <dir>] [--no-mark] [--store <path>] [--json] + +# Lifecycle — mark a trend handled so the brief stops re-surfacing it (id shown in the brief / list --json): +node --import tsx src/cli.ts act --id <id> # wrote about it +node --import tsx src/cli.ts skip --id <id> # decided to pass on it +node --import tsx src/cli.ts reset --id <id> # return it to the queue + +# Autonomous trigger (RE-R3c) — emit/install a daily headless brief. PRINT-FIRST: the tool never runs +# launchctl or the cron table; --install writes only the inert launchd plist file. Deterministic +# (no AI capture — a later slice). Default 07:00; --platform auto → launchd on macOS, cron on Linux. +node --import tsx src/cli.ts schedule --pillars "agents,engineering" \ + [--at 07:00] [--fresh-days 7] [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>] +``` + +Both `capture` and `add` dedupe on normalized title+url — re-capturing the same trend +never appends a duplicate, it only unions any new topics in. + +## Morning brief (RE-R2b) + +`brief` is the dated, surfaced read over the store (distinct from `query`/`list`, which are +interactive dumps). It ranks the store against the user's pillars — overlap desc, then +`publishedAt ?? capturedAt` recency — buckets into top (2+ pillars), single (1 pillar), and +older (matched but outside the freshness window, default 7 days), and writes: + +``` +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/morning-brief/YYYY-MM-DD.md +``` + +The file's YAML frontmatter carries a single-line `summary` the SessionStart hook surfaces +verbatim (zero-tsx — it reads the Markdown, never the TS CLI). As of RE-R3a the brief ranks +each bucket on the persisted relevance **composite first** (then pillar-overlap, then recency); +a scored entry shows `· <priority> (<mode>)` and the summary names the top entry's band. + +As of **RE-R3b** the brief is a **work queue**: it **excludes** `acted`/`skipped` trends, shows +each entry's `id` in backticks (copy-paste-ready for `act`/`skip --id`), flags a re-surfaced item +with `· sett Nx` (prior-day count, ≥2), and — unless `--no-mark` — **records surfacing** on the +store (`surfacedCount`/`lastSurfacedAt`, per-day idempotent) after the pure render. + +## Autonomous trigger + headless entry (RE-R3c) + +`schedule` makes the daily loop **closed**: it emits — print-first — a launchd plist (macOS) or cron +line (Linux) firing the brief every morning, plus the exact activation command. `--install` writes only +the **inert** launchd plist file under `~/Library/LaunchAgents/`; the tool **never** runs `launchctl` +or the cron table — you run the one printed command. `--uninstall` prints the removal recipe (and +removes the plist file if present). + +Both schedulers invoke one tested headless wrapper, `run-daily.sh`, which runs the **deterministic** +brief from a scheduler's profile-less environment: it resolves node (baked `NODE_BIN`, else +`command -v`, else common locations), `cd`s into the package so `tsx` resolves, and appends one compact +line `<ISO-ts> exit=<code> <json>` to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log`. + +The nightly run is **deterministic-brief-only (C1)**: it re-renders the brief from the current store +— freshness-aging drops stale trends, `surfacedCount` accumulates day-over-day — but does **not** poll +new sources. A double-fire on the same day is a safe no-op (RE-R3b per-day idempotency: byte-identical +`.md`, `surfacedCount` not double-counted). The autonomous AI capture step (poll → score → capture +before the brief) plugs into the documented seam in `run-daily.sh` as a later slice (e); a +brief-history diff is also a later slice. + +## Temporal overlay (RE-R3d) + +The brief applies a **derived temporal overlay** when it ranks — two signals computed at render time +from already-persisted fields (`publishedAt`/`capturedAt` → age, `surfacedCount` → self-exposure), so +**nothing new is stored** (`SCHEMA_VERSION` stays 4) and the signal can never go stale: + +- **first-mover** — recent (`ageDays ≤ --first-mover-days`, default **2**) AND never surfaced on a + prior day. Ranked up; badge `· 🥇 først ute`. A future-dated trend (`ageDays < 0`) is excluded. +- **saturation** — surfaced on `≥ --saturation-at` (default **3**) prior distinct days. Ranked down; + badge `· 🔁 mettet (Nx)`. This is **self-surfacing** ("you keep seeing this") from OUR seen-log — not + market coverage (that needs external polling, a later slice). +- **warming** (surfaced 1..at-1) keeps the RE-R3b `· sett Nx` badge, but **only at ≥2** (that badge + contract is unchanged); **neutral** (no exposure signal) carries no badge. + +Ranking integration: the relevance composite (RE-R3a) stays the **primary** sort key; the temporal +rank (first-mover↑ / saturated↓) is a new key inserted **after** pillar-overlap and **before** the +`effectiveDate` recency tiebreaker — so the overlay only re-orders *within* a (composite, overlap) +tier, never overriding relevance. Prior-day surfacings exclude today (via `lastSurfacedAt`), so a +same-day re-render is byte-identical. Tune per run with `--first-mover-days N` / `--saturation-at N` +(the scheduled nightly run uses the defaults). + +## Brief history + diff (RE-R3e) + +Each brief records the trend ids it showed into its own frontmatter — one `surfaced: <id-csv>` line += `surfacedIds(ranking)` (the cohort the brief surfaced). This bumps the **artifact** schema +`BRIEF_SCHEMA_VERSION` **1 → 2**; the store's `SCHEMA_VERSION` stays **4** (no store field — the +membership lives in the artifact, the diff is derived at the CLI edge). + +When a brief is written, the CLI discovers the most recent **prior** dated file +(`selectPriorBriefFile`: the greatest `YYYY-MM-DD.md` strictly `< today`, so a same-day re-run diffs +against the true previous day and stays byte-identical), parses its `surfaced:` line +(`parseSurfacedFrontmatter`, degrading to "empty prior" on any absent/blank/malformed/pre-R3e file), +and computes the symmetric set difference (`diffSurfaced`): + +- **added** — in today, not in the prior brief: the headline "what's new", rendered with titles + resolved from the ranking under a `## 🆕 Nytt siden sist (<prior-date>)` section that **leads** the + ranked list. +- **carried** / **dropped** — in both / in the prior only: a one-line count (`N båret over, M ikke + vist i dag`). Framed "ikke vist i dag" (not "resolved") — a count cannot prove why a trend left. + +A ` N nye siden sist.` marker is appended to the one-line `summary:` the SessionStart hook surfaces, +so the delta shows **without opening the file** (no hook change). The three helpers are **pure** +(string/array in, value out); the directory + file reads live at the `cli.ts` edge, so `brief.ts` +stays fs-free. `--no-mark` is unaffected (it governs only the store seen-log; `surfaced:` always +records what the brief showed). + +## Tests + +```bash +cd scripts/trends +npm install +npm test # deterministic store: normalize/id, load/save, dedup+union, query, history +npm run build +``` diff --git a/scripts/trends/package-lock.json b/scripts/trends/package-lock.json new file mode 100644 index 0000000..f62a7fe --- /dev/null +++ b/scripts/trends/package-lock.json @@ -0,0 +1,566 @@ +{ + "name": "linkedin-trends-store", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "linkedin-trends-store", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/scripts/trends/package.json b/scripts/trends/package.json new file mode 100644 index 0000000..25a37b2 --- /dev/null +++ b/scripts/trends/package.json @@ -0,0 +1,16 @@ +{ + "name": "linkedin-trends-store", + "version": "0.1.0", + "type": "module", + "description": "Persistent trend store (research-engine §5, foundation layer) — a topic-tagged, provenance-bearing inventory of captured trend signals so the research engine accumulates history instead of starting amnesiac each session. Fully generic: nothing niche-specific lives here. Deterministic store + dedup + query; the capture/scoring lives in the agent/command layer.", + "scripts": { + "build": "tsc", + "test": "node --import tsx --test tests/*.test.ts", + "start": "node --import tsx src/cli.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/scripts/trends/run-daily.sh b/scripts/trends/run-daily.sh new file mode 100755 index 0000000..c6dc401 --- /dev/null +++ b/scripts/trends/run-daily.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# RE-R3c headless entry: runs the DETERMINISTIC morning brief from a scheduler's +# profile-less env (launchd/cron inherit no shell profile). No AI. +# +# The (e) slice will insert a pre-brief AI capture step here (poll -> score -> capture) +# before the `brief` call below; R3c builds ONLY the deterministic store -> artifact path. +# +# Data-path: the FOURTH sanctioned twin of store.ts:defaultStorePath / data-root.mjs:getDataRoot +# / analytics/src/utils/storage.ts:getDataRoot (the shell form of references/data-path-convention.md +# rule 1). Keep in sync. Bash 3.2-compatible: ASCII-only, all expansions quoted, no bash-4 features. +set -eu + +DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DIR" # so `--import tsx` resolves node_modules even under cron's $HOME cwd + +NODE_BIN="${NODE_BIN:-$(command -v node 2>/dev/null || true)}" +if [ -z "$NODE_BIN" ]; then + for c in /usr/local/bin/node /opt/homebrew/bin/node /usr/bin/node; do + if [ -x "$c" ]; then NODE_BIN="$c"; break; fi + done +fi + +LOG="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/trends/cron.log" +mkdir -p "$(dirname "$LOG")" +TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +if [ -z "$NODE_BIN" ]; then + printf '%s exit=127 node not found\n' "$TS" >> "$LOG" + exit 127 +fi + +set +e +OUT="$("$NODE_BIN" --import tsx "$DIR/src/cli.ts" brief "$@" --json 2>&1)"; CODE=$? +set -e + +# `brief --json` is pretty-printed (cli.ts JSON.stringify(…, null, 2)); collapse it to ONE line. +OUT="$(printf '%s' "$OUT" | tr '\n' ' ' | tr -s ' ')" +printf '%s exit=%s %s\n' "$TS" "$CODE" "$OUT" >> "$LOG" +exit "$CODE" diff --git a/scripts/trends/src/brief.ts b/scripts/trends/src/brief.ts new file mode 100644 index 0000000..3bbe4d8 --- /dev/null +++ b/scripts/trends/src/brief.ts @@ -0,0 +1,404 @@ +/** + * The dated morning-brief layer (research-engine §5, RE-R2b — the visible layer). + * + * Pure read-only view over the persistent trend store: rank the accumulated, + * publish-dated signals against the user's content pillars (overlap), filter to a + * freshness window, and render a dated Markdown artifact a later session surfaces. + * No fs, no clock, no AI, no network — `today` and `pillars` are injected by the + * caller (the CLI edge), exactly like the store's `capturedAt`. Determinism is the + * contract: same (store, pillars, today, freshDays) -> byte-identical output. + * + * Ranking uses ONLY persisted fields (pillar overlap + publishedAt/capturedAt + * recency). A persisted relevance/saturation score, an autonomous trigger, and a + * seen-log freshness model are all later slices (R3); this module ships the + * deterministic read the surfacing needs and nothing more. + */ + +import { join, dirname } from "node:path"; + +import { defaultStorePath, effectiveStatus } from "./store.js"; +import type { TrendStore, TrendRecord } from "./types.js"; + +/** The morning-brief artifact's own format version (distinct from the store's SCHEMA_VERSION). */ +export const BRIEF_SCHEMA_VERSION = 2; + +/** + * The live temporal overlay (RE-R3d): two derived signals — first-mover (recent AND + * never surfaced on a prior day) and saturation (surfaced on >= saturationAt prior + * days). DERIVED at brief time from already-persisted fields (publishedAt/capturedAt + * -> ageDays, surfacedCount); never stored (no SCHEMA_VERSION bump), so it can never go + * stale. Mirrors ageDays: a function of the record + today, computed per rank, not persisted. + */ +export type TemporalTier = "first-mover" | "neutral" | "warming" | "saturated"; +export interface TemporalSignal { + /** recent AND never surfaced on a prior day — "you'd be early". */ + firstMover: boolean; + /** prior-day surfacings (surfacedCount ?? 0) — the self-exposure level saturation reads. */ + surfacings: number; + /** the ordinal class (best -> worst opportunity). */ + tier: TemporalTier; + /** descending sort rank: first-mover 3 > neutral 2 > warming 1 > saturated 0. */ + rank: number; +} + +/** One ranked trend in the brief, with its pillar overlap + freshness. */ +export interface BriefEntry { + trend: TrendRecord; + /** How many of the user's pillars the trend's topics matched. */ + overlap: number; + /** The matched pillar names, in pillar order, original case preserved. */ + matchedPillars: string[]; + /** publishedAt ?? capturedAt — the date freshness + ordering use. */ + effectiveDate: string; + /** Whole days from effectiveDate to the injected `today` (negative if future). */ + ageDays: number; + /** The live temporal overlay (RE-R3d) — derived per rank from ageDays + surfacedCount. */ + temporal: TemporalSignal; +} + +/** The full ranking the brief renders from. */ +export interface BriefRanking { + today: string; + freshDays: number; + totals: { trends: number; matched: number; fresh: number }; + /** overlap >= 2 AND fresh. */ + topMatches: BriefEntry[]; + /** overlap === 1 AND fresh. */ + singleMatches: BriefEntry[]; + /** overlap >= 1 AND NOT fresh. */ + olderMatched: BriefEntry[]; +} + +export interface RankOptions { + /** Freshness window in days (effectiveDate within N days of today). Default 7. */ + freshDays?: number; + /** first-mover recency window in days (ageDays <= N AND unsurfaced). Default 2 (RE-R3d). */ + firstMoverDays?: number; + /** surfacedCount at/above which a trend is "saturated". Default 3 (RE-R3d). */ + saturationAt?: number; +} + +/** + * Whole days from `effectiveDate` to `today` (floor). Local to this module — NOT + * imported from cli.ts's daysBetween: cli.ts imports brief.ts, so importing back + * would invert the dependency direction. brief.ts stays a leaf the CLI composes. + */ +function ageDaysBetween(effectiveDate: string, today: string): number { + return Math.floor((Date.parse(today) - Date.parse(effectiveDate)) / 86400000); +} + +/** + * The live temporal overlay (RE-R3d): classify a trend's opportunity window from its age + + * self-exposure. Pure — every input is injected. first-mover = recent AND never surfaced on a + * prior day (the `ageDays >= 0` guard keeps a future-dated glitch out of "act now"); saturated = + * surfaced on >= saturationAt prior days; warming = surfaced 1..at-1; neutral = the no-signal + * baseline. `at` is clamped to >= 1 so a stray `saturationAt 0` cannot mark every trend saturated. + */ +export function temporalSignal( + ageDays: number, + surfacedCount: number | undefined, + opts: { firstMoverDays: number; saturationAt: number }, +): TemporalSignal { + const surfacings = surfacedCount ?? 0; + const at = Math.max(1, opts.saturationAt); + const firstMover = ageDays >= 0 && ageDays <= opts.firstMoverDays && surfacings === 0; + const tier: TemporalTier = firstMover + ? "first-mover" + : surfacings >= at + ? "saturated" + : surfacings >= 1 + ? "warming" + : "neutral"; + const rank = tier === "first-mover" ? 3 : tier === "neutral" ? 2 : tier === "warming" ? 1 : 0; + return { tier, firstMover, surfacings, rank }; +} + +/** + * Rank the store against the user's pillars. Off-pillar trends (overlap 0) are + * dropped; the rest bucket into top (>=2 & fresh), single (==1 & fresh), and older + * (>=1 & stale). Each bucket is a TOTAL order: overlap desc, then effectiveDate + * desc (freshest first), then title asc, then url asc — so the output is fixed + * regardless of store insertion order. + */ +export function rankForBrief( + store: TrendStore, + pillars: string[], + today: string, + opts: RankOptions = {}, +): BriefRanking { + const freshDays = opts.freshDays ?? 7; + const firstMoverDays = opts.firstMoverDays ?? 2; + const saturationAt = opts.saturationAt ?? 3; + const wantedLower = pillars.map((p) => p.toLowerCase()); + + const entries: BriefEntry[] = []; + for (const trend of store.trends) { + // RE-R3b (A3): acted/skipped are handled — drop from the work queue (the brief is a queue, not an archive). + if (effectiveStatus(trend) !== "new") continue; + const have = new Set(trend.topics.map((t) => t.toLowerCase())); + const matchedPillars: string[] = []; + for (let i = 0; i < pillars.length; i++) { + if (have.has(wantedLower[i])) matchedPillars.push(pillars[i]); + } + const overlap = matchedPillars.length; + if (overlap === 0) continue; // off-pillar noise + const effectiveDate = trend.publishedAt ?? trend.capturedAt; + const ageDays = ageDaysBetween(effectiveDate, today); + // Prior-DAY surfacings: the seen-log count EXCLUDING today. The brief renders BEFORE the CLI + // records today's surfacing, so on the first run of a day surfacedCount is already prior-day — + // but a same-day RE-RUN loads a count that already includes today (lastSurfacedAt === today), + // so subtract it back out. This keeps a same-day re-render byte-identical (RE-R3c SC7) and makes + // "first-mover" (prior surfacings === 0) stable across the render → mark step. + const surfaced = trend.surfacedCount ?? 0; + const priorSurfacings = trend.lastSurfacedAt === today ? Math.max(0, surfaced - 1) : surfaced; + entries.push({ + trend, + overlap, + matchedPillars, + effectiveDate, + ageDays, + temporal: temporalSignal(ageDays, priorSurfacings, { firstMoverDays, saturationAt }), + }); + } + + // Composite is the PRIMARY within-bucket key (RE-R3a / D2): a higher persisted relevance + // composite sorts first; an unscored record uses the sentinel -1 (composite is a weighted + // sum of [1,10] dims, so it is always >= 1.0 — -1 sorts unscored last and subtracts + // cleanly, where -Infinity - -Infinity = NaN would corrupt the comparator). Buckets are + // unchanged; composite only re-orders WITHIN a bucket. + // RE-R3d inserts the temporal-overlay rank (first-mover↑ / saturated↓) AFTER overlap and + // BEFORE effectiveDate: composite + overlap stay primary, and the overlay refines recency + // (a coarse temporal class) ahead of the raw effectiveDate it sits in front of. The existing + // overlap → temporal → effectiveDate → title → url chain still gives a total order (the + // (title,url) pair is the unique dedupe id; rank is a small integer, no NaN risk). + const cmp = (a: BriefEntry, b: BriefEntry): number => + (b.trend.score?.composite ?? -1) - (a.trend.score?.composite ?? -1) || + b.overlap - a.overlap || + b.temporal.rank - a.temporal.rank || + b.effectiveDate.localeCompare(a.effectiveDate) || + a.trend.title.localeCompare(b.trend.title) || + a.trend.url.localeCompare(b.trend.url); + + const isFresh = (e: BriefEntry): boolean => e.ageDays <= freshDays; + + const topMatches = entries.filter((e) => e.overlap >= 2 && isFresh(e)).sort(cmp); + const singleMatches = entries.filter((e) => e.overlap === 1 && isFresh(e)).sort(cmp); + const olderMatched = entries.filter((e) => !isFresh(e)).sort(cmp); // overlap>=1 (0 already excluded) + + return { + today, + freshDays, + totals: { trends: store.trends.length, matched: entries.length, fresh: topMatches.length + singleMatches.length }, + topMatches, + singleMatches, + olderMatched, + }; +} + +/** + * The single source of the brief's one-line summary — used by renderBrief (the + * frontmatter line the SessionStart hook surfaces verbatim) AND the CLI --json. + * One line, no embedded double-quote, no newline, so the hook's extractYaml regex + * (^summary: *"?([^"\n]*)"?) captures it whole. + */ +export function briefSummary(ranking: BriefRanking, diff?: BriefDiff): string { + // RE-R3e: the day-over-day delta marker — appended only when a prior brief exists AND + // something is new (suppressed on the first brief / when nothing changed). Digits + ASCII + // + a period: no `"`/`\n`, so the hook's ^summary: regex still captures the line whole. + const delta = + diff && diff.priorDate !== null && diff.added.length > 0 ? ` ${diff.added.length} nye siden sist.` : ""; + const fresh = ranking.totals.fresh; + if (fresh > 0) { + const top = ranking.topMatches[0] ?? ranking.singleMatches[0]; + const pillar = top.matchedPillars[0]; + // Band only (no mode) — the mode stays a body-entry detail to keep the one-line headline clean. + const band = top.trend.score ? ` · ${top.trend.score.priority}` : ""; + // RE-R3d: surface the first-mover marker on the one-line headline ("act now, you're early"). + const fm = top.temporal.firstMover ? " · 🥇 først ute" : ""; + return `${fresh} ferske tema-signaler matcher pillarene dine. Topp: «${top.trend.title}» (${pillar}${band}${fm} · ${top.ageDays}d).${delta}`; + } + return `Ingen ferske tema-signaler på pillarene dine (av ${ranking.totals.trends} i lager).${delta}`; +} + +/** ` · <priority> (<mode>)` when scored, else "" — the band+mode token shared by both renders (RE-R3a). */ +function scoreToken(e: BriefEntry): string { + const s = e.trend.score; + return s ? ` · ${s.priority} (${s.mode})` : ""; +} + +/** + * The temporal-overlay badge (RE-R3d): promotes the R3b `· sett Nx` hint into a graded set. + * `first-mover` → `· 🥇 først ute`; `saturated` → `· 🔁 mettet (Nx)`; `warming` → `· sett Nx` + * ONLY at surfacings>=2 (preserving the EXACT R3b ≥2 display contract — the warming TIER still + * demotes a surfaced-once trend in rank, but its badge stays suppressed); `neutral` → "". + * The count is PRIOR-DAY (the brief renders before the CLI records today's surfacing). + */ +function temporalToken(e: BriefEntry): string { + const t = e.temporal; + if (t.tier === "first-mover") return " · 🥇 først ute"; + if (t.tier === "saturated") return ` · 🔁 mettet (${t.surfacings}x)`; + if (t.tier === "warming" && t.surfacings >= 2) return ` · sett ${t.surfacings}x`; + return ""; +} + +function renderTopEntry(e: BriefEntry, n: number): string[] { + const lines = [ + `### ${n}. ${e.trend.title}`, + `- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``, + ]; + if (e.trend.summary) lines.push(`- ${e.trend.summary}`); + lines.push(`- 🔗 ${e.trend.url}`); + lines.push(""); + return lines; +} + +function renderBulletEntry(e: BriefEntry): string { + return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``; +} + +/** + * Render the full dated Markdown artifact: YAML frontmatter (date, the shared + * summary, store stats, ranking descriptor, schemaVersion) + a three-section body. + * All three section headers are always emitted (stable structure → determinism). + */ +export function renderBrief( + ranking: BriefRanking, + diff: BriefDiff = { priorDate: null, added: [], carried: [], dropped: [] }, +): string { + const { totals } = ranking; + const lines: string[] = []; + + lines.push("---"); + lines.push(`date: ${ranking.today}`); + lines.push(`summary: ${briefSummary(ranking, diff)}`); + lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`); + lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`); + // RE-R3e: the set of ids this brief showed — the record the NEXT day's diff reads. Always + // emitted (even blank for an empty store); independent of --no-mark (a property of the render). + lines.push(`surfaced: ${surfacedIds(ranking).join(",")}`); + lines.push(`schemaVersion: ${BRIEF_SCHEMA_VERSION}`); + lines.push("---"); + lines.push(""); + lines.push(`# Morgen-brief — ${ranking.today}`); + lines.push(""); + lines.push( + `**${totals.fresh} ferske signaler** (publisert ≤${ranking.freshDays} dager) matcher temaene dine, av ${totals.trends} i lager.`, + ); + lines.push(""); + + // RE-R3e: the day-over-day delta leads, then the full ranked list below. + lines.push(diff.priorDate !== null ? `## 🆕 Nytt siden sist (${diff.priorDate})` : "## 🆕 Nytt siden sist"); + if (diff.priorDate === null) { + lines.push(diff.added.length > 0 ? "_Første brief — alt nedenfor er nytt._" : "_Første brief._", ""); + } else if (diff.added.length === 0) { + lines.push( + `_Ingenting nytt siden ${diff.priorDate}._`, + `_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, + "", + ); + } else { + const byId = new Map( + [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched].map((e) => [e.trend.id, e]), + ); + for (const id of diff.added) { + const e = byId.get(id); + if (e) lines.push(renderBulletEntry(e)); + } + lines.push(`_${diff.carried.length} båret over, ${diff.dropped.length} ikke vist i dag._`, ""); + } + + lines.push("## 🎯 Topp-treff (2+ pillarer)"); + if (ranking.topMatches.length === 0) { + lines.push("_Ingen i dag._", ""); + } else { + ranking.topMatches.forEach((e, i) => lines.push(...renderTopEntry(e, i + 1))); + } + + lines.push("## 📌 Enkelt-treff (1 pillar)"); + if (ranking.singleMatches.length === 0) lines.push("_Ingen i dag._"); + else ranking.singleMatches.forEach((e) => lines.push(renderBulletEntry(e))); + lines.push(""); + + lines.push(`## 💤 Eldre i lager (matcher, men >${ranking.freshDays}d) — ${ranking.olderMatched.length} stk`); + ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e))); + lines.push(""); + + lines.push("---"); + lines.push("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_"); + + return lines.join("\n") + "\n"; +} + +/** + * The ids of the entries renderBrief actually shows: topMatches ∪ singleMatches ∪ the first 5 + * olderMatched (mirroring the render's .slice(0,5)). The brief CLI feeds these to markSurfaced so + * the seen-log records exactly what the operator saw. Pure (RE-R3b). + */ +export function surfacedIds(ranking: BriefRanking): string[] { + return [...ranking.topMatches, ...ranking.singleMatches, ...ranking.olderMatched.slice(0, 5)].map((e) => e.trend.id); +} + +/** + * RE-R3e — the day-over-day diff of a brief's surfaced cohort against the most recent + * prior brief. `priorDate` null ⇒ no prior (the first brief / a fresh data dir). + * `added`/`carried`/`dropped` are the three DISJOINT partitions of the symmetric set + * difference (an id is in exactly one), each order-stable. Pure: the id lists + the + * prior date are injected by the CLI edge (no fs/clock here — like `today`/`pillars`). + */ +export interface BriefDiff { + priorDate: string | null; + added: string[]; + carried: string[]; + dropped: string[]; +} + +/** The symmetric set difference of today's surfaced ids against the prior brief's. Pure (no fs/clock). */ +export function diffSurfaced(currentIds: string[], priorIds: string[], priorDate: string | null): BriefDiff { + const prior = new Set(priorIds); + const cur = new Set(currentIds); + return { + priorDate, + added: currentIds.filter((id) => !prior.has(id)), + carried: currentIds.filter((id) => prior.has(id)), + dropped: priorIds.filter((id) => !cur.has(id)), + }; +} + +/** + * Extract a brief's `surfaced:` id list from its full text via one line-anchored regex + * (the hook's `extractYaml` idiom). Absent / blank / malformed → [] (a pre-R3e or + * hand-edited brief degrades to "empty prior"); never throws. Ids are comma-free hex. + */ +export function parseSurfacedFrontmatter(md: string): string[] { + const m = md.match(/^surfaced: *([^\n]*)/m); + if (!m) return []; + return m[1] + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** + * The most recent prior brief's filename: the lexicographically greatest `YYYY-MM-DD.md` + * strictly < `${today}.md` (ISO dates sort = date order), else null. Excludes today + any + * future-dated file, so a same-day re-run diffs against the true previous day (determinism). + */ +export function selectPriorBriefFile(filenames: string[], today: string): string | null { + const todayFile = `${today}.md`; + return ( + filenames + .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f) && f < todayFile) + .sort() + .pop() ?? null + ); +} + +/** + * Default brief directory under the per-user data dir, DERIVED from + * defaultStorePath() so root resolution lives in exactly one place: + * <root>/trends/trends.json -> <root>/trends/morning-brief. Colocated with the + * store the brief reads. Pure path computation, no fs. + */ +export function defaultBriefDir(): string { + return join(dirname(defaultStorePath()), "morning-brief"); +} diff --git a/scripts/trends/src/cli.ts b/scripts/trends/src/cli.ts new file mode 100644 index 0000000..d864419 --- /dev/null +++ b/scripts/trends/src/cli.ts @@ -0,0 +1,488 @@ +#!/usr/bin/env node +/** + * CLI for the persistent trend store (research-engine §5, foundation layer). + * + * node --import tsx src/cli.ts add --title "<t>" --url "<u>" --topics <a,b> + * [--source <s>] [--summary "<s>"] [--store <path>] + * node --import tsx src/cli.ts query --topics <a,b> [--store <path>] [--json] + * node --import tsx src/cli.ts list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json] + * node --import tsx src/cli.ts status [--store <path>] [--json] + * node --import tsx src/cli.ts act|skip|reset --id <id> [--store <path>] + * echo '<raw item|batch>' | node --import tsx src/cli.ts normalize + * echo '<scored candidates>' | node --import tsx src/cli.ts score [--mode kortform|long-form] [--threshold N] + * echo '<raw item|batch>' | node --import tsx src/cli.ts capture [--store <path>] [--json] + * 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 schedule --pillars <a,b> [--at HH:MM] [--fresh-days N] + * [--platform auto|launchd|cron] [--install|--uninstall] [--store <path>] + * + * The capture agent (research-engine) folds freshly-polled trends into the store via + * `capture` (the normalizing batch path: stdin → normalizeItem(s) → itemToInput → + * addTrend) — which, when an item carries the agent's five judgment scores (RE-R3a), + * persists an optional relevance `score` (the deterministically-computed composite + band, + * one owner) first-sight on the record so the morning brief ranks on it — and reasons over + * accumulated history via `query`/`list`. `brief` (RE-R2b) + * renders a dated, pillar-ranked morning brief over the store to a Markdown file the + * SessionStart hook surfaces; RE-R3d adds a DERIVED temporal overlay (first-mover up / saturated + * down) as a within-tier ranking refinement, tunable via `--first-mover-days`/`--saturation-at`. + * `add` is the MANUAL single-trend path (raw flags, no + * normalization, publish-date-free). `act`/`skip`/`reset --id` set a trend's lifecycle status + * (RE-R3b): the morning brief EXCLUDES acted/skipped and records each surfacing (per-day-idempotent + * `surfacedCount`) so the loop stops re-surfacing handled work; a re-capture refreshes the score + * (timing decays). The polling + relevance-scoring itself lives upstream; this is the deterministic store. + * + * `normalize` + `score` (RE-R1) and `capture` (RE-R2a) are the deterministic + * research-engine seam: all read their JSON PAYLOAD FROM STDIN (so they do not overload + * `--json`, which stays an output toggle). `normalize` validates raw items into the + * canonical envelope; `score` triages scored candidates (composite/band/threshold); + * `capture` normalizes + folds each valid item into the store (persisting `publishedAt`), + * reporting content-invalid items in the summary `errors[]`, never via the exit code. + * + * `schedule` (RE-R3c) is PRINT-FIRST: it emits a launchd plist (macOS) / cron line (Linux) firing the + * DETERMINISTIC `brief` daily via the `run-daily.sh` headless wrapper, plus the exact activation command. + * `--install` writes only the inert launchd plist FILE; the tool never runs `launchctl` or the cron table + * (the operator runs the one printed command). No AI capture in the nightly run — that is a later slice. + * + * Exit code: 0 on success, 2 on usage error or a not-found id (act/skip/reset). A wrong --id is an + * argument-class error; capture's content-invalid items stay in errors[] (never via the exit code). + * `schedule` adds no new exit code: an autonomy install never RUNS the system mutation. + */ + +import { readFileSync, readdirSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; + +import { + addTrend, + defaultStorePath, + history, + loadStore, + markSurfaced, + newestCaptureDate, + queryByTopic, + saveStore, + setStatus, +} from "./store.js"; +import type { TrendStatus } from "./types.js"; +import { normalizeItem, normalizeItems, itemToInput } from "./item.js"; +import { triage } from "./score.js"; +import type { ScoreMode } from "./score.js"; +import { + rankForBrief, + renderBrief, + briefSummary, + defaultBriefDir, + surfacedIds, + diffSurfaced, + parseSurfacedFrontmatter, + selectPriorBriefFile, +} from "./brief.js"; +import { launchdPlist, crontabLine, installInstructions, uninstallInstructions, defaultLabel } from "./schedule.js"; +import type { ScheduleSpec } from "./schedule.js"; + +function parseFlags(args: string[]): Record<string, string> { + const out: Record<string, string> = {}; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) { + out[key] = "true"; + } else { + out[key] = next; + i++; + } + } + } + return out; +} + +function splitTopics(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +function usage(msg: string): never { + console.error(`error: ${msg}`); + console.error( + "usage:\n" + + ' add --title "<t>" --url "<u>" --topics <a,b> [--source <s>] [--summary "<s>"] [--store <path>]\n' + + " query --topics <a,b> [--store <path>] [--json]\n" + + " list [--since <YYYY-MM-DD>] [--limit <n>] [--store <path>] [--json]\n" + + " status [--store <path>] [--json]\n" + + " act|skip|reset --id <id> [--store <path>]\n" + + " normalize < raw-item-or-batch.json\n" + + " score [--mode kortform|long-form] [--threshold N] < scored-candidates.json\n" + + " capture [--store <path>] [--json] < raw-item-or-batch.json\n" + + " brief [--pillars <a,b>] [--fresh-days N] [--first-mover-days N] [--saturation-at N] [--out <dir>] [--no-mark] [--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); +} + +/** Read the full JSON payload from stdin, or exit 2 if it is empty/unparseable. */ +function readStdinJson(): unknown { + let raw = ""; + try { + raw = readFileSync(0, "utf8").trim(); + } catch { + raw = ""; + } + if (raw.length === 0) usage("expected a JSON payload on stdin"); + try { + return JSON.parse(raw); + } catch { + usage("stdin is not valid JSON"); + } +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** Whole days from one ISO date to another (floor); negative if `from` is later. */ +function daysBetween(fromIso: string, toIso: string): number { + return Math.floor((new Date(toIso).getTime() - new Date(fromIso).getTime()) / 86400000); +} + +function main(): void { + const [command, ...rest] = process.argv.slice(2); + const flags = parseFlags(rest); + const storePath = flags.store ?? defaultStorePath(); + const asJson = flags.json === "true"; + + if (command === "add") { + const title = flags.title; + if (!title || title === "true") usage('add needs --title "<text>"'); + const url = flags.url; + if (!url || url === "true") usage('add needs --url "<url>"'); + const topics = splitTopics(flags.topics); + if (topics.length === 0) usage("add needs --topics <a,b>"); + const store = loadStore(storePath); + const res = addTrend(store, { + title, + url, + source: flags.source && flags.source !== "true" ? flags.source : "manual", + capturedAt: today(), + topics, + ...(flags.summary && flags.summary !== "true" ? { summary: flags.summary } : {}), + }); + saveStore(storePath, res.store); + if (res.added) { + console.log(`Added: ${title}`); + } else { + console.log(`Duplicate — already in store${res.merged ? " (topics unioned)" : ""}: ${title}`); + } + console.log(`Store: ${storePath} (${res.store.trends.length} trends)`); + return; + } + + if (command === "query") { + const topics = splitTopics(flags.topics); + if (topics.length === 0) usage("query needs --topics <a,b>"); + const hits = queryByTopic(loadStore(storePath), topics); + if (asJson) { + console.log(JSON.stringify(hits, null, 2)); + return; + } + if (hits.length === 0) { + console.log(`No trends found for topics: ${topics.join(", ")}`); + console.log("→ poll fresh signals (research engine), then `add` them."); + return; + } + console.log(`${hits.length} trend(s) for: ${topics.join(", ")}`); + for (const { trend, topicOverlap } of hits) { + console.log(`\n · (overlap ${topicOverlap}) ${trend.title}`); + console.log(` ${trend.url}`); + console.log(` topics: ${trend.topics.join(", ")} — ${trend.source}, ${trend.capturedAt}`); + if (trend.summary) console.log(` ${trend.summary}`); + } + return; + } + + if (command === "list") { + const opts: { since?: string; limit?: number } = {}; + if (flags.since && flags.since !== "true") opts.since = flags.since; + if (flags.limit && flags.limit !== "true") { + const n = Number.parseInt(flags.limit, 10); + if (Number.isNaN(n) || n < 0) usage("--limit must be a non-negative integer"); + opts.limit = n; + } + const rows = history(loadStore(storePath), opts); + if (asJson) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + const scope = opts.since ? ` since ${opts.since}` : ""; + console.log(`Store: ${storePath} — ${rows.length} trend(s)${scope}`); + for (const t of rows) { + console.log(` · ${t.capturedAt} ${t.title} {${t.topics.join(", ")}}`); + } + return; + } + + if (command === "status") { + const store = loadStore(storePath); + const newest = newestCaptureDate(store); + const daysStale = newest === null ? null : daysBetween(newest, today()); + if (asJson) { + console.log(JSON.stringify({ store: storePath, count: store.trends.length, newest, daysStale })); + return; + } + console.log(`Store: ${storePath}`); + console.log(` trends: ${store.trends.length}`); + if (newest === null) { + console.log(" newest: — (empty store; no captures yet)"); + } else { + console.log(` newest: ${newest} (${daysStale}d ago)`); + } + return; + } + + if (command === "act" || command === "skip" || command === "reset") { + const id = flags.id; + if (!id || id === "true") usage(`${command} needs --id <id>`); + const status: TrendStatus = command === "act" ? "acted" : command === "skip" ? "skipped" : "new"; + const store = loadStore(storePath); + const res = setStatus(store, id, status); + if (!res.found) { + console.error(`error: no trend with id: ${id}`); + process.exit(2); + } + saveStore(storePath, store); + console.log(`Marked ${id} ${status}`); + return; + } + + if (command === "normalize") { + const payload = readStdinJson(); + const out = Array.isArray(payload) ? normalizeItems(payload) : normalizeItem(payload); + console.log(JSON.stringify(out, null, 2)); + return; + } + + if (command === "score") { + const mode = flags.mode && flags.mode !== "true" ? flags.mode : "kortform"; + if (mode !== "kortform" && mode !== "long-form") { + usage('score --mode must be "kortform" or "long-form"'); + } + let threshold = 4.0; + if (flags.threshold && flags.threshold !== "true") { + const t = Number.parseFloat(flags.threshold); + if (Number.isNaN(t)) usage("--threshold must be a number"); + threshold = t; + } + const payload = readStdinJson(); + if (!Array.isArray(payload)) usage("score expects a JSON array of scored candidates on stdin"); + try { + const result = triage(payload as Array<{ scores: Record<string, number> }>, { + mode: mode as ScoreMode, + threshold, + }); + console.log(JSON.stringify(result, null, 2)); + } catch (e) { + usage(`scoring failed: ${(e as Error).message}`); + } + return; + } + + if (command === "capture") { + const payload = readStdinJson(); // exits 2 on empty/unparseable stdin + const raw = Array.isArray(payload) ? payload : [payload]; + const { items, errors } = normalizeItems(raw); + const store = loadStore(storePath); + // Tally derived from AddResult {added, merged} (no `duplicates` field): a fold is + // `added` (new), else `merged` (existing gained topics and/or a refreshed score), else a plain `duplicate`. + let added = 0; + let merged = 0; + let duplicates = 0; + for (const item of items) { + const res = addTrend(store, itemToInput(item, today())); + if (res.added) added++; + else if (res.merged) merged++; + else duplicates++; + } + saveStore(storePath, store); + if (asJson) { + console.log(JSON.stringify({ added, duplicates, merged, errors }, null, 2)); + return; + } + console.log( + `Captured into ${storePath}: ${added} added, ${merged} merged, ` + + `${duplicates} duplicate, ${errors.length} invalid (${store.trends.length} total)`, + ); + return; + } + + if (command === "brief") { + const pillars = splitTopics(flags.pillars); + let freshDays = 7; + if (flags["fresh-days"] && flags["fresh-days"] !== "true") { + const n = Number.parseInt(flags["fresh-days"], 10); + if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer"); + freshDays = n; + } + // RE-R3d temporal-overlay thresholds (defaults mirror brief.ts's RankOptions defaults). + let firstMoverDays = 2; + if (flags["first-mover-days"] && flags["first-mover-days"] !== "true") { + const n = Number.parseInt(flags["first-mover-days"], 10); + if (Number.isNaN(n) || n < 0) usage("--first-mover-days must be a non-negative integer"); + firstMoverDays = n; + } + let saturationAt = 3; + if (flags["saturation-at"] && flags["saturation-at"] !== "true") { + const n = Number.parseInt(flags["saturation-at"], 10); + if (Number.isNaN(n) || n < 1) usage("--saturation-at must be a positive integer"); + saturationAt = n; + } + // A bare `--out` yields the string "true" (parseFlags); the guard falls back to + // defaultBriefDir() so it never writes to ./true. + const outDir = flags.out && flags.out !== "true" ? flags.out : defaultBriefDir(); + const day = today(); // one wall-clock read for both the ranking and the filename + const store = loadStore(storePath); // hoisted: also needed for the surfacing write below + const ranking = rankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt }); + // RE-R3e: discover the most recent prior brief in outDir and diff today's surfaced cohort + // against its `surfaced:` line. Any fs error (no dir, unreadable file) degrades to the + // empty-prior (first-brief) path. The dir read lives HERE (the edge) — brief.ts stays fs-free. + const todayIds = surfacedIds(ranking); + let priorIds: string[] = []; + let priorDate: string | null = null; + try { + if (existsSync(outDir)) { + const priorFile = selectPriorBriefFile(readdirSync(outDir), day); + if (priorFile) { + priorIds = parseSurfacedFrontmatter(readFileSync(join(outDir, priorFile), "utf8")); + priorDate = priorFile.slice(0, 10); + } + } + } catch { + priorIds = []; + priorDate = null; + } + const diff = diffSurfaced(todayIds, priorIds, priorDate); + const md = renderBrief(ranking, diff); + const path = join(outDir, `${day}.md`); + mkdirSync(outDir, { recursive: true }); + writeFileSync(path, md, "utf8"); + // RE-R3b: record surfacing on the store AFTER the pure render (per-day idempotent), unless --no-mark. + // The handled (acted/skipped) records were filtered from the ranking but remain in `store`, so the + // resave preserves them; only the surfaced ids' surfacedCount/lastSurfacedAt change. + const mark = flags["no-mark"] !== "true"; + const marked = mark ? markSurfaced(store, surfacedIds(ranking), day).marked : 0; + if (mark) saveStore(storePath, store); + const summary = briefSummary(ranking, diff); // SAME source the frontmatter carries (RE-R3e: incl. the delta marker) + if (asJson) { + console.log( + JSON.stringify( + { + path, + date: ranking.today, + totals: ranking.totals, + summary, + marked, + diff: { + priorDate: diff.priorDate, + added: diff.added.length, + carried: diff.carried.length, + dropped: diff.dropped.length, + }, + }, + null, + 2, + ), + ); + return; + } + const deltaNote = diff.added.length > 0 && diff.priorDate !== null ? `, ${diff.added.length} nye siden sist` : ""; + console.log( + `Wrote brief: ${path} (${ranking.totals.matched} matched, ${ranking.totals.fresh} fresh, ${marked} surfaced)${deltaNote}`, + ); + 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). + const pillars = splitTopics(flags.pillars); + if (pillars.length === 0) usage("schedule needs --pillars <a,b>"); + + const at = flags.at && flags.at !== "true" ? flags.at : "07:00"; + const [hStr, mStr] = at.split(":"); + const hour = Number.parseInt(hStr, 10); + const minute = Number.parseInt(mStr ?? "", 10); + if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { + usage("--at must be HH:MM (00:00-23:59)"); + } + + let freshDays = 7; + if (flags["fresh-days"] && flags["fresh-days"] !== "true") { + const n = Number.parseInt(flags["fresh-days"], 10); + if (Number.isNaN(n) || n < 0) usage("--fresh-days must be a non-negative integer"); + freshDays = n; + } + + const platformFlag = flags.platform && flags.platform !== "true" ? flags.platform : "auto"; + if (platformFlag !== "auto" && platformFlag !== "launchd" && platformFlag !== "cron") { + usage("--platform must be auto|launchd|cron"); + } + const platform: "launchd" | "cron" = + platformFlag === "auto" + ? process.platform === "darwin" + ? "launchd" + : "cron" + : (platformFlag as "launchd" | "cron"); + + // Resolve every absolute path FROM THE RUNTIME (never hard-coded → domain-general). + const here = dirname(fileURLToPath(import.meta.url)); // .../scripts/trends/src + const wrapperPath = join(here, "..", "run-daily.sh"); + const workingDir = join(here, ".."); + const nodeBin = process.execPath; + // logPath is anchored to the DATA ROOT (dirname(defaultStorePath())), NOT the --store override, so a + // custom --store never splits the plist StandardOutPath from the wrapper's own cron.log (sibling of + // morning-brief/, matching brief.ts defaultBriefDir's dirname(defaultStorePath()) idiom). + const logPath = join(dirname(defaultStorePath()), "cron.log"); + const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); + // env is canonical: always NODE_BIN + a resolved-absolute data root — pins the scheduled run to the + // install-time root AND removes the wrapper's $HOME-unset `set -u` edge under a profile-less env. + const env: Record<string, string> = { NODE_BIN: nodeBin, LINKEDIN_STUDIO_DATA: root }; + + // The wrapper hard-codes the `brief` subcommand → the baked args carry NO leading "brief". + const args = ["--pillars", pillars.join(","), "--fresh-days", String(freshDays)]; + if (flags.store && flags.store !== "true") args.push("--store", storePath); + + const label = defaultLabel(); + const spec: ScheduleSpec = { platform, label, nodeBin, wrapperPath, args, hour, minute, logPath, workingDir, env }; + const plistTarget = join(homedir(), "Library", "LaunchAgents", `${label}.plist`); + + if (flags.uninstall === "true") { + console.log(uninstallInstructions(spec, platform === "launchd" ? plistTarget : undefined)); + if (platform === "launchd" && existsSync(plistTarget)) rmSync(plistTarget); + return; + } + if (flags.install === "true") { + if (platform === "launchd") { + mkdirSync(dirname(plistTarget), { recursive: true }); + writeFileSync(plistTarget, launchdPlist(spec), "utf8"); + console.log(plistTarget); + console.log(installInstructions(spec, plistTarget)); + } else { + console.log(crontabLine(spec)); + console.log(installInstructions(spec)); + } + return; + } + // default / --print — emit the artifact + the activation command. No fs. + console.log(platform === "launchd" ? launchdPlist(spec) : crontabLine(spec)); + console.log(installInstructions(spec, platform === "launchd" ? plistTarget : undefined)); + return; + } + + usage(command ? `unknown command: ${command}` : "no command given"); +} + +main(); diff --git a/scripts/trends/src/item.ts b/scripts/trends/src/item.ts new file mode 100644 index 0000000..59cef75 --- /dev/null +++ b/scripts/trends/src/item.ts @@ -0,0 +1,206 @@ +/** + * Canonical ingress item schema + normalizer for the research engine (RE-R1, B1). + * + * A `TrendItem` is the ONE envelope every source/adapter emits before a trend reaches + * the store — "the one schema downstream never branches on". This module validates + + * normalizes that envelope deterministically (no AI, no network): required-field + * validation, topic normalize + dedupe, optional publishedAt ISO validation. It is the + * trend-side twin of the store's own normalize/dedupe discipline (scripts/trends/src/store.ts). + * + * Scope (RE-R1): the validated envelope + normalizer ONLY. The item->store bridge — + * injecting the store's `capturedAt` and persisting `publishedAt` — is R2 orchestration + * and lives in the CLI/agent layer, not here. The envelope carries NO `id`: the store + * derives it via addTrend->trendId, so an id here would be a second source of truth. + * + * GENERIC BY ARCHITECTURE: nothing niche-specific lives here. Which topics matter and + * which sources to poll are decided upstream (config/profile + the capture agent). + */ + +import { normalizeField } from "./store.js"; +import type { TrendInput } from "./store.js"; +import { requiredDimensions, scoreEnvelope } from "./score.js"; +import type { ScoreMode, DimensionScores } from "./score.js"; + +export interface TrendItem { + /** Capture origin: a research-MCP name ("tavily"), "websearch", or "manual". Stored VERBATIM. */ + source: string; + /** The trend headline, VERBATIM (case + spacing preserved — the store keeps it verbatim too). */ + title: string; + /** The source URL, VERBATIM (case-sensitive paths must survive). */ + url: string; + /** + * The SOURCE's own publish date (ISO-8601), validated-if-present. Carried for + * forward-compat (B4 freshness) — distinct from the store's `capturedAt`, and NOT + * persisted in R1. Absent -> the key is omitted. + */ + publishedAt?: string; + /** Topic tags, normalized (lowercase + whitespace-collapsed via normalizeField) + deduped. */ + topics: string[]; + /** Optional short summary, VERBATIM. Absent/blank -> the key is omitted. */ + summary?: string; + /** + * The agent's relevance JUDGMENT (RE-R3a): the mode + the five 1–10 dimension scores — + * NOT a precomputed composite (the store computes that, one owner). Validated by + * normalizeItem; turned into the persisted envelope by itemToInput→scoreEnvelope. + * Absent/invalid -> the key is omitted. + */ + score?: { mode: ScoreMode; dimensions: DimensionScores }; +} + +export type NormalizeResult = { ok: true; item: TrendItem } | { ok: false; errors: string[] }; + +/** One failed entry in a batch: its index in the input + the field errors. */ +export interface ItemError { + index: number; + errors: string[]; +} + +const REQUIRED_FIELDS = ["source", "title", "url"] as const; + +/** Strict ISO-8601: a calendar date (YYYY-MM-DD), optionally with a time/zone. Rejects impossible dates. */ +function isValidIso(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/.test(value)) { + return false; + } + const ms = Date.parse(value); + if (Number.isNaN(ms)) return false; + // Round-trip the date part: catches 2026-02-30 / out-of-range that the regex lets through. + return new Date(ms).toISOString().slice(0, 10) === value.slice(0, 10); +} + +function isNonEmptyString(v: unknown): v is string { + return typeof v === "string" && v.trim().length > 0; +} + +/** A plain (non-array, non-null) object. */ +function isPlainObject(v: unknown): v is Record<string, unknown> { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +const SCORE_MODES = ["kortform", "long-form"] as const; + +/** + * Validate a raw `score` structurally — never throws (returns a reason on failure). The + * mode must be known; `dimensions` must be a non-array object carrying every key the mode + * requires (requiredDimensions) as a number in [1,10]. On success returns the VALIDATED + * envelope (the validated dimensions object, not the raw one). + */ +function validateScore( + raw: unknown, +): { ok: true; score: { mode: ScoreMode; dimensions: DimensionScores } } | { ok: false; reason: string } { + if (!isPlainObject(raw)) return { ok: false, reason: "score must be an object" }; + const mode = raw.mode; + if (typeof mode !== "string" || !(SCORE_MODES as readonly string[]).includes(mode)) { + return { ok: false, reason: `mode must be one of ${SCORE_MODES.join(", ")} (got ${String(mode)})` }; + } + const dims = raw.dimensions; + if (!isPlainObject(dims)) return { ok: false, reason: "dimensions must be an object" }; + const validated: DimensionScores = {}; + for (const key of requiredDimensions(mode as ScoreMode)) { + const value = dims[key]; + if (typeof value !== "number" || Number.isNaN(value) || value < 1 || value > 10) { + return { ok: false, reason: `dimension "${key}" must be a number in [1,10] (got ${String(value)})` }; + } + validated[key] = value; + } + return { ok: true, score: { mode: mode as ScoreMode, dimensions: validated } }; +} + +/** Normalize each topic via the store's normalizeField, drop blanks, dedupe (first-seen order). */ +function normalizeTopics(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + const out: string[] = []; + const seen = new Set<string>(); + for (const t of raw) { + if (typeof t !== "string") continue; + const norm = normalizeField(t); + if (norm.length === 0 || seen.has(norm)) continue; + seen.add(norm); + out.push(norm); + } + return out; +} + +/** + * Validate + normalize one raw item into the canonical envelope. Pure. Returns a + * structured error (never a silent partial) when a required field is missing/empty + * or publishedAt is present-but-invalid. + */ +export function normalizeItem(raw: unknown): NormalizeResult { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return { ok: false, errors: ["raw item must be an object"] }; + } + const r = raw as Record<string, unknown>; + const errors: string[] = []; + + for (const field of REQUIRED_FIELDS) { + if (!isNonEmptyString(r[field])) { + errors.push(`missing or empty required field: ${field}`); + } + } + + let publishedAt: string | undefined; + if (r.publishedAt !== undefined && r.publishedAt !== null) { + if (typeof r.publishedAt !== "string" || !isValidIso(r.publishedAt)) { + errors.push(`invalid publishedAt (expected an ISO-8601 date): ${String(r.publishedAt)}`); + } else { + publishedAt = r.publishedAt; + } + } + + let score: { mode: ScoreMode; dimensions: DimensionScores } | undefined; + if (r.score !== undefined && r.score !== null) { + const res = validateScore(r.score); + if (!res.ok) errors.push(`invalid score: ${res.reason}`); + else score = res.score; + } + + if (errors.length > 0) return { ok: false, errors }; + + const item: TrendItem = { + source: r.source as string, + title: r.title as string, + url: r.url as string, + topics: normalizeTopics(r.topics), + ...(publishedAt !== undefined ? { publishedAt } : {}), + ...(isNonEmptyString(r.summary) ? { summary: r.summary as string } : {}), + ...(score !== undefined ? { score } : {}), + }; + return { ok: true, item }; +} + +/** + * Map a validated envelope to a store input (the item→store bridge RE-R1 deferred). + * Pure: injects `capturedAt` (the store's "when WE saw it", supplied by the caller — + * never derived here) and carries the rest verbatim. Does NOT re-validate (the item is + * already validated by normalizeItem) and does NOT derive an `id` (the store owns id via + * addTrend→trendId). `publishedAt`/`summary`/`score` are carried only when present (key + * omitted otherwise), mirroring the store's conditional-spread idiom. The `score` is turned + * into the persisted envelope here (judgment → composite, via scoreEnvelope). On the capture + * path the dims are pre-validated by normalizeItem, so scoreEnvelope→composite cannot throw; + * called DIRECTLY with bad dims it throws by contract (defense-in-depth — SC2). + */ +export function itemToInput(item: TrendItem, capturedAt: string): TrendInput { + return { + source: item.source, + title: item.title, + url: item.url, + capturedAt, + topics: [...item.topics], + ...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}), + ...(item.summary !== undefined ? { summary: item.summary } : {}), + ...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}), + }; +} + +/** Partition a raw batch into normalized items + per-index errors (never throws). */ +export function normalizeItems(raw: unknown[]): { items: TrendItem[]; errors: ItemError[] } { + const items: TrendItem[] = []; + const errors: ItemError[] = []; + raw.forEach((entry, index) => { + const res = normalizeItem(entry); + if (res.ok) items.push(res.item); + else errors.push({ index, errors: res.errors }); + }); + return { items, errors }; +} diff --git a/scripts/trends/src/schedule.ts b/scripts/trends/src/schedule.ts new file mode 100644 index 0000000..7366c11 --- /dev/null +++ b/scripts/trends/src/schedule.ts @@ -0,0 +1,111 @@ +/** + * RE-R3c — pure string emitters for the autonomous-trigger artifacts (research-engine). + * + * No clock, no fs, no env, no AI: every value the emitters render is injected via `ScheduleSpec` + * (the CLI is the only edge that reads `process.execPath` / `import.meta.url` / `defaultStorePath`). + * Mirrors `brief.ts`'s `renderBrief` purity → byte-deterministic given inputs, fully testable. + * + * Print-first (C2): these are STRINGS. `launchdPlist`/`crontabLine` emit the artifact; the install/ + * uninstall instructions emit the exact command the operator runs. Nothing here ever executes + * `launchctl` or the cron table — the emitted recipes are surfaced for the operator to run. + */ + +export interface ScheduleSpec { + platform: "launchd" | "cron"; + label: string; + nodeBin: string; + wrapperPath: string; + args: string[]; + hour: number; + minute: number; + logPath: string; + workingDir: string; + /** Canonical injected environment — always { NODE_BIN, LINKEDIN_STUDIO_DATA(resolved-absolute) }. */ + env: Record<string, string>; +} + +/** Defensive XML escaping for well-formedness (paths are normally safe, but escape regardless). */ +function xmlEscape(s: string): string { + return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); +} + +/** + * The launchd plist (macOS). A pinned, well-formed `<?xml … !DOCTYPE plist …>` template firing + * the wrapper daily via StartCalendarInterval. `RunAtLoad` is false (a calendar job, not boot-time). + */ +export function launchdPlist(spec: ScheduleSpec): string { + const programArguments = ["/bin/bash", spec.wrapperPath, ...spec.args] + .map((a) => ` <string>${xmlEscape(a)}</string>`) + .join("\n"); + const environment = Object.entries(spec.env) + .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(v)}</string>`) + .join("\n"); + return `<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Label</key> + <string>${xmlEscape(spec.label)}</string> + <key>ProgramArguments</key> + <array> +${programArguments} + </array> + <key>StartCalendarInterval</key> + <dict> + <key>Hour</key> + <integer>${spec.hour}</integer> + <key>Minute</key> + <integer>${spec.minute}</integer> + </dict> + <key>EnvironmentVariables</key> + <dict> +${environment} + </dict> + <key>WorkingDirectory</key> + <string>${xmlEscape(spec.workingDir)}</string> + <key>StandardOutPath</key> + <string>${xmlEscape(spec.logPath)}</string> + <key>StandardErrorPath</key> + <string>${xmlEscape(spec.logPath)}</string> + <key>RunAtLoad</key> + <false/> +</dict> +</plist> +`; +} + +/** + * The cron schedule line (Linux). Returns the line as a STRING; never executes the cron table. + * `<min> <hour> * * * <env-prefix> /bin/bash <wrapper> <args…> >> <log> 2>&1 # <label>`. + */ +export function crontabLine(spec: ScheduleSpec): string { + const envPrefix = Object.entries(spec.env) + .map(([k, v]) => `${k}=${v}`) + .join(" "); + const prefix = envPrefix.length > 0 ? `${envPrefix} ` : ""; + return ( + `${spec.minute} ${spec.hour} * * * ${prefix}/bin/bash ${spec.wrapperPath} ` + + `${spec.args.join(" ")} >> ${spec.logPath} 2>&1 # ${spec.label}` + ); +} + +/** The exact activation command for the operator to run (print-first; the tool never runs it). */ +export function installInstructions(spec: ScheduleSpec, plistTargetPath?: string): string { + if (spec.platform === "launchd") { + return `Wrote ${plistTargetPath}. Activate it with:\n launchctl bootstrap gui/$(id -u) ${plistTargetPath}`; + } + return `Add the line above to your schedule with:\n (crontab -l 2>/dev/null; echo '${crontabLine(spec)}') | crontab -`; +} + +/** The symmetric removal command (print-first). */ +export function uninstallInstructions(spec: ScheduleSpec, plistTargetPath?: string): string { + if (spec.platform === "launchd") { + return `Deactivate + remove with:\n launchctl bootout gui/$(id -u)/${spec.label} && rm ${plistTargetPath}`; + } + return `Remove the scheduled line with:\n crontab -l | grep -vF '# ${spec.label}' | crontab -`; +} + +/** The reverse-DNS plugin namespace — domain-general, no vendor/sector token. */ +export function defaultLabel(): string { + return "com.linkedin-studio.trends.daily"; +} diff --git a/scripts/trends/src/score.ts b/scripts/trends/src/score.ts new file mode 100644 index 0000000..604f8b4 --- /dev/null +++ b/scripts/trends/src/score.ts @@ -0,0 +1,154 @@ +/** + * Deterministic triage scorer for the research engine (RE-R1, B2). + * + * Owns ONLY the arithmetic the SSOT (references/trend-scoring-modes.md) defines: the + * per-mode weighted composite, the composite->band map, and the threshold triage. + * Producing the five 1-10 dimension scores stays MODEL JUDGMENT by design — this module + * never scores; it only combines + classifies + ranks. No AI, no network: pure and tested. + * + * SSOT discipline: the weights, the four band thresholds, and the ten band action strings + * below MIRROR references/trend-scoring-modes.md (the human source of truth). score.test.ts + * pins all three against the SSOT values so silent drift in any of them fails loudly. The + * ORDERING of the weights is the signal; the exact percentages are a documented choice, not + * a measured coefficient (SSOT "How to read this file"). + */ + +export type ScoreMode = "kortform" | "long-form"; +export type DimensionScores = Record<string, number>; + +/** kortform weights (SSOT "Mode: kortform"). Sigma = 1.0. */ +export const KORTFORM_WEIGHTS = { + pillar: 0.3, + audience: 0.25, + timing: 0.2, + angle: 0.15, + authority: 0.1, +} as const; + +/** long-form weights (SSOT "Mode: long-form"). Sigma = 1.0. */ +export const LONG_FORM_WEIGHTS = { + pillar: 0.3, + depth: 0.25, + angle: 0.2, + authority: 0.15, + currency: 0.1, +} as const; + +const WEIGHTS: Record<ScoreMode, Record<string, number>> = { + kortform: KORTFORM_WEIGHTS, + "long-form": LONG_FORM_WEIGHTS, +}; + +export type Priority = "Immediate" | "High" | "Medium" | "Low" | "Skip"; + +export interface Band { + priority: Priority; + kortformAction: string; + longformAction: string; +} + +/** + * Composite->band map (SSOT "Composite -> action"). Descending by `min`; the first band + * whose `min` the composite reaches wins. Thresholds + action strings are pinned by + * score.test.ts against the SSOT, so any drift here fails the gate. + */ +const BANDS: ReadonlyArray<{ readonly min: number } & Band> = [ + { min: 8.0, priority: "Immediate", kortformAction: "Draft within 24h", longformAction: "Promote to the edition backlog now" }, + { min: 6.0, priority: "High", kortformAction: "Publish within 48–72h", longformAction: "Strong edition candidate — schedule it" }, + { min: 4.0, priority: "Medium", kortformAction: "Add to this week's calendar", longformAction: "Hold as a backlog candidate, revisit" }, + { min: 2.0, priority: "Low", kortformAction: "Note, skip for now", longformAction: "Park unless the angle sharpens" }, + { min: 0, priority: "Skip", kortformAction: "Off positioning", longformAction: "Off positioning" }, +]; + +function round1(x: number): number { + return Math.round(x * 10) / 10; +} + +function toBand(b: { readonly min: number } & Band): Band { + return { priority: b.priority, kortformAction: b.kortformAction, longformAction: b.longformAction }; +} + +/** + * Weighted composite on the shared 0-10 scale, rounded to 1 decimal (the SSOT's display + * granularity). Validates each of the mode's five dimensions in [1,10]; a missing or + * out-of-range dimension throws — the scores are model output, and a bad one is a contract + * violation, not a value to silently clamp. + */ +export function composite(scores: DimensionScores, mode: ScoreMode): number { + const weights = WEIGHTS[mode]; + let sum = 0; + for (const [dim, weight] of Object.entries(weights)) { + const value = scores[dim]; + if (typeof value !== "number" || Number.isNaN(value) || value < 1 || value > 10) { + throw new RangeError(`dimension "${dim}" must be a number in [1,10] (got ${String(value)})`); + } + sum += value * weight; + } + return round1(sum); +} + +/** Map a composite to its priority band + the mode-specific action strings. */ +export function band(composite: number): Band { + for (const b of BANDS) { + if (composite >= b.min) return toBand(b); + } + // composite < 0 (off the scale) — classify as Skip rather than throw; band is a classifier. + return toBand(BANDS[BANDS.length - 1]); +} + +/** + * The persist-ready relevance envelope (RE-R3a): the agent's judgment (mode + the five + * dimension scores) plus the deterministically-derived composite + priority band. Lives + * in score.ts (the score domain owns it); types.ts imports it (one-way — score.ts imports + * nothing internal, so no cycle). + */ +export interface TrendScore { + mode: ScoreMode; + dimensions: DimensionScores; + composite: number; + priority: Priority; +} + +/** + * The mode's five dimension keys, in SSOT weight-literal order. `normalizeItem` consumes + * this as a membership set; score.test pins the order so a silent SSOT reorder fails. + */ +export function requiredDimensions(mode: ScoreMode): string[] { + return Object.keys(WEIGHTS[mode]); +} + +/** + * Compose the persist-ready envelope from the agent's judgment: the composite is + * `composite(dimensions, mode)` and the priority is `band(composite).priority` — the + * existing pure functions stay the single owners (no new arithmetic). Throws via + * `composite` on an out-of-range dimension (its contract). + */ +export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): TrendScore { + const c = composite(dimensions, mode); + return { mode, dimensions, composite: c, priority: band(c).priority }; +} + +export interface TriageOptions { + mode: ScoreMode; + threshold: number; +} + +export type Triaged<T> = T & { composite: number; band: Band }; + +/** + * Score each candidate, keep composite >= threshold (ranked composite-desc), drop below + * (also composite-desc). Each returned entry is annotated with its composite + band. Pure. + */ +export function triage<T extends { scores: DimensionScores }>( + candidates: T[], + opts: TriageOptions, +): { kept: Array<Triaged<T>>; dropped: Array<Triaged<T>> } { + const annotated: Array<Triaged<T>> = candidates.map((c) => { + const comp = composite(c.scores, opts.mode); + return { ...c, composite: comp, band: band(comp) }; + }); + const byCompositeDesc = (a: Triaged<T>, b: Triaged<T>) => b.composite - a.composite; + const kept = annotated.filter((a) => a.composite >= opts.threshold).sort(byCompositeDesc); + const dropped = annotated.filter((a) => a.composite < opts.threshold).sort(byCompositeDesc); + return { kept, dropped }; +} diff --git a/scripts/trends/src/store.ts b/scripts/trends/src/store.ts new file mode 100644 index 0000000..d1c42cf --- /dev/null +++ b/scripts/trends/src/store.ts @@ -0,0 +1,255 @@ +/** + * Deterministic store + query for the persistent trend store (research-engine §5). + * + * Pure where it matters: id derivation, dedupe, topic query, and history are + * side-effect-free and fully testable. Only loadStore/saveStore touch the + * filesystem. No AI, no network — this module is reliable inventory, not a + * creatively-interpreted blob. The capture that POPULATES the store (polling + * research MCPs / web search, scoring relevance) lives in the agent/command + * layer; this module only stores, dedupes, and serves trend signals. + * + * Twin of scripts/specifics-bank/src/bank.ts — same discipline, different + * dedupe key (normalized title+URL instead of free-text content). + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { createHash } from "node:crypto"; + +import { SCHEMA_VERSION } from "./types.js"; +import type { TrendStore, TrendRecord, TrendQueryHit, TrendStatus } from "./types.js"; +import type { TrendScore } from "./score.js"; + +export { SCHEMA_VERSION } from "./types.js"; + +/** What a caller supplies to addTrend — the id is derived, never passed in. */ +export interface TrendInput { + title: string; + url: string; + source: string; + capturedAt: string; + /** The source's own publish date (ISO-8601), if known. Distinct from capturedAt; persisted first-sight, never back-filled. */ + publishedAt?: string; + topics: string[]; + summary?: string; + /** The persisted relevance envelope (RE-R3a), if the caller computed one. First-sight, never updated on re-capture. */ + score?: TrendScore; +} + +export interface AddResult { + store: TrendStore; + /** true iff a new trend was appended (false = duplicate title+url). */ + added: boolean; + /** true iff an existing duplicate was mutated — topic tags unioned and/or its score refreshed (RE-R3b). */ + merged: boolean; +} + +/** Options for a recency-ordered history slice. */ +export interface HistoryOptions { + /** Inclusive lower bound on capturedAt (ISO date); older trends are excluded. */ + since?: string; + /** Cap on the number of returned trends (newest kept). */ + limit?: number; +} + +/** Lowercase + trim + collapse all whitespace runs to a single space. */ +export function normalizeField(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +/** + * Stable id = first 12 hex of sha256(normalized title + "\n" + normalized url); + * also the dedupe key. The URL is folded to lowercase too: host case is + * insignificant, and the combined title+url key makes a false merge on a + * case-only path difference vanishingly unlikely. Richer URL canonicalization + * (trailing-slash / query-param stripping) is deferred to a later slice if + * dedup ever proves leaky. + */ +export function trendId(title: string, url: string): string { + const key = normalizeField(title) + "\n" + normalizeField(url); + return createHash("sha256").update(key).digest("hex").slice(0, 12); +} + +export function emptyStore(): TrendStore { + return { schemaVersion: SCHEMA_VERSION, trends: [] }; +} + +/** Read the store; a missing file is an empty store (never throws on absence). */ +export function loadStore(path: string): TrendStore { + if (!existsSync(path)) return emptyStore(); + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<TrendStore>; + // Forward migrate-on-load: stamp to the current version, never downgrade. v1→v2→v3→v4 are + // all purely additive-optional (an old record is already a valid v4 record that simply + // lacks the optional publishedAt [v2] / score [v3] / status+surfacedCount+lastSurfacedAt + // [v4]), so the migration is the version stamp alone — records pass through untouched + // (lossless + idempotent for any well-formed store; new optional fields survive + // JSON.stringify on resave). + // A string / NaN / absent version coerces to the current version (never crashes); the + // non-array `trends` coercion below is unchanged and out of the losslessness claim. + const onDisk = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : SCHEMA_VERSION; + return { + schemaVersion: Math.max(onDisk, SCHEMA_VERSION), + trends: Array.isArray(parsed.trends) ? parsed.trends : [], + }; +} + +/** Write the store as pretty JSON, creating the parent dir if needed. */ +export function saveStore(path: string, store: TrendStore): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(store, null, 2) + "\n", "utf8"); +} + +/** Union of two tag lists, order-stable on the first list, case-insensitive dedupe. */ +function unionTopics(existing: string[], incoming: string[]): { topics: string[]; changed: boolean } { + const seen = new Set(existing.map((t) => t.toLowerCase())); + const topics = [...existing]; + let changed = false; + for (const t of incoming) { + if (!seen.has(t.toLowerCase())) { + seen.add(t.toLowerCase()); + topics.push(t); + changed = true; + } + } + return { topics, changed }; +} + +/** + * Add a trend, deduping on normalized title+url. A duplicate does not append a + * second entry — instead its topic tags are unioned in, so the same trend + * re-surfaced under a new edition's topics enriches the existing record. The + * FIRST sighting's source + capturedAt are kept (provenance of first sight); + * title/url/summary are stored VERBATIM, only the id is normalized. + */ +export function addTrend(store: TrendStore, input: TrendInput): AddResult { + const id = trendId(input.title, input.url); + const existing = store.trends.find((t) => t.id === id); + if (existing) { + const { topics, changed } = unionTopics(existing.topics, input.topics); + existing.topics = topics; + let mutated = changed; + // RE-R3b: re-score on re-capture (last-wins). `score` is the ONE mutable field — a fresh + // judgment (timing decays) replaces the stored one; the JSON compare avoids a false-merge + // on an identical re-score. Provenance (source/capturedAt/publishedAt) and lifecycle + // (status/surfacedCount/lastSurfacedAt) are untouched. + if (input.score !== undefined && JSON.stringify(existing.score) !== JSON.stringify(input.score)) { + existing.score = input.score; + mutated = true; + } + return { store, added: false, merged: mutated }; + } + const trend: TrendRecord = { + id, + title: input.title, + url: input.url, + source: input.source, + capturedAt: input.capturedAt, + ...(input.publishedAt !== undefined ? { publishedAt: input.publishedAt } : {}), + topics: [...input.topics], + ...(input.summary !== undefined ? { summary: input.summary } : {}), + ...(input.score !== undefined ? { score: input.score } : {}), + }; + store.trends.push(trend); + return { store, added: true, merged: false }; +} + +// ── RE-R3b lifecycle helpers (the trend's life AFTER first capture) ── + +/** The record's lifecycle status, defaulting absent → "new" (the single reader of that convention). Pure. */ +export function effectiveStatus(t: TrendRecord): TrendStatus { + return t.status ?? "new"; +} + +/** + * Set a trend's lifecycle status by id (the act/skip/reset verbs). Mutates the matched + * record in place and returns the same store; an unknown id is a no-op reported as + * { found: false } (never throws). Pure (no fs). + */ +export function setStatus( + store: TrendStore, + id: string, + status: TrendStatus, +): { store: TrendStore; found: boolean } { + const t = store.trends.find((x) => x.id === id); + if (!t) return { store, found: false }; + t.status = status; + return { store, found: true }; +} + +/** + * Record that the given trends were surfaced in a brief on `today` (the seen-log, B4). + * PER-DAY IDEMPOTENT: a record already surfaced on `today` is skipped, so re-running the + * same day's brief does not double-count. Increments surfacedCount (absent ⇒ 0) and stamps + * lastSurfacedAt; returns how many records were actually incremented. Pure — `today` is + * injected by the caller (the CLI edge), like the store's capturedAt. + */ +export function markSurfaced( + store: TrendStore, + ids: string[], + today: string, +): { store: TrendStore; marked: number } { + const wanted = new Set(ids); + let marked = 0; + for (const t of store.trends) { + if (!wanted.has(t.id)) continue; + if (t.lastSurfacedAt === today) continue; // per-day idempotent + t.surfacedCount = (t.surfacedCount ?? 0) + 1; + t.lastSurfacedAt = today; + marked++; + } + return { store, marked }; +} + +/** + * Trends whose topics overlap the query, ranked by overlap (desc) then recency + * (capturedAt desc). Topic matching is case-insensitive. Non-matches are + * excluded. + */ +export function queryByTopic(store: TrendStore, topics: string[]): TrendQueryHit[] { + const wanted = topics.map((t) => t.toLowerCase()); + const hits: TrendQueryHit[] = []; + for (const trend of store.trends) { + const have = new Set(trend.topics.map((t) => t.toLowerCase())); + const topicOverlap = wanted.reduce((n, t) => (have.has(t) ? n + 1 : n), 0); + if (topicOverlap > 0) hits.push({ trend, topicOverlap }); + } + hits.sort( + (a, b) => b.topicOverlap - a.topicOverlap || b.trend.capturedAt.localeCompare(a.trend.capturedAt), + ); + return hits; +} + +/** + * Trend history, newest first. Optionally filtered to capturedAt >= `since` + * (inclusive) and capped to `limit`. The time-scoped complement to + * queryByTopic's topic-scoped view; together they are the "historikk-query". + */ +export function history(store: TrendStore, opts: HistoryOptions = {}): TrendRecord[] { + let out = [...store.trends]; + if (opts.since !== undefined) out = out.filter((t) => t.capturedAt >= opts.since!); + out.sort((a, b) => b.capturedAt.localeCompare(a.capturedAt)); + if (opts.limit !== undefined) out = out.slice(0, opts.limit); + return out; +} + +/** + * The most recent capturedAt across the store (ISO date), or null when the + * store is empty. The staleness signal behind the SessionStart trend-freshness + * nudge (B-S3) and the `status` CLI subcommand: reuses history()'s newest-first + * ordering so the module has a single notion of "newest". Pure — the days-stale + * arithmetic (needs "today") lives in the caller, not here. + */ +export function newestCaptureDate(store: TrendStore): string | null { + return history(store, { limit: 1 })[0]?.capturedAt ?? null; +} + +/** + * Default store path under the per-user data dir (M0 data-path convention), so + * the trend history survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA` + * overrides the root; otherwise `~/.claude/linkedin-studio`. + */ +export function defaultStorePath(): string { + const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio"); + return join(root, "trends", "trends.json"); +} diff --git a/scripts/trends/src/types.ts b/scripts/trends/src/types.ts new file mode 100644 index 0000000..53e6f9f --- /dev/null +++ b/scripts/trends/src/types.ts @@ -0,0 +1,93 @@ +/** + * Types for the persistent trend store (research-engine §5, foundation layer). + * + * The store is the research engine's accumulating memory: a topic-tagged, + * provenance-bearing inventory of trend signals captured over time, so the + * engine reasons over HISTORY instead of starting amnesiac each session. It is + * the trend-side twin of the lived-specifics bank (scripts/specifics-bank): + * same deterministic store/dedup/query discipline, different dedupe key + * (normalized title+URL, since a trend is identified by its headline+source, + * not by free-text content). + * + * GENERIC BY ARCHITECTURE (retning §5 — nisje-agnostisk motor): nothing + * niche-specific lives in this module. A `TrendRecord` carries free-form + * `topics` tags and a free-form `source` string; which topics matter and which + * sources to poll are decided upstream (config/profile + the capture agent), + * never hard-coded here. The same store serves any niche. + * + * Forward-compatible with the profile-evolution / "second brain" architecture: + * a typed store in the per-user data dir (`${LINKEDIN_STUDIO_DATA}`), so the + * trend history survives plugin upgrades/reinstalls via the M0 data-path seam. + * The minimal core here (title, url, source, capturedAt, topics, optional + * summary) can gain fields (first-mover timing, status) in a later slice without + * breaking the shape — the relevance `score` field (RE-R3a) is the first such + * realized addition. + */ + +import type { TrendScore } from "./score.js"; + +/** The lifecycle state of a trend (RE-R3b). Absent on a record ⇒ "new" (see effectiveStatus). */ +export type TrendStatus = "new" | "acted" | "skipped"; + +export interface TrendRecord { + /** Stable id — a short hash of the normalized title+url; doubles as the dedupe key. */ + id: string; + /** The trend headline, stored VERBATIM (normalized only to derive the id). */ + title: string; + /** The source URL, stored VERBATIM (normalized only to derive the id). */ + url: string; + /** Capture origin: a research-MCP name (e.g. "tavily"), "websearch", or "manual". */ + source: string; + /** ISO-8601 date the trend was captured. Supplied by the caller (CLI edge). */ + capturedAt: string; + /** + * The SOURCE's own publish date (ISO-8601), if the captured item carried one — + * distinct from `capturedAt` (when WE saw it): this is when the source published. + * First-sight provenance: kept, never overwritten on re-capture. Forward-compat + * for B4 freshness ranking. Absent when the source gave no date (key omitted). + */ + publishedAt?: string; + /** Topic tags for query-by-topic. Unioned across re-captures of the same trend. */ + topics: string[]; + /** Optional short summary of the trend, stored VERBATIM. */ + summary?: string; + /** + * The persisted relevance assessment (RE-R3a): the agent's judgment (mode + the + * five 1–10 dimension scores) plus the deterministically-derived composite + band, + * computed once at first sight by the store's single scorer owner. First-sight, + * never updated on re-capture (re-score pairs with the R3b status slice). Absent on + * pre-R3a records and on the score-free `add` manual path (key omitted). + * + * RE-R3b makes `score` the one MUTABLE field: a re-capture carrying a fresh judgment + * refreshes it (last-wins, timing decays), via addTrend's duplicate branch. + */ + score?: TrendScore; + /** + * The trend's lifecycle status (RE-R3b). Absent ⇒ "new" (effectiveStatus). Set only by + * the act/skip/reset CLI verbs, never on capture — a freshly-captured trend is implicitly + * new. The morning brief excludes anything not "new" (a work queue, not an archive). + */ + status?: TrendStatus; + /** + * The seen-log count (RE-R3b, B4): distinct days this trend has appeared in a generated + * brief. Absent ⇒ 0. Incremented (per-day-idempotent) by the brief CLI after the pure + * ranking — the temporal foundation slices (c)+(b) read. + */ + surfacedCount?: number; + /** ISO date of the most recent surfacing (RE-R3b). Absent ⇒ never. The per-day idempotency key. */ + lastSurfacedAt?: string; +} + +export interface TrendStore { + schemaVersion: number; + trends: TrendRecord[]; +} + +/** A query hit: the trend plus how many of the queried topics it matched. */ +export interface TrendQueryHit { + trend: TrendRecord; + /** Number of queried topics present on the trend (≥1 for a hit). */ + topicOverlap: number; +} + +export const SCHEMA_VERSION = 4; diff --git a/scripts/trends/tests/brief.test.ts b/scripts/trends/tests/brief.test.ts new file mode 100644 index 0000000..37108c9 --- /dev/null +++ b/scripts/trends/tests/brief.test.ts @@ -0,0 +1,734 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; + +import { + rankForBrief, + renderBrief, + briefSummary, + defaultBriefDir, + surfacedIds, + diffSurfaced, + parseSurfacedFrontmatter, + selectPriorBriefFile, + temporalSignal, + BRIEF_SCHEMA_VERSION, +} from "../src/brief.js"; +import { SCHEMA_VERSION } from "../src/types.js"; +import type { TrendRecord, TrendStore } from "../src/types.js"; + +const TODAY = "2026-06-24"; + +type TestScore = { + mode: "kortform" | "long-form"; + dimensions: Record<string, number>; + composite: number; + priority: "Immediate" | "High" | "Medium" | "Low" | "Skip"; +}; + +function mkTrend( + p: { + title: string; + url: string; + topics: string[]; + capturedAt: string; + publishedAt?: string; + source?: string; + summary?: string; + score?: TestScore; + status?: "new" | "acted" | "skipped"; + surfacedCount?: number; + lastSurfacedAt?: string; + }, +): TrendRecord { + return { + id: p.title + "|" + p.url, + title: p.title, + url: p.url, + source: p.source ?? "tavily", + capturedAt: p.capturedAt, + ...(p.publishedAt !== undefined ? { publishedAt: p.publishedAt } : {}), + topics: p.topics, + ...(p.summary !== undefined ? { summary: p.summary } : {}), + ...(p.score !== undefined ? { score: p.score } : {}), + ...(p.status !== undefined ? { status: p.status } : {}), + ...(p.surfacedCount !== undefined ? { surfacedCount: p.surfacedCount } : {}), + ...(p.lastSurfacedAt !== undefined ? { lastSurfacedAt: p.lastSurfacedAt } : {}), + }; +} + +/** A composite-bearing score for the rank tests (mode/priority kept consistent for render asserts). */ +function mkScore(composite: number, priority: TestScore["priority"], mode: TestScore["mode"] = "kortform"): TestScore { + return { mode, dimensions: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 }, composite, priority }; +} +function mkStore(trends: TrendRecord[]): TrendStore { + return { schemaVersion: 2, trends }; +} + +describe("rankForBrief — grouping (SC1)", () => { + const pillars = ["AI", "gov"]; + const store = mkStore([ + mkTrend({ title: "T1 top", url: "https://e/1", topics: ["ai", "gov", "x"], publishedAt: "2026-06-22", capturedAt: "2026-06-23" }), + mkTrend({ title: "T2 single", url: "https://e/2", topics: ["AI"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), + mkTrend({ title: "T3 older1", url: "https://e/3", topics: ["gov"], publishedAt: "2026-06-01", capturedAt: "2026-06-01" }), + mkTrend({ title: "T4 noise", url: "https://e/4", topics: ["unrelated"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }), + mkTrend({ title: "T5 older2", url: "https://e/5", topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }), + ]); + const r = rankForBrief(store, pillars, TODAY); + + test("topMatches = overlap>=2 & fresh only", () => { + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["T1 top"]); + }); + test("singleMatches = overlap===1 & fresh only", () => { + assert.deepEqual(r.singleMatches.map((e) => e.trend.title), ["T2 single"]); + }); + test("olderMatched = overlap>=1 & stale, sorted overlap desc", () => { + assert.deepEqual(r.olderMatched.map((e) => e.trend.title), ["T5 older2", "T3 older1"]); + }); + test("overlap===0 excluded everywhere", () => { + const all = [...r.topMatches, ...r.singleMatches, ...r.olderMatched].map((e) => e.trend.title); + assert.ok(!all.includes("T4 noise")); + }); + test("totals reflect trends/matched/fresh", () => { + assert.deepEqual(r.totals, { trends: 5, matched: 4, fresh: 2 }); + }); + test("matchedPillars preserve pillar case (case-insensitive match)", () => { + assert.deepEqual(r.topMatches[0].matchedPillars, ["AI", "gov"]); + }); +}); + +describe("rankForBrief — within-group total order (SC1)", () => { + test("effectiveDate desc orders before title (both neutral so the RE-R3d temporal key ties)", () => { + // RE-R3d: both entries are neutral (surfaced 0, ageDays 4-6 > firstMoverDays 2), so the temporal + // key ties and effectiveDate is the deciding key; titles disagree with date to isolate effectiveDate. + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a1", topics: ["a", "b"], publishedAt: "2026-06-18", capturedAt: "2026-06-18" }), + mkTrend({ title: "Bravo", url: "https://e/b1", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), + ]); + const r = rankForBrief(store, ["a", "b"], TODAY); + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Bravo", "Alpha"]); + }); + test("same title+effectiveDate+overlap -> url asc tie-break (total order)", () => { + const store = mkStore([ + mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + ]); + const r = rankForBrief(store, ["a", "b"], TODAY); + assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]); + }); +}); + +describe("rankForBrief — freshness (SC2)", () => { + const pillars = ["a"]; + test("effectiveDate = publishedAt when present (fresh despite old capturedAt)", () => { + const r = rankForBrief(mkStore([mkTrend({ title: "P", url: "https://e/p", topics: ["a"], publishedAt: "2026-06-22", capturedAt: "2026-01-01" })]), pillars, TODAY); + assert.equal(r.singleMatches.length, 1); + assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22"); + assert.equal(r.olderMatched.length, 0); + }); + test("fallback to capturedAt when publishedAt absent", () => { + const r = rankForBrief(mkStore([mkTrend({ title: "C", url: "https://e/c", topics: ["a"], capturedAt: "2026-06-22" })]), pillars, TODAY); + assert.equal(r.singleMatches.length, 1); + assert.equal(r.singleMatches[0].effectiveDate, "2026-06-22"); + }); + test("stale when capturedAt old and no publishedAt", () => { + const r = rankForBrief(mkStore([mkTrend({ title: "S", url: "https://e/s", topics: ["a"], capturedAt: "2026-01-01" })]), pillars, TODAY); + assert.equal(r.olderMatched.length, 1); + assert.equal(r.singleMatches.length, 0); + }); + test("boundary: ageDays === freshDays is fresh (<=)", () => { + const r = rankForBrief(mkStore([mkTrend({ title: "B", url: "https://e/bd", topics: ["a"], publishedAt: "2026-06-17", capturedAt: "2026-06-17" })]), pillars, TODAY, { freshDays: 7 }); + assert.equal(r.singleMatches.length, 1, "7d with freshDays 7 must be fresh"); + assert.equal(r.singleMatches[0].ageDays, 7); + }); + test("freshDays configurable: 10d fresh at 14, stale at 7", () => { + const t = mkTrend({ title: "X", url: "https://e/x", topics: ["a"], publishedAt: "2026-06-14", capturedAt: "2026-06-14" }); + assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 14 }).singleMatches.length, 1); + assert.equal(rankForBrief(mkStore([t]), pillars, TODAY, { freshDays: 7 }).olderMatched.length, 1); + }); +}); + +describe("renderBrief + briefSummary (SC3)", () => { + const pillars = ["AI", "gov"]; + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", summary: "A short summary" }), + mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), + mkTrend({ title: "Gamma", url: "https://e/g", topics: ["gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }), + ]); + const r = rankForBrief(store, pillars, TODAY); + const md = renderBrief(r); + + test("starts with YAML frontmatter", () => { + assert.ok(md.startsWith("---\n"), "brief must open with YAML frontmatter"); + }); + test("frontmatter carries date, store, schemaVersion", () => { + assert.match(md, /\ndate: 2026-06-24\n/); + assert.match(md, new RegExp("\\nschemaVersion: " + BRIEF_SCHEMA_VERSION + "\\n")); + assert.match(md, /\nstore:/); + }); + test("frontmatter summary === briefSummary(ranking); single line, no quote/newline", () => { + const summary = briefSummary(r); + assert.ok(!summary.includes('"'), "summary must not contain a double-quote"); + assert.ok(!summary.includes("\n"), "summary must be a single line"); + const m = md.match(/^summary: (.*)$/m); + assert.ok(m, "frontmatter has a summary line"); + assert.equal(m![1], summary); + }); + test("summary names the top entry when fresh matches exist", () => { + assert.ok(briefSummary(r).includes("Alpha")); + }); + test("body has the three section markers", () => { + assert.ok(md.includes("Topp-treff"), "top section"); + assert.ok(md.includes("Enkelt-treff"), "single section"); + assert.ok(md.includes("Eldre i lager"), "older section"); + }); + test("deterministic: identical input -> identical bytes", () => { + assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY))); + }); + test("empty ranking renders a valid no-fresh brief", () => { + const empty = rankForBrief(mkStore([]), pillars, TODAY); + const emd = renderBrief(empty); + assert.ok(emd.startsWith("---\n")); + assert.ok(briefSummary(empty).startsWith("Ingen ferske"), "no-fresh summary line"); + const m = emd.match(/^summary: (.*)$/m); + assert.equal(m![1], briefSummary(empty)); + }); +}); + +describe("rankForBrief — composite primary within bucket (RE-R3a / SC5)", () => { + const pillars = ["a", "b"]; + + test("higher composite sorts first at the same overlap + freshness", () => { + const store = mkStore([ + mkTrend({ title: "Low", url: "https://e/low", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }), + mkTrend({ title: "High", url: "https://e/high", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }), + ]); + const r = rankForBrief(store, pillars, TODAY); + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["High", "Low"], "composite 9 before composite 6"); + }); + + test("an unscored record sorts after every scored record in its bucket (sentinel -1)", () => { + const store = mkStore([ + mkTrend({ title: "Unscored", url: "https://e/u", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + mkTrend({ title: "Scored low", url: "https://e/s", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(2.0, "Low") }), + ]); + const r = rankForBrief(store, pillars, TODAY); + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Scored low", "Unscored"], "any scored beats unscored"); + }); + + test("both-unscored same-title/diff-url pair falls back to url asc (total order intact)", () => { + const store = mkStore([ + mkTrend({ title: "Same", url: "https://e/zzz", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + mkTrend({ title: "Same", url: "https://e/aaa", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + ]); + const r = rankForBrief(store, pillars, TODAY); + assert.deepEqual(r.topMatches.map((e) => e.trend.url), ["https://e/aaa", "https://e/zzz"]); + }); + + test("composite overrides effectiveDate within the bucket (composite is the leading key)", () => { + const store = mkStore([ + mkTrend({ title: "Fresher lower", url: "https://e/fl", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(5.0, "Medium") }), + mkTrend({ title: "Older higher", url: "https://e/oh", topics: ["a", "b"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }), + ]); + const r = rankForBrief(store, pillars, TODAY); + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Older higher", "Fresher lower"]); + }); + + test("deterministic: identical scored input -> identical ranking order", () => { + const trends = [ + mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }), + mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(6.0, "High") }), + ]; + const r1 = rankForBrief(mkStore(trends), pillars, TODAY); + const r2 = rankForBrief(mkStore(trends), pillars, TODAY); + assert.deepEqual(r1.topMatches.map((e) => e.trend.title), r2.topMatches.map((e) => e.trend.title)); + }); +}); + +describe("renderBrief — band + mode surfacing (RE-R3a / SC6)", () => { + const pillars = ["AI", "gov"]; + + test("scored top-entry meta line is the full pinned shape (· <priority> (<mode>) between age and Pillarer)", () => { + // neutral age (4d > firstMoverDays 2) so the RE-R3d temporal overlay adds no badge — isolates the score token. + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }), + ]); + const md = renderBrief(rankForBrief(store, pillars, TODAY)); + assert.ok( + md.includes("- Kilde: tavily · Publisert: 2026-06-20 (4d) · Immediate (kortform) · Pillarer: AI, gov"), + "scored top-entry meta line must carry · <priority> (<mode>) between (<age>d) and · Pillarer", + ); + }); + + test("unscored top-entry meta line is UNCHANGED (no token)", () => { + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), + ]); + const md = renderBrief(rankForBrief(store, pillars, TODAY)); + assert.ok( + md.includes("- Kilde: tavily · Publisert: 2026-06-20 (4d) · Pillarer: AI, gov"), + "unscored neutral top-entry meta line carries no score and no temporal token", + ); + }); + + test("scored bullet (single match) is the full pinned shape (· <priority> (<mode>) before · 🔗)", () => { + const store = mkStore([ + mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }), + ]); + const md = renderBrief(rankForBrief(store, pillars, TODAY)); + assert.ok( + md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · High (kortform) · 🔗 https://e/b"), + "scored bullet must carry · <priority> (<mode>) before · 🔗", + ); + }); + + test("unscored bullet (single match) is UNCHANGED (no token)", () => { + const store = mkStore([ + mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), + ]); + const md = renderBrief(rankForBrief(store, pillars, TODAY)); + assert.ok( + md.includes("- **Beta** — «AI» · 2026-06-20 (4d) · 🔗 https://e/b"), + "unscored bullet must be unchanged", + ); + }); + + test("briefSummary names the band (no mode) on a scored top", () => { + // neutral age (4d) so the RE-R3d first-mover marker stays off — isolates the band token. + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }), + ]); + const s = briefSummary(rankForBrief(store, pillars, TODAY)); + assert.ok(s.includes("Topp: «Alpha» (AI · Immediate · 4d)."), `summary should carry the band: ${s}`); + assert.ok(!s.includes("kortform"), "summary must not carry the mode"); + }); + + test("briefSummary omits the band token on an unscored top", () => { + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), + ]); + const s = briefSummary(rankForBrief(store, pillars, TODAY)); + assert.ok(s.includes("Topp: «Alpha» (AI · 4d)."), `unscored summary should omit the band: ${s}`); + }); + + test("briefSummary stays one line, no double-quote, even when the top title contains a guillemet", () => { + const store = mkStore([ + mkTrend({ title: "«Quoted» take", url: "https://e/q", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }), + ]); + const s = briefSummary(rankForBrief(store, pillars, TODAY)); + assert.ok(!s.includes('"'), "summary must not contain a double-quote"); + assert.ok(!s.includes("\n"), "summary must be a single line"); + assert.ok(s.includes("Immediate"), "summary still carries the band"); + }); + + test("single-pillar unscored top -> summary renders with no · <priority> token, one line", () => { + const store = mkStore([ + mkTrend({ title: "Solo", url: "https://e/solo", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }), + ]); + const s = briefSummary(rankForBrief(store, ["AI"], TODAY)); + assert.ok(!s.includes("· ·"), "no empty priority slot"); + assert.ok(s.includes("Topp: «Solo» (AI · 4d)."), `single-pillar unscored summary: ${s}`); + assert.ok(!s.includes("\n")); + }); + + test("ranking: descriptor equals the exact pinned string", () => { + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }), + ]); + const md = renderBrief(rankForBrief(store, pillars, TODAY, { freshDays: 7 })); + assert.ok( + md.includes("ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays 7"), + "the ranking descriptor must carry the RE-R3d temporal key (RE-R3a base + RE-R3d)", + ); + }); + + test("deterministic: identical scored input -> identical bytes", () => { + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }), + mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(6.0, "High") }), + ]); + const r = rankForBrief(store, pillars, TODAY); + assert.equal(renderBrief(r), renderBrief(rankForBrief(store, pillars, TODAY))); + }); +}); + +describe("defaultBriefDir", () => { + test("ends with trends/morning-brief and honors LINKEDIN_STUDIO_DATA (derived from defaultStorePath)", () => { + const prev = process.env.LINKEDIN_STUDIO_DATA; + process.env.LINKEDIN_STUDIO_DATA = "/tmp/lis-brief-root"; + try { + assert.equal(defaultBriefDir(), join("/tmp/lis-brief-root", "trends", "morning-brief")); + } finally { + if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = prev; + } + }); +}); + +describe("RE-R3b — exclude acted/skipped (A3)", () => { + const pillars = ["AI", "gov"]; + const store = mkStore([ + mkTrend({ title: "New top", url: "https://e/n", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }), + mkTrend({ title: "Acted top", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", status: "acted" }), + mkTrend({ title: "Skipped single", url: "https://e/s", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", status: "skipped" }), + ]); + const r = rankForBrief(store, pillars, TODAY); + + test("RED: acted/skipped are dropped from every bucket", () => { + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["New top"]); + assert.deepEqual(r.singleMatches.map((e) => e.trend.title), []); + assert.deepEqual(r.olderMatched.map((e) => e.trend.title), []); + }); + test("RED: totals.trends counts the full inventory (incl. handled); matched is post-filter", () => { + assert.equal(r.totals.trends, 3, "full store count"); + assert.equal(r.totals.matched, 1, "only the new record is matched"); + }); + test("RED: a store whose only matches are handled → no fresh + the empty summary", () => { + const s = mkStore([ + mkTrend({ title: "A", url: "https://e/aa", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", status: "acted" }), + mkTrend({ title: "B", url: "https://e/bb", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", status: "skipped" }), + ]); + const rr = rankForBrief(s, pillars, TODAY); + assert.equal(rr.totals.fresh, 0); + assert.match(briefSummary(rr), /Ingen ferske tema-signaler/); + }); +}); + +describe("RE-R3b — render id + surfaced marker + descriptor (D4/D5)", () => { + const pillars = ["AI", "gov"]; + + test("RED: a top entry carries the id in backticks (copy-paste-ready for act/skip)", () => { + const s = mkStore([mkTrend({ title: "Top", url: "https://e/t", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" })]); + const md = renderBrief(rankForBrief(s, pillars, TODAY)); + assert.ok(md.includes("· `Top|https://e/t`"), "top entry meta line must end with the id in backticks"); + }); + test("RED: a single-match bullet carries the id in backticks", () => { + const s = mkStore([mkTrend({ title: "Single", url: "https://e/sg", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" })]); + const md = renderBrief(rankForBrief(s, pillars, TODAY)); + assert.ok(md.includes("· `Single|https://e/sg`"), "bullet must end with the id in backticks"); + }); + test("· sett Nx appears only when surfacedCount >= 2 (warming badge, RE-R3b contract preserved by RE-R3d)", () => { + const s = mkStore([ + mkTrend({ title: "Seen", url: "https://e/seen", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 2 }), + mkTrend({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1 }), + ]); + const md = renderBrief(rankForBrief(s, pillars, TODAY)); + assert.ok(md.includes("· sett 2x"), "surfacedCount 2 → warming → · sett 2x"); + assert.ok(!md.includes("sett 1x"), "surfacedCount 1 → no marker (the preserved ≥2 gate)"); + }); + test("RED: the ranking: descriptor ends with '; excludes acted/skipped'", () => { + const md = renderBrief(rankForBrief(mkStore([]), pillars, TODAY)); + assert.match( + md, + /\nranking: composite desc, then pillar-overlap desc, then temporal \(first-mover↑\/saturated↓\), then publishedAt desc \(capturedAt fallback\); freshDays 7; excludes acted\/skipped\n/, + ); + }); +}); + +describe("RE-R3b — surfacedIds (D7, Phase B)", () => { + test("RED: surfacedIds = topMatches ∪ singleMatches ∪ olderMatched.slice(0,5)", () => { + const olders = Array.from({ length: 7 }, (_, i) => + mkTrend({ title: "O" + i, url: "https://e/o" + i, topics: ["ai", "gov"], publishedAt: "2026-05-01", capturedAt: "2026-05-01" }), + ); + const s = mkStore([ + mkTrend({ title: "Top", url: "https://e/t", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }), + mkTrend({ title: "Sg", url: "https://e/sg", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22" }), + ...olders, + ]); + const r = rankForBrief(s, ["AI", "gov"], TODAY); + const expected = [...r.topMatches, ...r.singleMatches, ...r.olderMatched.slice(0, 5)].map((e) => e.trend.id); + assert.deepEqual(surfacedIds(r), expected); + assert.equal(surfacedIds(r).length, 1 + 1 + 5, "older capped at 5"); + }); +}); + +// ── RE-R3d: temporal overlay (first-mover + saturation) ── + +describe("temporalSignal — first-mover detection (RE-R3d / SC1)", () => { + const opts = { firstMoverDays: 2, saturationAt: 3 }; + test("recent + unsurfaced is first-mover (rank 3); undefined surfacedCount counts as 0", () => { + for (const a of [0, 1, 2]) { + const s = temporalSignal(a, 0, opts); + assert.equal(s.tier, "first-mover", `ageDays ${a} surfaced 0`); + assert.equal(s.firstMover, true); + assert.equal(s.rank, 3); + } + assert.equal(temporalSignal(1, undefined, opts).tier, "first-mover"); + }); + test("past the window is neutral (rank 2)", () => { + const s = temporalSignal(3, 0, opts); + assert.equal(s.tier, "neutral"); + assert.equal(s.firstMover, false); + assert.equal(s.rank, 2); + }); + test("recent but already surfaced is NOT first-mover (warming)", () => { + const s = temporalSignal(1, 1, opts); + assert.equal(s.tier, "warming"); + assert.equal(s.firstMover, false); + }); + test("future publishedAt (negative ageDays) is NOT first-mover (>=0 guard)", () => { + const s = temporalSignal(-1, 0, opts); + assert.equal(s.tier, "neutral"); + assert.equal(s.firstMover, false); + }); +}); + +describe("temporalSignal — saturation grading + clamp (RE-R3d / SC2, SC9)", () => { + const opts = { firstMoverDays: 2, saturationAt: 3 }; + test("surfacings >= saturationAt is saturated (rank 0, inclusive)", () => { + for (const c of [3, 4]) { + const s = temporalSignal(5, c, opts); + assert.equal(s.tier, "saturated", `surfaced ${c}`); + assert.equal(s.rank, 0); + assert.equal(s.surfacings, c); + } + }); + test("1..saturationAt-1 is warming (rank 1)", () => { + for (const c of [1, 2]) { + const s = temporalSignal(5, c, opts); + assert.equal(s.tier, "warming", `surfaced ${c}`); + assert.equal(s.rank, 1); + } + }); + test("surfaced 0 (not recent) is neutral (rank 2)", () => { + assert.equal(temporalSignal(5, 0, opts).tier, "neutral"); + }); + test("defensive clamp: saturationAt 0 does NOT mark every non-first-mover saturated", () => { + assert.equal(temporalSignal(5, 5, { firstMoverDays: 2, saturationAt: 0 }).tier, "saturated"); + assert.equal(temporalSignal(5, 0, { firstMoverDays: 2, saturationAt: 0 }).tier, "neutral"); + }); + test("pure: same inputs -> same output", () => { + assert.deepEqual(temporalSignal(2, 1, opts), temporalSignal(2, 1, opts)); + }); +}); + +describe("rankForBrief — temporal overlay re-orders within tier, composite dominates (RE-R3d / SC3)", () => { + const pillars = ["a", "b"]; + // DISAGREEMENT fixture: temporal.rank and effectiveDate-desc disagree, so the new key is what decides. + // (surfacedCount correlates with age, so a naive first-mover-vs-saturated fixture would already be ordered + // correctly by the existing effectiveDate key — that test would pass WITHOUT the feature.) + const store = mkStore([ + // A: neutral (surfaced 0, ageDays 5 > firstMoverDays), OLDER date, composite 7.0 + mkTrend({ title: "A", url: "https://e/a", topics: ["a", "b"], publishedAt: "2026-06-19", capturedAt: "2026-06-19", score: mkScore(7.0, "High") }), + // B: warming (surfaced 2), NEWER date, composite 7.0 + mkTrend({ title: "B", url: "https://e/b", topics: ["a", "b"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 2, score: mkScore(7.0, "High") }), + // Z: saturated (surfaced 4) but HIGHER composite 8.5 + mkTrend({ title: "Z", url: "https://e/z", topics: ["a", "b"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", surfacedCount: 4, score: mkScore(8.5, "Immediate") }), + ]); + test("[Z, A, B]: composite primary (Z); then temporal (A neutral > B warming) over newer-date B", () => { + const r = rankForBrief(store, pillars, TODAY); + assert.deepEqual(r.topMatches.map((e) => e.trend.title), ["Z", "A", "B"]); + }); + test("total order: deterministic regardless of store insertion order", () => { + const reordered = mkStore([store.trends[2], store.trends[0], store.trends[1]]); + assert.deepEqual(rankForBrief(reordered, pillars, TODAY).topMatches.map((e) => e.trend.title), ["Z", "A", "B"]); + }); +}); + +describe("renderBrief — temporal badges + the >=2 boundary (RE-R3d / SC4)", () => { + const pillars = ["AI", "gov"]; + const render = (p: Parameters<typeof mkTrend>[0]): string => renderBrief(rankForBrief(mkStore([mkTrend(p)]), pillars, TODAY)); + test("first-mover entry carries · 🥇 først ute", () => { + const md = render({ title: "FM", url: "https://e/fm", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23" }); + assert.ok(md.includes("· 🥇 først ute"), "ageDays 1 + unsurfaced -> first-mover badge"); + }); + test("saturated entry carries · 🔁 mettet (3x)", () => { + const md = render({ title: "Sat", url: "https://e/sat", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 3 }); + assert.ok(md.includes("· 🔁 mettet (3x)"), "surfacedCount 3 -> saturated badge"); + }); + test("warming entry with surfacedCount 2 carries · sett 2x", () => { + const md = render({ title: "Warm", url: "https://e/warm", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 2 }); + assert.ok(md.includes("· sett 2x"), "surfacedCount 2 -> warming badge (>=2)"); + }); + test("warming entry with surfacedCount 1 carries NO badge (preserves the R3b >=2 contract)", () => { + const md = render({ title: "Once", url: "https://e/once", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", surfacedCount: 1 }); + assert.ok(!md.includes("sett 1x"), "surfacedCount 1 -> no marker"); + assert.ok(!md.includes("mettet"), "surfacedCount 1 is not saturated"); + }); + test("neutral entry carries none of the temporal badges", () => { + const md = render({ title: "Neu", url: "https://e/neu", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20" }); + assert.ok(!md.includes("først ute") && !md.includes("mettet") && !md.includes("· sett "), "ageDays 4 + unsurfaced -> neutral"); + }); +}); + +describe("briefSummary — first-mover marker (RE-R3d / SC5)", () => { + const pillars = ["AI", "gov"]; + test("first-mover top carries · 🥇 først ute and stays regex-safe", () => { + const s = briefSummary(rankForBrief(mkStore([ + mkTrend({ title: "Fresh", url: "https://e/f", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", score: mkScore(9.0, "Immediate") }), + ]), pillars, TODAY)); + assert.ok(s.includes("· 🥇 først ute"), `first-mover summary marker: ${s}`); + assert.ok(!s.includes('"') && !s.includes("\n"), "summary stays single-line, no double-quote"); + }); + test("non-first-mover top omits the marker", () => { + const s = briefSummary(rankForBrief(mkStore([ + mkTrend({ title: "Old", url: "https://e/o", topics: ["ai", "gov"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(9.0, "Immediate") }), + ]), pillars, TODAY)); + assert.ok(!s.includes("først ute"), `neutral top, no marker: ${s}`); + }); +}); + +describe("rankForBrief — no schema/score mutation (RE-R3d / SC8)", () => { + test("ranking does not mutate score.composite; schema versions unchanged", () => { + const t = mkTrend({ title: "X", url: "https://e/x", topics: ["ai"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(8.0, "Immediate") }); + const before = t.score!.composite; + rankForBrief(mkStore([t]), ["AI"], TODAY); + assert.equal(t.score!.composite, before, "rankForBrief must not mutate the stored composite"); + assert.equal(BRIEF_SCHEMA_VERSION, 2); // RE-R3e: artifact frontmatter gained surfaced: (1->2); store SCHEMA_VERSION stays 4 + assert.equal(SCHEMA_VERSION, 4); + }); +}); + +describe("rankForBrief — prior-day surfacings exclude today (RE-R3d / same-day re-run idempotency)", () => { + const pillars = ["AI", "gov"]; + test("a first-mover trend already surfaced TODAY stays first-mover (today excluded from the prior count)", () => { + // surfacedCount 1 but lastSurfacedAt === today -> prior-day count 0 -> still first-mover, so a + // same-day re-render (which loads the post-mark count) is byte-identical to the first run (RE-R3c SC7). + const s = mkStore([ + mkTrend({ title: "FM", url: "https://e/fm", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1, lastSurfacedAt: TODAY }), + ]); + const e = rankForBrief(s, pillars, TODAY).topMatches[0]; + assert.equal(e.temporal.tier, "first-mover", "today's own surfacing must not demote it out of first-mover"); + assert.equal(e.temporal.surfacings, 0, "prior-day surfacings exclude today"); + }); + test("the same count surfaced on a PRIOR day is warming (today not excluded)", () => { + const s = mkStore([ + mkTrend({ title: "W", url: "https://e/w", topics: ["ai", "gov"], publishedAt: "2026-06-23", capturedAt: "2026-06-23", surfacedCount: 1, lastSurfacedAt: "2026-06-23" }), + ]); + const e = rankForBrief(s, pillars, TODAY).topMatches[0]; + assert.equal(e.temporal.tier, "warming", "a prior-day surfacing counts"); + assert.equal(e.temporal.surfacings, 1); + }); +}); + +// --- RE-R3e: brief history + day-over-day diff --- + +describe("diffSurfaced — partitions + order + empty prior (RE-R3e / SC1)", () => { + test("symmetric set difference, order-stable", () => { + const d = diffSurfaced(["a", "b", "c"], ["b", "c", "d"], "2026-06-25"); + assert.deepEqual(d, { priorDate: "2026-06-25", added: ["a"], carried: ["b", "c"], dropped: ["d"] }); + }); + test("empty prior ⇒ everything added, nothing dropped", () => { + const d = diffSurfaced(["a", "b"], [], null); + assert.deepEqual(d, { priorDate: null, added: ["a", "b"], carried: [], dropped: [] }); + }); + test("the three partitions are mutually disjoint (cross-partition exclusivity)", () => { + const d = diffSurfaced(["a", "b", "c"], ["b", "c", "d"], "2026-06-25"); + const all = [...d.added, ...d.carried, ...d.dropped]; + assert.equal(new Set(all).size, all.length, "no id appears in two buckets"); + }); + test("pure: same inputs → same output", () => { + assert.deepEqual(diffSurfaced(["x"], ["y"], "2026-06-20"), diffSurfaced(["x"], ["y"], "2026-06-20")); + }); +}); + +describe("parseSurfacedFrontmatter — read + degrade (RE-R3e / SC2)", () => { + const block = (surfaced: string) => + `---\ndate: 2026-06-26\nsummary: hi\nsurfaced: ${surfaced}\nstore: { trends: 3 }\nschemaVersion: 2\n---\nbody`; + test("parses a csv of ids", () => { + assert.deepEqual(parseSurfacedFrontmatter(block("1a2b,3c4d,5e6f")), ["1a2b", "3c4d", "5e6f"]); + }); + test("trims whitespace around ids", () => { + assert.deepEqual(parseSurfacedFrontmatter(block(" a , b ,c ")), ["a", "b", "c"]); + }); + test("a blank surfaced: line → []", () => { + assert.deepEqual(parseSurfacedFrontmatter(block("")), []); + }); + test("an absent surfaced: line (a pre-R3e brief) → []", () => { + assert.deepEqual(parseSurfacedFrontmatter(`---\ndate: 2026-06-26\nsummary: hi\nschemaVersion: 1\n---\nbody`), []); + }); + test("line-anchored: a summary: with commas does not leak in", () => { + assert.deepEqual(parseSurfacedFrontmatter(`---\ndate: 2026-06-26\nsummary: a,b,c\nschemaVersion: 1\n---`), []); + }); + test("never throws on malformed input", () => { + assert.doesNotThrow(() => parseSurfacedFrontmatter("not a brief at all")); + }); +}); + +describe("selectPriorBriefFile — strict-prior selection (RE-R3e / SC3)", () => { + const files = ["2026-06-24.md", "2026-06-25.md", "2026-06-26.md", "README.md", "2026-06-30.md"]; + test("greatest dated file strictly < today (excludes today + future, ignores non-dated)", () => { + assert.equal(selectPriorBriefFile(files, "2026-06-26"), "2026-06-25.md"); + }); + test("no file < today → null", () => { + assert.equal(selectPriorBriefFile(["2026-06-26.md", "2026-06-30.md"], "2026-06-26"), null); + }); + test("empty list → null", () => { + assert.equal(selectPriorBriefFile([], "2026-06-26"), null); + }); +}); + +describe("renderBrief/briefSummary — surfaced: + diff section + marker (RE-R3e / SC4–SC8)", () => { + const pillars = ["AI", "gov"]; + const store = mkStore([ + mkTrend({ title: "Alpha", url: "https://e/a", topics: ["ai", "gov"], publishedAt: "2026-06-22", capturedAt: "2026-06-22", score: mkScore(9.0, "Immediate") }), + mkTrend({ title: "Beta", url: "https://e/b", topics: ["ai"], publishedAt: "2026-06-20", capturedAt: "2026-06-20", score: mkScore(7.0, "High") }), + ]); + const r = rankForBrief(store, pillars, TODAY); + const ids = surfacedIds(r); + const emptyDiff = { priorDate: null, added: [], carried: [], dropped: [] }; + + test("SC4: one surfaced: line = surfacedIds(r).join(','), before schemaVersion: 2", () => { + const md = renderBrief(r, emptyDiff); + const m = md.match(/^surfaced: (.*)$/m); + assert.ok(m, "a surfaced: frontmatter line exists"); + assert.equal(m![1], ids.join(",")); + assert.ok(md.indexOf("\nsurfaced:") < md.indexOf("\nschemaVersion:"), "surfaced: precedes schemaVersion:"); + assert.match(md, /\nschemaVersion: 2\n/); + }); + test("SC4: round-trips via parseSurfacedFrontmatter", () => { + assert.deepEqual(parseSurfacedFrontmatter(renderBrief(r, emptyDiff)), ids); + }); + test("SC4: an empty store → a blank surfaced: line", () => { + const empty = rankForBrief(mkStore([]), pillars, TODAY); + assert.match(renderBrief(empty, emptyDiff), /\nsurfaced: \n/); + }); + + test("SC5: section header present + precedes Topp-treff (delta leads)", () => { + const md = renderBrief(r, emptyDiff); + assert.ok(md.includes("## 🆕 Nytt siden sist"), "section header present"); + assert.ok(md.indexOf("## 🆕 Nytt siden sist") < md.indexOf("## 🎯 Topp-treff"), "delta precedes the ranked list"); + }); + test("SC5: first brief with added → 'Første brief — alt nedenfor er nytt'", () => { + assert.ok(renderBrief(r, { priorDate: null, added: ids, carried: [], dropped: [] }).includes("Første brief — alt nedenfor er nytt")); + }); + test("SC5: empty first brief → 'Første brief.'", () => { + assert.ok(renderBrief(r, emptyDiff).includes("_Første brief._")); + }); + test("SC5: prior + added → the added title + the carried/dropped tally", () => { + const md = renderBrief(r, { priorDate: "2026-06-23", added: [ids[0]], carried: [ids[1]], dropped: ["gone"] }); + assert.ok(md.includes("Alpha"), "the added entry's title is rendered"); + assert.ok(md.includes("1 båret over, 1 ikke vist i dag"), "the carried/dropped tally"); + }); + test("SC5: prior + no added → 'Ingenting nytt siden <date>' + tally", () => { + const md = renderBrief(r, { priorDate: "2026-06-23", added: [], carried: ids, dropped: [] }); + assert.ok(md.includes("Ingenting nytt siden 2026-06-23")); + assert.ok(md.includes("båret over")); + }); + + test("SC6: marker ' N nye siden sist.' when prior + added", () => { + const s = briefSummary(r, { priorDate: "2026-06-25", added: ["x", "y"], carried: [], dropped: [] }); + assert.ok(s.endsWith(" 2 nye siden sist."), `marker appended: ${s}`); + }); + test("SC6: no marker on the first brief or when nothing new", () => { + assert.ok(!briefSummary(r, { priorDate: null, added: ["x"], carried: [], dropped: [] }).includes("nye siden sist")); + assert.ok(!briefSummary(r, { priorDate: "2026-06-25", added: [], carried: [], dropped: [] }).includes("nye siden sist")); + }); + test("SC6: briefSummary(r) === briefSummary(r, emptyDiff) (keeps the :166 summary-equality test green)", () => { + assert.equal(briefSummary(r), briefSummary(r, emptyDiff)); + }); + test("SC6: the marker carries no quote/newline", () => { + const s = briefSummary(r, { priorDate: "2026-06-25", added: ["x"], carried: [], dropped: [] }); + assert.ok(!s.includes('"') && !s.includes("\n")); + }); + + test("SC7: BRIEF_SCHEMA_VERSION === 2; SCHEMA_VERSION === 4", () => { + assert.equal(BRIEF_SCHEMA_VERSION, 2); + assert.equal(SCHEMA_VERSION, 4); + }); + + test("SC8: same (r, diff) → byte-identical render", () => { + const d = { priorDate: "2026-06-23", added: [ids[0]], carried: [ids[1]], dropped: [] }; + assert.equal(renderBrief(r, d), renderBrief(r, d)); + }); +}); diff --git a/scripts/trends/tests/cli.test.ts b/scripts/trends/tests/cli.test.ts new file mode 100644 index 0000000..6397ac6 --- /dev/null +++ b/scripts/trends/tests/cli.test.ts @@ -0,0 +1,742 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, renameSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { SCHEMA_VERSION } from "../src/types.js"; + +// Resolve the package root (scripts/trends) so the subprocess `src/cli.ts` path + the +// `tsx` loader resolve regardless of the runner's cwd. +const trendsDir = fileURLToPath(new URL("..", import.meta.url)); + +function run(args: string[], input: string): { 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 }; +} + +describe("trends CLI — normalize/score subcommands (RE-R1 / Step 4)", () => { + describe("normalize (stdin JSON in, JSON out)", () => { + test("happy path: a JSON batch on stdin -> exit 0 + {items,errors} JSON", () => { + const batch = JSON.stringify([ + { source: "tavily", title: "Good", url: "https://example.com/a", topics: ["AI", "ai"] }, + { source: "tavily", title: "", url: "https://example.com/b", topics: ["x"] }, // bad: empty title + ]); + const { status, stdout } = run(["normalize"], batch); + assert.equal(status, 0); + const out = JSON.parse(stdout); + assert.equal(out.items.length, 1); + assert.deepEqual(out.items[0].topics, ["ai"]); // deduped + lowercased + assert.equal(out.errors.length, 1); + assert.equal(out.errors[0].index, 1); + }); + + test("bad invocation: unparseable stdin -> exit 2", () => { + const { status } = run(["normalize"], "not json at all"); + assert.equal(status, 2); + }); + }); + + describe("score (stdin JSON in, JSON out)", () => { + test("happy path: scored candidates on stdin -> exit 0 + {kept,dropped} JSON", () => { + const candidates = JSON.stringify([ + { id: "high", scores: { pillar: 8, audience: 8, timing: 8, angle: 8, authority: 8 } }, // 8.0 + { id: "low", scores: { pillar: 2, audience: 2, timing: 2, angle: 2, authority: 2 } }, // 2.0 + ]); + const { status, stdout } = run(["score", "--mode", "kortform", "--threshold", "4.0"], candidates); + assert.equal(status, 0); + const out = JSON.parse(stdout); + assert.deepEqual( + out.kept.map((k: { id: string }) => k.id), + ["high"], + ); + assert.equal(out.kept[0].composite, 8.0); + assert.equal(out.kept[0].band.priority, "Immediate"); + assert.deepEqual( + out.dropped.map((d: { id: string }) => d.id), + ["low"], + ); + }); + + test("bad invocation: an unknown --mode -> exit 2", () => { + const candidates = JSON.stringify([ + { id: "x", scores: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 } }, + ]); + const { status } = run(["score", "--mode", "bogus"], candidates); + assert.equal(status, 2); + }); + }); + + describe("capture (stdin raw item|batch -> folds into the store) (RE-R2a / Step 4)", () => { + const tmpStore = () => join(mkdtempSync(join(tmpdir(), "trends-capture-")), "trends.json"); + + test("happy path: a valid item piped in -> folded into the store, added:1, publishedAt persisted", () => { + const store = tmpStore(); + try { + const batch = JSON.stringify([ + { + source: "tavily", + title: "Captured", + url: "https://example.com/c", + topics: ["ai"], + publishedAt: "2026-06-20", + }, + ]); + const { status, stdout } = run(["capture", "--store", store, "--json"], batch); + assert.equal(status, 0); + const summary = JSON.parse(stdout); + assert.equal(summary.added, 1); + assert.equal(summary.errors.length, 0); + assert.equal( + summary.added + summary.merged + summary.duplicates + summary.errors.length, + 1, + "tally must sum to the input size", + ); + const persisted = JSON.parse(readFileSync(store, "utf8")); + assert.equal(persisted.schemaVersion, SCHEMA_VERSION); + assert.equal(persisted.trends.length, 1); + assert.equal(persisted.trends[0].publishedAt, "2026-06-20"); + assert.match(persisted.trends[0].capturedAt, /^\d{4}-\d{2}-\d{2}$/); + assert.notEqual( + persisted.trends[0].capturedAt, + persisted.trends[0].publishedAt, + "capturedAt (when WE saw it) must be distinct from publishedAt (source date)", + ); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + + test("a batch with one content-invalid item -> valid added, invalid in errors[], exit 0", () => { + const store = tmpStore(); + try { + const batch = JSON.stringify([ + { source: "tavily", title: "Valid", url: "https://example.com/v", topics: ["x"] }, + { title: "no source or url" }, + ]); + const { status, stdout } = run(["capture", "--store", store, "--json"], batch); + assert.equal(status, 0); + const summary = JSON.parse(stdout); + assert.equal(summary.added, 1); + assert.equal(summary.errors.length, 1); + assert.equal( + summary.added + summary.merged + summary.duplicates + summary.errors.length, + 2, + ); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + + test("re-capturing the same trend with a new topic -> merged:1, tally still sums", () => { + const store = tmpStore(); + try { + const item = (topics: string[]) => + JSON.stringify([{ source: "tavily", title: "Dup", url: "https://example.com/d", topics }]); + run(["capture", "--store", store, "--json"], item(["a"])); + const { status, stdout } = run(["capture", "--store", store, "--json"], item(["a", "b"])); + assert.equal(status, 0); + const summary = JSON.parse(stdout); + assert.equal(summary.added, 0); + assert.equal(summary.merged, 1); + assert.equal(summary.duplicates, 0); + assert.equal( + summary.added + summary.merged + summary.duplicates + summary.errors.length, + 1, + ); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + + test("bad invocation: empty stdin -> exit 2", () => { + const { status } = run(["capture"], ""); + assert.equal(status, 2); + }); + + // ── RE-R3a: capture persists the computed relevance score (SC7) ── + test("a valid per-item score -> record carries the computed composite/priority (read back via list --json)", () => { + const store = tmpStore(); + try { + const batch = JSON.stringify([ + { + source: "tavily", + title: "Scored capture", + url: "https://example.com/sc", + topics: ["ai"], + score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 } }, + }, + ]); + const cap = run(["capture", "--store", store, "--json"], batch); + assert.equal(cap.status, 0); + const summary = JSON.parse(cap.stdout); + assert.equal(summary.added, 1); + assert.equal(summary.errors.length, 0); + + const ls = run(["list", "--store", store, "--json"], ""); + assert.equal(ls.status, 0); + const rows = JSON.parse(ls.stdout); + assert.equal(rows.length, 1); + // 9*.30 + 8*.25 + 9*.20 + 7*.15 + 6*.10 = 8.15 -> round1 8.1 (8.15*10 = 81.4999… in IEEE-754) -> Immediate + assert.equal(rows[0].score.composite, 8.1); + assert.equal(rows[0].score.priority, "Immediate"); + assert.equal(rows[0].score.mode, "kortform"); + assert.deepEqual(rows[0].score.dimensions, { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + + test("a batch with one bad score (timing:99) -> that item in errors[], the valid one added, exit 0", () => { + const store = tmpStore(); + try { + const batch = JSON.stringify([ + { + source: "tavily", + title: "Good scored", + url: "https://example.com/good", + topics: ["ai"], + score: { mode: "kortform", dimensions: { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 } }, + }, + { + source: "tavily", + title: "Bad scored", + url: "https://example.com/bad", + topics: ["ai"], + score: { mode: "kortform", dimensions: { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 } }, + }, + ]); + const { status, stdout } = run(["capture", "--store", store, "--json"], batch); + assert.equal(status, 0, "a bad score must not fail the run"); + const summary = JSON.parse(stdout); + assert.equal(summary.added, 1, "the valid scored item is added"); + assert.equal(summary.errors.length, 1, "the bad-score item lands in errors[]"); + assert.equal( + summary.added + summary.merged + summary.duplicates + summary.errors.length, + 2, + "tally must sum to the input size", + ); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + }); +}); + +describe("trends CLI — brief subcommand (RE-R2b / Step 3)", () => { + // brief is flag-driven (reads the store, not stdin). spawn with an env-overridable + // LINKEDIN_STUDIO_DATA so defaultBriefDir() resolves into a temp root (never the real HOME). + function runBrief(args: string[], env: Record<string, string> = {}): { status: number | null; stdout: string } { + const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "brief", ...args], { + input: "", + encoding: "utf8", + cwd: trendsDir, + env: { ...process.env, ...env }, + }); + return { status: res.status, stdout: res.stdout }; + } + const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10); + function seedStore(trends: unknown[]): string { + const store = join(mkdtempSync(join(tmpdir(), "trends-brief-")), "trends.json"); + writeFileSync(store, JSON.stringify({ schemaVersion: 2, trends })); + return store; + } + + test("happy: writes a dated brief, --json carries path/date/totals/summary", () => { + const store = seedStore([ + { id: "a", title: "Fresh Match", url: "https://e/a", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai", "gov"] }, + ]); + const out = mkdtempSync(join(tmpdir(), "brief-out-")); + try { + const { status, stdout } = runBrief(["--pillars", "ai,gov", "--store", store, "--out", out, "--json"]); + assert.equal(status, 0); + const summary = JSON.parse(stdout); + assert.match(summary.path, /\d{4}-\d{2}-\d{2}\.md$/); + assert.match(summary.date, /^\d{4}-\d{2}-\d{2}$/); + assert.equal(summary.totals.trends, 1); + assert.equal(summary.totals.fresh, 1); + assert.ok(existsSync(summary.path), "the dated brief file is written"); + const md = readFileSync(summary.path, "utf8"); + const m = md.match(/^summary: (.*)$/m); + assert.ok(m, "the brief frontmatter has a summary line"); + assert.equal(m![1], summary.summary, "--json summary equals the file frontmatter summary (one source)"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + rmSync(out, { recursive: true, force: true }); + } + }); + + test("bad invocation: --fresh-days non-numeric -> exit 2", () => { + const store = seedStore([]); + try { + const { status } = runBrief(["--pillars", "ai", "--store", store, "--fresh-days", "xyz"]); + assert.equal(status, 2); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + + test("empty --pillars -> exit 0 + a no-match brief is still written", () => { + const store = seedStore([ + { id: "a", title: "X", url: "https://e/x", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"] }, + ]); + const out = mkdtempSync(join(tmpdir(), "brief-out-")); + try { + const { status, stdout } = runBrief(["--store", store, "--out", out, "--json"]); + assert.equal(status, 0); + const summary = JSON.parse(stdout); + assert.equal(summary.totals.matched, 0, "no pillars -> nothing matched"); + assert.ok(existsSync(summary.path), "a dated no-match brief is still written"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + rmSync(out, { recursive: true, force: true }); + } + }); + + test("bare --out (no value) -> falls back to defaultBriefDir, never ./true", () => { + const store = seedStore([]); + const dataRoot = mkdtempSync(join(tmpdir(), "brief-data-")); + try { + // bare --out --json: parseFlags yields out:"true"; the !== "true" guard must + // fall back to defaultBriefDir() = <LINKEDIN_STUDIO_DATA>/trends/morning-brief. + const { status, stdout } = runBrief(["--pillars", "ai", "--store", store, "--json", "--out"], { LINKEDIN_STUDIO_DATA: dataRoot }); + assert.equal(status, 0); + const summary = JSON.parse(stdout); + assert.ok( + summary.path.startsWith(join(dataRoot, "trends", "morning-brief")), + "bare --out must fall back to defaultBriefDir under LINKEDIN_STUDIO_DATA, not ./true", + ); + assert.ok(!existsSync(join(trendsDir, "true")), "no ./true dir was created"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + rmSync(dataRoot, { recursive: true, force: true }); + } + }); +}); + +describe("trends CLI — lifecycle: act/skip/reset + brief surfacing (RE-R3b)", () => { + // One temp dir per fixture holds both the store file and the brief out dir, so the + // real per-user data dir (defaultBriefDir) is never touched. + const fixture = () => { + const dir = mkdtempSync(join(tmpdir(), "trends-r3b-")); + return { dir, store: join(dir, "trends.json"), out: join(dir, "briefs") }; + }; + const listJson = (store: string): Array<Record<string, any>> => + JSON.parse(run(["list", "--store", store, "--json"], "").stdout); + const seedScored = (store: string, title: string, url: string, timing = 9): void => { + const batch = JSON.stringify([ + { + source: "tavily", + title, + url, + topics: ["ai", "gov"], + publishedAt: "2026-06-23", + score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing, angle: 7, authority: 6 } }, + }, + ]); + run(["capture", "--store", store], batch); + }; + + test("RED: act --id → acted; skip → skipped; reset → new (read back via list --json)", () => { + const { dir, store } = fixture(); + try { + seedScored(store, "Lifecycle", "https://e/lc"); + const id = listJson(store)[0].id; + assert.equal(run(["act", "--id", id, "--store", store], "").status, 0); + assert.equal(listJson(store)[0].status, "acted"); + run(["skip", "--id", id, "--store", store], ""); + assert.equal(listJson(store)[0].status, "skipped"); + run(["reset", "--id", id, "--store", store], ""); + assert.equal(listJson(store)[0].status, "new"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("RED: an unknown id → exit 2 + store unchanged; a missing --id → exit 2", () => { + const { dir, store } = fixture(); + try { + seedScored(store, "X", "https://e/x"); + const before = readFileSync(store, "utf8"); + assert.equal(run(["act", "--id", "nope", "--store", store], "").status, 2); + assert.equal(readFileSync(store, "utf8"), before, "store untouched on a not-found id"); + assert.equal(run(["skip", "--store", store], "").status, 2, "missing --id → exit 2"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("RED: brief records surfacing (surfacedCount + lastSurfacedAt + marked); a second same-day run is idempotent", () => { + const { dir, store, out } = fixture(); + try { + seedScored(store, "Surf", "https://e/surf"); + const o1 = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout); + assert.equal(o1.marked, 1, "first brief marks 1"); + const rec = listJson(store)[0]; + assert.equal(rec.surfacedCount, 1); + assert.match(rec.lastSurfacedAt, /^\d{4}-\d{2}-\d{2}$/); + const o2 = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout); + assert.equal(o2.marked, 0, "second same-day brief is idempotent"); + assert.equal(listJson(store)[0].surfacedCount, 1, "count unchanged on the second same-day run"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("RED: brief --no-mark writes no surfacedCount", () => { + const { dir, store, out } = fixture(); + try { + seedScored(store, "Dry", "https://e/dry"); + const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--no-mark", "--out", out, "--store", store, "--json"], "").stdout); + assert.equal(o.marked, 0); + assert.equal("surfacedCount" in listJson(store)[0], false, "--no-mark must not write surfacedCount"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("RED: the brief .md omits an acted record", () => { + const { dir, store, out } = fixture(); + try { + seedScored(store, "Handled", "https://e/handled"); + const id = listJson(store)[0].id; + run(["act", "--id", id, "--store", store], ""); + const o = JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout); + const md = readFileSync(o.path, "utf8"); + assert.ok(!md.includes("Handled"), "an acted record must not appear in the brief"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("RED: a re-capture with a changed score reports merged:1 + refreshes the composite", () => { + const { dir, store } = fixture(); + try { + seedScored(store, "Re", "https://e/re", 9); + const c1 = listJson(store)[0].score.composite; + const batch = JSON.stringify([ + { + source: "tavily", + title: "Re", + url: "https://e/re", + topics: ["ai", "gov"], + publishedAt: "2026-06-23", + score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 2, angle: 7, authority: 6 } }, + }, + ]); + const o = JSON.parse(run(["capture", "--store", store, "--json"], batch).stdout); + assert.equal(o.merged, 1, "a changed score on a duplicate → merged"); + const c2 = listJson(store)[0].score.composite; + assert.ok(c2 < c1, "a lower timing → lower composite (re-score last-wins)"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("trends CLI — schedule subcommand (RE-R3c / autonomous trigger, print-first)", () => { + // schedule is flag-driven + print-first. Capture stdout AND stderr (usage → stderr); override HOME + // (the ~/Library/LaunchAgents target) + LINKEDIN_STUDIO_DATA (the cron.log seam) into a temp dir so + // a real HOME is never touched. (os.homedir() respects $HOME on POSIX — verified.) + function runSched( + args: string[], + env: Record<string, string> = {}, + ): { status: number | null; stdout: string; stderr: string } { + const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "schedule", ...args], { + input: "", + encoding: "utf8", + cwd: trendsDir, + env: { ...process.env, ...env }, + }); + return { status: res.status, stdout: res.stdout, stderr: res.stderr }; + } + const tmpHome = () => mkdtempSync(join(tmpdir(), "sched-home-")); + + // A dependency-free well-formedness check: tokenize element tags and assert they nest/balance + // (the `<?xml?>` PI and `<!DOCTYPE>` are skipped — neither starts with a letter after `<`). + // SC1 asserts balance + key-completeness; `plutil -lint` is the deps-present manual check (Step 7). + function isBalancedXml(xml: string): boolean { + const stack: string[] = []; + const re = /<(\/?)([a-zA-Z][\w.:-]*)[^>]*?(\/?)>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(xml)) !== null) { + if (m[3] === "/") continue; // self-closing (e.g. <false/>) + if (m[1] === "/") { + if (stack.pop() !== m[2]) return false; + } else { + stack.push(m[2]); + } + } + return stack.length === 0; + } + + test("SC1: --platform launchd --at 07:30 --print → key-complete, well-formed plist, exit 0", () => { + const home = tmpHome(); + try { + const { status, stdout } = runSched( + ["--pillars", "ai,gov", "--platform", "launchd", "--at", "07:30", "--print"], + { HOME: home, LINKEDIN_STUDIO_DATA: home }, + ); + assert.equal(status, 0); + assert.match(stdout, /<key>Label<\/key>\s*<string>com\.linkedin-studio\.trends\.daily<\/string>/); + assert.ok(stdout.includes("<key>ProgramArguments</key>"), "ProgramArguments present"); + assert.ok(stdout.includes("run-daily.sh"), "invokes the wrapper"); + assert.ok(stdout.includes("--pillars") && stdout.includes("ai,gov"), "carries the pillars"); + assert.match(stdout, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7"); + assert.match(stdout, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30"); + assert.ok(stdout.includes("<key>StandardOutPath</key>") && stdout.includes("<key>StandardErrorPath</key>"), "Std*Path present"); + assert.ok(stdout.includes("cron.log"), "Std*Path point at cron.log"); + assert.ok(stdout.includes("<key>EnvironmentVariables</key>"), "EnvironmentVariables present"); + assert.ok(stdout.includes("NODE_BIN") && stdout.includes("LINKEDIN_STUDIO_DATA"), "env carries NODE_BIN + data root"); + assert.ok(isBalancedXml(stdout), "the plist XML is balanced / well-formed"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("SC1: two --print runs (same args) are byte-identical (the emitter is pure)", () => { + const home = tmpHome(); + try { + const a = runSched(["--pillars", "ai,gov", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home }); + const b = runSched(["--pillars", "ai,gov", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home }); + assert.equal(a.status, 0); + assert.equal(a.stdout, b.stdout); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("SC2: --platform cron --at 07:30 --print → cron line + install recipe (string only), exit 0", () => { + const home = tmpHome(); + try { + const { status, stdout } = runSched( + ["--pillars", "ai,gov", "--platform", "cron", "--at", "07:30", "--print"], + { HOME: home, LINKEDIN_STUDIO_DATA: home }, + ); + assert.equal(status, 0); + assert.match(stdout, /^30 7 \* \* \* /m, "the cron line fires at 07:30 daily"); + assert.ok(stdout.includes("run-daily.sh"), "invokes the wrapper"); + assert.ok(stdout.includes("--pillars ai,gov"), "carries the pillars"); + assert.ok(stdout.includes(">> ") && stdout.includes("2>&1"), "redirects stdout+stderr to the log"); + assert.ok(stdout.includes("com.linkedin-studio.trends.daily"), "the label comment"); + assert.ok(stdout.includes("crontab -"), "the install recipe is printed (a string the operator runs)"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("SC3: platform auto (no --platform) → launchd on darwin, cron elsewhere", () => { + const home = tmpHome(); + try { + const { status, stdout } = runSched(["--pillars", "ai", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home }); + assert.equal(status, 0); + if (process.platform === "darwin") { + assert.ok(stdout.includes("<key>Label</key>"), "darwin auto → launchd plist"); + } else { + assert.match(stdout, /^\d+ \d+ \* \* \* /m, "non-darwin auto → cron line"); + } + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("SC4: --print writes nothing (temp-HOME LaunchAgents stays absent), exit 0", () => { + const home = tmpHome(); + try { + const { status, stdout } = runSched(["--pillars", "ai", "--platform", "launchd", "--print"], { HOME: home, LINKEDIN_STUDIO_DATA: home }); + assert.equal(status, 0); + assert.ok(stdout.includes("<key>Label</key>"), "the plist is on stdout"); + assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "--print creates no LaunchAgents dir"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("SC5: --install launchd → inert plist FILE written + launchctl printed (never run), exit 0", () => { + const home = tmpHome(); + try { + const { status, stdout } = runSched(["--pillars", "ai", "--platform", "launchd", "--install"], { HOME: home, LINKEDIN_STUDIO_DATA: home }); + assert.equal(status, 0); + const plist = join(home, "Library", "LaunchAgents", "com.linkedin-studio.trends.daily.plist"); + assert.ok(existsSync(plist), "the inert plist file is written"); + const content = readFileSync(plist, "utf8"); + assert.ok(content.includes("<key>Label</key>") && content.includes("com.linkedin-studio.trends.daily"), "the file holds the plist"); + assert.ok(stdout.includes("launchctl bootstrap"), "the activation command is PRINTED (the tool never runs launchctl)"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("SC6: --install cron → line + install recipe printed, writes no file, exit 0", () => { + const home = tmpHome(); + try { + const { status, stdout } = runSched(["--pillars", "ai", "--platform", "cron", "--install"], { HOME: home, LINKEDIN_STUDIO_DATA: home }); + assert.equal(status, 0); + assert.match(stdout, /^\d+ \d+ \* \* \* /m, "the cron line is printed"); + assert.ok(stdout.includes("crontab -"), "the install recipe is printed (never executed)"); + assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "cron --install writes no plist"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("SC9: no --pillars / bad --at / bad --platform → exit 2; the fs is untouched", () => { + const home = tmpHome(); + try { + assert.equal(runSched([], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "no --pillars → exit 2"); + assert.equal(runSched(["--pillars", "ai", "--at", "25:00"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at 25:00 → exit 2"); + assert.equal(runSched(["--pillars", "ai", "--at", "7:99"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at 7:99 → exit 2"); + assert.equal(runSched(["--pillars", "ai", "--at", "noon"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--at noon → exit 2"); + assert.equal(runSched(["--pillars", "ai", "--platform", "bogus"], { HOME: home, LINKEDIN_STUDIO_DATA: home }).status, 2, "--platform bogus → exit 2"); + assert.ok(!existsSync(join(home, "Library", "LaunchAgents")), "a validation error writes nothing"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + +describe("trends CLI — brief temporal flags (RE-R3d / SC6)", () => { + // brief is flag-driven; spawn into a temp store/out so the real per-user data dir is never touched. + function runBrief(args: string[], env: Record<string, string> = {}): { status: number | null; stdout: string } { + const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", "brief", ...args], { + input: "", + encoding: "utf8", + cwd: trendsDir, + env: { ...process.env, ...env }, + }); + return { status: res.status, stdout: res.stdout }; + } + const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10); + function seedStore(trends: unknown[]): string { + const store = join(mkdtempSync(join(tmpdir(), "trends-r3d-")), "trends.json"); + writeFileSync(store, JSON.stringify({ schemaVersion: 2, trends })); + return store; + } + // --no-mark so sequential runs on the same store do NOT accumulate surfacedCount (which would + // confound the threshold assertions); tier badges live in the .md body, not in --json. + function briefMd(args: string[]): { status: number | null; md: string } { + const out = mkdtempSync(join(tmpdir(), "r3d-out-")); + const { status, stdout } = runBrief([...args, "--out", out, "--no-mark", "--json"]); + const path = status === 0 ? (JSON.parse(stdout).path as string) : ""; + return { status, md: path ? readFileSync(path, "utf8") : "" }; + } + + test("--saturation-at 2 escalates a surfacedCount-2 trend from warming (sett 2x) to saturated (mettet 2x)", () => { + const store = seedStore([ + { id: "s", title: "Seen", url: "https://e/s", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"], surfacedCount: 2 }, + ]); + try { + const dflt = briefMd(["--pillars", "ai", "--store", store]); + assert.equal(dflt.status, 0); + assert.ok(dflt.md.includes("· sett 2x"), "default saturationAt 3 -> surfacedCount 2 is warming"); + const tuned = briefMd(["--pillars", "ai", "--store", store, "--saturation-at", "2"]); + assert.equal(tuned.status, 0); + assert.ok(tuned.md.includes("· 🔁 mettet (2x)"), "--saturation-at 2 -> surfacedCount 2 is saturated"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + + test("--first-mover-days 1 drops a 2-day-old unsurfaced trend out of first-mover", () => { + const store = seedStore([ + { id: "f", title: "Fresh", url: "https://e/f", source: "tavily", capturedAt: freshIso, publishedAt: freshIso, topics: ["ai"] }, + ]); + try { + const dflt = briefMd(["--pillars", "ai", "--store", store]); + assert.ok(dflt.md.includes("· 🥇 først ute"), "default firstMoverDays 2 -> a 2-day unsurfaced trend is first-mover"); + const tuned = briefMd(["--pillars", "ai", "--store", store, "--first-mover-days", "1"]); + assert.ok(!tuned.md.includes("først ute"), "--first-mover-days 1 -> a 2-day trend is neutral"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); + + test("invalid threshold flags -> exit 2", () => { + const store = seedStore([]); + try { + assert.equal(runBrief(["--pillars", "ai", "--store", store, "--first-mover-days", "x"]).status, 2, "--first-mover-days x"); + assert.equal(runBrief(["--pillars", "ai", "--store", store, "--first-mover-days", "-1"]).status, 2, "--first-mover-days -1"); + assert.equal(runBrief(["--pillars", "ai", "--store", store, "--saturation-at", "0"]).status, 2, "--saturation-at 0"); + assert.equal(runBrief(["--pillars", "ai", "--store", store, "--saturation-at", "x"]).status, 2, "--saturation-at x"); + } finally { + rmSync(join(store, ".."), { recursive: true, force: true }); + } + }); +}); + +describe("trends CLI — brief history + day-over-day diff (RE-R3e / SC9)", () => { + const fixture = () => { + const dir = mkdtempSync(join(tmpdir(), "trends-r3e-")); + return { dir, store: join(dir, "trends.json"), out: join(dir, "briefs") }; + }; + // freshIso keeps the seeds inside the freshness window regardless of when the test runs. + const fresh = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10); + const seedOnPillar = (store: string, title: string, url: string): void => { + const batch = JSON.stringify([ + { + source: "tavily", + title, + url, + topics: ["ai", "gov"], + publishedAt: fresh, + score: { mode: "kortform", dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 } }, + }, + ]); + run(["capture", "--store", store], batch); + }; + const briefJson = (store: string, out: string) => + JSON.parse(run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store, "--json"], "").stdout); + + test("two-day (rename-real-write): day-2 shows the new trend; --json carries the diff", () => { + const { dir, store, out } = fixture(); + try { + // Day 1 — two on-pillar trends. The brief records its surfaced cohort + a null-prior diff. + seedOnPillar(store, "Alpha", "https://e/a"); + seedOnPillar(store, "Beta", "https://e/b"); + const o1 = briefJson(store, out); + assert.equal(o1.diff.priorDate, null, "first brief has no prior (empty dir)"); + assert.match(readFileSync(o1.path, "utf8"), /^surfaced: .+/m, "day-1 records a non-empty surfaced: line"); + + // Rename the REAL day-1 brief to a fixed past date — its surfaced: ids are genuine store + // ids, so the day-2 diff's carried/added are id-exact, clock-free (no today() advance). + const prior = "2026-06-20"; + renameSync(o1.path, join(out, `${prior}.md`)); + + // Day 2 — capture one NEW on-pillar trend, re-run. It is the sole "added". + seedOnPillar(store, "Gamma", "https://e/g"); + const o2 = briefJson(store, out); + assert.equal(o2.diff.priorDate, prior, "day-2 discovers the renamed strict-prior"); + assert.ok(o2.diff.added >= 1, "at least the new trend is added"); + assert.equal(o2.diff.carried, 2, "the two day-1 trends carry over"); + assert.equal(o2.diff.dropped, 0, "nothing dropped"); + const md2 = readFileSync(o2.path, "utf8"); + assert.ok(md2.includes(`## 🆕 Nytt siden sist (${prior})`), "the diff section names the prior date"); + assert.ok(md2.includes("Gamma"), "the new trend's title is in the Nytt-siden-sist section"); + + // The non-JSON console line appends the delta marker. + const console2 = run(["brief", "--pillars", "ai,gov", "--out", out, "--store", store], "").stdout; + assert.ok(console2.includes("nye siden sist"), `console line carries the delta: ${console2}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("a first run (empty dir) → diff.priorDate === null, no Nytt-siden-sist date", () => { + const { dir, store, out } = fixture(); + try { + seedOnPillar(store, "Solo", "https://e/s"); + const o = briefJson(store, out); + assert.equal(o.diff.priorDate, null); + const md = readFileSync(o.path, "utf8"); + assert.ok(md.includes("Første brief"), "a first brief says 'Første brief'"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/trends/tests/item.test.ts b/scripts/trends/tests/item.test.ts new file mode 100644 index 0000000..6c0fe3f --- /dev/null +++ b/scripts/trends/tests/item.test.ts @@ -0,0 +1,340 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeItem, normalizeItems, itemToInput } from "../src/item.js"; +import type { TrendItem } from "../src/item.js"; +import { normalizeField } from "../src/store.js"; +import { scoreEnvelope } from "../src/score.js"; + +describe("trends item normalizer (RE-R1 / B1)", () => { + describe("normalizeItem — well-formed", () => { + test("a well-formed raw item normalizes to a canonical item (string fields verbatim)", () => { + const raw = { + source: "tavily", + title: "OpenAI ships a new reasoning model", + url: "https://example.com/Article-Path", + topics: ["ai", "reasoning"], + summary: "A short summary.", + }; + const res = normalizeItem(raw); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.equal(res.item.source, "tavily"); + assert.equal(res.item.title, "OpenAI ships a new reasoning model"); // verbatim, case preserved + assert.equal(res.item.url, "https://example.com/Article-Path"); // verbatim, case-sensitive path + assert.equal(res.item.summary, "A short summary."); + assert.deepEqual(res.item.topics, ["ai", "reasoning"]); + }); + + test("topics are normalized (lowercase + whitespace) and deduped, order-stable", () => { + const res = normalizeItem({ + source: "manual", + title: "T", + url: "https://example.com/t", + topics: ["AI", " Machine Learning ", "ai", "Machine Learning"], + }); + assert.equal(res.ok, true); + if (!res.ok) return; + // "AI"/"ai" dedupe -> "ai"; " Machine Learning "/"Machine Learning" dedupe -> "machine learning" + assert.deepEqual(res.item.topics, ["ai", "machine learning"]); + // each topic equals store.normalizeField of the raw form (the same normalization) + assert.equal(res.item.topics[1], normalizeField(" Machine Learning ")); + }); + + test("the canonical item carries NO id (the store derives it via addTrend->trendId)", () => { + const res = normalizeItem({ + source: "tavily", + title: "No id here", + url: "https://example.com/x", + topics: ["x"], + }); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.equal((res.item as Record<string, unknown>).id, undefined); + assert.equal(Object.prototype.hasOwnProperty.call(res.item, "id"), false); + }); + + test("summary is optional — absent -> no summary key", () => { + const res = normalizeItem({ source: "manual", title: "T", url: "https://example.com/t", topics: ["x"] }); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.equal("summary" in res.item, false); + }); + + test("topics absent -> empty topics array", () => { + const res = normalizeItem({ source: "manual", title: "T", url: "https://example.com/t" }); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.deepEqual(res.item.topics, []); + }); + }); + + describe("normalizeItem — required-field validation", () => { + for (const field of ["source", "title", "url"] as const) { + test(`missing ${field} -> {ok:false} naming the field`, () => { + const base: Record<string, unknown> = { + source: "tavily", + title: "T", + url: "https://example.com/t", + topics: ["x"], + }; + delete base[field]; + const res = normalizeItem(base); + assert.equal(res.ok, false); + if (res.ok) return; + assert.ok( + res.errors.some((e) => e.includes(field)), + `error should name ${field}: ${res.errors.join("; ")}`, + ); + }); + + test(`empty/whitespace ${field} -> {ok:false} naming the field (no silent partial)`, () => { + const base: Record<string, unknown> = { + source: "tavily", + title: "T", + url: "https://example.com/t", + topics: ["x"], + }; + base[field] = " "; + const res = normalizeItem(base); + assert.equal(res.ok, false); + if (res.ok) return; + assert.ok(res.errors.some((e) => e.includes(field))); + }); + } + + test("a non-object raw -> {ok:false}", () => { + const res = normalizeItem("not an object" as unknown); + assert.equal(res.ok, false); + }); + }); + + describe("normalizeItem — publishedAt", () => { + test("present and valid ISO date -> kept", () => { + const res = normalizeItem({ + source: "tavily", + title: "T", + url: "https://example.com/t", + topics: ["x"], + publishedAt: "2026-06-20", + }); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.equal(res.item.publishedAt, "2026-06-20"); + }); + + test("absent -> undefined (no key)", () => { + const res = normalizeItem({ source: "tavily", title: "T", url: "https://example.com/t", topics: ["x"] }); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.equal(res.item.publishedAt, undefined); + assert.equal("publishedAt" in res.item, false); + }); + + test("present but invalid -> {ok:false} naming publishedAt", () => { + const res = normalizeItem({ + source: "tavily", + title: "T", + url: "https://example.com/t", + topics: ["x"], + publishedAt: "not-a-date", + }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.ok(res.errors.some((e) => e.includes("publishedAt"))); + }); + + test("present but impossible calendar date -> {ok:false}", () => { + const res = normalizeItem({ + source: "tavily", + title: "T", + url: "https://example.com/t", + topics: ["x"], + publishedAt: "2026-13-45", + }); + assert.equal(res.ok, false); + }); + }); + + describe("normalizeItems — batch partition", () => { + test("partitions a batch into {items, errors} with error indices", () => { + const raw = [ + { source: "tavily", title: "Good A", url: "https://example.com/a", topics: ["x"] }, + { source: "tavily", title: "", url: "https://example.com/b", topics: ["y"] }, // bad: empty title + { source: "manual", title: "Good C", url: "https://example.com/c", topics: ["z", "z"] }, + ]; + const { items, errors } = normalizeItems(raw); + assert.equal(items.length, 2); + assert.equal(errors.length, 1); + assert.equal(errors[0].index, 1); + assert.ok(errors[0].errors.some((e) => e.includes("title"))); + assert.deepEqual( + items.map((i) => i.title), + ["Good A", "Good C"], + ); + assert.deepEqual(items[1].topics, ["z"]); // deduped + }); + + test("an empty batch -> empty partition", () => { + const { items, errors } = normalizeItems([]); + assert.deepEqual(items, []); + assert.deepEqual(errors, []); + }); + }); + + describe("itemToInput — bridge to the store input (RE-R2a / B-bridge)", () => { + // Build a realistic envelope via the normalizer (the actual upstream path). + const mkItem = (extra: Record<string, unknown> = {}) => { + const r = normalizeItem({ + source: "tavily", + title: "OpenAI ships a reasoning model", + url: "https://example.com/Article", + topics: ["ai", "reasoning"], + summary: "A short summary.", + ...extra, + }); + assert.equal(r.ok, true); + if (!r.ok) throw new Error("fixture item failed to normalize"); + return r.item; + }; + + test("injects capturedAt and carries source/title/url/topics/summary/publishedAt verbatim", () => { + const input = itemToInput(mkItem({ publishedAt: "2026-06-20" }), "2026-06-24"); + assert.equal(input.capturedAt, "2026-06-24"); + assert.equal(input.source, "tavily"); + assert.equal(input.title, "OpenAI ships a reasoning model"); + assert.equal(input.url, "https://example.com/Article"); + assert.deepEqual(input.topics, ["ai", "reasoning"]); + assert.equal(input.summary, "A short summary."); + assert.equal(input.publishedAt, "2026-06-20"); + }); + + test("derives NO id (the store owns id via addTrend->trendId)", () => { + const input = itemToInput(mkItem(), "2026-06-24") as Record<string, unknown>; + assert.equal("id" in input, false); + }); + + test("an item without publishedAt -> input omits the key (not undefined-valued)", () => { + const input = itemToInput(mkItem(), "2026-06-24"); // mkItem carries no publishedAt + assert.equal("publishedAt" in input, false); + }); + + test("field-confusion guard: capturedAt (injected) is distinct from publishedAt (carried)", () => { + const input = itemToInput(mkItem({ publishedAt: "2026-06-20" }), "2026-06-24"); + assert.notEqual(input.capturedAt, input.publishedAt); + assert.equal(input.publishedAt, "2026-06-20"); + assert.equal(input.capturedAt, "2026-06-24"); + }); + }); + + describe("normalizeItem — score validation (RE-R3a / SC2)", () => { + const base = { + source: "tavily", + title: "Scored item", + url: "https://example.com/s", + topics: ["ai"], + }; + const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }; + + test("a valid kortform score -> carried with the validated dims", () => { + const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: validDims } }); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.deepEqual(res.item.score, { mode: "kortform", dimensions: validDims }); + }); + + test("a valid long-form score -> carried with its five dims", () => { + const longDims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 }; + const res = normalizeItem({ ...base, score: { mode: "long-form", dimensions: longDims } }); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.deepEqual(res.item.score, { mode: "long-form", dimensions: longDims }); + }); + + test("absent score -> key omitted", () => { + const res = normalizeItem(base); + assert.equal(res.ok, true); + if (!res.ok) return; + assert.equal("score" in res.item, false); + }); + + test("a bad mode -> structured error (no throw)", () => { + const res = normalizeItem({ ...base, score: { mode: "bogus", dimensions: validDims } }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.ok(res.errors.some((e) => e.includes("invalid score")), res.errors.join("; ")); + }); + + test("a missing dimension -> structured error (no throw)", () => { + const { authority, ...missing } = validDims; + const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: missing } }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.ok(res.errors.some((e) => e.includes("invalid score"))); + }); + + test("a dimension out of [1,10] (0 or 11) -> structured error (no throw)", () => { + const lo = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 0 } } }); + assert.equal(lo.ok, false); + const hi = normalizeItem({ ...base, score: { mode: "kortform", dimensions: { ...validDims, pillar: 11 } } }); + assert.equal(hi.ok, false); + }); + + test("a non-object score -> structured error (no throw)", () => { + const res = normalizeItem({ ...base, score: "high" }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.ok(res.errors.some((e) => e.includes("invalid score"))); + }); + + test("an array dimensions -> structured error (no throw)", () => { + const res = normalizeItem({ ...base, score: { mode: "kortform", dimensions: [9, 8, 9, 7, 6] } }); + assert.equal(res.ok, false); + if (res.ok) return; + assert.ok(res.errors.some((e) => e.includes("invalid score"))); + }); + + test("an array score -> structured error (no throw)", () => { + const res = normalizeItem({ ...base, score: [] }); + assert.equal(res.ok, false); + }); + }); + + describe("itemToInput — score bridge + the throw contract (RE-R3a / SC2)", () => { + const validDims = { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }; + + test("a scored item -> input.score equals scoreEnvelope(mode, dimensions)", () => { + const item: TrendItem = { + source: "tavily", + title: "T", + url: "https://example.com/t", + topics: ["ai"], + score: { mode: "kortform", dimensions: validDims }, + }; + const input = itemToInput(item, "2026-06-24"); + assert.deepEqual(input.score, scoreEnvelope("kortform", validDims)); + }); + + test("an unscored item -> no score key", () => { + const item: TrendItem = { + source: "tavily", + title: "T", + url: "https://example.com/t", + topics: ["ai"], + }; + const input = itemToInput(item, "2026-06-24") as Record<string, unknown>; + assert.equal("score" in input, false); + }); + + test("called directly with an out-of-range dim -> throws by contract (defense-in-depth)", () => { + const item: TrendItem = { + source: "tavily", + title: "T", + url: "https://example.com/t", + topics: ["ai"], + score: { mode: "kortform", dimensions: { ...validDims, timing: 99 } }, + }; + assert.throws(() => itemToInput(item, "2026-06-24")); + }); + }); +}); diff --git a/scripts/trends/tests/run-daily.test.ts b/scripts/trends/tests/run-daily.test.ts new file mode 100644 index 0000000..320e4b2 --- /dev/null +++ b/scripts/trends/tests/run-daily.test.ts @@ -0,0 +1,134 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; + +import { defaultStorePath } from "../src/store.js"; +// The hooks `.mjs` twin of the data-root seam — SC8 binds it into the consistency check. +import { getDataRoot } from "../../../hooks/scripts/data-root.mjs"; + +// Package root (scripts/trends) so the wrapper path + `tsx` resolution are cwd-independent. +const trendsDir = fileURLToPath(new URL("..", import.meta.url)); +const wrapper = join(trendsDir, "run-daily.sh"); +const today = new Date().toISOString().slice(0, 10); + +// Invoke THROUGH `bash` (never a direct executable spawn): an ABSENT wrapper exits 127 +// (a clean assertion-RED), where a direct exec of a missing file would throw ENOENT / +// status:null (a module-not-found-class failure). Folded — plan-critic #7. +function runWrapper( + args: string[], + env: Record<string, string> = {}, + cwd: string = trendsDir, +): { status: number | null; stdout: string; stderr: string } { + const res = spawnSync("bash", [wrapper, ...args], { + input: "", + encoding: "utf8", + cwd, + env: { ...process.env, ...env }, + }); + return { status: res.status, stdout: res.stdout, stderr: res.stderr }; +} + +describe("trends headless wrapper — run-daily.sh (RE-R3c / SC7, SC8)", () => { + const freshIso = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10); + + // One temp dir is BOTH the LINKEDIN_STUDIO_DATA root (so cron.log lands at <dir>/trends/cron.log) + // AND holds the seeded store + brief out-dir, so the real per-user data dir is never touched. + function fixture(): { dir: string; store: string; out: string } { + const dir = mkdtempSync(join(tmpdir(), "r3c-wrap-")); + const store = join(dir, "s.json"); + writeFileSync( + store, + JSON.stringify({ + schemaVersion: 2, + trends: [ + { + id: "a", + title: "Fresh Match", + url: "https://e/a", + source: "tavily", + capturedAt: freshIso, + publishedAt: freshIso, + topics: ["ai", "gov"], + }, + ], + }), + ); + return { dir, store, out: join(dir, "mb") }; + } + + test("SC7: seeded store → dated brief .md written + exactly one compact cron.log line + exit 0", () => { + const { dir, store, out } = fixture(); + try { + const { status } = runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { + LINKEDIN_STUDIO_DATA: dir, + }); + assert.equal(status, 0, "the deterministic brief exits 0"); + assert.ok(existsSync(join(out, `${today}.md`)), "the dated brief .md is written"); + const log = readFileSync(join(dir, "trends", "cron.log"), "utf8"); + const lines = log.split("\n").filter((l) => l.trim().length > 0); + assert.equal(lines.length, 1, "exactly one log line (the pretty-printed brief json compacted to one line)"); + assert.match(lines[0], /^\S+ exit=0 /, "the line is `<ts> exit=0 <compact-json>`"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("SC7: a second same-day run → byte-identical .md, surfacedCount not double-counted, second log line", () => { + const { dir, store, out } = fixture(); + try { + assert.equal(runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }).status, 0); + const md1 = readFileSync(join(out, `${today}.md`), "utf8"); + assert.equal(runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }).status, 0); + const md2 = readFileSync(join(out, `${today}.md`), "utf8"); + assert.equal(md1, md2, "the same-day re-render is byte-identical"); + const persisted = JSON.parse(readFileSync(store, "utf8")); + assert.equal(persisted.trends[0].surfacedCount, 1, "surfacedCount stays 1 (R3b per-day idempotency)"); + const lines = readFileSync(join(dir, "trends", "cron.log"), "utf8").split("\n").filter((l) => l.trim().length > 0); + assert.equal(lines.length, 2, "two runs → two log lines"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("SC7: CWD-independent — runs from an unrelated cwd (the wrapper's `cd \"$DIR\"` resolves tsx)", () => { + const { dir, store, out } = fixture(); + const otherCwd = mkdtempSync(join(tmpdir(), "r3c-cwd-")); + try { + const { status } = runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }, otherCwd); + assert.equal(status, 0, "the wrapper resolves tsx from a cwd that is not the package dir"); + assert.ok(existsSync(join(out, `${today}.md`)), "the dated brief is still written"); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(otherCwd, { recursive: true, force: true }); + } + }); + + test("SC8: data-path twin consistency — wrapper log dir == dirname(defaultStorePath()) == getDataRoot('trends')", () => { + const { dir, store, out } = fixture(); + const saved = process.env.LINKEDIN_STUDIO_DATA; + try { + runWrapper(["--pillars", "ai,gov", "--store", store, "--out", out], { LINKEDIN_STUDIO_DATA: dir }); + const wrapperLogDir = join(dir, "trends"); // where the wrapper actually wrote cron.log + assert.ok(existsSync(join(wrapperLogDir, "cron.log")), "the wrapper wrote into <data>/trends"); + + // The TS store twin + the hooks `.mjs` twin, resolved under the SAME override. + process.env.LINKEDIN_STUDIO_DATA = dir; + assert.equal(dirname(defaultStorePath()), wrapperLogDir, "TS dirname(defaultStorePath()) == wrapper log dir"); + assert.equal(getDataRoot("trends"), wrapperLogDir, "hooks getDataRoot('trends') == wrapper log dir"); + + // Override-independence: a different root still resolves the two TS/JS twins identically. + const other = mkdtempSync(join(tmpdir(), "r3c-twin-")); + process.env.LINKEDIN_STUDIO_DATA = other; + assert.equal(dirname(defaultStorePath()), getDataRoot("trends"), "the twins agree for any override root"); + rmSync(other, { recursive: true, force: true }); + } finally { + if (saved === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = saved; + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/trends/tests/schedule.test.ts b/scripts/trends/tests/schedule.test.ts new file mode 100644 index 0000000..bc33a75 --- /dev/null +++ b/scripts/trends/tests/schedule.test.ts @@ -0,0 +1,120 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { + launchdPlist, + crontabLine, + installInstructions, + uninstallInstructions, + defaultLabel, + type ScheduleSpec, +} from "../src/schedule.js"; + +// A fully-resolved spec, exactly as the CLI builds it at generation time (every value injected — +// the emitters read no clock/fs/env). `env` is canonical: always { NODE_BIN, LINKEDIN_STUDIO_DATA }. +function launchdSpec(): ScheduleSpec { + return { + platform: "launchd", + label: "com.linkedin-studio.trends.daily", + nodeBin: "/usr/local/bin/node", + wrapperPath: "/repo/scripts/trends/run-daily.sh", + args: ["--pillars", "ai,gov", "--fresh-days", "7"], + hour: 7, + minute: 30, + logPath: "/data/trends/cron.log", + workingDir: "/repo/scripts/trends", + env: { NODE_BIN: "/usr/local/bin/node", LINKEDIN_STUDIO_DATA: "/data" }, + }; +} +const cronSpec = (): ScheduleSpec => ({ ...launchdSpec(), platform: "cron" }); + +function isBalancedXml(xml: string): boolean { + const stack: string[] = []; + const re = /<(\/?)([a-zA-Z][\w.:-]*)[^>]*?(\/?)>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(xml)) !== null) { + if (m[3] === "/") continue; + if (m[1] === "/") { + if (stack.pop() !== m[2]) return false; + } else { + stack.push(m[2]); + } + } + return stack.length === 0; +} + +describe("schedule.ts — pure launchd plist emitter (SC1)", () => { + test("key-complete: Label, ProgramArguments, StartCalendarInterval(H/M), Std*Path, EnvironmentVariables", () => { + const p = launchdPlist(launchdSpec()); + assert.ok(p.includes("<key>Label</key>") && p.includes("com.linkedin-studio.trends.daily"), "Label"); + assert.ok(p.includes("<key>ProgramArguments</key>") && p.includes("/bin/bash") && p.includes("run-daily.sh"), "ProgramArguments"); + assert.ok(p.includes("--pillars") && p.includes("ai,gov"), "the args are rendered as <string> entries"); + assert.ok(p.includes("<key>StartCalendarInterval</key>"), "StartCalendarInterval"); + assert.match(p, /<key>Hour<\/key>\s*<integer>7<\/integer>/, "Hour 7"); + assert.match(p, /<key>Minute<\/key>\s*<integer>30<\/integer>/, "Minute 30"); + assert.ok(p.includes("<key>StandardOutPath</key>") && p.includes("<key>StandardErrorPath</key>"), "Std*Path"); + assert.ok(p.includes("/data/trends/cron.log"), "Std*Path point at the resolved cron.log"); + assert.ok(p.includes("<key>EnvironmentVariables</key>") && p.includes("NODE_BIN") && p.includes("LINKEDIN_STUDIO_DATA"), "env rendered"); + assert.ok(p.includes("<key>WorkingDirectory</key>") && p.includes("/repo/scripts/trends"), "WorkingDirectory"); + assert.ok(p.includes("RunAtLoad"), "RunAtLoad declared"); + }); + + test("well-formed: balanced tags + plist DOCTYPE wrapper", () => { + const p = launchdPlist(launchdSpec()); + assert.ok(p.startsWith("<?xml"), "begins with the XML declaration"); + assert.ok(p.includes("<!DOCTYPE plist"), "has the plist DOCTYPE"); + assert.ok(p.trimEnd().endsWith("</plist>"), "closes the plist element"); + assert.ok(isBalancedXml(p), "every element tag is balanced"); + }); + + test("pure / deterministic: two calls are byte-identical", () => { + assert.equal(launchdPlist(launchdSpec()), launchdPlist(launchdSpec())); + }); +}); + +describe("schedule.ts — pure cron line emitter (SC2, string only)", () => { + test("the line carries time, env prefix, /bin/bash, wrapper, args, log redirect, label comment", () => { + const line = crontabLine(cronSpec()); + assert.match(line, /^30 7 \* \* \* /, "fires 07:30 daily"); + assert.ok(line.includes("NODE_BIN=/usr/local/bin/node"), "the env prefix carries NODE_BIN"); + assert.ok(line.includes("LINKEDIN_STUDIO_DATA=/data"), "the env prefix carries the data root"); + assert.ok(line.includes("/bin/bash") && line.includes("run-daily.sh"), "invokes the wrapper via bash"); + assert.ok(line.includes("--pillars ai,gov"), "carries the pillars"); + assert.ok(line.includes(">> /data/trends/cron.log 2>&1"), "redirects stdout+stderr to the log"); + assert.ok(line.trimEnd().endsWith("# com.linkedin-studio.trends.daily"), "ends with the label comment"); + assert.ok(!line.includes("\n"), "a single line"); + }); +}); + +describe("schedule.ts — install / uninstall instruction strings", () => { + const plistTarget = "/home/u/Library/LaunchAgents/com.linkedin-studio.trends.daily.plist"; + + test("install launchd: written-path + launchctl bootstrap recipe", () => { + const out = installInstructions(launchdSpec(), plistTarget); + assert.ok(out.includes(plistTarget), "names the written plist path"); + assert.ok(out.includes("launchctl bootstrap"), "the activation recipe"); + }); + + test("install cron: the crontab - install recipe carrying the line", () => { + const out = installInstructions(cronSpec()); + assert.ok(out.includes("crontab -"), "the install recipe"); + assert.ok(out.includes("run-daily.sh"), "carries the line to install"); + }); + + test("uninstall launchd: bootout + rm the plist", () => { + const out = uninstallInstructions(launchdSpec(), plistTarget); + assert.ok(out.includes("launchctl bootout"), "the deactivation recipe"); + assert.ok(out.includes(plistTarget), "removes the plist file"); + }); + + test("uninstall cron: the line-removal recipe", () => { + const out = uninstallInstructions(cronSpec()); + assert.ok(out.includes("crontab -") && out.includes("com.linkedin-studio.trends.daily"), "removes by the label comment"); + }); +}); + +describe("schedule.ts — defaultLabel (SC1/SC2 the plugin namespace)", () => { + test("is the reverse-DNS plugin namespace", () => { + assert.equal(defaultLabel(), "com.linkedin-studio.trends.daily"); + }); +}); diff --git a/scripts/trends/tests/score.test.ts b/scripts/trends/tests/score.test.ts new file mode 100644 index 0000000..98eb0c6 --- /dev/null +++ b/scripts/trends/tests/score.test.ts @@ -0,0 +1,191 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { + KORTFORM_WEIGHTS, + LONG_FORM_WEIGHTS, + composite, + band, + triage, + requiredDimensions, + scoreEnvelope, +} from "../src/score.js"; + +const r1 = (x: number) => Math.round(x * 10) / 10; +const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0); + +describe("trends scorer (RE-R1 / B2)", () => { + // SSOT: references/trend-scoring-modes.md — weights, band thresholds, and action + // strings are pinned here so silent drift in any of them fails loudly. + describe("pinned weights (SSOT)", () => { + test("kortform weights match the SSOT and sum to 1.0", () => { + assert.equal(KORTFORM_WEIGHTS.pillar, 0.3); + assert.equal(KORTFORM_WEIGHTS.audience, 0.25); + assert.equal(KORTFORM_WEIGHTS.timing, 0.2); + assert.equal(KORTFORM_WEIGHTS.angle, 0.15); + assert.equal(KORTFORM_WEIGHTS.authority, 0.1); + assert.equal(r1(sum(KORTFORM_WEIGHTS)), 1.0); + }); + + test("long-form weights match the SSOT and sum to 1.0", () => { + assert.equal(LONG_FORM_WEIGHTS.pillar, 0.3); + assert.equal(LONG_FORM_WEIGHTS.depth, 0.25); + assert.equal(LONG_FORM_WEIGHTS.angle, 0.2); + assert.equal(LONG_FORM_WEIGHTS.authority, 0.15); + assert.equal(LONG_FORM_WEIGHTS.currency, 0.1); + assert.equal(r1(sum(LONG_FORM_WEIGHTS)), 1.0); + }); + }); + + describe("composite", () => { + test("all-tens -> exactly 10.0 (proves Sigma weights = 1.0) for both modes", () => { + const kort = { pillar: 10, audience: 10, timing: 10, angle: 10, authority: 10 }; + const long = { pillar: 10, depth: 10, angle: 10, authority: 10, currency: 10 }; + assert.equal(composite(kort, "kortform"), 10.0); + assert.equal(composite(long, "long-form"), 10.0); + }); + + test("asymmetric golden vector {10,8,6,4,2} in dimension order -> 7.0 for both modes", () => { + // 10*.30 + 8*.25 + 6*.20 + 4*.15 + 2*.10 = 3.0 + 2.0 + 1.2 + 0.6 + 0.2 = 7.0 + const kort = { pillar: 10, audience: 8, timing: 6, angle: 4, authority: 2 }; + const long = { pillar: 10, depth: 8, angle: 6, authority: 4, currency: 2 }; + assert.equal(composite(kort, "kortform"), 7.0); + assert.equal(composite(long, "long-form"), 7.0); + }); + + test("a dimension below 1 throws", () => { + const kort = { pillar: 0, audience: 5, timing: 5, angle: 5, authority: 5 }; + assert.throws(() => composite(kort, "kortform"), /range|1.*10|dimension/i); + }); + + test("a dimension above 10 throws", () => { + const kort = { pillar: 11, audience: 5, timing: 5, angle: 5, authority: 5 }; + assert.throws(() => composite(kort, "kortform")); + }); + + test("a missing dimension throws", () => { + const kort = { pillar: 5, audience: 5, timing: 5, angle: 5 }; // authority missing + assert.throws(() => composite(kort as Record<string, number>, "kortform")); + }); + }); + + describe("band — boundaries + exact SSOT action strings", () => { + test("8.0 -> Immediate", () => { + const b = band(8.0); + assert.equal(b.priority, "Immediate"); + assert.equal(b.kortformAction, "Draft within 24h"); + assert.equal(b.longformAction, "Promote to the edition backlog now"); + }); + + test("6.0 -> High", () => { + const b = band(6.0); + assert.equal(b.priority, "High"); + assert.equal(b.kortformAction, "Publish within 48–72h"); + assert.equal(b.longformAction, "Strong edition candidate — schedule it"); + }); + + test("4.0 -> Medium", () => { + const b = band(4.0); + assert.equal(b.priority, "Medium"); + assert.equal(b.kortformAction, "Add to this week's calendar"); + assert.equal(b.longformAction, "Hold as a backlog candidate, revisit"); + }); + + test("2.0 -> Low", () => { + const b = band(2.0); + assert.equal(b.priority, "Low"); + assert.equal(b.kortformAction, "Note, skip for now"); + assert.equal(b.longformAction, "Park unless the angle sharpens"); + }); + + test("below 2.0 -> Skip", () => { + const b = band(1.9); + assert.equal(b.priority, "Skip"); + assert.equal(b.kortformAction, "Off positioning"); + assert.equal(b.longformAction, "Off positioning"); + }); + + test("just below a boundary lands in the lower band (7.9->High, 5.9->Medium, 3.9->Low)", () => { + assert.equal(band(7.9).priority, "High"); + assert.equal(band(5.9).priority, "Medium"); + assert.equal(band(3.9).priority, "Low"); + }); + }); + + describe("triage", () => { + const candidates = [ + { id: "low", scores: { pillar: 2, audience: 2, timing: 2, angle: 2, authority: 2 } }, // 2.0 + { id: "high", scores: { pillar: 8, audience: 8, timing: 8, angle: 8, authority: 8 } }, // 8.0 + { id: "mid", scores: { pillar: 5, audience: 5, timing: 5, angle: 5, authority: 5 } }, // 5.0 + { id: "below", scores: { pillar: 3, audience: 3, timing: 3, angle: 3, authority: 3 } }, // 3.0 + ]; + + test("keeps composite >= threshold, drops below, ranks kept composite-desc, annotates", () => { + const { kept, dropped } = triage(candidates, { mode: "kortform", threshold: 4.0 }); + assert.deepEqual( + kept.map((k) => k.id), + ["high", "mid"], + ); // 8.0, 5.0 desc; both >= 4.0 + assert.deepEqual( + dropped.map((d) => d.id).sort(), + ["below", "low"], + ); // 3.0, 2.0 < 4.0 + assert.equal(kept[0].composite, 8.0); + assert.equal(kept[0].band.priority, "Immediate"); + assert.equal(kept[1].composite, 5.0); + assert.equal(kept[1].band.priority, "Medium"); + }); + + test("threshold is inclusive (composite == threshold is kept)", () => { + const { kept } = triage(candidates, { mode: "kortform", threshold: 5.0 }); + assert.deepEqual( + kept.map((k) => k.id), + ["high", "mid"], + ); // mid == 5.0 kept + }); + + test("an empty candidate list -> empty kept/dropped", () => { + const { kept, dropped } = triage([], { mode: "kortform", threshold: 4.0 }); + assert.deepEqual(kept, []); + assert.deepEqual(dropped, []); + }); + }); + + describe("requiredDimensions (RE-R3a / SC1)", () => { + test("kortform -> the five keys in SSOT weight order", () => { + assert.deepEqual(requiredDimensions("kortform"), ["pillar", "audience", "timing", "angle", "authority"]); + }); + + test("long-form -> the five keys in SSOT weight order", () => { + assert.deepEqual(requiredDimensions("long-form"), ["pillar", "depth", "angle", "authority", "currency"]); + }); + + test("order is pinned to the SSOT weight literals (a silent reorder fails)", () => { + assert.deepEqual(requiredDimensions("kortform"), Object.keys(KORTFORM_WEIGHTS)); + assert.deepEqual(requiredDimensions("long-form"), Object.keys(LONG_FORM_WEIGHTS)); + }); + }); + + describe("scoreEnvelope (RE-R3a / SC1)", () => { + test("composes composite()+band() — composite/priority equal the existing functions (one owner)", () => { + const dims = { pillar: 8, audience: 7, timing: 9, angle: 6, authority: 5 }; + const env = scoreEnvelope("kortform", dims); + assert.equal(env.mode, "kortform"); + assert.deepEqual(env.dimensions, dims); + assert.equal(env.composite, composite(dims, "kortform")); + assert.equal(env.priority, band(composite(dims, "kortform")).priority); + }); + + test("long-form envelope composes the long-form composite/band", () => { + const dims = { pillar: 9, depth: 8, angle: 7, authority: 6, currency: 5 }; + const env = scoreEnvelope("long-form", dims); + assert.equal(env.composite, composite(dims, "long-form")); + assert.equal(env.priority, band(composite(dims, "long-form")).priority); + }); + + test("a bad dimension makes scoreEnvelope throw (via composite — defense-in-depth contract)", () => { + const dims = { pillar: 8, audience: 7, timing: 99, angle: 6, authority: 5 }; + assert.throws(() => scoreEnvelope("kortform", dims)); + }); + }); +}); diff --git a/scripts/trends/tests/store.test.ts b/scripts/trends/tests/store.test.ts new file mode 100644 index 0000000..788fd68 --- /dev/null +++ b/scripts/trends/tests/store.test.ts @@ -0,0 +1,854 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + normalizeField, + trendId, + emptyStore, + loadStore, + saveStore, + addTrend, + queryByTopic, + history, + newestCaptureDate, + effectiveStatus, + setStatus, + markSurfaced, +} from "../src/store.js"; +import { SCHEMA_VERSION } from "../src/types.js"; +import type { TrendStore } from "../src/types.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "trends-store-")); + +describe("trends store", () => { + describe("normalizeField + trendId", () => { + test("normalizeField collapses whitespace + lowercases", () => { + assert.equal(normalizeField(" AI Trends\n2026 "), "ai trends 2026"); + }); + + test("id is deterministic and (title+url)-derived", () => { + assert.equal( + trendId("AI agents go mainstream", "https://example.com/a"), + trendId("AI agents go mainstream", "https://example.com/a"), + ); + }); + + test("id is stable across case + surrounding/collapsed whitespace in title AND url", () => { + const a = trendId("AI agents go mainstream", "https://example.com/article"); + const b = trendId(" ai AGENTS go mainstream ", " HTTPS://EXAMPLE.COM/article "); + assert.equal(a, b, "normalization should collapse case + whitespace on both fields"); + }); + + test("different title yields a different id (same url)", () => { + assert.notEqual( + trendId("AI agents go mainstream", "https://example.com/a"), + trendId("AI copilots go mainstream", "https://example.com/a"), + ); + }); + + test("different url yields a different id (same title)", () => { + assert.notEqual( + trendId("Same headline", "https://example.com/a"), + trendId("Same headline", "https://example.com/b"), + ); + }); + }); + + describe("emptyStore + load/save round-trip", () => { + test("emptyStore has the schema version and no trends", () => { + const s = emptyStore(); + assert.equal(s.schemaVersion, SCHEMA_VERSION); + assert.deepEqual(s.trends, []); + }); + + test("loadStore on a missing file returns an empty store (never throws)", () => { + const dir = tmp(); + try { + const s = loadStore(join(dir, "does-not-exist.json")); + assert.equal(s.schemaVersion, SCHEMA_VERSION); + assert.deepEqual(s.trends, []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("saveStore creates the parent dir, then loadStore round-trips", () => { + const dir = tmp(); + const path = join(dir, "nested", "trends.json"); + try { + let store: TrendStore = emptyStore(); + store = addTrend(store, { + title: "Norway opens public-sector AI sandbox", + url: "https://example.com/sandbox", + source: "tavily", + capturedAt: "2026-06-20", + topics: ["public-sector", "ai-policy"], + summary: "A regulatory sandbox for public bodies.", + }).store; + saveStore(path, store); + assert.ok(existsSync(path)); + const loaded = loadStore(path); + assert.equal(loaded.trends.length, 1); + assert.equal(loaded.trends[0].title, "Norway opens public-sector AI sandbox"); + assert.equal(loaded.trends[0].source, "tavily"); + assert.equal(loaded.trends[0].summary, "A regulatory sandbox for public bodies."); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + }); + + describe("addTrend", () => { + test("adds a new trend with a computed id and reports added:true", () => { + const res = addTrend(emptyStore(), { + title: "Agentic workflows hit production", + url: "https://example.com/agentic", + source: "websearch", + capturedAt: "2026-06-20", + topics: ["agents", "engineering"], + }); + assert.equal(res.added, true); + assert.equal(res.merged, false); + assert.equal(res.store.trends.length, 1); + assert.equal(res.store.trends[0].id, trendId(res.store.trends[0].title, res.store.trends[0].url)); + }); + + test("preserves title/url/summary verbatim (normalizes only for the id)", () => { + const title = " Mixed CASE Headline "; + const url = " https://EXAMPLE.com/Path "; + const summary = " A summary with spacing. "; + const res = addTrend(emptyStore(), { + title, + url, + source: "manual", + capturedAt: "2026-06-20", + topics: ["x"], + summary, + }); + assert.equal(res.store.trends[0].title, title, "title must not be mutated"); + assert.equal(res.store.trends[0].url, url, "url must not be mutated"); + assert.equal(res.store.trends[0].summary, summary, "summary must not be mutated"); + }); + + test("omitting summary stores no summary key", () => { + const res = addTrend(emptyStore(), { + title: "No summary here", + url: "https://example.com/n", + source: "manual", + capturedAt: "2026-06-20", + topics: ["x"], + }); + assert.equal("summary" in res.store.trends[0], false); + }); + + test("dedupes on normalized title+url: same trend twice → one entry, topics unioned", () => { + let store = emptyStore(); + store = addTrend(store, { + title: "Reasoning models reshape RAG", + url: "https://example.com/rag", + source: "tavily", + capturedAt: "2026-06-19", + topics: ["rag"], + }).store; + const res2 = addTrend(store, { + title: " reasoning MODELS reshape rag ", + url: " HTTPS://EXAMPLE.COM/rag ", + source: "gemini", + capturedAt: "2026-06-20", + topics: ["search", "rag"], + }); + assert.equal(res2.added, false, "duplicate title+url must not add a second entry"); + assert.equal(res2.merged, true, "new topics on a duplicate must report merged:true"); + assert.equal(res2.store.trends.length, 1); + assert.deepEqual( + [...res2.store.trends[0].topics].sort(), + ["rag", "search"], + "topics must be unioned on re-capture", + ); + }); + + test("a duplicate keeps the first sighting's source + capturedAt (provenance of first sight)", () => { + let store = emptyStore(); + store = addTrend(store, { + title: "Same trend", + url: "https://example.com/s", + source: "first-source", + capturedAt: "2026-06-01", + topics: ["a"], + }).store; + store = addTrend(store, { + title: "Same trend", + url: "https://example.com/s", + source: "second-source", + capturedAt: "2026-06-20", + topics: ["b"], + }).store; + assert.equal(store.trends[0].source, "first-source"); + assert.equal(store.trends[0].capturedAt, "2026-06-01"); + }); + + test("a duplicate with no new topics reports merged:false", () => { + let store = emptyStore(); + store = addTrend(store, { + title: "Stable trend", + url: "https://example.com/st", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["a", "b"], + }).store; + const res2 = addTrend(store, { + title: "Stable trend", + url: "https://example.com/st", + source: "tavily", + capturedAt: "2026-06-02", + topics: ["b", "a"], + }); + assert.equal(res2.added, false); + assert.equal(res2.merged, false, "no new tags → merged:false"); + }); + + // ── RE-R2a: publishedAt persistence ── + test("RED: persists publishedAt on a new record when present", () => { + const res = addTrend(emptyStore(), { + title: "Dated trend", + url: "https://example.com/d", + source: "tavily", + capturedAt: "2026-06-24", + topics: ["ai"], + publishedAt: "2026-06-20", + }); + assert.equal(res.store.trends[0].publishedAt, "2026-06-20"); + }); + + test("regression guard: omits publishedAt when absent (no undefined-valued key)", () => { + const res = addTrend(emptyStore(), { + title: "Undated trend", + url: "https://example.com/u", + source: "tavily", + capturedAt: "2026-06-24", + topics: ["ai"], + }); + assert.equal("publishedAt" in res.store.trends[0], false); + }); + + test("RED: re-capture keeps the first sighting's publishedAt (no overwrite), unions topics", () => { + let store = emptyStore(); + store = addTrend(store, { + title: "Same dated trend", + url: "https://example.com/sd", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["a"], + publishedAt: "2026-05-30", + }).store; + const res2 = addTrend(store, { + title: "Same dated trend", + url: "https://example.com/sd", + source: "gemini", + capturedAt: "2026-06-20", + topics: ["a", "b"], + publishedAt: "2026-06-15", + }); + assert.equal(res2.added, false); + assert.equal(res2.merged, true); + assert.equal(res2.store.trends[0].publishedAt, "2026-05-30", "first-sight publishedAt kept"); + assert.deepEqual([...res2.store.trends[0].topics].sort(), ["a", "b"]); + }); + + test("regression guard: no back-fill — re-capture carrying publishedAt onto a record that lacked one does NOT add it", () => { + let store = emptyStore(); + store = addTrend(store, { + title: "Undated first sight", + url: "https://example.com/uf", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["a"], + }).store; // no publishedAt on first sight + const res2 = addTrend(store, { + title: "Undated first sight", + url: "https://example.com/uf", + source: "tavily", + capturedAt: "2026-06-20", + topics: ["a", "b"], + publishedAt: "2026-06-15", + }); + assert.equal(res2.merged, true, "topic union still reported"); + assert.equal("publishedAt" in res2.store.trends[0], false, "no back-fill of first-sight provenance"); + }); + + // ── RE-R3a: relevance score first-sight persistence (SC3) ── + const kortScore = { + mode: "kortform" as const, + dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }, + composite: 8.1, + priority: "Immediate" as const, + }; + + test("RED: persists score on a new record when present (first-sight)", () => { + const res = addTrend(emptyStore(), { + title: "Scored trend", + url: "https://example.com/sc", + source: "tavily", + capturedAt: "2026-06-24", + topics: ["ai"], + score: kortScore, + }); + assert.deepEqual(res.store.trends[0].score, kortScore); + }); + + test("regression guard: omits score when absent (no undefined-valued key)", () => { + const res = addTrend(emptyStore(), { + title: "Unscored trend", + url: "https://example.com/us", + source: "tavily", + capturedAt: "2026-06-24", + topics: ["ai"], + }); + assert.equal("score" in res.store.trends[0], false); + }); + + test("re-capture REFRESHES the score (last-wins, RE-R3b reverses R3a's D3), unions topics", () => { + let store = emptyStore(); + store = addTrend(store, { + title: "Same scored trend", + url: "https://example.com/ssc", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["a"], + score: kortScore, + }).store; + const lowerScore = { + mode: "kortform" as const, + dimensions: { pillar: 6, audience: 5, timing: 6, angle: 5, authority: 5 }, + composite: 5.6, + priority: "Medium" as const, + }; + const res2 = addTrend(store, { + title: "Same scored trend", + url: "https://example.com/ssc", + source: "gemini", + capturedAt: "2026-06-20", + topics: ["a", "b"], + score: lowerScore, + }); + assert.equal(res2.added, false); + assert.equal(res2.merged, true); + assert.deepEqual(res2.store.trends[0].score, lowerScore, "score refreshed last-wins (RE-R3b reverses R3a's D3)"); + assert.deepEqual([...res2.store.trends[0].topics].sort(), ["a", "b"]); + }); + }); + + describe("queryByTopic", () => { + const seed = (): TrendStore => { + let store = emptyStore(); + store = addTrend(store, { + title: "Trend A", + url: "https://example.com/a", + source: "tavily", + capturedAt: "2026-05-01", + topics: ["agents", "engineering"], + }).store; + store = addTrend(store, { + title: "Trend B", + url: "https://example.com/b", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["agents", "engineering", "public-sector"], + }).store; + store = addTrend(store, { + title: "Trend C", + url: "https://example.com/c", + source: "manual", + capturedAt: "2026-06-10", + topics: ["privacy"], + }).store; + return store; + }; + + test("ranks hits by topic overlap (desc)", () => { + // B carries both queried topics; A carries only "agents" → distinct overlaps. + const hits = queryByTopic(seed(), ["agents", "public-sector"]); + assert.equal(hits.length, 2); + assert.equal(hits[0].trend.title, "Trend B"); // overlap 2 + assert.equal(hits[0].topicOverlap, 2); + assert.equal(hits[1].trend.title, "Trend A"); // overlap 1 (agents only) + assert.equal(hits[1].topicOverlap, 1); + }); + + test("matching is case-insensitive on topics", () => { + const hits = queryByTopic(seed(), ["AGENTS"]); + assert.equal(hits.length, 2); + }); + + test("returns [] when nothing matches", () => { + assert.deepEqual(queryByTopic(seed(), ["nothing"]), []); + }); + + test("ties on overlap are broken by capturedAt (newest first)", () => { + const hits = queryByTopic(seed(), ["agents"]); // A and B both overlap 1 + assert.equal(hits[0].trend.title, "Trend B"); // 2026-06-01 newer than 2026-05-01 + assert.equal(hits[1].trend.title, "Trend A"); + }); + }); + + describe("history", () => { + const seed = (): TrendStore => { + let store = emptyStore(); + for (const [title, capturedAt] of [ + ["Oldest", "2026-04-01"], + ["Middle", "2026-05-01"], + ["Newest", "2026-06-01"], + ] as const) { + store = addTrend(store, { + title, + url: `https://example.com/${title}`, + source: "tavily", + capturedAt, + topics: ["x"], + }).store; + } + return store; + }; + + test("returns all trends newest-first", () => { + const h = history(seed()); + assert.deepEqual(h.map((t) => t.title), ["Newest", "Middle", "Oldest"]); + }); + + test("respects an inclusive `since` filter", () => { + const h = history(seed(), { since: "2026-05-01" }); + assert.deepEqual(h.map((t) => t.title), ["Newest", "Middle"]); + }); + + test("respects `limit`", () => { + const h = history(seed(), { limit: 1 }); + assert.deepEqual(h.map((t) => t.title), ["Newest"]); + }); + }); + + describe("newestCaptureDate (B-S3 staleness signal)", () => { + test("empty store → null (no captures yet → no nudge)", () => { + assert.equal(newestCaptureDate(emptyStore()), null); + }); + + test("single trend → its capturedAt", () => { + const store = addTrend(emptyStore(), { + title: "Solo", + url: "https://example.com/solo", + source: "tavily", + capturedAt: "2026-06-05", + topics: ["x"], + }).store; + assert.equal(newestCaptureDate(store), "2026-06-05"); + }); + + test("multiple trends → the max (newest) capturedAt, regardless of insertion order", () => { + let store = emptyStore(); + for (const [title, capturedAt] of [ + ["Middle", "2026-05-01"], + ["Newest", "2026-06-01"], + ["Oldest", "2026-04-01"], + ] as const) { + store = addTrend(store, { + title, + url: `https://example.com/${title}`, + source: "tavily", + capturedAt, + topics: ["x"], + }).store; + } + assert.equal(newestCaptureDate(store), "2026-06-01"); + }); + }); + + describe("schema migration (RE-R2a / publishedAt v1→v2)", () => { + const withFixture = (contents: string, fn: (path: string) => void) => { + const dir = tmp(); + const path = join(dir, "trends.json"); + try { + writeFileSync(path, contents, "utf8"); + fn(path); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + + // ── genuinely RED: old loadStore returns the on-disk/garbage version unchanged ── + test("RED: a v1 store loads stamped as v2, records intact, no publishedAt invented", () => { + const v1 = JSON.stringify({ + schemaVersion: 1, + trends: [ + { + id: "abc123", + title: "Old trend", + url: "https://example.com/o", + source: "tavily", + capturedAt: "2026-05-01", + topics: ["ai"], + }, + ], + }); + withFixture(v1, (path) => { + const s = loadStore(path); + assert.equal(s.schemaVersion, SCHEMA_VERSION, "v1 store must migrate to the current version"); + assert.equal(s.trends.length, 1); + assert.equal(s.trends[0].title, "Old trend"); + assert.equal(s.trends[0].capturedAt, "2026-05-01"); + assert.deepEqual(s.trends[0].topics, ["ai"]); + assert.equal("publishedAt" in s.trends[0], false, "migration must not invent publishedAt"); + }); + }); + + test("RED: round-trip loadStore→saveStore writes the current schemaVersion to disk", () => { + withFixture(JSON.stringify({ schemaVersion: 1, trends: [] }), (path) => { + saveStore(path, loadStore(path)); + const onDisk = JSON.parse(readFileSync(path, "utf8")); + assert.equal(onDisk.schemaVersion, SCHEMA_VERSION); + }); + }); + + test("RED: a non-numeric schemaVersion is coerced to the current version (old code passes the string through)", () => { + withFixture(JSON.stringify({ schemaVersion: "weird", trends: [] }), (path) => { + assert.equal(loadStore(path).schemaVersion, SCHEMA_VERSION); + }); + }); + + // ── GREEN-only regression guards: old code already returns the current version ── + test("regression guard: a v2 store loads idempotent (records + publishedAt intact)", () => { + const v2 = JSON.stringify({ + schemaVersion: 2, + trends: [ + { + id: "x", + title: "T", + url: "https://example.com/t", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["ai"], + publishedAt: "2026-05-30", + }, + ], + }); + withFixture(v2, (path) => { + const s = loadStore(path); + assert.equal(s.schemaVersion, SCHEMA_VERSION); + assert.equal(s.trends[0].publishedAt, "2026-05-30"); + }); + }); + + test("regression guard: a store with no schemaVersion is stamped to the current version", () => { + const noVer = JSON.stringify({ + trends: [ + { + id: "x", + title: "T", + url: "https://example.com/t", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["ai"], + }, + ], + }); + withFixture(noVer, (path) => { + assert.equal(loadStore(path).schemaVersion, SCHEMA_VERSION); + }); + }); + }); + + describe("schema migration (RE-R3a / score v2→v3)", () => { + const withFixture = (contents: string, fn: (path: string) => void) => { + const dir = tmp(); + const path = join(dir, "trends.json"); + try { + writeFileSync(path, contents, "utf8"); + fn(path); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + + // RE-R3a v2→v3 block, reconciled to the v4 bump (RE-R3b): the stamped-version assertions track + // SCHEMA_VERSION (a v2 store now migrates to the current version), the v2 INPUT fixture stays literal. + test("a v2 store (no score) loads stamped as the current version, records intact, no score invented", () => { + const v2 = JSON.stringify({ + schemaVersion: 2, + trends: [ + { + id: "abc123", + title: "Old dated trend", + url: "https://example.com/o", + source: "tavily", + capturedAt: "2026-05-01", + topics: ["ai"], + publishedAt: "2026-04-30", + }, + ], + }); + withFixture(v2, (path) => { + const s = loadStore(path); + assert.equal(s.schemaVersion, SCHEMA_VERSION, "v2 store must migrate to the current version"); + assert.equal(s.trends.length, 1); + assert.equal(s.trends[0].title, "Old dated trend"); + assert.equal(s.trends[0].capturedAt, "2026-05-01"); + assert.equal(s.trends[0].publishedAt, "2026-04-30"); + assert.deepEqual(s.trends[0].topics, ["ai"]); + assert.equal("score" in s.trends[0], false, "migration must not invent a score"); + }); + }); + + test("round-trip loadStore→saveStore writes the current schemaVersion to disk", () => { + withFixture(JSON.stringify({ schemaVersion: 2, trends: [] }), (path) => { + saveStore(path, loadStore(path)); + const onDisk = JSON.parse(readFileSync(path, "utf8")); + assert.equal(onDisk.schemaVersion, SCHEMA_VERSION); + }); + }); + + test("a v3 store with score migrates to the current version, score preserved", () => { + const v3 = JSON.stringify({ + schemaVersion: 3, + trends: [ + { + id: "x", + title: "Scored", + url: "https://example.com/s", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["ai"], + score: { + mode: "kortform", + dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }, + composite: 8.1, + priority: "Immediate", + }, + }, + ], + }); + withFixture(v3, (path) => { + const s = loadStore(path); + assert.equal(s.schemaVersion, SCHEMA_VERSION); + assert.deepEqual(s.trends[0].score, { + mode: "kortform", + dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }, + composite: 8.1, + priority: "Immediate", + }); + }); + }); + + test("RED: a v3 store's score survives load → save → load (field preservation)", () => { + const score = { + mode: "kortform", + dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }, + composite: 8.1, + priority: "Immediate", + }; + const v3 = JSON.stringify({ + schemaVersion: 3, + trends: [ + { + id: "x", + title: "Survivor", + url: "https://example.com/sv", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["ai"], + score, + }, + ], + }); + withFixture(v3, (path) => { + const first = loadStore(path); + saveStore(path, first); + const second = loadStore(path); + assert.deepEqual(second.trends[0].score, score, "score must survive a load+resave (no field stripping)"); + }); + }); + }); + + // ── RE-R3b: re-score on re-capture (last-wins; A2) ── + describe("addTrend — re-score on re-capture (RE-R3b)", () => { + const mkScore = (composite: number, priority: string, timing = 5) => ({ + mode: "kortform", + dimensions: { pillar: 5, audience: 5, timing, angle: 5, authority: 5 }, + composite, + priority, + }); + const seed = (score?: unknown) => ({ + title: "Re-scored trend", + url: "https://example.com/rs", + source: "tavily", + capturedAt: "2026-06-01", + topics: ["ai"], + ...(score !== undefined ? { score } : {}), + }); + + test("RED: a duplicate with a DIFFERENT score replaces the stored score (last-wins), merged:true", () => { + let store = emptyStore(); + store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store; + const res = addTrend(store, { + ...seed(mkScore(4.0, "Medium", 3)), + capturedAt: "2026-06-08", + topics: ["ai", "rag"], + }); + assert.equal(res.added, false); + assert.equal(res.merged, true, "a changed score (or new topics) → merged:true"); + assert.deepEqual(res.store.trends[0].score, mkScore(4.0, "Medium", 3), "score must be the fresh one"); + assert.equal(res.store.trends[0].source, "tavily", "provenance source unchanged"); + assert.equal(res.store.trends[0].capturedAt, "2026-06-01", "provenance capturedAt unchanged (first-sight)"); + assert.deepEqual([...res.store.trends[0].topics].sort(), ["ai", "rag"], "topics still unioned"); + }); + + test("RED: a re-capture with a BYTE-IDENTICAL score and no new topics → merged:false (no false-merge)", () => { + let store = emptyStore(); + store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store; + const res = addTrend(store, { ...seed(mkScore(8.1, "Immediate", 9)), capturedAt: "2026-06-09" }); + assert.equal(res.added, false); + assert.equal(res.merged, false, "identical score + same topics → not a merge"); + assert.deepEqual(res.store.trends[0].score, mkScore(8.1, "Immediate", 9)); + }); + + test("RED: a duplicate with NO score leaves the stored score unchanged", () => { + let store = emptyStore(); + store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store; + const res = addTrend(store, { ...seed(), capturedAt: "2026-06-10" }); + assert.equal(res.added, false); + assert.deepEqual(res.store.trends[0].score, mkScore(8.1, "Immediate", 9), "no input score → keep the stored one"); + }); + + test("RED: re-scoring an ACTED trend updates the score but never resets status/surfacedCount", () => { + let store = emptyStore(); + store = addTrend(store, seed(mkScore(8.1, "Immediate", 9))).store; + // Simulate a handled, surfaced record (status/surfacedCount are set by act/markSurfaced, not addTrend). + (store.trends[0] as Record<string, unknown>).status = "acted"; + (store.trends[0] as Record<string, unknown>).surfacedCount = 3; + const res = addTrend(store, { ...seed(mkScore(4.0, "Medium", 3)), capturedAt: "2026-06-11" }); + assert.deepEqual(res.store.trends[0].score, mkScore(4.0, "Medium", 3), "score refreshed"); + assert.equal((res.store.trends[0] as Record<string, unknown>).status, "acted", "status must NOT reset on re-score"); + assert.equal((res.store.trends[0] as Record<string, unknown>).surfacedCount, 3, "surfacedCount must NOT change on re-score"); + }); + }); + + // ── RE-R3b: schema migration v3→v4 (additive-optional lifecycle fields) ── + describe("schema migration (RE-R3b / lifecycle v3→v4)", () => { + const withFixture = (contents: string, fn: (path: string) => void) => { + const dir = tmp(); + const path = join(dir, "trends.json"); + try { + writeFileSync(path, contents, "utf8"); + fn(path); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const scored = { + mode: "kortform", + dimensions: { pillar: 9, audience: 8, timing: 9, angle: 7, authority: 6 }, + composite: 8.1, + priority: "Immediate", + }; + + // ── genuinely RED while SCHEMA_VERSION=3: loadStore(v3).schemaVersion===3 ≠ 4 (hard-4 device) ── + test("RED: a v3 store (no lifecycle fields) loads stamped as v4, records intact, no field invented", () => { + const v3 = JSON.stringify({ + schemaVersion: 3, + trends: [ + { id: "x", title: "Scored", url: "https://example.com/s", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], score: scored }, + ], + }); + withFixture(v3, (path) => { + const s = loadStore(path); + assert.equal(s.schemaVersion, SCHEMA_VERSION, "v3 store must migrate to the current version"); + assert.equal(s.trends.length, 1); + assert.deepEqual(s.trends[0].score, scored, "score intact"); + assert.equal("status" in s.trends[0], false, "migration must not invent a status"); + assert.equal("surfacedCount" in s.trends[0], false, "migration must not invent a surfacedCount"); + assert.equal("lastSurfacedAt" in s.trends[0], false, "migration must not invent a lastSurfacedAt"); + }); + }); + + test("RED: round-trip loadStore→saveStore writes schemaVersion:4 to disk", () => { + withFixture(JSON.stringify({ schemaVersion: 3, trends: [] }), (path) => { + saveStore(path, loadStore(path)); + const onDisk = JSON.parse(readFileSync(path, "utf8")); + assert.equal(onDisk.schemaVersion, SCHEMA_VERSION); + }); + }); + + test("a v4 store with lifecycle fields loads idempotent", () => { + const v4 = JSON.stringify({ + schemaVersion: SCHEMA_VERSION, + trends: [ + { id: "y", title: "Handled", url: "https://example.com/h", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], status: "acted", surfacedCount: 3, lastSurfacedAt: "2026-06-25" }, + ], + }); + withFixture(v4, (path) => { + const s = loadStore(path); + assert.equal(s.schemaVersion, SCHEMA_VERSION); + assert.equal(s.trends[0].status, "acted"); + assert.equal(s.trends[0].surfacedCount, 3); + assert.equal(s.trends[0].lastSurfacedAt, "2026-06-25"); + }); + }); + + test("a v4 store's lifecycle fields survive load → save → load (field preservation)", () => { + const v4 = JSON.stringify({ + schemaVersion: 4, + trends: [ + { id: "z", title: "Persist", url: "https://example.com/p", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"], status: "skipped", surfacedCount: 2, lastSurfacedAt: "2026-06-24" }, + ], + }); + withFixture(v4, (path) => { + const first = loadStore(path); + saveStore(path, first); + const second = loadStore(path); + assert.equal(second.trends[0].status, "skipped"); + assert.equal(second.trends[0].surfacedCount, 2); + assert.equal(second.trends[0].lastSurfacedAt, "2026-06-24"); + }); + }); + }); + + // ── RE-R3b: lifecycle functions (Phase B — RED against the stubs) ── + describe("lifecycle functions: effectiveStatus / setStatus / markSurfaced (RE-R3b)", () => { + const seedStore = () => + addTrend( + addTrend(emptyStore(), { title: "A", url: "https://e/a", source: "tavily", capturedAt: "2026-06-01", topics: ["ai"] }).store, + { title: "B", url: "https://e/b", source: "tavily", capturedAt: "2026-06-02", topics: ["gov"] }, + ).store; + + test("RED: effectiveStatus is the stored status, or 'new' when absent", () => { + assert.equal(effectiveStatus({ status: "acted" } as TrendRecord), "acted"); + assert.equal(effectiveStatus({} as TrendRecord), "new"); + }); + + test("RED: setStatus sets a present record's status (found:true); an absent id → found:false", () => { + const store = seedStore(); + const idA = store.trends[0].id; + const res = setStatus(store, idA, "acted"); + assert.equal(res.found, true); + assert.equal(store.trends[0].status, "acted"); + assert.equal(setStatus(store, "missing-id", "skipped").found, false, "absent id → found:false (no throw)"); + }); + + test("RED: markSurfaced increments + sets lastSurfacedAt; per-day idempotent; later day re-increments", () => { + const store = seedStore(); + const idA = store.trends[0].id; + const r1 = markSurfaced(store, [idA], "2026-06-25"); + assert.equal(r1.marked, 1); + assert.equal(store.trends[0].surfacedCount, 1); + assert.equal(store.trends[0].lastSurfacedAt, "2026-06-25"); + assert.equal("surfacedCount" in store.trends[1], false, "an id not in the set is untouched"); + const r2 = markSurfaced(store, [idA], "2026-06-25"); + assert.equal(r2.marked, 0, "same-day re-mark is idempotent"); + assert.equal(store.trends[0].surfacedCount, 1); + const r3 = markSurfaced(store, [idA], "2026-06-26"); + assert.equal(r3.marked, 1, "a later day increments again"); + assert.equal(store.trends[0].surfacedCount, 2); + assert.equal(store.trends[0].lastSurfacedAt, "2026-06-26"); + }); + }); +}); diff --git a/scripts/trends/tsconfig.json b/scripts/trends/tsconfig.json new file mode 100644 index 0000000..eaa1e8e --- /dev/null +++ b/scripts/trends/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "tests"] +} diff --git a/skills/linkedin-analytics/SKILL.md b/skills/linkedin-analytics/SKILL.md index 85943f5..3574699 100644 --- a/skills/linkedin-analytics/SKILL.md +++ b/skills/linkedin-analytics/SKILL.md @@ -49,18 +49,18 @@ This skill covers everything related to LinkedIn analytics, performance measurem Node.js CLI tool for parsing LinkedIn CSV exports into structured JSON. **Location:** `scripts/analytics/` -**Data:** `assets/analytics/` (gitignored -- personal performance data) +**Data:** `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/` (gitignored -- personal performance data) **CLI usage:** ```bash -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import <file> -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week <W> -ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics" node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period <P> --metric <M> +node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" import <file> +node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" report --week <W> +node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/analytics/src/cli.ts" trends --period <P> --metric <M> ``` **Storage structure:** ``` -assets/analytics/ +${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/ +-- exports/ # Raw CSV from LinkedIn (drop files here) +-- posts/ # Imported post data as JSON +-- weekly-reports/ # Generated weekly reports diff --git a/skills/linkedin-content-creation/SKILL.md b/skills/linkedin-content-creation/SKILL.md index c4e8c61..b49d518 100644 --- a/skills/linkedin-content-creation/SKILL.md +++ b/skills/linkedin-content-creation/SKILL.md @@ -72,9 +72,9 @@ Identify what type of raw material the user has: - "Who's your primary audience for this?" - "What action or reaction do you want from readers?" -### Step 2: Identify Thought Leadership Angles +### Step 2: Identify Content Angles -Read `references/thought-leadership-angles.md` to understand the 8 universal angles for any content. +Read `references/content-angles.md` to understand the 8 universal angles for any content. **For the user's input, identify 2-3 possible angles:** 1. Which angle best fits their content? @@ -106,7 +106,7 @@ Read `references/linkedin-formats.md` for format specifications and performance Read `references/engagement-frameworks.md` for hook types, story structures, and CTA patterns. -**Standard Thought Leadership Post (1,200-1,800 chars):** +**Standard Post (1,200-1,800 chars):** 1. **Hook (110-140 chars):** Grab attention 2. **Context (200-300 chars):** Set up why this matters 3. **Insight/Argument (400-800 chars):** Main point with evidence @@ -189,8 +189,8 @@ The Content Matrix creates 40+ post ideas through systematic combination. - Poll (with context) - Document (PDF) -**Axis 2: The 8 Thought Leadership Angles** -(See `references/thought-leadership-angles.md`) +**Axis 2: The 8 Content Angles** +(See `references/content-angles.md`) - Contrarian Take - Pattern Recognition - Uncomfortable Truth @@ -232,7 +232,7 @@ The Content Matrix creates 40+ post ideas through systematic combination. For full video scripting workflows, use `/linkedin:video` which supports talking head, screen recording, and slideshow formats in 30s/60s/90s/2min lengths with pacing, visual cues, and captions. **Quick reference (30-90 seconds optimal):** -- First 3 seconds: Hook (8 words max — determines 70% of retention) +- First 3 seconds: Hook (8 words max — disproportionately determines retention) - Middle: Core message (2-3 key points max) - Last 10 seconds: CTA @@ -287,11 +287,11 @@ For full scoring system, see `assets/checklists/quality-scorecard.md`. | File | When to Read | |------|--------------| -| `references/thought-leadership-angles.md` | Choosing post angle | +| `references/content-angles.md` | Choosing post angle | | `references/engagement-frameworks.md` | Writing hooks, CTAs | | `references/linkedin-formats.md` | Choosing format | | `references/url-processing-templates.md` | Converting external content | -| `references/ai-content-framework.md` | AI-specific angles | +| `references/content-framework.md` | Domain content pillars + angles | | `references/articles-strategy-guide.md` | Long-form content | | `references/newsletter-strategy-guide.md` | Newsletter content | | `references/poll-strategy-guide.md` | Poll question types and engagement patterns | diff --git a/skills/linkedin-strategy/SKILL.md b/skills/linkedin-strategy/SKILL.md index b74aefe..dbf0111 100644 --- a/skills/linkedin-strategy/SKILL.md +++ b/skills/linkedin-strategy/SKILL.md @@ -173,7 +173,7 @@ The plugin tracks progress toward a follower target (default 10,000) with monthl | Foundation | 0-1K | Consistency, profile-content alignment | 50-100/month | | Validation | 1K-3K | Topical consistency, first-hour engagement | 100-200/month | | Acceleration | 3K-6K | Format diversification, collaborations | 200-400/month | -| Authority | 6K-10K | Thought leadership, cross-platform | 300-500/month | +| Authority | 6K-10K | Signature content, cross-platform | 300-500/month | | Scale | 10K+ | Monetization, delegation, leverage | 500+/month | ### Growth Rate Benchmarks diff --git a/skills/linkedin-studio/SKILL.md b/skills/linkedin-studio/SKILL.md index 44056c9..efec389 100644 --- a/skills/linkedin-studio/SKILL.md +++ b/skills/linkedin-studio/SKILL.md @@ -10,7 +10,7 @@ description: | **To customize this skill for your voice and goals:** -1. Copy `config/user-profile.template.md` to `config/user-profile.local.md` +1. Copy `config/user-profile.template.md` to `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/user-profile.md` 2. Fill in your profile, voice preferences, and goals 3. The skill will use your settings when generating content @@ -77,7 +77,7 @@ These rules apply to ALL content created by any skill or command: 3. **No external links** in post body (correlate with lower reach — see `references/algorithm-signals-reference.md`) 4. **No corporate buzzwords:** leverage, synergy, paradigm shift, thought leader, disruptive, value proposition, ecosystem, holistic approach 5. **Topic alignment:** Must align with user's 5 core expertise areas (topic-relevance signal) -6. **Voice:** Always read `assets/voice-samples/` before generating content +6. **Voice:** Always read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` before generating content 7. **Quality scorecard:** See `assets/checklists/quality-scorecard.md` --- diff --git a/skills/linkedin-voice/SKILL.md b/skills/linkedin-voice/SKILL.md index c69e95a..467eba5 100644 --- a/skills/linkedin-voice/SKILL.md +++ b/skills/linkedin-voice/SKILL.md @@ -138,12 +138,12 @@ Over time, content can drift from your authentic voice -- especially when using **Prevention:** - Quarterly voice audits (use voice-trainer agent) - Read posts aloud before publishing -- Maintain voice samples in `assets/voice-samples/` +- Maintain voice samples in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` - Compare drafts against your voice profile ### Voice Samples -**Rule:** Always read `assets/voice-samples/` before generating content. This directory contains reference posts that represent the user's authentic voice. +**Rule:** Always read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` before generating content. This directory contains reference posts that represent the user's authentic voice. --- @@ -199,5 +199,5 @@ The differentiation-checker agent evaluates content across five dimensions: |------|--------------| | `references/algorithm-signals-reference.md` | Profile optimization, topic-relevance | | `references/linkedin-visual-style.md` | Visual identity consistency | -| `assets/voice-samples/` | Voice reference (always read before content creation) | +| `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/voice-samples/` | Voice reference (always read before content creation) | | `config/user-profile.template.md` | User personalization setup | diff --git a/agents/__tests__/content-reviewer-fixture.test.mjs b/tests/content-reviewer-fixture.test.mjs similarity index 97% rename from agents/__tests__/content-reviewer-fixture.test.mjs rename to tests/content-reviewer-fixture.test.mjs index 5cadc85..5439c8f 100644 --- a/agents/__tests__/content-reviewer-fixture.test.mjs +++ b/tests/content-reviewer-fixture.test.mjs @@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'; // [GATE]/[OPERATØR], never self-certified here. const FIXTURE_PATH = fileURLToPath( - new URL('../fixtures/content-reviewer-cases.md', import.meta.url) + new URL('./fixtures/content-reviewer-cases.md', import.meta.url) ); const fixture = readFileSync(FIXTURE_PATH, 'utf8'); diff --git a/agents/__tests__/editorial-reviewer-fixture.test.mjs b/tests/editorial-reviewer-fixture.test.mjs similarity index 97% rename from agents/__tests__/editorial-reviewer-fixture.test.mjs rename to tests/editorial-reviewer-fixture.test.mjs index ba39674..4c73ed9 100644 --- a/agents/__tests__/editorial-reviewer-fixture.test.mjs +++ b/tests/editorial-reviewer-fixture.test.mjs @@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'; // never self-certified here. const FIXTURE_PATH = fileURLToPath( - new URL('../fixtures/editorial-reviewer-cases.md', import.meta.url) + new URL('./fixtures/editorial-reviewer-cases.md', import.meta.url) ); const fixture = readFileSync(FIXTURE_PATH, 'utf8'); diff --git a/agents/__tests__/fact-checker-fixture.test.mjs b/tests/fact-checker-fixture.test.mjs similarity index 97% rename from agents/__tests__/fact-checker-fixture.test.mjs rename to tests/fact-checker-fixture.test.mjs index fdb1841..ddab72d 100644 --- a/agents/__tests__/fact-checker-fixture.test.mjs +++ b/tests/fact-checker-fixture.test.mjs @@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url'; // self-certified here. const FIXTURE_PATH = fileURLToPath( - new URL('../fixtures/fact-checker-cases.md', import.meta.url) + new URL('./fixtures/fact-checker-cases.md', import.meta.url) ); const VERDICTS = ['🟢', '🔴', '🟡']; diff --git a/agents/__tests__/fact-reviewer-fixture.test.mjs b/tests/fact-reviewer-fixture.test.mjs similarity index 97% rename from agents/__tests__/fact-reviewer-fixture.test.mjs rename to tests/fact-reviewer-fixture.test.mjs index 83764a6..52d1bcc 100644 --- a/agents/__tests__/fact-reviewer-fixture.test.mjs +++ b/tests/fact-reviewer-fixture.test.mjs @@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'; // [GATE]/[OPERATØR], never self-certified here. const FIXTURE_PATH = fileURLToPath( - new URL('../fixtures/fact-reviewer-cases.md', import.meta.url) + new URL('./fixtures/fact-reviewer-cases.md', import.meta.url) ); const fixture = readFileSync(FIXTURE_PATH, 'utf8'); diff --git a/agents/fixtures/content-reviewer-cases.md b/tests/fixtures/content-reviewer-cases.md similarity index 100% rename from agents/fixtures/content-reviewer-cases.md rename to tests/fixtures/content-reviewer-cases.md diff --git a/agents/fixtures/editorial-reviewer-cases.md b/tests/fixtures/editorial-reviewer-cases.md similarity index 100% rename from agents/fixtures/editorial-reviewer-cases.md rename to tests/fixtures/editorial-reviewer-cases.md diff --git a/agents/fixtures/fact-checker-cases.md b/tests/fixtures/fact-checker-cases.md similarity index 100% rename from agents/fixtures/fact-checker-cases.md rename to tests/fixtures/fact-checker-cases.md diff --git a/agents/fixtures/fact-reviewer-cases.md b/tests/fixtures/fact-reviewer-cases.md similarity index 100% rename from agents/fixtures/fact-reviewer-cases.md rename to tests/fixtures/fact-reviewer-cases.md diff --git a/agents/fixtures/language-reviewer-cases.md b/tests/fixtures/language-reviewer-cases.md similarity index 100% rename from agents/fixtures/language-reviewer-cases.md rename to tests/fixtures/language-reviewer-cases.md diff --git a/agents/fixtures/persona-reviewer-cases.md b/tests/fixtures/persona-reviewer-cases.md similarity index 100% rename from agents/fixtures/persona-reviewer-cases.md rename to tests/fixtures/persona-reviewer-cases.md diff --git a/agents/__tests__/language-reviewer-fixture.test.mjs b/tests/language-reviewer-fixture.test.mjs similarity index 97% rename from agents/__tests__/language-reviewer-fixture.test.mjs rename to tests/language-reviewer-fixture.test.mjs index 8c76f93..209369c 100644 --- a/agents/__tests__/language-reviewer-fixture.test.mjs +++ b/tests/language-reviewer-fixture.test.mjs @@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'; // the fasit directions is [GATE]/[OPERATØR], never self-certified here. const FIXTURE_PATH = fileURLToPath( - new URL('../fixtures/language-reviewer-cases.md', import.meta.url) + new URL('./fixtures/language-reviewer-cases.md', import.meta.url) ); const fixture = readFileSync(FIXTURE_PATH, 'utf8'); diff --git a/agents/__tests__/persona-reviewer-fixture.test.mjs b/tests/persona-reviewer-fixture.test.mjs similarity index 97% rename from agents/__tests__/persona-reviewer-fixture.test.mjs rename to tests/persona-reviewer-fixture.test.mjs index 1dedf0c..e5e55de 100644 --- a/agents/__tests__/persona-reviewer-fixture.test.mjs +++ b/tests/persona-reviewer-fixture.test.mjs @@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'; // never self-certified here. const FIXTURE_PATH = fileURLToPath( - new URL('../fixtures/persona-reviewer-cases.md', import.meta.url) + new URL('./fixtures/persona-reviewer-cases.md', import.meta.url) ); const fixture = readFileSync(FIXTURE_PATH, 'utf8');