feat(linkedin-studio): S16 — optional manual saves in analytics + close deferred onboarding Write MAJOR

Lifts the original v4.0.0 Non-Goal: an optional, manually-entered `saves`
metric through the analytics layer, built location-agnostic (option c) so
UI-brief §9b/M0 relocates the data dir in one place later.

- types: PostMetrics.saves? + Weekly/Monthly summary.totalSaves? (optional);
  new RankableMetric type for the always-numeric index-access whitelist
- parser: dedicated parseOptionalCount() — blank/non-numeric/negative -> undefined
  ("unknown != 0"), genuine 0 kept; saves NOT folded into engagementRate
- reports: totalSaves set only when >=1 post carries saves (backward-compat)
- cli: saves surfaced in import summary + weekly/monthly totals + per-post
- S16-pre: onboarding.md allowed-tools gains Write (closes S15-deferred MAJOR)
- docs (three-doc rule): plugin README boundary + analytics README + root README
  + plugin CLAUDE.md + CHANGELOG; dwell stays explicitly unmeasurable

Independent /trekreview: brief-conformance 0 findings; code-correctness 2 MAJOR
(own lockstep misses) FIXED in-session (parseOptionalCount + edge tests). Gate:
tsc clean, analytics 116/116, lint 74/0/0, hooks 98/98. Within-v4.1.0 refinement
(no surface/count/version change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-05-30 22:23:12 +02:00
commit 55c94ee964
18 changed files with 417 additions and 118 deletions

View file

@ -120,7 +120,11 @@ Build authentic LinkedIn authority through algorithmic understanding, strategic
- **Long-form newsletter pipeline** — a multi-phase orchestrator (research → skeleton → prose → de-AI/voice scrub → fact-check → editorial and persona gates → visual assets → lock → delivery) with maintained edition state
- **Adversarial review** — cold headless content, language, and fact reviewers re-check a frozen draft with no drafting-session context; `/linkedin:pivot` re-opens cleared gates on major rewrites
- **Content engine** — Content Matrix (40+ ideas from one topic), voice training with drift detection, full ideation → publish → 48-hour monitoring → analytics
- **Content engine** — Content Matrix (40+ ideas from one topic), voice training with drift detection, full ideation → publish → 48-hour monitoring
- **Honest analytics** — CSV-import pipeline with weekly/monthly reports and anomaly alerts. Adds an
**optional, manually-entered `saves`** count (add a `Saves` column with the number read off native
LinkedIn analytics; never auto-tracked, `unknown` ≠ 0, excluded from the engagement rate). `dwell`
time stays **explicitly unmeasurable** — no fabricated metric or surface
- **Growth and monetization** — phase-specific guidance from 0 to 10K+ followers; topic-relevance profile optimization aligned to LinkedIn's 2026 ranking model
Key commands: `/linkedin:create` + `/linkedin:measure` (journey front-doors), `/linkedin:onboarding`, `/linkedin:post`, `/linkedin:newsletter`, `/linkedin:report`

View file

@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Compatibility
- **Minor / additive.** No command removed, renamed, or behavior-changed; the 27 existing commands, all 19 agents, and all state shapes are unchanged. The two new commands (`create`, `measure`) register when the plugin command set is rebuilt at session start — **reload required** to see them.
### Added — within 4.1.0 (refinement sessions, no surface/count/version change)
- **Manual per-post saves in analytics (Voyage S16).** Lifts the original v4.0.0 Non-Goal: `PostMetrics` gains an **optional** `saves` field, ingested when the user adds a `Saves` column to the CSV with the count read off native LinkedIn post analytics (count-only, ~Sept 2025; absent from the export, no self-serve API). The parser (`scripts/analytics/src/parsers/csv-parser.ts`) reads it when present; weekly/monthly summaries gain an optional `totalSaves`; the CLI (`import`/`report`) surfaces saves per-post and as a total. **Backward-compatible** — a missing column or blank cell leaves saves *unknown* (never coerced to 0), saves is **not** folded into `engagementRate` (which stays comparable to older imports), and saves-free data round-trips byte-identical. **Dwell stays explicitly unmeasurable** — no dwell field or surface was added. This refines the v4.0.0 "the plugin cannot read those signals" wording: the plugin still cannot *auto-track* saves, but it now ingests a *manually-entered* count. Built location-agnostically through the existing `getAnalyticsRoot()` seam so the planned data-dir migration (UI brief §9b/M0) relocates it in one place. New `RankableMetric` type fixes the trend/alert index access that the optional field would otherwise widen to `number | undefined`.
- **Onboarding tool-grant fix (S16-pre).** `commands/onboarding.md` Phase 2 saves voice/user-profile files but its frontmatter omitted `Write`; added `Write` to `allowed-tools` (matching `first-post.md`). Closes a pre-existing tool-contract gap surfaced by the S15 review.
## [4.0.0] - 2026-05-30
### Summary

View file

@ -11,6 +11,17 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide
- **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.
## Hooks

View file

@ -437,9 +437,13 @@ profile**. As of 2026-05:
Community Management API app + a verified organization + a Page). It is **not
self-serve** for a solo personal profile, so the practical floor is the **CSV
export** you drop into `/linkedin:import`. Per-post **saves** are visible in your
**native** LinkedIn post analytics (count-only, since ~Sept 2025), but there is
no self-serve API to pull them — this tool does not auto-track them; read them in
LinkedIn directly.
**native** LinkedIn post analytics (count-only, since ~Sept 2025) but are absent
from the CSV export, and there is no self-serve API to pull them. The tool does
**not** auto-track saves — but you can record them **manually**: add a `Saves`
column to the CSV with the counts you read off LinkedIn, and `/linkedin:import`
ingests them and surfaces them in the weekly/monthly reports (omit the column and
saves simply stays unknown — never counted as 0, never folded into engagement
rate).
- **Auto-publish** — technically **possible** self-serve via the `w_member_social`
scope, so this is **not** an API limitation. LinkedIn Studio **deliberately does
not** post for you: the OAuth/token overhead plus LinkedIn's terms on automated

