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

@ -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": []
}
```