diff --git a/plugins/linkedin-studio/CLAUDE.md b/plugins/linkedin-studio/CLAUDE.md index 045d1b4..4005fce 100644 --- a/plugins/linkedin-studio/CLAUDE.md +++ b/plugins/linkedin-studio/CLAUDE.md @@ -1,6 +1,6 @@ # LinkedIn Studio Plugin (v4.0.0) -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 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). 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. +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. ## Architecture diff --git a/plugins/linkedin-studio/README.md b/plugins/linkedin-studio/README.md index f8cc075..de222eb 100644 --- a/plugins/linkedin-studio/README.md +++ b/plugins/linkedin-studio/README.md @@ -627,7 +627,7 @@ Scheduled posts are tracked in `assets/drafts/queue.json`: | Version | Date | Highlights | |---------|------|-----------| -| **4.0.0** | 2026-05-30 | **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: every claim made honest or removed; **all 11 orphaned agents wired** (no deletions → 19 agents); new **`/linkedin:firsthour`** command (→ 27 commands) + short-form de-AI gate + video quality gate; `post-feedback-monitor` → Opus; honest newsletter-distribution / profile-SEO / outreach surfaces; **algorithm signals reconciled to one sourced statement** (no model name/date) with `references/algorithm-signals-reference.md` as the single source of truth; analytics fresh-clone crash fixed; **voice-profile leak closed** (placeholder + sentinel + gitignore); structure lint rebuilt with version/count/stat-consistency guards; 7-gate long-form review stack measured → no gate trimmed. **Breaking — reinstall/reload required** for the newly-wired agents; consolidates the v3.0.0 identity break. | +| **4.0.0** | 2026-05-30 | **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: every claim made honest or removed; **all 11 orphaned agents wired** (no deletions → 19 agents); new **`/linkedin:firsthour`** command (→ 27 commands) + short-form de-AI gate + video quality gate; `post-feedback-monitor` → Opus; honest newsletter-distribution / profile-SEO / outreach surfaces; **algorithm signals reconciled to one sourced statement** (no model name/date) with `references/algorithm-signals-reference.md` as the single source of truth; analytics fresh-clone crash fixed; **voice-profile leak closed** (placeholder + sentinel + gitignore); structure lint rebuilt with version/count/stat/model-consistency + render-chain-propagation + `$`-safety guards (post-release remediation S8–S13); 7-gate long-form review stack measured → no gate trimmed. **Breaking — reinstall/reload required** for the newly-wired agents; consolidates the v3.0.0 identity break. | | **3.1.0** | 2026-05-29 | Adversarial review package (Endring 9). New **`/linkedin:headless-review`** command + **Step 6.5 (headless-review)** in `/linkedin:newsletter`: three cold/headless archetypes — **`content-reviewer`** (argument), **`language-reviewer`** (Norwegian), **`fact-reviewer`** (cold re-verification + pivot-risk) — plus `persona-reviewer` (resonance/conversion), all with NO drafting-session context. New **`/linkedin:pivot`** command + pivot-detection gate (> 20 % word-count / > 2 new sections re-opens cleared gates before lock). **Per-artifact personas** (`articles.NN.personas`). Pipeline 15 → 16 phases; 24 → 26 commands; 16 → 19 agents; additive `personas`/`pivots`/`headlessReview` state. Backward-compatible; reload required for the new agents. | | **3.0.0** | 2026-05-29 | **Renamed** `linkedin-thought-leadership` → `linkedin-studio` ("LinkedIn Thought Leadership" → **LinkedIn Studio**). Breaking (slug + agent namespace `linkedin-studio:` + runtime state path `~/.claude/linkedin-studio.local.md` all change; reinstall required, state migrated in place), but **functionality byte-identical to v2.4.0**. The `/linkedin:*` commands are unchanged (frontmatter-namespaced, slug-independent). Catch-all skill dir renamed to match (`skills/linkedin-studio/`); the five functional skills unchanged. | | **2.4.0** | 2026-05-29 | Editor's craft gate as an explicit pipeline phase. New **`editorial-reviewer` agent** (Opus) + **Step 5.5 — Editorial review** in `/linkedin:newsletter` (between fact-check and the persona sweep): two axes — **prosa-håndverk** (em-dash density, verbatim repetition, postulated numbers, contradictions, versal-tic) + **narrativ-arkitektur** (concrete instantiation, theory-anchored hypotheses, series-title symmetry, equal action per addressee, un-overloaded conclusion); ≤10 flags BLOCK/REWORK/NICE as direction; operator-gated via `SendUserFile`; mirrors Maskinrommet §C2. Motivated by Del 4 (every persona PASS, yet 8 fresh editor points, ~6/8 craft/architecture blind spots). `editorial-review` phase + additive `editorialReview` state. Pipeline 14 → 15 phases; 15 → 16 agents. New agent + fasit fixture + lint test the only new files; commands (24) unchanged. | diff --git a/plugins/linkedin-studio/docs/integration-test-guide.md b/plugins/linkedin-studio/docs/integration-test-guide.md index f018ac1..ec9caf2 100644 --- a/plugins/linkedin-studio/docs/integration-test-guide.md +++ b/plugins/linkedin-studio/docs/integration-test-guide.md @@ -296,7 +296,7 @@ Before testing, ensure: 1. First create a test plan (Test 18) and manually create a test file with sample data 2. Run `/linkedin:ab-test` and select "Analyze test results" 3. Select the test to analyze -**Expected:** Results comparison table, significance assessment (20% rule), verdict, recommended next steps +**Expected:** Results comparison table, directional assessment (≥20% gap), verdict, recommended next steps **Validates:** File scanning, data analysis, result formatting ### Test 20: Enhanced Report — Trends & Alerts diff --git a/plugins/linkedin-studio/docs/remediation/brief.md b/plugins/linkedin-studio/docs/remediation/brief.md index 75cf560..21eeb44 100644 --- a/plugins/linkedin-studio/docs/remediation/brief.md +++ b/plugins/linkedin-studio/docs/remediation/brief.md @@ -279,6 +279,45 @@ gaps) → Phase 3 (long-form earn / redundancy measurement). - The plugin's own lint (`scripts/test-runner.sh`, rebuilt) and any `node --test` suites pass on the final state. +## Amendment (2026-05-30) — Finish scope (S13–S17) + +After the v4.0.0 remediation pushed (Phase 0–3 done; S12 review WARN, 2 findings open), +the operator commissioned a **finish pass** to close every remaining hole to a clean +ALLOW. Plan: `docs/remediation/finish-plan.md`. This amendment folds the new scope into +the contract so the review gate measures it as in-scope (not Non-Goal violation). + +**Re-opened Non-Goals (superseded by operator decision 2026-05-30):** +- *"Not changing the /linkedin:* command invocation surface"* — **re-opened** for S14 + command rationalization (a deliberate keep/develop/merge/cut pass; merges/cuts only on + explicit per-command operator approval). +- *"Not adding a manual-entry feature for saves/dwell"* — **re-opened for saves only** + (S16). Dwell stays unmeasurable (internal-only); no dwell surface is built. + +**New Success Criteria (Finish):** +- **S13:** `replaceField` (`state-updater.mjs:14-18`) inserts untrusted `last_post_topic` + literally (replacement function); a `$`-bearing test pins the `last_post_topic` scalar; + a structural `$`-safety lint (Section 12) fails on any string-replacement whose value + derives from an untrusted parameter; `/trekreview` → ALLOW. +- **S14:** `docs/remediation/command-rationalization.md` records a per-command + keep/develop/merge/cut recommendation for all commands; every merge/cut is operator- + approved; the lint count-guard + all rosters + three-doc agree with `ls commands/*.md`. +- **S15:** `/linkedin:onboarding` produces a draft post inline (no `Run /linkedin:first-post` + dead-end); `/linkedin` router is tiered (≤4 primary, ~1K-gated commands flagged + "locked"); `/linkedin:carousel` copies the full deck (all slides + caption), not just + the caption. +- **S16:** a manual saves value can be entered and surfaces in `/linkedin:report` without + crashing; CSV-only data still works (backward-compatible); dwell remains explicitly + unexportable. +- **S17:** every uncalibrated audit finding C13–C46 has a recorded disposition + (`docs/remediation/c13-c46-triage.md`: still-real / already-fixed / outdated-drop); every + still-real one is grep-verified closed. + +**Pending / out-of-band (not yet sequenced — operator will time it):** two additional +briefs the operator flagged — `docs/linkedin-studio-persona-brief.md` and +`docs/linkedin-studio-ui-brief.md`. If their scope conflicts with S13–S17 (esp. the UI +brief vs S15 router/onboarding/carousel UX), reconcile before executing the overlapping +session; do not let the finish pass pre-empt a decision the operator hasn't made. + ## Research Plan *The internal/file-level fixes (analytics-CLI crash, dead lint, voice-leak, diff --git a/plugins/linkedin-studio/docs/remediation/finish-plan.md b/plugins/linkedin-studio/docs/remediation/finish-plan.md new file mode 100644 index 0000000..a32471e --- /dev/null +++ b/plugins/linkedin-studio/docs/remediation/finish-plan.md @@ -0,0 +1,126 @@ +# LinkedIn Studio — Finish Plan (S13–S17) + +> Closes every remaining hole: the open S12 review findings + the gaps surfaced in the +> 2026-05-30 verified assessment. Brief-amendment to `docs/remediation/brief.md` (same +> project). Each session is one STATE.md-driven Voyage session, gated by `test-runner.sh` +> + `node --test` + `/trekreview` → push **only on ALLOW** (no more WARN-overrides). +> +> **Operator decisions (2026-05-30):** build manual saves entry (S16); triage C13–C46 +> (S17); fold into this project as a brief-amendment. **Dependency:** S14 (command set) +> precedes S15 (router tiering) — the router can't be tiered before the command set is final. + +## Sequence & dependency + +``` +S13 (close S12 WARN + $-class) → ALLOW [finishes the ORIGINAL brief] + │ +S14 (command rationalization) → ALLOW [sets the final command set] + │ +S15 (UX §6c — router on final set) → ALLOW + │ +S16 (saves manual entry) → ALLOW + │ +S17 (C13–C46 triage) → ALLOW [process complete] +``` + +--- + +## S13 — Close the open S12 findings + the `$`-replacement class +*Finishes the original brief; brings the existing scope to a clean ALLOW.* + +- **A1 (MINOR):** `hooks/scripts/state-updater.mjs:14-18` — convert `replaceField` to a + replacement **function** (`(_m) => \`${field}: ${value}\``) so the untrusted + `last_post_topic` at `:58` is inserted literally. +- **A2 (MAJOR):** add `assert.match(result.content, /^last_post_topic: "\$100 budget — \$& and \$1 rule"$/m)` + to the existing `$`-bearing test in `state-updater.test.mjs` (fails today, passes after A1). +- **A3 (systemic):** audit every `String.replace` in `hooks/scripts/*.mjs` whose replacement + is a **string** built from a function parameter that can carry user content + (`grep -nE '\.replace\([^,]+, *\`'`). Confirm `replaceField` was the last such site. + Add a **structural `$`-safety lint** (`test-runner.sh` Section 12) that flags + string-replacement sites whose value derives from an untrusted parameter — non-vacuity + self-test + e2e mutation-proof, mirroring Sections 8/10/11. +- **Engine:** inline (small, surgical). +- **Verify:** A2 assert FAILS pre-A1, PASSES post-A1 · `node --test` green · `test-runner.sh` + green (incl. new Section 12 self-test) · audit-grep returns 0 unsafe sites · + `/trekreview` → **ALLOW** → commit (review.md + S13) → push. + +## S14 — Command rationalization (re-opens the original command-surface Non-Goal) +*Analysis → operator decision → execute. Nothing deleted without explicit per-command yes.* + +- **14a Analysis (no edits):** cold per-command review of all 27 → `docs/remediation/command-rationalization.md`. + Per command: purpose · overlap with siblings · invocation leverage (algorithmic + likely use) · + recommendation **keep / develop / merge→X / cut** + rationale. Delegate the cold read to an + Agent (Opus) for independence. +- **14b Operator decision:** present the doc; operator decides per command (`AskUserQuestion` + batched). No mechanical deletion until approved. +- **14c Execute approved:** apply merges/cuts; for a merge, fold the source command's unique + surface into the target and delete the source; update `EXPECT_COMMANDS` in `test-runner.sh`, + all rosters (CLAUDE.md/README/SKILL.md/router), CHANGELOG, version bump if the surface count + changes (breaking → minor/major per SemVer judgment). +- **Engine:** Agent (14a) → inline (14c). +- **Verify:** `ls commands/*.md | wc -l` == every declared count · lint count-guard green · + three-doc synced · `grep` old count → 0 stale · `/trekreview` → **ALLOW** → push. + +## S15 — UX finish (§6c), on the FINAL command set +- **B1 Onboarding inline:** `commands/onboarding.md` — replace the + `"Run /linkedin:first-post"` hand-off with the first-post steps embedded in the wizard, so + the flow produces a draft post inline (no dead-end). *Verify:* a walkthrough yields a draft + inside onboarding; 0 `Run /linkedin:first-post` dead-end strings. **Scope guard (UI brief + §12b):** fix the dead-end ONLY — do NOT add extensibility/provider "seams" or progressive- + disclosure config to onboarding; those are unresolved UI-brief decisions (keep onboarding + lean per the persona "first value without forking"). +- **B2 Router tiering:** `commands/linkedin.md` — restructure into **Primary** (3–4: + post/quick/newsletter/firsthour), **Secondary** (the rest of the final set), **Locked ~1K** + (monetize/outreach/competitive, marked "unlocks later"). *Verify:* tier sections present; + primary ≤4; locked commands flagged, not inline with primaries. +- **B3 Carousel full-deck clipboard:** `commands/carousel.md` — assemble the **entire deck** + (every slide's copy + the caption) into the clipboard payload, not just the caption. + *Verify:* clipboard payload contains slide text; grep shows full-deck assembly before the + `clipboard-helper.mjs` call. +- **Engine:** inline. +- **Verify:** all three grep/observation checks pass · `test-runner.sh` green · + `/trekreview` → **ALLOW** → push. + +## S16 — Saves manual-entry surface (operator-requested; lifts the original Non-Goal) + +> ⚠️ **CONFLICT — reconcile before building (UI brief §9b/M0).** The UI brief makes it +> **binding** that all mutable personal data (`assets/analytics/*`, `queue.json`, `*.local.md`) +> moves OUT of the plugin tree into a stable per-user data dir, "in the v4.0.0 remediation or +> immediately after." S16 extends the analytics data model — if built against the current +> in-tree `assets/analytics/` it gets **reworked by M0**. Decision needed: (a) do **M0 first** +> (insert as S15.5), then build S16 in the final location; or (b) **defer S16** to ride along +> with M0/the UI build. Do NOT build S16 blind to M0. + +- Add a manual-entry path for **saves** (visible in native LinkedIn post analytics, count-only, + ~Sept 2025) to the analytics data model (`scripts/analytics/` types + import/report path), + additive and backward-compatible. Re-rank the actionable-signal output to include saves where + the CSV/manual data now contains it. **Dwell stays explicitly unmeasurable** (internal-only) — + do not fabricate a dwell surface. +- **Engine:** inline (+ analytics CLI knowledge; may need `tsx` types touch). +- **Verify:** a report run with a manual saves value surfaces it without crashing; existing + CSV-only data still works (backward-compat); honesty wording retained for dwell · + `node --test` / analytics tests green · `/trekreview` → **ALLOW** → push. + +## S17 — Triage the uncalibrated audit findings (C13–C46) +- Read the ~34 findings the audit never put through a second hostile pass (`§10`); for each: + classify **still-real / already-fixed / outdated-drop**; close every still-real one; record the + disposition in `docs/remediation/c13-c46-triage.md`. Delegate the cold read to an Agent (Opus). +- **Engine:** Agent (triage) → inline (fixes). +- **Verify:** every C13–C46 finding has a recorded disposition; still-real ones grep-verify closed · + `test-runner.sh` + `node --test` green · `/trekreview` → **ALLOW** → push. **Process complete.** + +--- + +## Verification (whole plan) +- **Per session:** `bash scripts/test-runner.sh` exit 0 · `node --test hooks/scripts/__tests__/*.test.mjs` + all pass · `/trekreview --project docs/remediation/` → **ALLOW** (not WARN) before push. +- **Plan-complete signal:** all of S13–S17 pushed on ALLOW; `command-rationalization.md` + + `c13-c46-triage.md` committed; no open `/trekreview` finding; STATE.md "Aktiv oppgave" reads + "remediering FERDIG — ren ALLOW". +- **Counts contract stays live:** the lint's count-guard (19/27/25/6 today; 27 may change in S14) + is updated in lockstep with any command merge/cut; `grep` for the prior count returns 0 stale hits. + +## Locked constraints (inherited from brief) +- Opus on everything · no hidden costs (cost-warn `/trekcontinue`·`/trekreview`, standing yes) · + three-doc rule · version-sync · bash 3.2 + Node-only hooks · push only to Forgejo · stage own + files only · fix-in-next-session for any review finding. diff --git a/plugins/linkedin-studio/docs/remediation/review.md b/plugins/linkedin-studio/docs/remediation/review.md new file mode 100644 index 0000000..6246c61 --- /dev/null +++ b/plugins/linkedin-studio/docs/remediation/review.md @@ -0,0 +1,166 @@ +--- +type: trekreview +review_version: "1.0" +task: "Remediate linkedin-studio from the baseline audit — correctness, honesty, generalization, and the highest-leverage 2026 coverage gaps (full Phase 0–3 roadmap, phased)" +slug: remediation +project_dir: docs/remediation/ +brief_path: docs/remediation/brief.md +scope_sha_start: c5b4c58f4f390aca83c8937880c5fd0bcc983e44 +scope_sha_end: 36f79dd702b9315a0cd9100c3a8dd6dd81b3797f +reviewed_files_count: 50 +verdict: ALLOW +mode: default +effort: high +profile: premium +findings: [] +--- + +# Review — linkedin-studio audit-remediation (S13 re-review: `$`-class closure + scalar-test fix) + +## Executive Summary + +**Verdict: ALLOW** — 0 BLOCKER, 0 MAJOR, 0 MINOR, 0 SUGGESTION. + +This is the **S13 re-review** — the **seventh** full-brief sweep +(`c5b4c58..36f79dd` + the uncommitted S13 working-tree delta), run COLD and +high-effort. S13 was commissioned to close the two findings the S12 re-review +left open (verdict WARN, 0/1/1/0): the MAJOR `MISSING_TEST` (the S12 `$`-bearing +test asserted the Recent Posts section but never the `last_post_topic` scalar, so +the corruption shipped green) and the MINOR `MISSING_ERROR_HANDLING` (the +`last_post_topic` scalar was still `$`-unsafe because `replaceField` used a +replacement *string* for untrusted content). Two independent reviewers +(brief-conformance, code-correctness) ran without cross-feeding; the coordinator +applied bounded dedup + the HubSpot Judge filters + verdict (high-effort → +Cloudflare reasonableness filter skipped; the operator weighs borderline findings). +**Both reviewers returned empty finding sets.** + +**Both S12 findings are CLOSED at the reviewed (uncommitted) state:** +- `replaceField` (`hooks/scripts/state-updater.mjs:14-24`) now uses a replacement + **function** (`() => \`${field}: ${value}\``), so the untrusted `last_post_topic` + at the `:64`-equivalent call site is inserted verbatim — no `$&`/`$1`/`` $` `` + expansion. The MINOR is closed. +- The existing `$`-bearing test (`hooks/scripts/__tests__/state-updater.test.mjs`) + now carries `assert.match(result.content, /^last_post_topic: "\$100 budget — \$& + and \$1 rule"$/m, …)`, distinct from the section-entry `includes()` it already + had. This assertion **fails on the old string-`replaceField` and passes on the + function form** (orchestrator-verified by reverting the fix: the test went + 40 pass / 1 fail). The false-green MAJOR is closed. + +**The class — not just the line — is closed.** The recurring S9→S12 lesson is +"close the class, not the line"; the class here is "untrusted user content reaching +ANY `String.replace` replacement *string*". Beyond the `replaceField` scalar, S13 +also converted the three remaining additive-insert sites (`recordFirstHourPlan` +`:246`-equiv; `recordOutreachContact` `:305/:308`-equiv) from a string replacement +carrying an intentional `$1` backref + interpolated date to a replacement function +(`(m) => \`${m}\n…\``). The code-correctness reviewer verified rigorously that this +is **behavior-preserving**: each regex's capture group spans the *entire* match (the +only chars outside the group are the zero-width `^`/`$` anchors), so the full match +`m` is character-identical to the old `$1`. After S13, **every `.replace()` in +`state-updater.mjs` uses a replacement function or a `$`-free literal** — the class +is closed by construction, not by per-line patch. + +**A structural guard replaces the per-line proof.** New +`scripts/check-replace-safety.mjs` (wired as `test-runner.sh` Section 12) proves the +property behaviorally: it drives every exported mutator with an adversarial payload +of every special replacement token (`$&`, `` $` ``, `$'`, `$$`, `$n`) in every +free-text *and* date field and asserts the payload survives verbatim. Two structural +backstops run on every invocation — **coverage-completeness** (a newly-exported +mutator without `$`-coverage fails the guard) and a **non-vacuity self-test** (a +naive string-replace MUST corrupt the payload and a function MUST preserve it, else a +PASS is meaningless), mirroring the Section 8/10/11 self-tests. The orchestrator +mutation-proved it end-to-end: reverting `replaceField` to a string makes the guard +exit 1 with two findings; restoring it returns exit 0. + +**No Phase-0–3 Success Criterion regressed.** The brief-conformance reviewer traced +each S13 clause to delivered code and confirmed the counts (19 agents / 27 commands / +25 references / 6 skills), the version (4.0.0), the single-source algorithm-signal, +the model-consistency guard, and the render-chain-propagation guard all still hold; +S13 touched no command/agent/reference file. The two Non-Goals the brief amendment +re-opens (the command invocation surface for S14; saves manual-entry for S16) trace +to **explicit operator decisions** in the brief amendment, and S13 itself did not +touch either surface — no `SCOPE_CREEP_BUILT`. + +**Push decision: ALLOW.** The two S12 findings are closed, the class is closed +structurally, the lint is non-vacuous and mutation-proven, all suites are green +(`scripts/test-runner.sh` → 71/0/0; `node --test` → 98/98), and no SC regressed. The +ORIGINAL remediation brief now closes clean. Per `feedback_trekreview_always_last` + +Handover 6, this review is the gate; with ALLOW, S13 may push. + +## Coverage + +Scope SHA range: `c5b4c58` (= `origin/main`, parent of remediation Steg 1) → +`36f79dd` (HEAD, the S12 commit) **plus the uncommitted S13 working-tree delta** +(annotated `[uncommitted]` — a brief-level contract; the brief's Assumptions allow +uncommitted review). The committed range (47 files) was already deep-reviewed and +cleared at S12 except the 2 WARN findings; the active S13 delta is the 9 working-tree +files below. **No silent skips.** + +| Treatment | Count | Notes | +|-----------|-------|-------| +| `deep-review` (hooks/** + the new guard) | 4 | `state-updater.mjs`, `state-updater.test.mjs` `[uncommitted]`; `scripts/check-replace-safety.mjs` `[uncommitted, new]`; `scripts/test-runner.sh` `[uncommitted]` | +| `summary-only` | 46 | the committed `c5b4c58..36f79dd` range (already cleared at S12) + the S13 doc edits `CLAUDE.md`/`README.md`/`docs/integration-test-guide.md` `[uncommitted]` + `docs/remediation/{brief.md (amendment), finish-plan.md}` `[uncommitted]` | +| `skip` | 0 | no lockfiles / svg / generated / dist | + +**Cross-cutting execution criteria (run by orchestrator):** +`scripts/test-runner.sh` → 71 passed / 0 failed / 0 warnings, exit 0 (was 70; ++1 — Section 12 `$`-safety guard). +`node --test hooks/scripts/__tests__/*.test.mjs` → 98 tests, 98 pass, 0 fail (the +S13 scalar assertion was added to an existing test, not a new test). +`node scripts/check-replace-safety.mjs` → exit 0 (8 adversarial cases / 5 mutators, +coverage-complete, self-test non-vacuous); mutation-proven (reverting the fix → exit 1). + +**S12 findings — both confirmed CLOSED:** +`replaceField` (`state-updater.mjs:14-24`) → replacement function; the `$`-bearing +test now pins the `last_post_topic` scalar (`state-updater.test.mjs`); the three +remaining additive-insert string-replacements (`:246/:305/:308`) → functions; the +class is closed and guarded structurally (Section 12). + +## Findings + +None. Both independent reviewers returned empty finding sets; the coordinator's +bounded passes (dedup, HubSpot Judge, verdict) on an empty set yield ALLOW. + +## Remediation Summary + +**Gate: ALLOW.** The S12 WARN is fully resolved: the `replaceField` scalar +`$`-corruption (MINOR) and the false-green test (MAJOR) are both closed; the +`$`-injection class is closed across the whole `state-updater.mjs` mutation surface; +and a behavioral, coverage-complete, self-testing Section-12 lint guards it +structurally against future regressions. All suites green; no Phase-0–3 SC regressed; +the two re-opened Non-Goals trace to explicit operator decisions and S13 stayed in its +lane. + +**Two non-blocking observations, recorded by both reviewers, neither rising to a +catalogue finding:** +1. The S13 dead binding both reviewers named (`check-replace-safety.mjs` `HERE` + + its now-unused `node:url`/`node:path` imports) was removed during this review + pass; the guard remains green (exit 0) after removal. +2. The behavioral guard catches a new *unguarded exported mutator* (coverage + backstop) but not a new unsafe `String.replace` added *inside* an existing + battery-covered mutator on a field the battery does not fuzz. This is a documented, + deliberate limit of the behavioral proxy (vs a per-`.replace()` AST enumeration) + and is **moot today** — every `.replace()` in `state-updater.mjs` is already a + function. Recorded as a known boundary, not a defect; closing it further is out of + S13 scope (no real `$`-unsafe site exists to catch). + +The two adjacent machine-value `.replace()` sites the correctness reviewer probed — +`session-start.mjs:396` (`${actualWeek}`, a computed ISO week) and `week-rollover.mjs` +(computed week / literal int) — carry no untrusted content and are therefore not +members of the defect class, consistent with how the S12 review itself classified the +date/integer `replaceField` call sites. No finding. + +Per Handover 6, this `review.md` is consumable by +`/trekplan --brief docs/remediation/review.md`; the trailing JSON block is the machine +contract for that handover. With an ALLOW verdict and no BLOCKER/MAJOR findings, no +follow-up remediation plan is required — the ORIGINAL brief is closed clean and the +finish-plan continues at S14. + +```json +{ + "verdict": "ALLOW", + "scope": { "sha_start": "c5b4c58f4f390aca83c8937880c5fd0bcc983e44", "sha_end": "36f79dd702b9315a0cd9100c3a8dd6dd81b3797f", "reviewed_files_count": 50, "uncommitted_delta": true }, + "counts": { "BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0 }, + "findings": [], + "dropped_findings": [] +} +``` diff --git a/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs b/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs index eef32cc..abf6f62 100644 --- a/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs +++ b/plugins/linkedin-studio/hooks/scripts/__tests__/state-updater.test.mjs @@ -231,6 +231,14 @@ describe('updatePostTracking', () => { assert.notEqual(result, null); assert.ok(result.content.includes('$100 budget — $& and $1 rule'), 'topic with $-tokens must be inserted verbatim'); assert.ok(result.content.includes('"We cut $1 of $5"'), 'hook with $-tokens must be inserted verbatim'); + // S13: the SCALAR path (replaceField → state-updater.mjs:58) must ALSO insert + // verbatim. The S12 test only checked the Recent Posts section entry (a function + // append), so a `$&`-corrupted `last_post_topic` scalar shipped green: a + // replacement *string* expands `$&` to the whole matched line, rewriting the + // field to `last_post_topic: "$100 budget — last_post_topic: "AI strategy" …"`. + // This assertion fails until replaceField is a replacement function. + assert.match(result.content, /^last_post_topic: "\$100 budget — \$& and \$1 rule"$/m, + 'last_post_topic scalar must carry the $-bearing topic verbatim (no $&/$1 expansion)'); const headings = result.content.match(/^## Recent Posts$/gm) || []; assert.equal(headings.length, 1, 'heading must not be re-injected by a $1/$& expansion'); }); diff --git a/plugins/linkedin-studio/hooks/scripts/state-updater.mjs b/plugins/linkedin-studio/hooks/scripts/state-updater.mjs index ea60a05..df1d9a5 100644 --- a/plugins/linkedin-studio/hooks/scripts/state-updater.mjs +++ b/plugins/linkedin-studio/hooks/scripts/state-updater.mjs @@ -12,9 +12,15 @@ const HOME = process.env.HOME || process.env.USERPROFILE || ''; const STATE_FILE = process.env.STATE_FILE || join(HOME, '.claude', 'linkedin-studio.local.md'); function replaceField(content, field, value) { + // Replacement FUNCTION, not string: most call sites pass dates/integers/booleans, + // but `:58` passes the untrusted `last_post_topic`. In a replacement *string*, + // `$&`/`` $` ``/`$'`/`$$` (and `$n` group refs) are special, so a `$`-bearing topic + // (e.g. "$& budget") would expand `$&` to the whole matched line and silently + // corrupt the scalar. A function inserts `value` verbatim — closing the last member + // of the `$`-injection class the S12 section-append fix targeted. return content.replace( new RegExp(`^${field}: .*`, 'm'), - `${field}: ${value}` + () => `${field}: ${value}` ); } @@ -237,7 +243,7 @@ export function recordFirstHourPlan(stateContent, { planDate, postTopic = '', ta content = replaceField(content, 'last_firsthour_date', `"${planDate}"`); changes.push(`last_firsthour_date → ${planDate}`); } else if (/^last_post_date: .*/m.test(content)) { - content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_firsthour_date: "${planDate}"`); + content = content.replace(/^(last_post_date: .*)$/m, (m) => `${m}\nlast_firsthour_date: "${planDate}"`); // function, not string: `m` === the matched line (was `$1`); keeps planDate `$`-safe by construction changes.push(`last_firsthour_date → ${planDate}`); } @@ -296,10 +302,10 @@ export function recordOutreachContact(stateContent, { contactDate, track = '', p content = replaceField(content, 'last_outreach_date', `"${contactDate}"`); changes.push(`last_outreach_date → ${contactDate}`); } else if (/^last_firsthour_date: .*/m.test(content)) { - content = content.replace(/^(last_firsthour_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`); + content = content.replace(/^(last_firsthour_date: .*)$/m, (m) => `${m}\nlast_outreach_date: "${contactDate}"`); // function, not string: `m` === the matched line (was `$1`); keeps contactDate `$`-safe by construction changes.push(`last_outreach_date → ${contactDate}`); } else if (/^last_post_date: .*/m.test(content)) { - content = content.replace(/^(last_post_date: .*)$/m, `$1\nlast_outreach_date: "${contactDate}"`); + content = content.replace(/^(last_post_date: .*)$/m, (m) => `${m}\nlast_outreach_date: "${contactDate}"`); // function, not string: `m` === the matched line (was `$1`); keeps contactDate `$`-safe by construction changes.push(`last_outreach_date → ${contactDate}`); } diff --git a/plugins/linkedin-studio/scripts/check-replace-safety.mjs b/plugins/linkedin-studio/scripts/check-replace-safety.mjs new file mode 100644 index 0000000..a65caba --- /dev/null +++ b/plugins/linkedin-studio/scripts/check-replace-safety.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +// `$`-safety structural guard for hooks/scripts/state-updater.mjs (remediation S13). +// +// WHY: the S9→S12 "close the class, not the line" lesson recurred. S12 converted the +// 5 section-append `String.replace` sites to replacement *functions* but left +// `replaceField` — the scalar writer — on a replacement *string*, so an untrusted +// `last_post_topic` carrying `$&`/`$1`/`` $` `` silently corrupted state, and the S12 +// test asserted only the section entry (never the scalar), shipping the bug GREEN. +// The class is "untrusted user content reaching ANY `String.replace` replacement +// STRING": in a replacement string `$&` expands to the whole match, `` $` ``/`$'` to the +// pre/suffix, `$$`→`$`, `$n`→group n — so a `$`-bearing value rewrites the field. A +// replacement FUNCTION returns its string verbatim (no `$`-substitution), closing the +// class by construction. +// +// This guard proves the PROPERTY behaviorally rather than grepping a syntactic proxy +// (which cannot tell a replacement-position template literal from a RegExp-pattern one +// across multi-line calls): it drives every exported state mutator with an adversarial +// payload built from EVERY special replacement token, in every free-text AND date +// field that reaches a `.replace()`, and asserts the payload survives VERBATIM. Two +// structural backstops make it a CLASS guard, not a per-line test: +// (1) COVERAGE-COMPLETENESS — every exported mutator (minus the documented I/O +// wrapper) must appear in the battery. A NEW export added without `$`-coverage +// fails here, so future code cannot reintroduce the class unguarded. +// (2) NON-VACUITY SELF-TEST — a naive string-replace fed the same payload MUST +// corrupt, and a function replacement MUST preserve it. A guard that cannot +// observe the corruption it forbids certifies nothing, so it fails the suite +// instead (mirrors Section 8/10/11 self-tests; the S7→S10 "proof run once by +// hand, never committed" lesson applied to the `$`-axis). +// +// Zero dependencies (node:assert + the module under test). Invoked from +// scripts/test-runner.sh Section 12; exit code mapped to pass/fail. + +import assert from "node:assert/strict"; +import * as mod from "../hooks/scripts/state-updater.mjs"; + +// Adversarial payload: every JS replacement-string special token. If ANY of these is +// interpreted (string replacement) instead of inserted literally (function +// replacement), the payload will not survive verbatim. +const TOKENS = ["$&", "$`", "$'", "$$", "$1", "$0"]; +const PAYLOAD = `lead ${TOKENS.join(" ")} tail`; + +// Minimal fixtures (mirror hooks/scripts/__tests__/state-updater.test.mjs). SAMPLE +// omits last_firsthour_date/last_outreach_date (additive-insert path); WITH_FH carries +// last_firsthour_date (the replaceField scalar path); TEMPLATE ships the two append +// sections pre-created (the production section-append path). +const SAMPLE = `--- +last_post_date: "2026-04-05" +first_post_date: "2026-01-15" +last_post_topic: "AI strategy" +posts_this_week: 2 +weekly_goal: 3 +current_streak: 5 +longest_streak: 12 +current_week: "2026-W14" +follower_count: 850 +follower_target: 10000 +target_date: "2026-12-31" +--- + +# LinkedIn Session State + +## Recent Posts + +- [2026-04-05] "AI governance is not about..." (1450) - AI strategy + +## Milestone Log +`; + +const WITH_FH = SAMPLE.replace( + 'last_post_date: "2026-04-05"', + () => 'last_post_date: "2026-04-05"\nlast_firsthour_date: null' +); + +const TEMPLATE = `--- +last_post_date: "2026-04-05" +last_post_topic: "AI strategy" +posts_this_week: 2 +follower_count: 850 +follower_target: 10000 +target_date: "2026-12-31" +--- + +# LinkedIn Session State + +## Recent Posts + +- [2026-04-05] "AI governance is not about..." (1450) - AI strategy + +## First-Hour Plans + + + +## Outreach Pipeline + + +`; + +// Coverage battery: one or more cases per exported mutator. `fields` lists the +// `.replace()` paths exercised; `expect` are substrings that MUST appear verbatim in +// the output (each would differ if a `$`-token expanded). +const BATTERY = [ + { + fn: "updatePostTracking", + cases: [ + { + path: "scalar replaceField (:58) + Recent Posts append (:122)", + run: () => mod.updatePostTracking(SAMPLE, { + postDate: "2026-04-09", postTopic: PAYLOAD, hookText: PAYLOAD, charCount: 1200, format: "post", + }), + expect: [`last_post_topic: "${PAYLOAD}"`, `"${PAYLOAD}" (1200) - ${PAYLOAD}`], + once: [/^## Recent Posts$/gm], + }, + ], + }, + { + fn: "pruneContentHistory", + cases: [ + { + path: "section rewrite of KEPT entries (:171)", + run: () => { + const today = new Date(); + const old = new Date(today); old.setDate(old.getDate() - 100); + const recent = new Date(today); recent.setDate(recent.getDate() - 10); + const oldDate = old.toISOString().slice(0, 10); + const recentDate = recent.toISOString().slice(0, 10); + // Plant the payload via a FUNCTION replace so the fixture itself is not + // pre-corrupted by the very expansion under test. + const state = SAMPLE.replace( + "## Recent Posts\n\n", + () => `## Recent Posts\n\n- [${oldDate}] "drop" (1000) - drop me\n- [${recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}\n` + ); + const r = mod.pruneContentHistory(state, 90); + return { ...r, _recentDate: recentDate, _oldDate: oldDate }; + }, + assert: (r) => { + assert.notEqual(r, null, "prune returned null"); + assert.equal(r.pruned, 1, "exactly the old entry pruned"); + assert.ok(!r.content.includes(r._oldDate), "old entry pruned"); + assert.ok(r.content.includes(`- [${r._recentDate}] "${PAYLOAD}" (1200) - ${PAYLOAD}`), "kept $-entry survives verbatim"); + }, + once: [/^## Recent Posts$/gm], + }, + ], + }, + { + fn: "updateFollowerCount", + cases: [ + { + // No free-text field: count is an integer, month is a `YYYY-MM` date used in + // date math. There is NO untrusted-string replacement surface here. Covered + // for completeness with a structural-integrity assertion on benign input. + path: "no untrusted-string replacement surface (count/month are int/date)", + run: () => mod.updateFollowerCount(SAMPLE, { count: 920, month: "2026-04" }), + expect: ["follower_count: 920", "[2026-04] 920 (+70)"], + once: [/^## Milestone Log$/gm], + }, + ], + }, + { + fn: "recordFirstHourPlan", + cases: [ + { + path: "section append, free-text topic/targets/comments (:271)", + run: () => mod.recordFirstHourPlan(TEMPLATE, { + planDate: "2026-05-30 09:00", postTopic: PAYLOAD, targets: [PAYLOAD], draftComments: [PAYLOAD], plan: ["09:00 — live"], + }), + expect: [`### [2026-05-30 09:00] ${PAYLOAD}`, `- ${PAYLOAD}`], + once: [/^## First-Hour Plans$/gm], + }, + { + path: "date additive-insert (:246) — planDate fuzzed", + run: () => mod.recordFirstHourPlan(SAMPLE, { planDate: PAYLOAD, postTopic: "t" }), + expect: [`last_firsthour_date: "${PAYLOAD}"`], + once: [/^last_firsthour_date:/gm], + }, + { + path: "date scalar replaceField (:237) — planDate fuzzed", + run: () => mod.recordFirstHourPlan(WITH_FH, { planDate: PAYLOAD, postTopic: "t" }), + expect: [`last_firsthour_date: "${PAYLOAD}"`], + once: [/^last_firsthour_date:/gm], + }, + ], + }, + { + fn: "recordOutreachContact", + cases: [ + { + path: "section append, free-text partner/stage/nextAction (:331)", + run: () => mod.recordOutreachContact(TEMPLATE, { + contactDate: "2026-05-30 14:00", track: "collab", partner: PAYLOAD, stage: PAYLOAD, nextAction: PAYLOAD, dueDate: "2026-06-06", + }), + expect: [`${PAYLOAD}`, `**Stage:** ${PAYLOAD}`, `**Next action:** ${PAYLOAD}`], + once: [/^## Outreach Pipeline$/gm], + }, + { + path: "date additive-insert via last_post_date anchor (:308) — contactDate fuzzed", + run: () => mod.recordOutreachContact(SAMPLE, { contactDate: PAYLOAD, partner: "p" }), + expect: [`last_outreach_date: "${PAYLOAD}"`], + once: [/^last_outreach_date:/gm], + }, + ], + }, +]; + +// --- Backstop 1: coverage completeness ------------------------------------------- +// Every exported mutator must be in the battery, minus the documented I/O wrapper. +// A new export added without `$`-coverage fails here. +const IO_WRAPPER_EXEMPT = new Set(["writeState"]); +const exportedFns = Object.keys(mod).filter((k) => typeof mod[k] === "function"); +const covered = new Set(BATTERY.map((b) => b.fn)); +const uncovered = exportedFns.filter((k) => !covered.has(k) && !IO_WRAPPER_EXEMPT.has(k)); + +let failed = 0; +const failures = []; + +if (uncovered.length) { + failed++; + failures.push(`coverage gap: exported mutator(s) without $-safety coverage → ${uncovered.join(", ")} (add a battery case or document an exemption)`); +} +// Guard the exemption too: if writeState is renamed/removed, surface it rather than +// silently exempting a stale name. +for (const name of IO_WRAPPER_EXEMPT) { + if (!exportedFns.includes(name)) { + failed++; + failures.push(`exemption stale: '${name}' is exempted but no longer exported — re-check the exemption list`); + } +} + +// --- Backstop 2: non-vacuity self-test ------------------------------------------- +// Prove the payload is genuinely dangerous (string replace corrupts) and that the +// fix shape (function replace) preserves it. A guard that cannot see the corruption +// it forbids enforces nothing. +{ + const subject = 'last_post_topic: "OLD"'; + const naive = subject.replace(/^last_post_topic: .*/m, `last_post_topic: "${PAYLOAD}"`); + const safe = subject.replace(/^last_post_topic: .*/m, () => `last_post_topic: "${PAYLOAD}"`); + if (naive.includes(PAYLOAD)) { + failed++; + failures.push("self-test vacuous: a STRING replacement did NOT corrupt the payload — the payload no longer exercises `$`-expansion, so a PASS is meaningless"); + } + if (!safe.includes(PAYLOAD)) { + failed++; + failures.push("self-test broken: a FUNCTION replacement did NOT preserve the payload — the verbatim-survival assertion is unsound"); + } +} + +// --- Run the battery ------------------------------------------------------------- +let cases = 0; +for (const { fn, cases: list } of BATTERY) { + for (const c of list) { + cases++; + try { + const out = c.run(); + if (c.assert) { + c.assert(out); + } else { + const content = out && out.content; + assert.ok(content, `${fn} [${c.path}]: no content returned`); + for (const sub of c.expect || []) { + assert.ok(content.includes(sub), `${fn} [${c.path}]: missing verbatim → ${JSON.stringify(sub)}`); + } + for (const re of c.once || []) { + const hits = (content.match(re) || []).length; + assert.equal(hits, 1, `${fn} [${c.path}]: structural anchor ${re} appeared ${hits}× (expected 1 — a $&/$1 expansion duplicated it)`); + } + } + } catch (e) { + failed++; + failures.push(`${fn} [${c.path}]: ${e.message}`); + } + } +} + +if (failed === 0) { + console.log(`✓ $-safety: ${cases} adversarial case(s) across ${BATTERY.length} mutator(s) preserved the payload verbatim; coverage complete; self-test non-vacuous`); + process.exit(0); +} else { + console.error(`✗ $-safety guard FAILED (${failed}):`); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +} diff --git a/plugins/linkedin-studio/scripts/test-runner.sh b/plugins/linkedin-studio/scripts/test-runner.sh index 35feee9..8ce02f7 100755 --- a/plugins/linkedin-studio/scripts/test-runner.sh +++ b/plugins/linkedin-studio/scripts/test-runner.sh @@ -13,8 +13,10 @@ # Step 21; the agent model-consistency guard (each agents/.md frontmatter # model: must match every surface declaration, and canonical rosters must list # every agent) in S11; the render-chain propagation guard (no honesty pattern a -# command was cleaned of survives in the reference it renders from) in S12. All -# four are live below (Sections 8, 9, 10 and 11). +# 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). # # Usage: bash scripts/test-runner.sh # bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`. @@ -417,6 +419,34 @@ fi echo "" +# --- Section 12: `$`-Safety (String.replace replacement) --- +echo "--- \$-Safety (String.replace replacement) ---" + +# state-updater.mjs mutates the state file from untrusted user content (post +# topics, hooks, targets, partners, …). In a JS replacement *string*, `$&`/`` $` ``/ +# `$'`/`$$`/`$n` are special, so a `$`-bearing value rewrites the field; a +# replacement *function* inserts its return verbatim. Added in S13 after a cold +# full-brief review found the LAST member of this class: S12 converted the 5 +# section-append sites to functions but left `replaceField` (the scalar writer) on a +# replacement string, and the S12 `$`-test asserted only the section entry — never +# the `last_post_topic` scalar — so the corruption shipped green. This is the S9→S12 +# "close the class, not the line" lesson on the `$`-axis: rather than grep a +# syntactic proxy (which cannot tell a replacement-position template literal from a +# RegExp-pattern one across multi-line calls), check-replace-safety.mjs drives EVERY +# exported mutator with an adversarial payload of every special token in every +# free-text + date field and asserts verbatim survival. Two structural backstops run +# inside it on every invocation: COVERAGE-COMPLETENESS (a new export without +# `$`-coverage fails) and a NON-VACUITY SELF-TEST (a naive string-replace MUST +# corrupt the payload, a function MUST preserve it — else a PASS is meaningless), +# mirroring Section 8/10/11. +if node scripts/check-replace-safety.mjs; then + pass "\$-safety: no untrusted value reaches a String.replace replacement string (behavioral, coverage-complete, self-testing)" +else + fail "\$-safety guard failed — a state-updater String.replace replacement is \$-unsafe; see check-replace-safety.mjs output above" +fi + +echo "" + # --- Summary --- echo "================================================" echo "RESULTS"