View file

@ -9,6 +9,25 @@ This directory contains imported analytics data from LinkedIn CSV exports.
3. Save the CSV file to `exports/` directory
4. Run `/linkedin:import` in Claude Code
### Optional: add per-post saves (manual)
LinkedIn's CSV export does **not** include saves, and there is no self-serve API
to pull them — but the per-post save **count** is visible in your native post
analytics (since ~Sept 2025). To track it, add a `Saves` column to the CSV and
type the count you read off LinkedIn. The importer picks it up automatically when
the column is present:
```
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Saves"
"My post...",2026-02-10,5000,100,30,15,200,42
```
A missing column — or a blank `Saves` cell — leaves saves **unknown** (never
counted as 0), and saves is **not** folded into the engagement rate (which stays
comparable to older imports). Saves is the strongest organic engagement signal,
so the reports surface it as its own line. **Dwell time stays unmeasurable**
it is internal to LinkedIn for organic posts, with no count to transcribe.
## Directory Structure
```
@ -50,6 +69,10 @@ Each file contains a batch of imported posts:
}
```
`metrics.saves` is **optional** — present only on posts where you supplied a
`Saves` column value (see "Optional: add per-post saves" above). Posts without
it omit the field entirely, so older imports round-trip unchanged.
### Weekly Reports (weekly-reports/*.json)
Generated via `/linkedin:report`. Contains:

View file

@ -8,6 +8,7 @@ description: |
"just installed", "how do I start", "walk me through", "linkedin onboarding".
allowed-tools:
- Read
- Write
- Bash
- AskUserQuestion
---

View file

