#!/bin/bash # LinkedIn Studio Plugin — Structure Validator # Validates the REAL v3.1 layout: registration counts (derived dynamically), # frontmatter shape, hook drift, and plugin.json fields. Counts are asserted # against the declared contract below, which is kept in sync with the # CLAUDE.md "## Agents (N)" / "## Commands (N)" headers (cross-checked here) # and the STATE.md "Telling" block. Adding or removing an agent, command, # reference, or skill breaks the count-equality and fails the lint — this is # the registration guard that gates the remediation plan's later steps. # # The stat-consistency grep (one magnitude per algorithm effect across the # tree) was added in remediation Step 3; the version-consistency grep in # 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; the # `$`-safety guard (no untrusted value reaches a String.replace replacement STRING # in state-updater.mjs — proven behaviorally, coverage-complete, self-testing) in # S13. The external data-dir convention guard (M0: no command/agent/skill/hook prose # references a migrated user-data path in-plugin — bare or ${CLAUDE_PLUGIN_ROOT}-pinned; # + no ANALYTICS_ROOT in-plugin pin; + an SC2 dry-run that the tree holds no in-plugin # user data) in Section 13, each with a non-vacuity self-test; the contract-gate # binding guard (Slice 3: the §B/§C1 gate's own suite stays green and rules.ts # ratifies 1:1 against the maskinrommet §E-manifest — KTG-only, skipped for an adopter # shipping no deps/contract) in Section 14; the specifics-bank binding guard (Fix #2 # slice 3: the lived-specifics store/binding suite stays green and its case count # never erodes — KTG-only, skipped for an adopter shipping no deps) in Section 15; # the trends-store binding guard (research-engine slice 2b: the trend store's suite # stays green and its case count never erodes, now that trend-spotter persists its # findings through it — KTG-only, skipped for an adopter shipping no deps) in Section # 16; the trend-spotter de-niche guard (B-S1: agents/trend-spotter.md names no # hardcoded vendor/sector beat — the domain comes from the user's pillars at runtime, # never baked into the agent — with a non-vacuity self-test) in Section 17; the # assertion-count anti-erosion floor (SC6) in Section 18. All are live below # (Sections 8–18). # # Usage: bash scripts/test-runner.sh # bash 3.2-safe: plain arrays only, no `declare -A`, no `mapfile`/`readarray`. set -e PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$PLUGIN_ROOT" PASS=0 FAIL=0 WARN=0 RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # No Color pass() { echo -e "${GREEN}✓${NC} $1"; PASS=$((PASS + 1)); } fail() { echo -e "${RED}✗${NC} $1"; FAIL=$((FAIL + 1)); } warn() { echo -e "${YELLOW}⚠${NC} $1"; WARN=$((WARN + 1)); } # --- Declared registration contract (the "Telling" block) --- # Source of truth: CLAUDE.md headers + STATE.md Telling. Bump these together # with the files when adding/removing an agent, command, reference, or skill. EXPECT_AGENTS=19 EXPECT_COMMANDS=29 EXPECT_REFS=27 EXPECT_SKILLS=6 # Pre-M0 references/ baseline was 25. Every ref doc added since is NAMED below, so the # count bump always maps to an intended, named addition — never an incidental doc masked # by the bump (m3/m11). M0 added data-path-convention.md; each later slice appends its # doc to POSTM0_REFS. The assert below proves EXPECT_REFS == 25 + 1 (M0) + |POSTM0_REFS| # AND that every named doc actually exists. bash 3.2-safe: plain indexed array. REFS_BASELINE_PRE_M0=25 M0_REF="references/data-path-convention.md" POSTM0_REFS=("references/trend-scoring-modes.md") # research-engine slice 2a (scoring SSOT) echo "================================================" echo "LinkedIn Studio Plugin — Structure Validator" echo "Plugin root: $PLUGIN_ROOT" echo "================================================" echo "" # --- Section 1: Core Files --- echo "--- Core Files ---" for f in ".claude-plugin/plugin.json" "CLAUDE.md" "CHANGELOG.md" "README.md" "config/REMEMBER.template.md"; do if [ -f "$f" ]; then pass "$f exists" else fail "$f MISSING" fi done echo "" # --- Section 2: Registration Counts (dynamic) --- echo "--- Registration Counts ---" AGENTS=$(ls agents/*.md 2>/dev/null | wc -l | tr -d ' ') COMMANDS=$(ls commands/*.md 2>/dev/null | wc -l | tr -d ' ') REFS=$(ls references/*.md 2>/dev/null | wc -l | tr -d ' ') SKILLS=$(ls skills/*/SKILL.md 2>/dev/null | wc -l | tr -d ' ') assert_count() { # $1 label, $2 actual, $3 expected if [ "$2" -eq "$3" ]; then pass "$1: $2 (expected $3)" else fail "$1: $2 (expected $3) — registration drift" fi } assert_count "agents/*.md" "$AGENTS" "$EXPECT_AGENTS" assert_count "commands/*.md" "$COMMANDS" "$EXPECT_COMMANDS" assert_count "references/*.md" "$REFS" "$EXPECT_REFS" assert_count "skills/*/SKILL.md" "$SKILLS" "$EXPECT_SKILLS" # references/ count must map 1:1 to the NAMED additions (M0 + every later slice's doc), # and each named doc must exist. Guards against the bump silently absorbing an incidental # extra ref doc (m3/m11). To add a ref doc: append it to POSTM0_REFS and bump EXPECT_REFS. NAMED_REFS_OK=1 [ -f "$M0_REF" ] || NAMED_REFS_OK=0 for r in "${POSTM0_REFS[@]}"; do [ -f "$r" ] || NAMED_REFS_OK=0 done EXPECT_NAMED=$((REFS_BASELINE_PRE_M0 + 1 + ${#POSTM0_REFS[@]})) if [ "$EXPECT_REFS" -eq "$EXPECT_NAMED" ] && [ "$NAMED_REFS_OK" -eq 1 ]; then pass "references/ count maps to named additions (${REFS_BASELINE_PRE_M0} baseline +1 M0 +${#POSTM0_REFS[@]} post-M0 = ${EXPECT_REFS}; all named docs exist)" else fail "references/ count != named additions — bump EXPECT_REFS and name the doc in POSTM0_REFS (expected ${EXPECT_NAMED}, have ${EXPECT_REFS}; named-docs-exist=${NAMED_REFS_OK})" fi # Cross-check the CLAUDE.md declared headers against the contract (doc-drift guard) DOC_AGENTS=$(grep -oE '^## Agents \([0-9]+\)' CLAUDE.md | grep -oE '[0-9]+' | head -1) DOC_COMMANDS=$(grep -oE '^## Commands \([0-9]+\)' CLAUDE.md | grep -oE '[0-9]+' | head -1) if [ "$DOC_AGENTS" = "$EXPECT_AGENTS" ]; then pass "CLAUDE.md '## Agents ($DOC_AGENTS)' matches contract" else fail "CLAUDE.md agents header ($DOC_AGENTS) != contract ($EXPECT_AGENTS)" fi if [ "$DOC_COMMANDS" = "$EXPECT_COMMANDS" ]; then pass "CLAUDE.md '## Commands ($DOC_COMMANDS)' matches contract" else fail "CLAUDE.md commands header ($DOC_COMMANDS) != contract ($EXPECT_COMMANDS)" fi # README shields commands-count badge must match the contract too. Added after an # S14 /trekreview found the badge stale at commands-27 while the surface shipped 29: # the version-consistency grep (Section 9) checks only the version badge, and the # count guards above check the CLAUDE.md header, so the README count badge slipped # both. This closes that gap (the count-badge analogue of the version-badge check). if grep -q "badge/commands-${EXPECT_COMMANDS}-" README.md; then pass "README commands badge declares ${EXPECT_COMMANDS}" else fail "README commands badge != ${EXPECT_COMMANDS} (expected shields badge/commands-${EXPECT_COMMANDS}-)" fi echo "" # --- Section 3: Agent Frontmatter --- echo "--- Agent Frontmatter ---" for f in agents/*.md; do if head -1 "$f" | grep -q "^---"; then if grep -q "^name:" "$f" && grep -q "^description:" "$f"; then pass "$f (frontmatter OK)" else fail "$f (missing name:/description:)" fi else fail "$f (no YAML frontmatter)" fi done echo "" # --- Section 4: Command Frontmatter --- echo "--- Command Frontmatter ---" for f in commands/*.md; do if head -1 "$f" | grep -q "^---"; then if grep -q "^name:" "$f" && grep -q "^description:" "$f"; then pass "$f (frontmatter OK)" else fail "$f (missing name:/description:)" fi else fail "$f (no YAML frontmatter)" fi done echo "" # --- Section 5: Hook Configuration (drift) --- echo "--- Hook Configuration ---" if [ -f "hooks/hooks.json" ]; then pass "hooks/hooks.json exists" if python3 hooks/scripts/compile-hooks.py --check >/dev/null 2>&1; then pass "hooks.json matches compiled template (no drift)" else fail "hooks.json DRIFT — run: python3 hooks/scripts/compile-hooks.py" fi else fail "hooks/hooks.json MISSING" fi echo "" # --- Section 6: Plugin.json Validation --- echo "--- Plugin.json Validation ---" if python3 -c " import json, sys with open('.claude-plugin/plugin.json') as f: data = json.load(f) required = ['name', 'version', 'description'] missing = [field for field in required if field not in data] if missing: print('Missing fields:', missing) sys.exit(1) print('Version:', data['version']) " 2>/dev/null; then pass "plugin.json structure valid (name/version/description)" else fail "plugin.json structure invalid" fi echo "" # --- Section 7: Analytics Source --- echo "--- Analytics Source ---" if [ -f "scripts/analytics/src/cli.ts" ]; then pass "scripts/analytics/src/cli.ts exists" else fail "scripts/analytics/src/cli.ts MISSING" fi echo "" # --- Section 8: Algorithm-Stat Consistency --- echo "--- Algorithm-Stat Consistency ---" # The single source of truth for algorithm magnitudes is # references/algorithm-signals-reference.md. After the Phase-0 reconciliation, # stale/competing magnitudes — the retired engagement-coefficient folklore, the # unpublishable model params/brand, and the deployment date — must not reappear # anywhere else (cite the reference, do not restate). This enforces "one magnitude # per algorithm effect" by forbidding EVERY retired-class value from returning, so # the same grep that defines the Phase-0 Success Criterion fails on any survivor. # # S9 rebuild: the S8 list forbade only the two S7-named strings and went green # over six more survivors (the coefficient system in analytics-interpreter/ # content-optimizer/pipeline/glossary, the playbook 15x/5x, the 150B model). This # list is rebuilt to the FULL criterion. Forbidden classes (each maps to a # canonical statement in the reference): # - Carousel-rate folklore: 6.6% / 6.60% / 1.92% → reference: "~7% top format" # - Link-penalty folklore: 40-50% / 25-40% / -40-60% → reference: one ~38% correlational band # - Comment-multiplier folklore: "15x more reach/algorithmic", "5x more effective/ # less valuable/reach than" → reference: order only, comment ≈ 2x a like # - Video-multiplier folklore: "5x more conversations" → reference: video declining, no multiplier # - Engagement-coefficient system: 7-9x, 2.5x, 0.2x, (10x), (8x), "10x weight" # → reference: "never hard coefficients to optimize against" # - Model params/brand/date: the PATTERN CLASS [0-9]+[ -]?(B|billion)?[ -]?param # (covers 150-parameter / 150B param / 150 billion param) / 360Brew / January 2026 # → reference: "Not publishable as fact" # # S10: the model-precision token is now the pattern CLASS, not a literal-token # list. S9 forbade only "150 ?B param|150 billion param"; a hyphenated # "150-parameter" (no "B") slipped both the discovery grep and the lint, surviving # in glossary.md:10. The criterion is "no asserted model precision in ANY surface # form", so the lint now enforces the shape (a number adjacent to "param"), not an # enumeration. An adjacent digit is REQUIRED, so legitimate "param" uses with no # leading number — "Language parameter", "parameterized", "different parameters", # "«parametere»", "175-milliarders parametermodell" — do not match. # Bare "10x"/"15x"/"5x" are deliberately NOT forbidden — they carry legitimate # uses (collaboration "10x your reach" hyperbole, "5x5x5", posting cadence, pixel # dims like 1080x1350), so each token targets the retired *phrasing*, not the bare # number. # # Scope covers every dir the criterion's grep covers, including assets/checklists/ # (the 360Brew survivor lived there, outside the S8 scan), assets/templates/, and # CHANGELOG.md (S10: the 360Brew/January-2026 survivor lived there, outside the S9 # scope). assets/{templates,checklists}/ — not all of assets/ — keeps the scan off # gitignored runtime data (assets/analytics/, assets/drafts/, voice-samples/). STALE_STATS='40-50%|25-40%|6\.6%|6\.60%|1\.92%|15x more reach|15x more algorithmic|5x more effective|5x less valuable|5x more reach than|5x more conversations|7-9x|2\.5x|0\.2x|\(10x\)|\(8x\)|10x weight|-40-60%|[0-9]+[ -]?(B|billion)?[ -]?param|360Brew|January 2026' # Non-vacuity self-test (S10). A grep criterion is only meaningful if it actually # MATCHES the forbidden forms and does NOT match legitimate ones. S7→S9 each # shipped a lint that passed green while a survivor slipped, because the proof was # run once by hand and never committed — so a hyphenated "150-parameter" form was # never re-checked. This makes the proof PERMANENT: it runs on every invocation # BEFORE the real scan, so narrowing STALE_STATS back to a literal-token list fails # the suite instead of silently certifying an unenforced criterion. The positive # set covers all three model-precision surface forms (incl. the exact S10 # "150-parameter" survivor); the negative set covers the legitimate "param"/x uses # that live in the tree today. SELFTEST_OK=1 while IFS= read -r probe; do [ -z "$probe" ] && continue if ! echo "$probe" | grep -qE "$STALE_STATS"; then SELFTEST_OK=0; echo " non-vacuity FAIL: forbidden form not caught -> $probe" fi done <<'POSITIVE' 40-50% link penalty 6.6% carousel rate 1.92% reach 15x more reach 5x more conversations 7-9x weight (10x) coefficient 10x weight 150-parameter foundation model 150B parameter foundation model 150 billion parameter model 360Brew January 2026 algorithm update POSITIVE while IFS= read -r probe; do [ -z "$probe" ] && continue if echo "$probe" | grep -qE "$STALE_STATS"; then SELFTEST_OK=0; echo " false-positive FAIL: legitimate form caught -> $probe" fi done <<'NEGATIVE' 5x5x5 pre-posting method post 3x per week 1080x1350 pixels 10x your reach Language parameter (configurable) parameterized content-gatekeeper Start over with different parameters 175-milliarders parametermodell NEGATIVE if [ "$SELFTEST_OK" -eq 1 ]; then pass "STALE_STATS self-test: 13 forbidden forms caught, 8 legitimate forms ignored" else fail "STALE_STATS self-test failed — the lint no longer enforces the full criterion" fi STAT_HITS=$(grep -rnE "$STALE_STATS" references/ commands/ skills/ hooks/prompts/ agents/ assets/templates/ assets/checklists/ CLAUDE.md README.md CHANGELOG.md .claude-plugin/plugin.json 2>/dev/null | grep -v 'algorithm-signals-reference' || true) if [ -z "$STAT_HITS" ]; then pass "no stale algorithm magnitudes / model brand outside the canonical reference" else fail "stale algorithm stat(s) reintroduced — cite algorithm-signals-reference.md instead:" echo "$STAT_HITS" fi echo "" # --- Section 9: Version Consistency --- echo "--- Version Consistency ---" # Single source of truth for the plugin version: .claude-plugin/plugin.json. # Its value must be declared identically in the README badge, the CLAUDE.md # header, and the CHANGELOG top entry. Historical references to older versions # (CHANGELOG history, the README version-history table, "vX added Y" prose) are # NOT checked here — only the current-version DECLARATIONS must agree. VERSION=$(python3 -c "import json; print(json.load(open('.claude-plugin/plugin.json'))['version'])" 2>/dev/null) if [ -z "$VERSION" ]; then fail "could not read version from plugin.json" else pass "plugin.json version: $VERSION" if grep -q "version-${VERSION}-blue" README.md; then pass "README badge declares v$VERSION" else fail "README badge does not declare v$VERSION (expected version-${VERSION}-blue)" fi if grep -q "LinkedIn Studio Plugin (v${VERSION})" CLAUDE.md; then pass "CLAUDE.md header declares v$VERSION" else fail "CLAUDE.md header does not declare (v$VERSION)" fi if grep -q "^## \[${VERSION}\]" CHANGELOG.md; then pass "CHANGELOG has a [$VERSION] entry" else fail "CHANGELOG missing a [$VERSION] entry" fi fi echo "" # --- Section 10: Agent Model-Consistency --- echo "--- Agent Model-Consistency ---" # Each agents/.md frontmatter `model:` is the source of truth; every # surface that DECLARES an agent's model (README, CLAUDE.md, the capability # matrix, the SKILL rosters) must match it, and the canonical rosters must list # every agent. Added in S11 after a cold full-brief review found # post-feedback-monitor published as Haiku across four surfaces while the agent # runs Opus — declaration drift the version/count/stat guards could not see. The # checker self-tests its own non-vacuity on every run (see the .mjs header): # a deliberately-mismatched probe must be caught and a correct one ignored, else # the suite fails instead of certifying an unenforced criterion. if node scripts/check-model-consistency.mjs; then pass "agent model-consistency: all surface declarations match frontmatter + canonical rosters complete" else fail "agent model-consistency drift — see check-model-consistency.mjs output above" fi echo "" # --- Section 11: Render-Chain Propagation --- echo "--- Render-Chain Propagation ---" # Commands render from the references they inline via # ${CLAUDE_PLUGIN_ROOT}/references/…. An honesty pattern removed from a command # surface must NOT survive in the reference that command renders from — otherwise # the user still hits it. Added in S12 after a cold full-brief review found the # banned A/B significance-verdict column (`Significant? (>20%)` with Yes/No cells) # still shipping in references/ab-testing-framework.md while commands/ab-test.md had # already been cleaned to the honest "Directional?" framing. The command-level fix # never propagated to its render-source, and Section 8's STALE_STATS grep targets # magnitudes, not this construct, so the survivor passed green. This generalizes the # fix from "clean the command" to "the banned construct is forbidden across the # WHOLE render chain (commands AND references)". Future propagation-class patterns # get appended to PROP_FORBIDDEN, mirroring how Section 8's STALE_STATS grew to the # full criterion rather than the single named token. # # Forbidden: the significance-VERDICT column — `Significant?` adjacent to a `(` (the # `(>20%)` verdict parenthetical) or a table pipe (`| Significant?`). The defect is a # column steering users to record a statistical-significance call that organic # personal-post volume never reaches; "directional" is the honest frame. Legitimate # descriptive prose ("Significantly higher", "Significant capability", "statistical # significance", a bare sentence-final "significant?") carries no `(`/`|`-adjacency # and is left alone. PROP_FORBIDDEN='Significant\?[[:space:]]*\(|\|[[:space:]]*Significant\?' # Non-vacuity self-test (mirrors Section 8): the criterion is only meaningful if it # MATCHES the verdict-column forms and IGNORES legitimate prose. Runs on every # invocation BEFORE the real scan, so weakening the pattern fails the suite instead # of silently certifying an unenforced guard. The positive set covers the exact S12 # survivor + its bare-column variant; the negative set covers the honest # "Directional?" fix and every legitimate "Significant"/"significance" string the # tree carries today. PROP_SELFTEST_OK=1 while IFS= read -r probe; do [ -z "$probe" ] && continue if ! echo "$probe" | grep -qE "$PROP_FORBIDDEN"; then PROP_SELFTEST_OK=0; echo " non-vacuity FAIL: forbidden form not caught -> $probe" fi done <<'PROP_POSITIVE' | Difference | Significant? (>20%) | Significant? (>20%) | Significant? | PROP_POSITIVE while IFS= read -r probe; do [ -z "$probe" ] && continue if echo "$probe" | grep -qE "$PROP_FORBIDDEN"; then PROP_SELFTEST_OK=0; echo " false-positive FAIL: legitimate form caught -> $probe" fi done <<'PROP_NEGATIVE' | Difference | Directional? (>20% gap) | Significantly higher weight than generic responses Significant capability breakthroughs Significantly Behind (<50%) LinkedIn analytics does not support statistical significance tests Is the difference significant? Probably not. PROP_NEGATIVE if [ "$PROP_SELFTEST_OK" -eq 1 ]; then pass "render-chain propagation self-test: 3 verdict-column forms caught, 6 legitimate forms ignored" else fail "render-chain propagation self-test failed — the guard no longer enforces the criterion" fi # Real scan across the whole user-facing render chain (commands + every reference # they inline) plus the adjacent surfaces a copy could migrate the table into. PROP_HITS=$(grep -rnE "$PROP_FORBIDDEN" references/ commands/ skills/ hooks/prompts/ agents/ assets/templates/ assets/checklists/ 2>/dev/null || true) if [ -z "$PROP_HITS" ]; then pass "no significance-verdict column survives in any command or its render-source reference" else fail "significance-verdict column reintroduced — use the honest 'Directional?' framing (see commands/ab-test.md):" echo "$PROP_HITS" 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 "" # --- Section 13: External Data-Dir Convention (R1 — M0) --- echo "--- External Data-Dir Convention ---" # After the M0 migration (user data lives under ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/ # linkedin-studio}/), no command/agent/skill/hook prose may reference a MIGRATED # user-data path in-plugin — neither bare (assets//) nor ${CLAUDE_PLUGIN_ROOT}- # prefixed — or a command writes the external dir while Claude follows stale in-plugin # prose (R1, brief §12.6). Shipped read-only assets keep ${CLAUDE_PLUGIN_ROOT} and are # exempt: the analytics README, every *-template.md seed, assets/checklists/, # assets/quick-post-resources.md, the shipped assets/templates/*, config/*.template.*. # config/personas.local.md is a deliberate in-plugin fallback library (un-migrated, out # of M0 scope) and is NOT in the class. Scope = the actionable prose dirs (commands/ # agents/skills/hooks-prompts); references/ is documentation (the convention doc # legitimately names scaffold paths) and is excluded. BARE_DATA='(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/(voice-samples|analytics|drafts|plans|audience-insights|examples|frameworks|case-studies|network)/|(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/repurposing-tracker\.md|(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/templates/my-post-templates\.md|(\$\{CLAUDE_PLUGIN_ROOT\}/)?config/user-profile\.local\.md' BARE_EXEMPT='README\.md|-template\.md' # Non-vacuity self-test (mirrors Section 8): the full criterion is regex-match AND # not-exempt. Positives MUST be violations; negatives (external token + every shipped # exemption + the out-of-scope personas lib) MUST NOT be. B13_OK=1 while IFS= read -r probe; do [ -z "$probe" ] && continue if echo "$probe" | grep -qE "$BARE_DATA" && ! echo "$probe" | grep -qE "$BARE_EXEMPT"; then :; else B13_OK=0; echo " non-vacuity FAIL: in-plugin data path not flagged -> $probe" fi done <<'POSITIVE13' assets/voice-samples/authentic-voice-samples.md ${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/ assets/drafts/queue.json assets/templates/my-post-templates.md assets/frameworks/ai-maturity-model.md assets/repurposing-tracker.md config/user-profile.local.md POSITIVE13 while IFS= read -r probe; do [ -z "$probe" ] && continue if echo "$probe" | grep -qE "$BARE_DATA" && ! echo "$probe" | grep -qE "$BARE_EXEMPT"; then B13_OK=0; echo " false-positive FAIL: legitimate path flagged -> $probe" fi done <<'NEGATIVE13' ${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/ assets/analytics/README.md assets/frameworks/framework-template.md assets/case-studies/case-study-template.md assets/checklists/quality-scorecard.md assets/quick-post-resources.md config/user-profile.template.md config/personas.local.md NEGATIVE13 if [ "$B13_OK" -eq 1 ]; then pass "no-bare-path self-test: 7 in-plugin data paths caught, 8 legitimate/exempt forms ignored" else fail "no-bare-path self-test failed — the R1 criterion no longer enforces in-plugin data-path detection" fi BARE_HITS=$(grep -rnE "$BARE_DATA" commands/ agents/ skills/ hooks/prompts/ 2>/dev/null | grep -vE "$BARE_EXEMPT" || true) if [ -z "$BARE_HITS" ]; then pass "no command/agent/skill/hook prose references a migrated user-data path in-plugin (R1)" else fail "in-plugin user-data path(s) in prose — route via \${LINKEDIN_STUDIO_DATA:-\$HOME/.claude/linkedin-studio}/ (see references/data-path-convention.md):" echo "$BARE_HITS" fi # Sibling no-pin guard (M3): the no-bare-path grep cannot catch an ANALYTICS_ROOT alias # pinned to the in-plugin analytics dir — Step 11 dropped these; forbid their return. PIN='ANALYTICS_ROOT=.{0,2}\$\{CLAUDE_PLUGIN_ROOT\}/assets/analytics' PIN_OK=1 echo 'ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"' | grep -qE "$PIN" || { PIN_OK=0; echo " non-vacuity FAIL: in-plugin pin not caught"; } if echo 'ANALYTICS_ROOT="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics"' | grep -qE "$PIN"; then PIN_OK=0; echo " false-positive FAIL: external alias caught"; fi if [ "$PIN_OK" -eq 1 ]; then pass "no-pin self-test: in-plugin ANALYTICS_ROOT pin caught, external alias ignored" else fail "no-pin self-test failed — the M3 pin criterion is not enforced" fi PIN_HITS=$(grep -rnE "$PIN" commands/ agents/ skills/ hooks/prompts/ 2>/dev/null || true) if [ -z "$PIN_HITS" ]; then pass "no prose pins ANALYTICS_ROOT to the in-plugin analytics dir (M3)" else fail "ANALYTICS_ROOT pinned to in-plugin analytics — drop the pin (external default applies):" echo "$PIN_HITS" fi # SC2 dry-run: the migrated in-plugin user-data classes (.gitignore) must hold NO data # files — after M0 they live external. --ignored surfaces gitignored stragglers a plain # porcelain would hide; filtered to the data classes, it must be empty. Catches a flow # (or a stray file) that wrote user data back into the plugin tree (SC2). personas is # excluded (un-migrated, out of M0 scope — consistent with Section 13's class). SC2_CLASSES='assets/analytics/(exports|posts|weekly-reports|monthly-reports)/|assets/analytics/content-history\.md|assets/drafts/queue\.json|assets/drafts/week-|assets/voice-samples/[^ ]*\.local\.md|config/user-profile\.local\.md' SC2_DIRT=$(git status --porcelain --ignored 2>/dev/null | grep -E "$SC2_CLASSES" || true) if [ -z "$SC2_DIRT" ]; then pass "SC2 dry-run: no in-plugin user-data files (analytics/drafts/voice/profile) in the tree" else fail "SC2 violation — migrated user data sits in the plugin tree (should be external):" echo "$SC2_DIRT" fi echo "" # --- Section 14: Contract-Gate Binding (Slice 3) --- echo "--- Contract-Gate Binding ---" # The deterministic §B/§C1 contract-gate (scripts/contract-gate) is wired into # /linkedin:newsletter as Step 4.5. Two invariants belong in CI, not per-edition: # (14a) the gate's own test suite stays green and its case count never erodes; and # (14b) rules.ts stays bound 1:1 to the maskinrommet skrivekontrakt §E-manifest # (ratify). Both are KTG-internal: skipped (warn, never fail) for an adopter that # ships no contract-gate deps or no contract — so the lint stays green everywhere. # bash 3.2-safe; `set +e` inside the test command-substitution keeps a red npm test # from aborting the whole runner (set -e is active), and ratify exit 1 is captured by # the `if (subshell)` form (condition context is exempt from set -e). CG_DIR="scripts/contract-gate" if [ -x "$CG_DIR/node_modules/.bin/tsx" ]; then # 14a: the gate suite is green (npm test exit 0) and holds its case-count floor. CG_OUT=$( set +e; (cd "$CG_DIR" && npm test) 2>&1; echo "CG_EXIT:$?" ) CG_EXIT=$(echo "$CG_OUT" | grep -oE 'CG_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) CG_TESTS=$(echo "$CG_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) CONTRACT_GATE_TESTS_FLOOR=33 if [ "$CG_EXIT" = "0" ] && [ -n "$CG_TESTS" ] && [ "$CG_TESTS" -ge "$CONTRACT_GATE_TESTS_FLOOR" ]; then pass "contract-gate suite green: $CG_TESTS tests pass (floor $CONTRACT_GATE_TESTS_FLOOR)" else fail "contract-gate suite NOT green (exit=${CG_EXIT:-?}, tests=${CG_TESTS:-?}, floor $CONTRACT_GATE_TESTS_FLOOR) — run: (cd $CG_DIR && npm test)" fi # 14b: ratify — rules.ts <-> §E-manifest 1:1. Skipped when the contract is absent. CONTRACT="${MASKINROMMET_CONTRACT:-$PLUGIN_ROOT/../../maskinrommet/docs/skrivekontrakt.md}" if [ -f "$CONTRACT" ]; then if (cd "$CG_DIR" && node --import tsx src/cli.ts --ratify) >/dev/null 2>&1; then pass "contract-gate ratify: rules.ts bound 1:1 to the §E-manifest (in sync)" else fail "contract-gate ratify DRIFT — rules.ts != §E-manifest; run: (cd $CG_DIR && npm run ratify)" fi else warn "contract-gate ratify skipped — contract absent ($CONTRACT); KTG-only invariant" fi else warn "contract-gate skipped — deps absent ($CG_DIR/node_modules); run: (cd $CG_DIR && npm install)" fi echo "" # --- Section 15: Specifics-Bank Binding (Fix #2 slice 3) --- echo "--- Specifics-Bank Binding ---" # The lived-specifics store + per-edition binding (scripts/specifics-bank) is wired # into /linkedin:newsletter as Step 1.5 (elicitation + slot-map) and its # validate-binding gate into the Step 2.5 skeleton-gate. The seam is now load-bearing, # so its suite belongs in CI: the bank/binding/kilder tests stay green and the case # count never erodes. KTG-internal: skipped (warn, never fail) for an adopter that # ships no specifics-bank deps — so the lint stays green everywhere. Same set +e / # subshell discipline as Section 14 (bash 3.2-safe; keeps a red npm test from # aborting the runner under set -e). SB_DIR="scripts/specifics-bank" if [ -x "$SB_DIR/node_modules/.bin/tsx" ]; then SB_OUT=$( set +e; (cd "$SB_DIR" && npm test) 2>&1; echo "SB_EXIT:$?" ) SB_EXIT=$(echo "$SB_OUT" | grep -oE 'SB_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) SB_TESTS=$(echo "$SB_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) SPECIFICS_BANK_TESTS_FLOOR=28 if [ "$SB_EXIT" = "0" ] && [ -n "$SB_TESTS" ] && [ "$SB_TESTS" -ge "$SPECIFICS_BANK_TESTS_FLOOR" ]; then pass "specifics-bank suite green: $SB_TESTS tests pass (floor $SPECIFICS_BANK_TESTS_FLOOR)" else fail "specifics-bank suite NOT green (exit=${SB_EXIT:-?}, tests=${SB_TESTS:-?}, floor $SPECIFICS_BANK_TESTS_FLOOR) — run: (cd $SB_DIR && npm test)" fi else warn "specifics-bank skipped — deps absent ($SB_DIR/node_modules); run: (cd $SB_DIR && npm install)" fi echo "" # --- Section 16: Trends-Store Binding (research-engine slice 2b) --- echo "--- Trends-Store Binding ---" # The persistent trend store (scripts/trends) is wired into the trend-spotter agent # (slice 2b): the agent queries prior history and persists each kept trend through the # store's deterministic add (dedup/union), so the store is now load-bearing for the # research engine's de-amnesia — not a standalone inventory. Its suite therefore belongs # in CI: the store/dedup/query tests stay green and the case count never erodes. Mirrors # the specifics-bank binding guard (Section 15) — the trend-side twin. KTG-internal: # skipped (warn, never fail) for an adopter that ships no trends deps. Same set +e / # subshell discipline as Sections 14–15 (bash 3.2-safe; keeps a red npm test from # aborting the runner under set -e). TR_DIR="scripts/trends" if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" ) TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) TRENDS_TESTS_FLOOR=24 # B-S3: +3 newestCaptureDate tests (staleness signal) if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)" else fail "trends-store suite NOT green (exit=${TR_EXIT:-?}, tests=${TR_TESTS:-?}, floor $TRENDS_TESTS_FLOOR) — run: (cd $TR_DIR && npm test)" fi else warn "trends-store skipped — deps absent ($TR_DIR/node_modules); run: (cd $TR_DIR && npm install)" fi echo "" # --- Section 16b: Brain Foundation (SB-S0) --- echo "--- Brain Foundation ---" # The second-brain foundation (scripts/brain, SB-S0) ships the brain/ scaffold # initialiser, the two-layer profile.md line-grammar, the lossless+idempotent # user-profile fold, and the canonical entity-id + provenance module. This is the # spine later slices hang on (the id threads through tributaries in SB-S3, the # evolution loop consumes the profile in SB-S2), so the suite stays green and its # case count never erodes. KTG-internal: skipped (warn, never fail) for an adopter # that ships no brain deps. Same set +e / subshell discipline as Sections 14-16 # (bash 3.2-safe; keeps a red npm test from aborting the runner under set -e). BR_DIR="scripts/brain" if [ -x "$BR_DIR/node_modules/.bin/tsx" ]; then BR_OUT=$( set +e; (cd "$BR_DIR" && npm test) 2>&1; echo "BR_EXIT:$?" ) BR_EXIT=$(echo "$BR_OUT" | grep -oE 'BR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) BR_TESTS=$(echo "$BR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) BRAIN_TESTS_FLOOR=34 # SB-S0: id(11) + profile(6) + fold(12) + scaffold(5) if [ "$BR_EXIT" = "0" ] && [ -n "$BR_TESTS" ] && [ "$BR_TESTS" -ge "$BRAIN_TESTS_FLOOR" ]; then pass "brain suite green: $BR_TESTS tests pass (floor $BRAIN_TESTS_FLOOR)" else fail "brain suite NOT green (exit=${BR_EXIT:-?}, tests=${BR_TESTS:-?}, floor $BRAIN_TESTS_FLOOR) — run: (cd $BR_DIR && npm test)" fi else warn "brain skipped — deps absent ($BR_DIR/node_modules); run: (cd $BR_DIR && npm install)" fi echo "" # --- Section 17: De-Niche Guard (B-S1 + B-S2) --- echo "--- De-Niche Guard ---" # The de-niche sweep removed the hardcoded KTG beat (Microsoft / public-sector) from # three surfaces so the domain comes from the user's profile/pillars at runtime, never # baked in (plugin-is-domain-general): # - agents/trend-spotter.md (B-S1: pillar-driven scanning; the agent's own # contract says "the niche lives in the source # list and the user's pillars, never in this agent") # - agents/content-planner.md (B-S2a: planner calendar recast off the beat) # - references/content-framework.md (B-S2a: recast + renamed from ai-content-framework.md) # Each must name NO specific vendor or sector beat; this guard forbids those KTG-beat # proper nouns from returning to any of them. Scoped per file BY DESIGN: the check runs # against this explicit allowlist, NOT the whole tree, because references/content-angles.md # legitimately lists "Public Sector" as one of six example industry tables (a KEEP surface, # generic illustration — not a hardcoded beat), and "AI" is the plugin's own subject, not a # niche token. Non-vacuity self-test mirrors Sections 8/13: the criterion must catch the beat # tokens and ignore generic prose (incl. the content-framework.md reference filename — B-S2a # recast + renamed it from ai-content-framework.md — and "AI"-flavoured planner prose, locking # in that "AI" is never treated as a beat). Case-insensitive: "Public sector" and "public # sector" name the same beat, and a future reintroduction could use either case — the # positive set locks that in. NICHE_TOKENS='Microsoft|Azure|Copilot|public sector|offentlig sektor' TS_SELFTEST_OK=1 while IFS= read -r probe; do [ -z "$probe" ] && continue if ! echo "$probe" | grep -qiE "$NICHE_TOKENS"; then TS_SELFTEST_OK=0; echo " non-vacuity FAIL: beat token not caught -> $probe" fi done <<'POSITIVE17' Microsoft platform changes Azure updates Copilot rollout Public sector leaders trender i offentlig sektor POSITIVE17 while IFS= read -r probe; do [ -z "$probe" ] && continue if echo "$probe" | grep -qiE "$NICHE_TOKENS"; then TS_SELFTEST_OK=0; echo " false-positive FAIL: generic prose caught -> $probe" fi done <<'NEGATIVE17' platform changes in the user's stack sector milestones in the user's domain the user's content pillars and expertise areas references/content-framework.md major product/model releases AI-driven content planning across the user's pillars NEGATIVE17 if [ "$TS_SELFTEST_OK" -eq 1 ]; then pass "de-niche self-test: 5 beat tokens caught (case-insensitive), 6 generic forms ignored (incl. 'AI' kept as the plugin's own subject)" else fail "de-niche self-test failed — the guard no longer enforces the no-beat criterion" fi for guarded in agents/trend-spotter.md agents/content-planner.md references/content-framework.md; do NICHE_HITS=$(grep -niE "$NICHE_TOKENS" "$guarded" 2>/dev/null || true) if [ -z "$NICHE_HITS" ]; then pass "$guarded names no hardcoded vendor/sector beat (domain comes from pillars)" else fail "$guarded hardcodes a vendor/sector beat — generalize to pillar-driven prose:" echo "$NICHE_HITS" fi done echo "" # --- Section 18: Assertion-Count Anti-Erosion (SC6) --- # The lint self-modifies its own checks, so a green run could mask a silently dropped # assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count # may only grow (brief-reviewer assumption 3). History: 74 pre-M0; +1 for the SB-S0 # brain-suite floor (Section 16b) = 75. Runs last so TOTAL_CHECKS sees every prior check. ASSERT_BASELINE_FLOOR=75 TOTAL_CHECKS=$((PASS + FAIL)) if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" else fail "assertion count $TOTAL_CHECKS < baseline floor $ASSERT_BASELINE_FLOOR — a check was silently removed" fi echo "" # --- Summary --- echo "================================================" echo "RESULTS" echo "================================================" echo -e "${GREEN}Passed: $PASS${NC}" echo -e "${RED}Failed: $FAIL${NC}" echo -e "${YELLOW}Warnings: $WARN${NC}" echo "" if [ $FAIL -eq 0 ]; then echo -e "${GREEN}All structural checks passed!${NC}" exit 0 else echo -e "${RED}$FAIL check(s) failed. Review above.${NC}" exit 1 fi