@ -1,13 +1,13 @@
---
type: trekreview
review_version: "1.0"
task: "S15 — UX finish §6c on the final 29-command set: B1 onboarding inline-draft + B3 carousel full-deck clipboard (B2 router-tiering already delivered in S14)"
task: "S16 — saves manual-entry surface (lifts the original Non-Goal): optional saves in the analytics data model (types + parser + weekly/monthly + CLI), built location-agnostic for M0 (option c); + S16-pre: close the deferred onboarding Write MAJOR"
slug: remediation
project_dir: docs/remediation/
brief_path: docs/remediation/brief.md
scope_sha_start: baca30f
scope_sha_end: baca30f
reviewed_files_count: 2
scope_sha_start: 8c52bdb
scope_sha_end: 8c52bdb
reviewed_files_count: 15
verdict: ALLOW
mode: default
effort: standard
@ -15,129 +15,140 @@ profile: premium
findings: []
---
# Review — linkedin-studio S15 (UX finish §6c: B1 onboarding inline + B3 carousel full-deck)
# Review — linkedin-studio S16 (saves manual-entry + S16-pre onboarding Write)
## Executive Summary
**Verdict: ALLOW** for S15's delivered scope — 0 BLOCKER, 0 MAJOR, 0 MINOR, 0 SUGGESTION
**attributable to the S15 diff**. Two independent reviewers (brief-conformance,
code-correctness) ran COLD, without cross-feeding, on the as-delivered uncommitted
working tree (HEAD `baca30f` + the S15 delta = two command files).
**Verdict: ALLOW** for S16's delivered scope — 0 BLOCKER, 0 MAJOR, 0 MINOR, 0 SUGGESTION
**open** against the diff. Two independent reviewers (brief-conformance, code-correctness)
ran COLD, without cross-feeding, on the as-delivered uncommitted working tree (HEAD
`8c52bdb` + the S16 delta).
- **brief-conformance-reviewer:** 0 findings. B1 and B3 both trace to delivered code; the
binding §12b scope-guard is honored (no extensibility / provider-seam / progressive-
disclosure / detect-and-offer language entered the diff); no version/count drift
(19/29/25/6 intact, v4.1.0 untouched); no files outside the two in scope.
- **code-correctness-reviewer:** 1 MAJOR — but **pre-existing and outside S15's scope**
(see Findings). The S15 changes themselves are correct: branches all terminate, the
state-updater + clipboard snippets mirror the established `first-post.md` pattern
exactly, `${CLAUDE_PLUGIN_ROOT}` is the established path var, and the larger carousel
clipboard payload introduces no new failure mode over the uniform plugin pattern.
- **brief-conformance-reviewer:** **0 findings.** Every Success Criterion traces to delivered
code (SC1 saves in types + parser + import/report; SC2 saves in the actionable-signal output;
SC3 dwell still explicitly unmeasurable, no fabricated field; SC4 backward-compat + no-crash;
SC5 onboarding `Write`). Every Non-Goal honored: **M0 not built** (I/O still routes through the
unmodified `getAnalyticsRoot()` seam — option (c)), dwell not fabricated, surface counts
(29/19/6/9) and version (v4.1.0) unchanged, no not-mine file touched.
- **code-correctness-reviewer:** **2 MAJOR — both in S16's own new code, both FIXED in-session**
(see Findings). Neither is pre-existing; both are lockstep misses in the saves parser delivered
this session, so per the operator rule ("in-session fix of the session's *own* misses =
completion") they were corrected here, not deferred.
S15 = **B1 + B3 only**. B2 (router tiering) was already delivered in S14 (the finish-plan
S15 body still lists B2, but the S14 amendment + the session-state label supersede it);
its absence here is correct, not a gap.
The M0 conflict (UI-brief §9b) was reconciled **before** building, by operator decision =
**option (c): build now, location-agnostic**. The conformance reviewer independently confirmed
the build honors it (no new hardcoded `assets/analytics` path; `storage.ts` untouched).
## Coverage
Scope: HEAD `baca30f` (S14's commit) + the **uncommitted S15 working-tree delta**
(annotated `[uncommitted]` — a brief-level contract; the brief's Assumptions allow
uncommitted review). 2 files = the operator's own changes. The 3 untracked not-mine files
(`docs/linkedin-studio-persona-brief.md`, `…-ui-brief.md`, `docs/voyage-build/progress.json`)
are explicitly excluded from scope and from the commit. **No silent skips.**
Scope: HEAD `8c52bdb` (S15's commit) + the **uncommitted S16 working-tree delta** (annotated
`[uncommitted]` — a brief-level contract; the brief's Assumptions allow uncommitted review).
The 3 untracked not-mine files (`docs/linkedin-studio-persona-brief.md`, `…-ui-brief.md`,
`docs/voyage-build/progress.json`) are explicitly excluded from scope and from the commit.
**No silent skips.**
| Treatment | Count | Notes |
|-----------|-------|-------|
| `deep-review` | 0 | neither file is under `hooks/**` / `auth/**` / `crypto/**` / `**/security/**` |
| `summary-only` | 2 | `commands/onboarding.md` (Phase 3 rewrite + 1 Phase-4 summary line), `commands/carousel.md` (Step 6 clipboard) |
| `deep-review` | 0 | nothing under `hooks/**` / `auth/**` / `crypto/**` / `**/security/**` (analytics CLI + one command frontmatter) |
| `summary-only` | 15 | analytics src (5) + analytics tests (3) + 2 fixtures + assets/analytics/README.md + README.md + CHANGELOG.md + commands/onboarding.md |
| `skip` | 0 | no lockfiles / svg / generated / dist |
**B1/B3 acceptance checks (orchestrator-run greps):**
- B1: `grep -E "[Rr]un \`?/linkedin:first-post" commands/onboarding.md` → **0 dead-end strings**;
inline draft steps 3.13.5 present (topic → 3-line draft → QC → present+clipboard → state-update that sets `first_post_date`).
- B3: full-deck assembly block at `carousel.md:191` precedes the `clipboard-helper.mjs` call at `:211`; payload contains slide text; caption-only `<CAROUSEL_CAPTION>` removed.
**Cross-cutting execution criteria:** `scripts/test-runner.sh` → **74 passed / 0 failed /
0 warnings**, exit 0. `node --test hooks/scripts/__tests__/*.test.mjs`**98/98** (no hook
logic changed). `ls commands/*.md`**29** (no surface-count change; no version bump —
consistent with S11S13 precedent: within-scope refinements stay at the current version).
**Execution criteria (orchestrator-run):**
- `npx tsc --noEmit` (analytics) → **clean** (the new `RankableMetric` type closes the
`metrics[keyof]` widening the optional `saves` would otherwise introduce in `alerts.ts` + `cli.ts`).
- `node --import tsx --test tests/*.test.ts` (analytics) → **116/116**.
- `bash scripts/test-runner.sh`**74 passed / 0 failed / 0 warnings**, exit 0.
- `node --test hooks/scripts/__tests__/*.test.mjs`**98/98** (no hook logic changed).
- `ls commands/*.md`**29** (no surface-count change; no version bump — within-v4.1.0 refinement, S11S13 precedent).
- **E2E:** import + weekly + monthly with a saves fixture surfaces saves without crashing;
saves-free fixtures round-trip byte-identical; explicit `"0"``0`, blank/`"n/a"` → undefined.
## Findings
**0 findings attributable to the S15 diff.** One MAJOR was raised by code-correctness; on
inspection it is **pre-existing and out of S15's scope**, and is routed to the next session
per the finish-plan's locked constraint ("fix-in-next-session for any review finding") and
the operator rule "ekte design-funn → neste sesjon" (in-session fix is reserved for the
session's *own* lockstep misses). It is recorded below and propagated to STATE.md — **not
dropped, not silenced.**
**0 open findings.** Code-correctness raised 2 MAJOR; both are **S16's own** new code (not
pre-existing — the saves parser is new this session), so both were **fixed in-session** as
completion of the delivered work, then re-verified green. Recorded below — not dropped, not
silenced.
### [MAJOR — DEFERRED to next session] onboarding Phase 2 file-saves need `Write`, not in `allowed-tools`
### [MAJOR — FIXED in-session] Non-numeric Saves cell silently coerced to 0 (`unknown != 0` contract violated)
*Raised by code-correctness (`PLAN_EXECUTE_DRIFT`), `commands/onboarding.md:142` (and `:157`).*
*Raised by code-correctness (`PLAN_EXECUTE_DRIFT`), `scripts/analytics/src/parsers/csv-parser.ts`.*
`onboarding.md` frontmatter grants `allowed-tools: [Read, Bash, AskUserQuestion]` — no
`Write`. **Phase 2** instructs "Save the responses to `assets/voice-samples/authentic-voice-samples.md`"
(`:142`, with a REPLACE-vs-append rule) and "Save to `config/user-profile.local.md`" (`:157`).
Persisting those profile files is a file write; the plugin's accepted pattern is the `Write`
tool, and the sibling `first-post.md` (same voice-save) declares `Write` (`first-post.md:9-14`).
With only Read/Bash/AskUserQuestion the agent cannot honor the two "Save to …" instructions
(the only scripted Bash writes are the `node -e` state and clipboard snippets). **Real defect.**
The original guard `if (savesRaw && savesRaw.trim() !== "")` then `metrics.saves =
parseMetric(savesRaw)` gated only on emptiness; `parseMetric` ends in `parseFloat(...) || 0`
+ `Math.max(0, …)`, so a non-empty non-numeric cell (`"n/a"`, `"~40"`) or a negative value
flattened to **0** — surfaced as a confident "top engagement signal". This contradicts the
`unknown != 0` contract stated in the same diff (`types.ts` saves NOTE + the parser comment).
**Why DEFERRED, not fixed in S15:**
- **Pre-existing.** The gap exists identically at HEAD `baca30f` *before* the S15 edits —
`git show HEAD:…/onboarding.md` shows `allowed-tools: [Read, Bash, AskUserQuestion]` already.
- **Out of S15's diff scope.** The S15 hunks are `@@ -166` (Phase 3) and `@@ -201` (Phase 4
summary line); lines 142/157 (Phase 2) are untouched. S15's *own* inline steps (3.13.5)
stay within Read/Bash/AskUserQuestion and need no `Write` (3.4 clipboard = Bash, 3.5
state-update = Bash `node -e`).
- **Not introduced or worsened by S15.** The reviewer's "now owns the save flow" framing does
not hold on inspection: Phase 2's saves are reached identically before and after S15, and
the removed `/linkedin:first-post` hand-off was about *post creation* (Phase 3), not Phase 2's
profile saves. S15 added no save-flow that needs `Write`.
- **Operator governance.** Fixing a Phase-2 tool-contract bug inside an S15 (B1+B3) push would
be exactly the "rydd opp i pre-existing som del av en bugfix" / scope-expansion the operator's
rules forbid; the documented default for out-of-scope items is **defer** (record, route to
next session) — not silently fix, not silently drop.
**Fix (this session):** added a dedicated `parseOptionalCount()` helper returning
`number | undefined` — blank / non-numeric / negative → `undefined`; a genuine number
(including explicit `"0"`) → that number; reuses the EU/US separator normalization. The saves
parse now routes through it. The blank-cell path is unchanged; the garbage/negative path now
honors the contract.
**Recommended action (next session):** add `Write` to `onboarding.md`'s `allowed-tools`
(matching `first-post.md:9-14`), or replace the two "Save to …" instructions with a `node -e`
Bash write so the steps stay within the declared Bash grant. Natural home: fold into S16 (which
already opens the onboarding/saves surface) or the S17 triage pass.
### [MAJOR — FIXED in-session] No test for the explicit-`0` vs non-numeric Saves boundary
*Raised by code-correctness (`MISSING_TEST`), `scripts/analytics/tests/csv-parser.test.ts`.*
The original saves tests covered `42`, blank→undefined, no-column→undefined, and
engagement-exclusion — but not the exact boundary where the parser's behavior diverged from
its intent: a literal `"0"` (genuine zero) vs a non-numeric cell (unknown).
**Fix (this session):** added `tests/fixtures/saves-edge-export.csv` (a `"0"` row + an `"n/a"`
row) and two cases pinning `saves === 0` for `"0"` and `saves === undefined` for non-numeric.
The non-numeric case was **red** before the parser fix above and **green** after (TDD), so the
contract boundary is now regression-guarded.
### [MAJOR — CLOSED] (carried from S15) onboarding Phase 2 file-saves need `Write`
*S15's deferred finding.* `commands/onboarding.md` Phase 2 saves voice/user-profile files but
the frontmatter omitted `Write`. **Closed in S16-pre:** `Write` added to `allowed-tools`
(matching `first-post.md:9-14`).
## Remediation Summary
**Gate: ALLOW** for S15's delivered scope (B1 + B3). Both reviewers confirm the two changes
are conformant (brief) and correct (code); the binding §12b scope-guard held; no version/count
drift; the inline first-post flow and the full-deck clipboard both function within the existing
tool grants and established patterns. The single MAJOR is a **pre-existing, out-of-scope**
tool-contract gap in Phase 2 that S15 neither introduced nor worsened; per the operator's
explicit "ekte design-funn → neste sesjon" rule it is **deferred and recorded**, not fixed
as scope creep. This is a genuine ALLOW of the scoped delivery — **not** a WARN-override (there
is no open finding *against* S15's diff).
**Gate: ALLOW** for S16's delivered scope. brief-conformance is clean; code-correctness's two
MAJORs were S16's own lockstep misses (not pre-existing design findings), so both were fixed
in-session and re-verified (tsc clean, analytics 116/116, lint 74/0/0, hooks 98/98) — this is a
genuine ALLOW with **no open finding**, not a WARN-override. The M0 conflict was reconciled
before building (option c, location-agnostic); the conformance reviewer confirmed M0 was not
built and the data-dir seam (`getAnalyticsRoot()`) is untouched, so the planned migration
relocates the root in one place. The S15-deferred onboarding `Write` MAJOR is closed.
Per Handover 6, this `review.md` is consumable by `/trekplan --brief …`. With an ALLOW verdict
on the delivered scope and the one deferred finding routed forward, S15 may commit + push, and
the finish-plan continues at S16 (with the deferred `Write` gap reconciled there or in S17).
Per Handover 6, this `review.md` is consumable by `/trekplan --brief …`. ALLOW → S16 commits +
pushes (own files only); the finish-plan continues at **S17** (C13C46 triage).
```json
{
"verdict": "ALLOW",
"verdict_scope": "S15 delivered changes (B1 + B3); 2 files",
"scope": { "sha_start": "baca30f", "sha_end": "baca30f", "reviewed_files_count": 2, "uncommitted_delta": true },
"verdict_scope": "S16 delivered changes (saves manual-entry + S16-pre onboarding Write); 15 files",
"scope": { "sha_start": "8c52bdb", "sha_end": "8c52bdb", "reviewed_files_count": 15, "uncommitted_delta": true },
"counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 },
"findings": [],
"deferred_findings": [
"fixed_in_session": [
{
"severity": "MAJOR",
"title": "onboarding Phase 2 file-saves need Write, not in allowed-tools",
"file": "commands/onboarding.md",
"line": 142,
"title": "Non-numeric Saves cell silently coerced to 0 (unknown != 0 contract)",
"file": "scripts/analytics/src/parsers/csv-parser.ts",
"rule_key": "PLAN_EXECUTE_DRIFT",
"status": "pre-existing, out of S15 scope (Phase 2; S15 diff = Phase 3 + Phase 4 line)",
"routed_to": "next session (S16 onboarding/saves surface, or S17 triage)",
"recommended_action": "add Write to onboarding.md allowed-tools (match first-post.md:9-14), or replace the two 'Save to ...' instructions with a node -e Bash write"
"resolution": "added parseOptionalCount() returning number|undefined (blank/non-numeric/negative -> undefined; genuine 0 kept); saves parse routes through it"
},
{
"severity": "MAJOR",
"title": "No test for explicit-0 vs non-numeric Saves boundary",
"file": "scripts/analytics/tests/csv-parser.test.ts",
"rule_key": "MISSING_TEST",
"resolution": "added saves-edge-export.csv fixture + 2 cases (0 -> 0, n/a -> undefined); TDD red-before/green-after the parser fix"
},
{
"severity": "MAJOR",
"title": "onboarding Phase 2 file-saves need Write (carried from S15)",
"file": "commands/onboarding.md",
"rule_key": "PLAN_EXECUTE_DRIFT",
"resolution": "S16-pre: added Write to allowed-tools, matching first-post.md"
}
],
"deferred_findings": [],
"dropped_findings": []
}
```

View file

@ -12,7 +12,7 @@ import { generateHeatmap } from "./reports/heatmap.js";
import { generateMonthlyReport } from "./reports/monthly.js";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { PostMetrics } from "./models/types.js";
import type { RankableMetric } from "./models/types.js";
const args = process.argv.slice(2);
const command = args[0];
@ -22,6 +22,14 @@ function parseOption(args: string[], flag: string): string | undefined {
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
}
/**
* Per-post saves suffix for report lines. Empty string when the post carries no
* manual saves data, so saves-free output stays identical to the pre-saves CLI.
*/
function savesSuffix(saves?: number): string {
return saves !== undefined ? ` | ${saves.toLocaleString()} saves` : "";
}
function printUsage() {
console.log(`
LinkedIn Analytics CLI
@ -75,6 +83,13 @@ async function handleImport(root: string, args: string[]) {
console.log(`Batch ID: ${batch.batchId}`);
console.log(`Saved to: posts/${savedFilename}`);
// Surface manually-entered saves when the CSV carried a Saves column.
const savesPosts = batch.posts.filter((p) => p.metrics.saves !== undefined);
if (savesPosts.length > 0) {
const totalSaves = savesPosts.reduce((sum, p) => sum + (p.metrics.saves ?? 0), 0);
console.log(`Saves entered: ${totalSaves.toLocaleString()} across ${savesPosts.length} post(s) (manual)`);
}
// Run alert detection on imported posts
const alerts = detectAlerts(batch.posts, "impressions");
@ -126,6 +141,9 @@ async function handleReport(root: string, args: string[]) {
console.log(`Total comments: ${report.summary.totalComments.toLocaleString()}`);
console.log(`Total shares: ${report.summary.totalShares.toLocaleString()}`);
console.log(`Total clicks: ${report.summary.totalClicks.toLocaleString()}`);
if (report.summary.totalSaves !== undefined) {
console.log(`Total saves: ${report.summary.totalSaves.toLocaleString()} (manual entry — top engagement signal)`);
}
console.log(`Avg engagement: ${report.summary.avgEngagementRate.toFixed(2)}%`);
console.log(`Avg impressions: ${Math.round(report.summary.avgImpressionsPerPost).toLocaleString()} per post`);
console.log();
@ -136,7 +154,7 @@ async function handleReport(root: string, args: string[]) {
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement | ${post.publishedDate}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
}
console.log();
}
@ -147,7 +165,7 @@ async function handleReport(root: string, args: string[]) {
for (const post of report.underperformers.slice(0, 3)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement | ${post.publishedDate}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
}
console.log();
}
@ -177,10 +195,11 @@ async function handleReport(root: string, args: string[]) {
}
/**
* Type guard to check if a string is a valid PostMetrics key
* Type guard to check if a string is a rankable (always-numeric) metric key.
* Excludes the optional, manually-entered `saves` it is not a trend metric.
*/
function isPostMetric(value: string): value is keyof PostMetrics {
const validMetrics: (keyof PostMetrics)[] = [
function isPostMetric(value: string): value is RankableMetric {
const validMetrics: RankableMetric[] = [
"impressions",
"reactions",
"comments",
@ -188,7 +207,7 @@ function isPostMetric(value: string): value is keyof PostMetrics {
"clicks",
"engagementRate",
];
return validMetrics.includes(value as keyof PostMetrics);
return validMetrics.includes(value as RankableMetric);
}
async function handleTrends(root: string, args: string[]) {
@ -203,7 +222,7 @@ async function handleTrends(root: string, args: string[]) {
}
if (!isPostMetric(metricOption)) {
const validMetrics: (keyof PostMetrics)[] = [
const validMetrics: RankableMetric[] = [
"impressions",
"reactions",
"comments",
@ -320,6 +339,9 @@ async function handleMonthlyReport(root: string, month: string) {
console.log(`Comments: ${s.totalComments.toLocaleString()}`);
console.log(`Shares: ${s.totalShares.toLocaleString()}`);
console.log(`Clicks: ${s.totalClicks.toLocaleString()}`);
if (s.totalSaves !== undefined) {
console.log(`Saves: ${s.totalSaves.toLocaleString()} (manual entry — top engagement signal)`);
}
console.log();
if (report.byWeek.length > 0) {
@ -337,7 +359,7 @@ async function handleMonthlyReport(root: string, month: string) {
for (const post of report.topPerformers.slice(0, 5)) {
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
console.log(`${title}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng | ${post.publishedDate}`);
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% eng${savesSuffix(post.metrics.saves)} | ${post.publishedDate}`);
}
console.log();
}

View file

@ -14,11 +14,18 @@ export interface PostMetrics {
shares: number;
clicks: number;
engagementRate: number; // (reactions+comments+shares+clicks)/impressions * 100
// NOTE: `saves` and `dwell` are intentionally absent. They are NOT in the
// LinkedIn analytics CSV export this tool parses, and there is no self-serve API
// to pull them. Saves are visible (count-only) in the native post analytics UI
// (~Sept 2025 onward) — read them there; dwell is internal to LinkedIn for
// organic posts. Do not add these fields without a real ingest source.
// `saves` is OPTIONAL and manually entered. LinkedIn's CSV export does NOT
// include it and there is no self-serve API to pull it — but the count IS
// visible in the native post analytics UI (~Sept 2025 onward). The ingest
// path is the user adding a `Saves` column to the CSV they read off it; the
// parser picks it up when present (see csv-parser.ts). When the column or a
// cell is absent, `saves` stays undefined — "unknown", never coerced to 0.
// It is deliberately NOT folded into engagementRate (which stays comparable
// to historical, saves-free data) — saves is surfaced as its own signal.
saves?: number;
// NOTE: `dwell` remains absent and unmeasurable. Dwell time is internal to
// LinkedIn for organic posts — not exportable, no UI count to transcribe, no
// API. Do not fabricate a dwell field or surface.
}
export interface AnalyticsBatch {
@ -40,6 +47,7 @@ export interface WeeklyReport {
totalComments: number;
totalShares: number;
totalClicks: number;
totalSaves?: number; // optional — present only when ≥1 post carries manual saves data
avgEngagementRate: number;
avgImpressionsPerPost: number;
};
@ -59,6 +67,21 @@ export interface WeeklyReport {
export type TrendDirection = "up" | "down" | "stable";
/**
* Metric keys that are always present and numeric safe for trend/alert ranking
* and `metrics[key]` index access. Excludes the optional, manually-entered
* `saves`, which is sparse and would type as `number | undefined` under index
* access (and is not a rankable trend metric). This is the runtime whitelist the
* CLI and alert engine have always used.
*/
export type RankableMetric =
| "impressions"
| "reactions"
| "comments"
| "shares"
| "clicks"
| "engagementRate";
export interface Alert {
type: "spike" | "drop" | "milestone";
severity: "info" | "warning" | "critical";
@ -98,6 +121,7 @@ export interface MonthlyReport {
totalComments: number;
totalShares: number;
totalClicks: number;
totalSaves?: number; // optional — present only when ≥1 post carries manual saves data
avgEngagementRate: number;
avgImpressionsPerPost: number;
};

View file

@ -57,6 +57,33 @@ function parseMetric(value: string): number {
return Math.max(0, parsed);
}
/**
* Parse an OPTIONAL manually-entered count (saves). Unlike parseMetric which
* coerces blanks, garbage, and negatives to 0 this preserves the "unknown vs
* zero" distinction the saves contract requires:
* - blank / absent undefined ("unknown", never 0)
* - non-numeric ("n/a", ) undefined ("unknown", never 0)
* - negative undefined (not a real save count)
* - a genuine number ("0") that number (an explicit 0 is a real reading)
* Reuses the same EU/US thousand-separator normalization as parseMetric so a
* "1.234"/"1,234" Saves cell parses consistently with the other columns.
*/
function parseOptionalCount(value: string): number | undefined {
if (!value) return undefined;
const cleaned = value.replace(/"/g, "").trim();
if (cleaned === "") return undefined;
const lastComma = cleaned.lastIndexOf(",");
const lastDot = cleaned.lastIndexOf(".");
const normalized = lastComma > lastDot
? cleaned.replace(/,/g, "")
: cleaned.replace(/\./g, "").replace(/,/g, ".");
const parsed = Number(normalized);
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
return parsed;
}
/**
* Normalizes date to YYYY-MM-DD format
* Handles: DD.MM.YYYY, MM/DD/YYYY, YYYY-MM-DD
@ -173,7 +200,8 @@ export function parseLinkedInCSV(
const shares = parseMetric(findColumn(record, ["share", "repost"]));
const clicks = parseMetric(findColumn(record, ["click"]));
// Calculate engagement rate
// Calculate engagement rate — saves is deliberately NOT in the numerator,
// so this stays comparable to historical, saves-free imports.
const totalEngagement = reactions + comments + shares + clicks;
const engagementRate = impressions > 0
? (totalEngagement / impressions) * 100
@ -188,6 +216,15 @@ export function parseLinkedInCSV(
engagementRate,
};
// Optional manual-entry saves: only when the user augmented this CSV with a
// Saves column (read off native LinkedIn analytics, ~Sept 2025+). A missing
// column, a blank cell, or a non-numeric/negative cell stays undefined —
// "unknown", never coerced to 0; a genuine 0 is kept as 0.
const saves = parseOptionalCount(findColumn(record, ["saves", "bookmark"]));
if (saves !== undefined) {
metrics.saves = saves;
}
return {
id: generatePostId(title, date),
title,

View file

@ -28,6 +28,12 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor
const totalComments = monthPosts.reduce((s, p) => s + p.metrics.comments, 0);
const totalShares = monthPosts.reduce((s, p) => s + p.metrics.shares, 0);
const totalClicks = monthPosts.reduce((s, p) => s + p.metrics.clicks, 0);
// Optional saves: present only when ≥1 post carries manual saves data —
// keeps saves-free months byte-identical to pre-saves output (backward-compat).
const savesPosts = monthPosts.filter(p => p.metrics.saves !== undefined);
const totalSaves = savesPosts.length > 0
? savesPosts.reduce((s, p) => s + (p.metrics.saves ?? 0), 0)
: undefined;
const avgEngagementRate = totalPosts > 0
? parseFloat(mean(monthPosts.map(p => p.metrics.engagementRate)).toFixed(2))
: 0;
@ -101,6 +107,7 @@ export function generateMonthlyReport(root: string, month: string): MonthlyRepor
totalComments,
totalShares,
totalClicks,
...(totalSaves !== undefined ? { totalSaves } : {}),
avgEngagementRate,
avgImpressionsPerPost,
},

View file

@ -154,12 +154,23 @@ export function generateWeeklyReport(analyticsRoot: string, week?: string): Week
}
// Calculate summary metrics
let totalSaves = 0;
let sawSaves = false;
for (const post of weekPosts) {
report.summary.totalImpressions += post.metrics.impressions;
report.summary.totalReactions += post.metrics.reactions;
report.summary.totalComments += post.metrics.comments;
report.summary.totalShares += post.metrics.shares;
report.summary.totalClicks += post.metrics.clicks;
if (post.metrics.saves !== undefined) {
totalSaves += post.metrics.saves;
sawSaves = true;
}
}
// Only surface saves when at least one post carried it — keeps saves-free
// reports byte-identical to pre-saves output (backward-compat).
if (sawSaves) {
report.summary.totalSaves = totalSaves;
}
// Calculate averages

View file

@ -1,7 +1,7 @@
import type {
PostAnalytics,
Alert,
PostMetrics,
RankableMetric,
} from "../models/types.js";
import { ALERT_THRESHOLDS } from "../models/types.js";
import {
@ -17,7 +17,7 @@ import {
*/
export function detectAlerts(
posts: PostAnalytics[],
metricKey: keyof PostMetrics = "impressions"
metricKey: RankableMetric = "impressions"
): Alert[] {
if (posts.length === 0) return [];

View file

@ -122,3 +122,72 @@ describe("CSV Parser", () => {
assert.equal(batch.dateRange.to, "2026-01-28", "Date range to should be latest date");
});
});
describe("Saves (manual-entry, optional)", () => {
it("should parse a Saves column when the user augments the CSV with it", () => {
const filePath = join(fixturesDir, "saves-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-export.csv");
assert.equal(batch.postCount, 2, "Should have 2 posts");
// Row 1 carries a saves count read from native LinkedIn analytics.
assert.equal(batch.posts[0].metrics.saves, 42, "Should parse the Saves cell value");
});
it("should leave saves undefined when the Saves cell is blank (unknown != zero)", () => {
const filePath = join(fixturesDir, "saves-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-export.csv");
// Row 2's Saves cell is empty — saves is unknown, NOT zero.
assert.equal(
batch.posts[1].metrics.saves,
undefined,
"Blank Saves cell must stay undefined, never coerced to 0"
);
});
it("should leave saves undefined for a standard export with no Saves column (backward-compat)", () => {
const filePath = join(fixturesDir, "sample-export.csv");
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
for (const post of batch.posts) {
assert.equal(
post.metrics.saves,
undefined,
"Existing CSV exports without a Saves column must round-trip unchanged"
);
}
});
it("should NOT fold saves into engagementRate (kept comparable to historical data)", () => {
const filePath = join(fixturesDir, "saves-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-export.csv");
// Row 1: (100+30+15+200)/5000 * 100 = 6.9 — saves (42) must NOT be in the numerator.
const expectedRate = ((100 + 30 + 15 + 200) / 5000) * 100;
assert.ok(
Math.abs(batch.posts[0].metrics.engagementRate - expectedRate) < 0.01,
`engagementRate should exclude saves (~${expectedRate}), got ${batch.posts[0].metrics.engagementRate}`
);
});
it("should treat an explicit '0' Saves cell as a genuine zero (not undefined)", () => {
const filePath = join(fixturesDir, "saves-edge-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-edge-export.csv");
// A literal 0 in the Saves column is a real reading — zero saves, not unknown.
assert.equal(batch.posts[0].metrics.saves, 0, "Explicit '0' must stay 0, not collapse to undefined");
});
it("should leave saves undefined for a non-numeric Saves cell (unknown, never coerced to 0)", () => {
const filePath = join(fixturesDir, "saves-edge-export.csv");
const batch = parseLinkedInCSV(filePath, "saves-edge-export.csv");
// "n/a" is not a count — saves stays unknown, NOT silently flattened to 0.
assert.equal(
batch.posts[1].metrics.saves,
undefined,
"Non-numeric Saves cell must stay undefined — never coerced to 0"
);
});
});

View file

@ -0,0 +1,3 @@
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Saves"
"Explicit zero saves — a real reading of zero, must stay 0 not undefined...",2026-02-12,4000,80,25,10,150,0
"Non-numeric saves cell — the user jotted a note, not a count; stays unknown...",2026-02-11,3500,70,22,9,130,n/a
1 Content Date Impressions Reactions Comments Shares Clicks Saves
2 Explicit zero saves — a real reading of zero, must stay 0 not undefined... 2026-02-12 4000 80 25 10 150 0
3 Non-numeric saves cell — the user jotted a note, not a count; stays unknown... 2026-02-11 3500 70 22 9 130 n/a

View file

@ -0,0 +1,3 @@
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks","Saves"
"A save-worthy framework post the user augmented with the native saves count...",2026-02-10,5000,100,30,15,200,42
"A post where the user left the Saves cell blank — unknown, not zero...",2026-02-09,3000,60,20,8,120,
1 Content Date Impressions Reactions Comments Shares Clicks Saves
2 A save-worthy framework post the user augmented with the native saves count... 2026-02-10 5000 100 30 15 200 42
3 A post where the user left the Saves cell blank — unknown, not zero... 2026-02-09 3000 60 20 8 120

View file

@ -83,6 +83,27 @@ describe("generateMonthlyReport", () => {
assert.equal(report.summary.avgImpressionsPerPost, 2000);
});
test("sums saves into totalSaves when posts carry manual saves data", () => {
const withSaves = (p: PostAnalytics, saves: number): PostAnalytics => ({
...p,
metrics: { ...p.metrics, saves },
});
const posts: PostAnalytics[] = [
withSaves(createPost("2026-03-03", 1000, 3.0), 8),
createPost("2026-03-05", 2000, 4.0), // no saves — partial coverage
withSaves(createPost("2026-03-10", 1500, 3.5), 13),
];
const root = setupTestRoot(posts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.totalSaves, 21, "totalSaves should sum 8 + 13");
});
test("leaves totalSaves undefined for saves-free months (backward-compat)", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");
assert.equal(report.summary.totalSaves, undefined);
});
test("generates weekly breakdown within month", () => {
const root = setupTestRoot(marchPosts);
const report = generateMonthlyReport(root, "2026-03");

View file

@ -275,6 +275,50 @@ describe("weekly", () => {
assert.ok(Math.abs(report.summary.avgEngagementRate - 8.49) < 0.01);
});
test("should sum saves into totalSaves when posts carry manual saves data", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({
id: "saved1",
publishedDate: "2026-01-12", // 2026-W03
metrics: { impressions: 1000, reactions: 50, comments: 10, shares: 5, clicks: 20, engagementRate: 8.5, saves: 12 },
}),
createTestPost({
id: "saved2",
publishedDate: "2026-01-13", // 2026-W03
// No saves on this one — partial coverage must still sum what exists.
metrics: { impressions: 2000, reactions: 100, comments: 20, shares: 10, clicks: 40, engagementRate: 8.5 },
}),
createTestPost({
id: "saved3",
publishedDate: "2026-01-14", // 2026-W03
metrics: { impressions: 1500, reactions: 75, comments: 15, shares: 7, clicks: 30, engagementRate: 8.47, saves: 30 },
}),
];
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-14" }, posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(report.summary.totalSaves, 42, "totalSaves should sum the posts that carry saves (12 + 30)");
});
test("should leave totalSaves undefined when no post carries saves (backward-compat)", () => {
tempDir = setupTempDir();
const posts: PostAnalytics[] = [
createTestPost({ id: "nosave1", publishedDate: "2026-01-12" }),
createTestPost({ id: "nosave2", publishedDate: "2026-01-13" }),
];
saveBatch(tempDir, createTestBatch({ dateRange: { from: "2026-01-12", to: "2026-01-13" }, posts }));
const report = generateWeeklyReport(tempDir, "2026-W03");
assert.equal(report.summary.totalSaves, undefined, "Saves-free data must not introduce a totalSaves field");
});
test("should identify top performers and underperformers", () => {
tempDir = setupTempDir();