#!/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/<name>.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 brain
# profile-reader guard (SB-S3a: strategy-advisor names brain/profile.md AND carries the
# anti-sycophancy literal 'evidence to TEST', with a non-vacuity self-test) in Section
# 16d; the brain operations-reader guard (SB-S3d: strategy-advisor names
# brain/operations.md AND carries the frozen-past-self literal 'deprecates older
# inferences', with a non-vacuity self-test) in Section 16e; the brain reconcile-wiring
# guard (SB-S3e: scripts/brain/src/cli.ts dispatches `reconcile` AND calls the core
# reconcileRecentPosts by literal name, with a non-vacuity self-test) in Section 16f;
# the trends-scorer wiring guard (RE-R1: scripts/trends/src/score.ts encodes both mode
# weight-sets AND agents/trend-spotter.md references the scorer CLI 'src/cli.ts score',
# with a non-vacuity self-test) in Section 16g; the trends-capture wiring guard (RE-R2a:
# scripts/trends/src/cli.ts dispatches `capture` AND agents/trend-spotter.md references the
# capture CLI 'src/cli.ts capture', with a non-vacuity self-test) in Section 16h; the
# trends-brief wiring guard (RE-R2b: scripts/trends/src/cli.ts dispatches `brief`,
# agents/trend-spotter.md references the brief CLI 'src/cli.ts brief', AND
# hooks/scripts/session-start.mjs surfaces it via 'latestMorningBrief', with a non-vacuity
# self-test) in Section 16i; the trends-score wiring guard (RE-R3a: scripts/trends/src/score.ts
# exports the 'export interface TrendScore' persist envelope, scripts/trends/src/types.ts carries
# 'score?: TrendScore' on the record, agents/trend-spotter.md carries the judgment via '"dimensions"',
# AND scripts/trends/src/brief.ts ranks on 'score?.composite', with a non-vacuity self-test) in
# Section 16j; the trends-lifecycle wiring guard (RE-R3b: scripts/trends/src/types.ts declares
# 'export type TrendStatus' AND carries 'surfacedCount', scripts/trends/src/store.ts owns
# 'export function markSurfaced', scripts/trends/src/brief.ts excludes handled via 'effectiveStatus',
# AND scripts/trends/src/cli.ts exposes 'command === "act"', with a non-vacuity self-test) in
# Section 16k; the trends-scheduler/headless wiring guard (RE-R3c: scripts/trends/src/schedule.ts
# emits 'export function launchdPlist' AND 'export function crontabLine', scripts/trends/src/cli.ts
# exposes 'command === "schedule"', scripts/trends/run-daily.sh runs 'cli.ts" brief' AND uses
# 'LINKEDIN_STUDIO_DATA:-', with a non-vacuity self-test) in Section 16l; the trends-temporal-overlay
# wiring guard (RE-R3d: scripts/trends/src/brief.ts has 'export function temporalSignal' AND the cmp
# key 'b.temporal.rank' AND the '"first-mover"' tier, scripts/trends/src/cli.ts exposes the
# 'first-mover-days' AND 'saturation-at' brief flags, with a non-vacuity self-test) in Section 16m;
# the trends-brief-history/diff wiring guard (RE-R3e: scripts/trends/src/brief.ts has
# 'export function diffSurfaced' AND 'parseSurfacedFrontmatter' AND the 'Nytt siden sist' section AND
# the 'surfaced: ' frontmatter emit, scripts/trends/src/cli.ts wires 'selectPriorBriefFile', with a
# non-vacuity self-test) in Section 16n; the randsone-gates guard (N9: newsletter.md Step 9 runs a scoped
# fact-reviewer + language-reviewer pass over the carousel slide text AND edition title, gates title-strength,
# declares the deterministic 'numbers, names, or quotes' recheck trigger, and both fact agents carry WebFetch,
# with a non-vacuity self-test) in Section 16p; the configurable target-level span guard (N10: the hardcoded
# 'non-technical line manager' is gone from references/ + config/, references/longform-quality-rules.md names
# the 'target-level span', config/personas.template.md requires a mandatory secondary at the opposite end +
# an explicit ceiling justification + seeds a technical-end persona, config/edition-state.template.json carries
# the additive articles.NN.targetLevel slot, commands/newsletter.md persists it, agents/persona-reviewer.md
# consumes it onto the span, with a non-vacuity self-test) in Section 16q; 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=20
EXPECT_COMMANDS=30
EXPECT_REFS=29
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)
  "references/figure-design-guidelines.md"   # MR-F4 N3 (coded-figure design rules + token convention)
  "references/fold-in-loop.md"               # N14 A2-F9 (capture->classify->promote->enforce, domain-general)
)

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/<name>.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/<x>/) nor ${CLAUDE_PLUGIN_ROOT}-
# prefixed — or a command writes the external dir while Claude follows stale in-plugin
# prose (R1, brief §12.6). Shipped read-only assets keep ${CLAUDE_PLUGIN_ROOT} and are
# exempt: the analytics README, every *-template.md seed, assets/checklists/,
# assets/quick-post-resources.md, the shipped assets/templates/*, config/*.template.*.
# config/personas.local.md is a deliberate in-plugin fallback library (un-migrated, out
# of M0 scope) and is NOT in the class. Scope = the actionable prose dirs (commands/
# agents/skills/hooks-prompts); references/ is documentation (the convention doc
# legitimately names scaffold paths) and is excluded.
BARE_DATA='(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/(voice-samples|analytics|drafts|plans|audience-insights|examples|frameworks|case-studies|network)/|(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/repurposing-tracker\.md|(\$\{CLAUDE_PLUGIN_ROOT\}/)?assets/templates/my-post-templates\.md|(\$\{CLAUDE_PLUGIN_ROOT\}/)?config/user-profile\.local\.md'
BARE_EXEMPT='README\.md|-template\.md'

# Non-vacuity self-test (mirrors Section 8): the full criterion is regex-match AND
# not-exempt. Positives MUST be violations; negatives (external token + every shipped
# exemption + the out-of-scope personas lib) MUST NOT be.
B13_OK=1
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if echo "$probe" | grep -qE "$BARE_DATA" && ! echo "$probe" | grep -qE "$BARE_EXEMPT"; then :; else
    B13_OK=0; echo "  non-vacuity FAIL: in-plugin data path not flagged -> $probe"
  fi
done <<'POSITIVE13'
assets/voice-samples/authentic-voice-samples.md
${CLAUDE_PLUGIN_ROOT}/assets/analytics/posts/
assets/drafts/queue.json
assets/templates/my-post-templates.md
assets/frameworks/ai-maturity-model.md
assets/repurposing-tracker.md
config/user-profile.local.md
POSITIVE13
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if echo "$probe" | grep -qE "$BARE_DATA" && ! echo "$probe" | grep -qE "$BARE_EXEMPT"; then
    B13_OK=0; echo "  false-positive FAIL: legitimate path flagged -> $probe"
  fi
done <<'NEGATIVE13'
${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/
assets/analytics/README.md
assets/frameworks/framework-template.md
assets/case-studies/case-study-template.md
assets/checklists/quality-scorecard.md
assets/quick-post-resources.md
config/user-profile.template.md
config/personas.local.md
NEGATIVE13
if [ "$B13_OK" -eq 1 ]; then
  pass "no-bare-path self-test: 7 in-plugin data paths caught, 8 legitimate/exempt forms ignored"
else
  fail "no-bare-path self-test failed — the R1 criterion no longer enforces in-plugin data-path detection"
fi

BARE_HITS=$(grep -rnE "$BARE_DATA" commands/ agents/ skills/ hooks/prompts/ 2>/dev/null | grep -vE "$BARE_EXEMPT" || true)
if [ -z "$BARE_HITS" ]; then
  pass "no command/agent/skill/hook prose references a migrated user-data path in-plugin (R1)"
else
  fail "in-plugin user-data path(s) in prose — route via \${LINKEDIN_STUDIO_DATA:-\$HOME/.claude/linkedin-studio}/ (see references/data-path-convention.md):"
  echo "$BARE_HITS"
fi

# Sibling no-pin guard (M3): the no-bare-path grep cannot catch an ANALYTICS_ROOT alias
# pinned to the in-plugin analytics dir — Step 11 dropped these; forbid their return.
PIN='ANALYTICS_ROOT=.{0,2}\$\{CLAUDE_PLUGIN_ROOT\}/assets/analytics'
PIN_OK=1
echo 'ANALYTICS_ROOT="${CLAUDE_PLUGIN_ROOT}/assets/analytics"' | grep -qE "$PIN" || { PIN_OK=0; echo "  non-vacuity FAIL: in-plugin pin not caught"; }
if echo 'ANALYTICS_ROOT="${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics"' | grep -qE "$PIN"; then PIN_OK=0; echo "  false-positive FAIL: external alias caught"; fi
if [ "$PIN_OK" -eq 1 ]; then
  pass "no-pin self-test: in-plugin ANALYTICS_ROOT pin caught, external alias ignored"
else
  fail "no-pin self-test failed — the M3 pin criterion is not enforced"
fi
PIN_HITS=$(grep -rnE "$PIN" commands/ agents/ skills/ hooks/prompts/ 2>/dev/null || true)
if [ -z "$PIN_HITS" ]; then
  pass "no prose pins ANALYTICS_ROOT to the in-plugin analytics dir (M3)"
else
  fail "ANALYTICS_ROOT pinned to in-plugin analytics — drop the pin (external default applies):"
  echo "$PIN_HITS"
fi

# SC2 dry-run: the migrated in-plugin user-data classes (.gitignore) must hold NO data
# files — after M0 they live external. --ignored surfaces gitignored stragglers a plain
# porcelain would hide; filtered to the data classes, it must be empty. Catches a flow
# (or a stray file) that wrote user data back into the plugin tree (SC2). personas is
# excluded (un-migrated, out of M0 scope — consistent with Section 13's class).
SC2_CLASSES='assets/analytics/(exports|posts|weekly-reports|monthly-reports)/|assets/drafts/queue\.json|assets/drafts/week-|assets/voice-samples/[^ ]*\.local\.md|config/user-profile\.local\.md'
SC2_DIRT=$(git status --porcelain --ignored 2>/dev/null | grep -E "$SC2_CLASSES" || true)
if [ -z "$SC2_DIRT" ]; then
  pass "SC2 dry-run: no in-plugin user-data files (analytics/drafts/voice/profile) in the tree"
else
  fail "SC2 violation — migrated user data sits in the plugin tree (should be external):"
  echo "$SC2_DIRT"
fi

echo ""

# --- Section 14: Contract-Gate Binding (Slice 3) ---
echo "--- Contract-Gate Binding ---"

# The deterministic §B/§C1 contract-gate (scripts/contract-gate) is wired into
# /linkedin:newsletter as Step 4.5. Two invariants belong in CI, not per-edition:
# (14a) the gate's own test suite stays green and its case count never erodes; and
# (14b) rules.ts stays bound 1:1 to the maskinrommet skrivekontrakt §E-manifest
# (ratify). Both are KTG-internal: skipped (warn, never fail) for an adopter that
# ships no contract-gate deps or no contract — so the lint stays green everywhere.
# bash 3.2-safe; `set +e` inside the test command-substitution keeps a red npm test
# from aborting the whole runner (set -e is active), and ratify exit 1 is captured by
# the `if (subshell)` form (condition context is exempt from set -e).
CG_DIR="scripts/contract-gate"
if [ -x "$CG_DIR/node_modules/.bin/tsx" ]; then
  # 14a: the gate suite is green (npm test exit 0) and holds its case-count floor.
  CG_OUT=$( set +e; (cd "$CG_DIR" && npm test) 2>&1; echo "CG_EXIT:$?" )
  CG_EXIT=$(echo "$CG_OUT" | grep -oE 'CG_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
  CG_TESTS=$(echo "$CG_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
  CONTRACT_GATE_TESTS_FLOOR=33
  if [ "$CG_EXIT" = "0" ] && [ -n "$CG_TESTS" ] && [ "$CG_TESTS" -ge "$CONTRACT_GATE_TESTS_FLOOR" ]; then
    pass "contract-gate suite green: $CG_TESTS tests pass (floor $CONTRACT_GATE_TESTS_FLOOR)"
  else
    fail "contract-gate suite NOT green (exit=${CG_EXIT:-?}, tests=${CG_TESTS:-?}, floor $CONTRACT_GATE_TESTS_FLOOR) — run: (cd $CG_DIR && npm test)"
  fi

  # 14b: ratify — rules.ts <-> §E-manifest 1:1. Skipped when the contract is absent.
  CONTRACT="${MASKINROMMET_CONTRACT:-$PLUGIN_ROOT/../../maskinrommet/docs/skrivekontrakt.md}"
  if [ -f "$CONTRACT" ]; then
    if (cd "$CG_DIR" && node --import tsx src/cli.ts --ratify) >/dev/null 2>&1; then
      pass "contract-gate ratify: rules.ts bound 1:1 to the §E-manifest (in sync)"
    else
      fail "contract-gate ratify DRIFT — rules.ts != §E-manifest; run: (cd $CG_DIR && npm run ratify)"
    fi
  else
    warn "contract-gate ratify skipped — contract absent ($CONTRACT); KTG-only invariant"
  fi
else
  warn "contract-gate skipped — deps absent ($CG_DIR/node_modules); run: (cd $CG_DIR && npm install)"
fi

echo ""

# --- Section 15: Specifics-Bank Binding (Fix #2 slice 3) ---
echo "--- Specifics-Bank Binding ---"

# The lived-specifics store + per-edition binding (scripts/specifics-bank) is wired
# into /linkedin:newsletter as Step 1.5 (elicitation + slot-map) and its
# validate-binding gate into the Step 2.5 skeleton-gate. The seam is now load-bearing,
# so its suite belongs in CI: the bank/binding/kilder tests stay green and the case
# count never erodes. KTG-internal: skipped (warn, never fail) for an adopter that
# ships no specifics-bank deps — so the lint stays green everywhere. Same set +e /
# subshell discipline as Section 14 (bash 3.2-safe; keeps a red npm test from
# aborting the runner under set -e).
SB_DIR="scripts/specifics-bank"
if [ -x "$SB_DIR/node_modules/.bin/tsx" ]; then
  SB_OUT=$( set +e; (cd "$SB_DIR" && npm test) 2>&1; echo "SB_EXIT:$?" )
  SB_EXIT=$(echo "$SB_OUT" | grep -oE 'SB_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
  SB_TESTS=$(echo "$SB_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
  SPECIFICS_BANK_TESTS_FLOOR=45
  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 15b: Series-Distillate Suite (N11 / C-5) ---
echo "--- Series Distillate ---"

# The series distillate (scripts/editions) is wired into /linkedin:newsletter as the Step 2.5
# destillat-sjekk and the Step 8 lock write. Same discipline as Section 15: the suite belongs in
# CI (store + similarity + skeleton-check stay green, case count never erodes), and an adopter
# without deps warn-skips rather than failing. Same set +e / subshell discipline (bash 3.2-safe).
ED_DIR="scripts/editions"
if [ -x "$ED_DIR/node_modules/.bin/tsx" ]; then
  ED_OUT=$( set +e; (cd "$ED_DIR" && npm test) 2>&1; echo "ED_EXIT:$?" )
  ED_EXIT=$(echo "$ED_OUT" | grep -oE 'ED_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
  ED_TESTS=$(echo "$ED_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
  EDITIONS_TESTS_FLOOR=72
  if [ "$ED_EXIT" = "0" ] && [ -n "$ED_TESTS" ] && [ "$ED_TESTS" -ge "$EDITIONS_TESTS_FLOOR" ]; then
    pass "editions suite green: $ED_TESTS tests pass (floor $EDITIONS_TESTS_FLOOR)"
  else
    fail "editions suite NOT green (exit=${ED_EXIT:-?}, tests=${ED_TESTS:-?}, floor $EDITIONS_TESTS_FLOOR) — run: (cd $ED_DIR && npm test)"
  fi
else
  warn "editions skipped — deps absent ($ED_DIR/node_modules); run: (cd $ED_DIR && npm install)"
fi

echo ""

# --- Section 16: Trends-Store Binding (research-engine slice 2b) ---
echo "--- Trends-Store Binding ---"

# The persistent trend store (scripts/trends) is wired into the trend-spotter agent
# (slice 2b): the agent queries prior history and persists each kept trend through the
# store's deterministic add (dedup/union), so the store is now load-bearing for the
# research engine's de-amnesia — not a standalone inventory. Its suite therefore belongs
# in CI: the store/dedup/query tests stay green and the case count never erodes. Mirrors
# the specifics-bank binding guard (Section 15) — the trend-side twin. KTG-internal:
# skipped (warn, never fail) for an adopter that ships no trends deps. Same set +e /
# subshell discipline as Sections 14–15 (bash 3.2-safe; keeps a red npm test from
# aborting the runner under set -e).
TR_DIR="scripts/trends"
if [ -x "$TR_DIR/node_modules/.bin/tsx" ]; then
  TR_OUT=$( set +e; (cd "$TR_DIR" && npm test) 2>&1; echo "TR_EXIT:$?" )
  TR_EXIT=$(echo "$TR_OUT" | grep -oE 'TR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
  TR_TESTS=$(echo "$TR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
  TRENDS_TESTS_FLOOR=245  # store 24 + RE-R1: item 18 + score 16 + cli 4 + RE-R2a: store +9 + item +4 + cli +4 (capture bridge + publishedAt) + RE-R2b: brief +21 + cli +4 (morning-brief) + RE-R3a: score +6, item +12, store +6, brief +16, cli +2 (relevance score persist + rank) + RE-R3b: store +11, brief +8, cli +6 (lifecycle: re-score + status + seen-log) + RE-R3c: schedule +9, cli +8, run-daily +4 (scheduler + headless wrapper) + RE-R3d: brief +21, cli +3 (temporal overlay: first-mover + saturation) + RE-R3e: brief +27, cli +2 (brief history + diff)
  if [ "$TR_EXIT" = "0" ] && [ -n "$TR_TESTS" ] && [ "$TR_TESTS" -ge "$TRENDS_TESTS_FLOOR" ]; then
    pass "trends-store suite green: $TR_TESTS tests pass (floor $TRENDS_TESTS_FLOOR)"
  else
    fail "trends-store suite NOT green (exit=${TR_EXIT:-?}, tests=${TR_TESTS:-?}, floor $TRENDS_TESTS_FLOOR) — run: (cd $TR_DIR && npm test)"
  fi
else
  warn "trends-store skipped — deps absent ($TR_DIR/node_modules); run: (cd $TR_DIR && npm install)"
fi

echo ""

# --- Section 16b: Brain Foundation (SB-S0) ---
echo "--- Brain Foundation ---"

# The second-brain foundation (scripts/brain, SB-S0) ships the brain/ scaffold
# initialiser, the two-layer profile.md line-grammar, the lossless+idempotent
# user-profile fold, and the canonical entity-id + provenance module. This is the
# spine later slices hang on (the id threads through tributaries in SB-S3, the
# evolution loop consumes the profile in SB-S2), so the suite stays green and its
# case count never erodes. KTG-internal: skipped (warn, never fail) for an adopter
# that ships no brain deps. Same set +e / subshell discipline as Sections 14-16
# (bash 3.2-safe; keeps a red npm test from aborting the runner under set -e).
BR_DIR="scripts/brain"
if [ -x "$BR_DIR/node_modules/.bin/tsx" ]; then
  BR_OUT=$( set +e; (cd "$BR_DIR" && npm test) 2>&1; echo "BR_EXIT:$?" )
  BR_EXIT=$(echo "$BR_OUT" | grep -oE 'BR_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
  BR_TESTS=$(echo "$BR_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
  BRAIN_TESTS_FLOOR=127  # SB-S0 34 [id(11)+profile(6)+fold(12)+scaffold(5)] + SB-S1 29 [ingest(14)+publish(9)+cli(6)] + SB-S2 19 [consolidate(12)+consolidate-cli(7)] + SB-S3b 12 [consolidate(10)+consolidate-cli(2)] + SB-S3c 19 [ingest(4)+publish(3)+assemble(8)+cli(4)] + SB-S3d 1 [scaffold dated-anchor seed] + SB-S3e 13 [reconcile: parse(5)+tiers(7)+io(1)]
  if [ "$BR_EXIT" = "0" ] && [ -n "$BR_TESTS" ] && [ "$BR_TESTS" -ge "$BRAIN_TESTS_FLOOR" ]; then
    pass "brain suite green: $BR_TESTS tests pass (floor $BRAIN_TESTS_FLOOR)"
  else
    fail "brain suite NOT green (exit=${BR_EXIT:-?}, tests=${BR_TESTS:-?}, floor $BRAIN_TESTS_FLOOR) — run: (cd $BR_DIR && npm test)"
  fi
else
  warn "brain skipped — deps absent ($BR_DIR/node_modules); run: (cd $BR_DIR && npm install)"
fi

echo ""

# --- Section 16c: Brain Published-Only Invariant (SB-S1) ---
echo "--- Brain Published-Only Invariant ---"

# SB-S1 wires voice-trainer to learn from the published gold signal ONLY: the agent
# must (a) name ingest/published/ as a source AND (b) carry the explicit exclusion of
# provenance=ai-draft (the model-collapse guard — never train voice on the engine's own
# drafts). Both literals are required. voice-trainer.md ALREADY contains "AI-generated"
# (a drift-cause description, line ~175), so a loose ai.?draft pattern would pass
# vacuously — we grep the EXACT literals with grep -F. The contract doc must also state
# the rule. Non-vacuity self-test mirrors Sections 8/13/17: a probe counts as "wired"
# iff it carries BOTH literals; probes missing either (incl. one with "AI-generated" but
# not "ai-draft") must NOT count as wired. The wiring is gate-enforced; the agent's
# runtime OBEDIENCE is not provable here (stated honestly in the contract doc).
PUB_LIT='ingest/published'
DRAFT_LIT='provenance=ai-draft'

pub_wired() {  # $1 = text; wired iff BOTH literals present (echo twice — grep consumes stdin)
  echo "$1" | grep -qF "$PUB_LIT" && echo "$1" | grep -qF "$DRAFT_LIT"
}

PUB_SELFTEST_OK=1
if ! pub_wired "reads from ingest/published/ and excludes provenance=ai-draft"; then
  PUB_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if pub_wired "$probe"; then
    PUB_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired probe accepted -> $probe"
  fi
done <<'NEGATIVE16C'
reads from ingest/published/ but never states the draft exclusion
excludes provenance=ai-draft but never names the published source
flags AI-generated drift but carries neither required literal
NEGATIVE16C
if [ "$PUB_SELFTEST_OK" -eq 1 ]; then
  pass "published-only self-test: full wiring detected; 3 under-wired forms rejected (incl. 'AI-generated'-only)"
else
  fail "published-only self-test failed — the invariant lint is vacuous or over-eager"
fi

VT="agents/voice-trainer.md"
if grep -qF "$PUB_LIT" "$VT" && grep -qF "$DRAFT_LIT" "$VT"; then
  pass "voice-trainer.md wired to published-only (names '$PUB_LIT', excludes '$DRAFT_LIT')"
else
  fail "voice-trainer.md missing published-only wiring — needs both '$PUB_LIT' and '$DRAFT_LIT'"
fi

PUB_DOC="docs/second-brain/ingest-manual-import.md"
if grep -qF "$DRAFT_LIT" "$PUB_DOC"; then
  pass "ingest-manual-import.md documents the published-only rule ('$DRAFT_LIT')"
else
  fail "ingest-manual-import.md missing the published-only rule literal '$DRAFT_LIT'"
fi

echo ""

# --- Section 16d: Brain Profile Reader (SB-S3a) ---
echo "--- Brain Profile Reader ---"

# SB-S3a wires the FIRST reader of the evolving second-brain profile: strategy-advisor
# must (a) name brain/profile.md as a context source AND (b) carry the anti-sycophancy
# literal 'evidence to TEST' (architecture.md:58 — the profile informs, it never dictates
# or flatters; every fact is counter-pressured against analytics/state). Both literals
# are required, grepped EXACT with grep -F: a loose 'profile' pattern would match the
# legacy flat profile/user-profile.md vacuously, and a loose lowercase 'evidence' would
# match generic prose. Non-vacuity self-test mirrors Sections 8/13/16c/17: a probe counts
# as "wired" iff it carries BOTH exact literals; probes missing either (incl. a decoy
# naming the legacy path + lowercase "evidence") must NOT count as wired. The wiring is
# gate-enforced; the agent's runtime OBEDIENCE is verified manually (SC4/SC5 at land —
# agent-prompt behaviour is not unit-testable; the brief states this honestly).
READER_LIT='brain/profile.md'
ANTISYC_LIT='evidence to TEST'

reader_wired() {  # $1 = text; wired iff BOTH literals present (echo twice — grep consumes stdin)
  echo "$1" | grep -qF "$READER_LIT" && echo "$1" | grep -qF "$ANTISYC_LIT"
}

READER_SELFTEST_OK=1
if ! reader_wired "reads brain/profile.md as evidence to TEST"; then
  READER_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if reader_wired "$probe"; then
    READER_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired probe accepted -> $probe"
  fi
done <<'NEGATIVE16D'
reads brain/profile.md but never frames how to weigh it
treats facts as evidence to TEST but names no source file
reads profile/user-profile.md and tests the evidence
NEGATIVE16D
if [ "$READER_SELFTEST_OK" -eq 1 ]; then
  pass "profile-reader self-test: full wiring detected; 3 under-wired forms rejected (incl. legacy-path + lowercase 'evidence' decoy)"
else
  fail "profile-reader self-test failed — the reader-wiring lint is vacuous or over-eager"
fi

SA="agents/strategy-advisor.md"
if grep -qF "$READER_LIT" "$SA" && grep -qF "$ANTISYC_LIT" "$SA"; then
  pass "strategy-advisor.md wired to brain profile reader (names '$READER_LIT', frames '$ANTISYC_LIT')"
else
  fail "strategy-advisor.md missing brain profile reader wiring — needs both '$READER_LIT' and '$ANTISYC_LIT'"
fi

echo ""

# --- Section 16e: Brain Operations Reader (SB-S3d) ---
echo "--- Brain Operations Reader ---"

# SB-S3d wires strategy-advisor to read the second-brain OPERATIONS centre: it must
# (a) name brain/operations.md as a context source AND (b) carry the frozen-past-self
# literal 'deprecates older inferences' (architecture.md:59 — the dated "who I am now"
# anchor outranks/deprecates older profile inferences; advisory/reader-side). Both
# literals are required, grepped EXACT with grep -F: a loose 'operations' pattern would
# match generic prose, and naming the sibling brain/profile.md must NOT satisfy the
# operations literal. Non-vacuity self-test mirrors Sections 8/13/16c/16d: a probe counts
# as "wired" iff it carries BOTH exact literals; probes missing either (incl. a sibling-
# file decoy naming brain/profile.md) must NOT count as wired. The wiring is gate-enforced;
# the agent's runtime OBEDIENCE is verified manually (SC6/SC7 at land — agent-prompt
# behaviour is not unit-testable; the brief states this honestly).
OPS_LIT='brain/operations.md'
ANCHOR_LIT='deprecates older inferences'

ops_reader_wired() {  # $1 = text; wired iff BOTH literals present (echo twice — grep consumes stdin)
  echo "$1" | grep -qF "$OPS_LIT" && echo "$1" | grep -qF "$ANCHOR_LIT"
}

OPS_SELFTEST_OK=1
if ! ops_reader_wired "reads brain/operations.md; the anchor deprecates older inferences"; then
  OPS_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if ops_reader_wired "$probe"; then
    OPS_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired probe accepted -> $probe"
  fi
done <<'NEGATIVE16E'
reads brain/operations.md but never says the anchor outranks anything
the anchor deprecates older inferences but names no source file
reads brain/profile.md and tests the evidence
NEGATIVE16E
if [ "$OPS_SELFTEST_OK" -eq 1 ]; then
  pass "ops-reader self-test: full wiring detected; 3 under-wired forms rejected (incl. sibling-file brain/profile.md decoy)"
else
  fail "ops-reader self-test failed — the ops-reader-wiring lint is vacuous or over-eager"
fi

SA_OPS="agents/strategy-advisor.md"
if grep -qF "$OPS_LIT" "$SA_OPS" && grep -qF "$ANCHOR_LIT" "$SA_OPS"; then
  pass "strategy-advisor.md wired to brain operations reader (names '$OPS_LIT', frames '$ANCHOR_LIT')"
else
  fail "strategy-advisor.md missing brain operations reader wiring — needs both '$OPS_LIT' and '$ANCHOR_LIT'"
fi

echo ""

# --- Section 16f: Brain Reconcile Wiring (SB-S3e) ---
echo "--- Brain Reconcile Wiring ---"

# SB-S3e wires `brain reconcile` (read-side triple-post reconciliation: silo 1
# `## Recent Posts` ↔ the silo 2↔3 graph). The CLI must (a) dispatch the reconcile
# command AND (b) call the pure core by its literal name. Both literals live in
# scripts/brain/src/cli.ts (the dispatch `if` + runReconcile's literal-name call),
# grepped EXACT with grep -F. Non-vacuity self-test mirrors Section 16e: a probe is
# "wired" iff it carries BOTH literals; D1/D2 miss EXACTLY ONE literal (the load-
# bearing discriminators), D3 is a fully-wired SIBLING command (assemble) carrying
# neither reconcile literal (specificity — the predicate is reconcile-specific, not
# "any command + any core"). Wiring is gate-enforced; the reconcile CORE correctness
# is covered by the brain suite (reconcile.test.ts) and the end-to-end run is verified
# manually (SC4/SC6 at land — CLI behaviour is not unit-tested here; brief says so).
RECON_CLI_LIT='command === "reconcile"'
RECON_CORE_LIT='reconcileRecentPosts'

reconcile_wired() {  # $1 = text; wired iff BOTH literals present (echo twice — grep consumes stdin)
  echo "$1" | grep -qF "$RECON_CLI_LIT" && echo "$1" | grep -qF "$RECON_CORE_LIT"
}

RECON_SELFTEST_OK=1
if ! reconcile_wired 'if (command === "reconcile") return runReconcile via reconcileRecentPosts'; then
  RECON_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if reconcile_wired "$probe"; then
    RECON_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired probe accepted -> $probe"
  fi
done <<'NEGATIVE16F'
if (command === "reconcile") return runReconcile(flags) but never calls the core
const nodes = reconcileRecentPosts({ recentPosts, records, graph }) with no command dispatch
if (command === "assemble") return runAssemble(flags) using assemblePostGraph
NEGATIVE16F
if [ "$RECON_SELFTEST_OK" -eq 1 ]; then
  pass "reconcile self-test: full wiring detected; D1/D2 single-literal forms + D3 assemble specificity decoy rejected"
else
  fail "reconcile self-test failed — the reconcile-wiring lint is vacuous or over-eager"
fi

CLI_RECON="scripts/brain/src/cli.ts"
if grep -qF "$RECON_CLI_LIT" "$CLI_RECON" && grep -qF "$RECON_CORE_LIT" "$CLI_RECON"; then
  pass "brain CLI wired to reconcile (dispatch + core call by literal name)"
else
  fail "brain CLI missing reconcile wiring — needs both '$RECON_CLI_LIT' and '$RECON_CORE_LIT' in $CLI_RECON"
fi

echo ""

# --- Section 17: De-Niche Guard (B-S1 + B-S2) ---
echo "--- De-Niche Guard ---"

# The de-niche sweep removed the hardcoded KTG beat (Microsoft / public-sector) from
# three surfaces so the domain comes from the user's profile/pillars at runtime, never
# baked in (plugin-is-domain-general):
#   - agents/trend-spotter.md          (B-S1: pillar-driven scanning; the agent's own
#                                        contract says "the niche lives in the source
#                                        list and the user's pillars, never in this agent")
#   - agents/content-planner.md        (B-S2a: planner calendar recast off the beat)
#   - references/content-framework.md  (B-S2a: recast + renamed from ai-content-framework.md)
# Each must name NO specific vendor or sector beat; this guard forbids those KTG-beat
# proper nouns from returning to any of them. Scoped per file BY DESIGN: the check runs
# against this explicit allowlist, NOT the whole tree, because references/content-angles.md
# legitimately lists "Public Sector" as one of six example industry tables (a KEEP surface,
# generic illustration — not a hardcoded beat), and "AI" is the plugin's own subject, not a
# niche token. Non-vacuity self-test mirrors Sections 8/13: the criterion must catch the beat
# tokens and ignore generic prose (incl. the content-framework.md reference filename — B-S2a
# recast + renamed it from ai-content-framework.md — and "AI"-flavoured planner prose, locking
# in that "AI" is never treated as a beat). Case-insensitive: "Public sector" and "public
# sector" name the same beat, and a future reintroduction could use either case — the
# positive set locks that in.
NICHE_TOKENS='Microsoft|Azure|Copilot|public sector|offentlig sektor'
TS_SELFTEST_OK=1
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if ! echo "$probe" | grep -qiE "$NICHE_TOKENS"; then
    TS_SELFTEST_OK=0; echo "  non-vacuity FAIL: beat token not caught -> $probe"
  fi
done <<'POSITIVE17'
Microsoft platform changes
Azure updates
Copilot rollout
Public sector leaders
trender i offentlig sektor
POSITIVE17
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if echo "$probe" | grep -qiE "$NICHE_TOKENS"; then
    TS_SELFTEST_OK=0; echo "  false-positive FAIL: generic prose caught -> $probe"
  fi
done <<'NEGATIVE17'
platform changes in the user's stack
sector milestones in the user's domain
the user's content pillars and expertise areas
references/content-framework.md
major product/model releases
AI-driven content planning across the user's pillars
NEGATIVE17
if [ "$TS_SELFTEST_OK" -eq 1 ]; then
  pass "de-niche self-test: 5 beat tokens caught (case-insensitive), 6 generic forms ignored (incl. 'AI' kept as the plugin's own subject)"
else
  fail "de-niche self-test failed — the guard no longer enforces the no-beat criterion"
fi

for guarded in agents/trend-spotter.md agents/content-planner.md references/content-framework.md; do
  NICHE_HITS=$(grep -niE "$NICHE_TOKENS" "$guarded" 2>/dev/null || true)
  if [ -z "$NICHE_HITS" ]; then
    pass "$guarded names no hardcoded vendor/sector beat (domain comes from pillars)"
  else
    fail "$guarded hardcodes a vendor/sector beat — generalize to pillar-driven prose:"
    echo "$NICHE_HITS"
  fi
done

echo ""

# --- Section 16g: Trends Scorer Wiring (research-engine RE-R1 / B2) ---
echo "--- Trends Scorer Wiring ---"

# RE-R1 lifts the composite/band/threshold arithmetic out of trend-spotter.md prose into
# tested code (scripts/trends/src/score.ts) behind a CLI seam. Two literals must hold,
# grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx):
#   (1) score.ts encodes BOTH mode weight-sets (the 'kortform' + 'long-form' literals), so a
#       silent collapse to one mode fails here (the per-mode arithmetic itself is unit-tested
#       in score.test.ts, behind the deps guard / trends-suite floor);
#   (2) agents/trend-spotter.md references the scorer CLI by the literal 'src/cli.ts score' —
#       the lift is real and grep-able, not merely documented.
# Non-vacuity self-test mirrors Sections 16c-17: the weight-set predicate (AND of both mode
# literals) must accept a both-modes probe and reject single-mode probes; the wiring predicate
# must accept a probe carrying the scorer-pointer literal and reject one without it. Labelled
# 16g but placed after Section 17 / before Section 18 (anti-erosion must run last so it sees
# every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR.
WEIGHT_KORT_LIT='kortform'
WEIGHT_LONG_LIT='long-form'
SCORER_WIRE_LIT='src/cli.ts score'

weights_both_modes() {  # $1 = text; true iff BOTH mode literals present (echo twice — grep consumes stdin)
  echo "$1" | grep -qF "$WEIGHT_KORT_LIT" && echo "$1" | grep -qF "$WEIGHT_LONG_LIT"
}

G16_SELFTEST_OK=1
if ! weights_both_modes 'the kortform weight-set and the long-form weight-set are both encoded'; then
  G16_SELFTEST_OK=0; echo "  non-vacuity FAIL: a both-modes weight probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if weights_both_modes "$probe"; then
    G16_SELFTEST_OK=0; echo "  false-positive FAIL: single-mode weight probe accepted -> $probe"
  fi
done <<'NEGATIVE16G'
only the kortform weight-set is present here
only the long-form weight-set is present here
NEGATIVE16G
if ! echo 'pipe the scores to src/cli.ts score for the composite' | grep -qF "$SCORER_WIRE_LIT"; then
  G16_SELFTEST_OK=0; echo "  non-vacuity FAIL: a wired scorer-pointer probe was not detected"
fi
if echo 'the agent computes the composite itself' | grep -qF "$SCORER_WIRE_LIT"; then
  G16_SELFTEST_OK=0; echo "  false-positive FAIL: an unwired probe matched the scorer pointer"
fi
if [ "$G16_SELFTEST_OK" -eq 1 ]; then
  pass "trends-scorer self-test: both-modes weight predicate + scorer-pointer predicate detect wiring, reject the under-wired forms"
else
  fail "trends-scorer self-test failed — the scorer-wiring lint is vacuous or over-eager"
fi

SCORE_TS="scripts/trends/src/score.ts"
if grep -qF "$WEIGHT_KORT_LIT" "$SCORE_TS" 2>/dev/null && grep -qF "$WEIGHT_LONG_LIT" "$SCORE_TS" 2>/dev/null; then
  pass "score.ts encodes both mode weight-sets ('$WEIGHT_KORT_LIT' + '$WEIGHT_LONG_LIT')"
else
  fail "score.ts missing a mode weight-set — needs both '$WEIGHT_KORT_LIT' and '$WEIGHT_LONG_LIT' in $SCORE_TS"
fi

if grep -qF "$SCORER_WIRE_LIT" agents/trend-spotter.md; then
  pass "trend-spotter.md references the scorer CLI ('$SCORER_WIRE_LIT') as the deterministic-step owner"
else
  fail "trend-spotter.md does not reference the scorer CLI — add a '$SCORER_WIRE_LIT' pointer (RE-R1 lift)"
fi

echo ""

# --- Section 16h: Trends Capture Wiring (research-engine RE-R2a) ---
echo "--- Trends Capture Wiring ---"

# RE-R2a closes the capture loop: the item->store bridge (itemToInput) + a `capture` CLI that
# normalizes a raw batch from stdin and folds it into the store (persisting publishedAt). Two
# literals must hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx):
#   (1) cli.ts dispatches the `capture` subcommand (the handler exists), by the literal
#       'command === "capture"' (the capture path is real, not merely documented);
#   (2) agents/trend-spotter.md re-points Step 4.5 to the capture CLI by the literal
#       'src/cli.ts capture' (one normalizing batch call, replacing the N x `add` block).
# Non-vacuity self-test mirrors Sections 16c-16g: the wiring predicate must accept a probe
# carrying the capture-pointer literal and reject one without it. Labelled 16h but placed after
# Section 17 / before Section 18 (anti-erosion must run last so it sees every prior check).
# UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR.
CAPTURE_HANDLER_LIT='command === "capture"'
CAPTURE_WIRE_LIT='src/cli.ts capture'

H16_SELFTEST_OK=1
if ! echo 'pipe the raw batch to src/cli.ts capture for the store fold' | grep -qF "$CAPTURE_WIRE_LIT"; then
  H16_SELFTEST_OK=0; echo "  non-vacuity FAIL: a wired capture-pointer probe was not detected"
fi
if echo 'the agent folds each trend into the store itself' | grep -qF "$CAPTURE_WIRE_LIT"; then
  H16_SELFTEST_OK=0; echo "  false-positive FAIL: an unwired probe matched the capture pointer"
fi
if [ "$H16_SELFTEST_OK" -eq 1 ]; then
  pass "trends-capture self-test: capture-pointer predicate detects wiring, rejects the under-wired form"
else
  fail "trends-capture self-test failed — the capture-wiring lint is vacuous or over-eager"
fi

if grep -qF "$CAPTURE_HANDLER_LIT" scripts/trends/src/cli.ts; then
  pass "cli.ts dispatches the capture subcommand ('$CAPTURE_HANDLER_LIT')"
else
  fail "cli.ts has no capture handler — add a '$CAPTURE_HANDLER_LIT' branch (RE-R2a capture loop)"
fi

if grep -qF "$CAPTURE_WIRE_LIT" agents/trend-spotter.md; then
  pass "trend-spotter.md references the capture CLI ('$CAPTURE_WIRE_LIT') as the Step 4.5 store-fold owner"
else
  fail "trend-spotter.md does not reference the capture CLI — re-point Step 4.5 to a '$CAPTURE_WIRE_LIT' batch call (RE-R2a wiring)"
fi

echo ""

# --- Section 16i: Trends Brief Wiring (research-engine RE-R2b) ---
echo "--- Trends Brief Wiring ---"

# RE-R2b makes the store VISIBLE: a `brief` CLI ranks the store by pillar-overlap + recency and
# writes a dated Markdown file the SessionStart hook surfaces. Three literals must hold, grepped
# EXACT (grep -F), deps-absent-safe (pure grep, no tsx):
#   (1) cli.ts dispatches the `brief` subcommand, by the literal 'command === "brief"';
#   (2) agents/trend-spotter.md generates the brief after capture, by the literal 'src/cli.ts brief';
#   (3) hooks/scripts/session-start.mjs surfaces it, by the literal 'latestMorningBrief' (the
#       surfacing is wired, not merely documented).
# Non-vacuity self-test mirrors Section 16h: the wiring predicate must accept a probe carrying the
# brief-pointer literal and reject one without it. Placed after Section 16h / before Section 18
# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts
# toward ASSERT_BASELINE_FLOOR.
BRIEF_HANDLER_LIT='command === "brief"'
BRIEF_WIRE_LIT='src/cli.ts brief'
BRIEF_SURFACE_LIT='latestMorningBrief'

I16_SELFTEST_OK=1
if ! echo 'after capture, run src/cli.ts brief --pillars to write the dated brief' | grep -qF "$BRIEF_WIRE_LIT"; then
  I16_SELFTEST_OK=0; echo "  non-vacuity FAIL: a wired brief-pointer probe was not detected"
fi
if echo 'the agent renders the morning brief itself' | grep -qF "$BRIEF_WIRE_LIT"; then
  I16_SELFTEST_OK=0; echo "  false-positive FAIL: an unwired probe matched the brief pointer"
fi
if [ "$I16_SELFTEST_OK" -eq 1 ]; then
  pass "trends-brief self-test: brief-pointer predicate detects wiring, rejects the under-wired form"
else
  fail "trends-brief self-test failed — the brief-wiring lint is vacuous or over-eager"
fi

if grep -qF "$BRIEF_HANDLER_LIT" scripts/trends/src/cli.ts; then
  pass "cli.ts dispatches the brief subcommand ('$BRIEF_HANDLER_LIT')"
else
  fail "cli.ts has no brief handler — add a '$BRIEF_HANDLER_LIT' branch (RE-R2b morning brief)"
fi

if grep -qF "$BRIEF_WIRE_LIT" agents/trend-spotter.md; then
  pass "trend-spotter.md references the brief CLI ('$BRIEF_WIRE_LIT') as the post-capture surfacing step"
else
  fail "trend-spotter.md does not reference the brief CLI — wire Step 4.6 to a '$BRIEF_WIRE_LIT' call (RE-R2b)"
fi

if grep -qF "$BRIEF_SURFACE_LIT" hooks/scripts/session-start.mjs; then
  pass "session-start.mjs surfaces the morning brief ('$BRIEF_SURFACE_LIT')"
else
  fail "session-start.mjs does not surface the brief — add the '$BRIEF_SURFACE_LIT' reader (RE-R2b hull 4)"
fi

echo ""

# --- Section 16j: Trends Score Wiring (research-engine RE-R3a) ---
echo "--- Trends Score Wiring ---"

# RE-R3a persists the relevance score the trend-spotter agent already computes and ranks the
# morning brief on its composite. Four literals must hold, grepped EXACT (grep -F),
# deps-absent-safe (pure grep, no tsx):
#   (1) score.ts exports the persist envelope, by the literal 'export interface TrendScore';
#   (2) types.ts carries it on the record, by the literal 'score?: TrendScore';
#   (3) agents/trend-spotter.md carries the judgment in the capture batch, by 'dimensions'
#       (verified absent pre-R3a -> the grep is non-vacuous);
#   (4) brief.ts ranks on the composite, by the literal 'score?.composite' (the payoff is wired,
#       not merely doc'd).
# Non-vacuity self-test mirrors Section 16i: the rank predicate must accept a probe carrying the
# composite-rank literal and reject one without it. Placed after Section 16i / before Section 18
# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts
# toward ASSERT_BASELINE_FLOOR.
SCORE_IFACE_LIT='export interface TrendScore'
SCORE_TYPE_LIT='score?: TrendScore'
SCORE_DIMS_LIT='"dimensions"'
SCORE_RANK_LIT='score?.composite'

I16J_SELFTEST_OK=1
if ! echo 'rankForBrief sorts on (b.trend.score?.composite ?? -1) first' | grep -qF "$SCORE_RANK_LIT"; then
  I16J_SELFTEST_OK=0; echo "  non-vacuity FAIL: a wired composite-rank probe was not detected"
fi
if echo 'the brief ranks on pillar overlap only' | grep -qF "$SCORE_RANK_LIT"; then
  I16J_SELFTEST_OK=0; echo "  false-positive FAIL: an unwired probe matched the composite-rank pointer"
fi
if [ "$I16J_SELFTEST_OK" -eq 1 ]; then
  pass "trends-score self-test: composite-rank predicate detects wiring, rejects the under-wired form"
else
  fail "trends-score self-test failed — the score-wiring lint is vacuous or over-eager"
fi

if grep -qF "$SCORE_IFACE_LIT" scripts/trends/src/score.ts; then
  pass "score.ts exports the persist envelope ('$SCORE_IFACE_LIT')"
else
  fail "score.ts has no TrendScore envelope — add '$SCORE_IFACE_LIT' (RE-R3a persist)"
fi

if grep -qF "$SCORE_TYPE_LIT" scripts/trends/src/types.ts; then
  pass "types.ts carries the score on the record ('$SCORE_TYPE_LIT')"
else
  fail "types.ts does not carry the score — add '$SCORE_TYPE_LIT' to TrendRecord (RE-R3a schema v3)"
fi

if grep -qF "$SCORE_DIMS_LIT" agents/trend-spotter.md; then
  pass "trend-spotter.md carries the judgment in the capture batch ($SCORE_DIMS_LIT)"
else
  fail "trend-spotter.md does not carry the judgment — add the per-item $SCORE_DIMS_LIT to Step 4.5 (RE-R3a wiring)"
fi

if grep -qF "$SCORE_RANK_LIT" scripts/trends/src/brief.ts; then
  pass "brief.ts ranks on the composite ('$SCORE_RANK_LIT')"
else
  fail "brief.ts does not rank on the composite — add the '$SCORE_RANK_LIT' comparator term (RE-R3a payoff)"
fi

echo ""

# --- Section 16k: Trends Lifecycle Wiring (research-engine RE-R3b) ---
echo "--- Trends Lifecycle Wiring ---"

# RE-R3b adds the trend lifecycle: re-score on re-capture, a status (new/acted/skipped) the brief
# EXCLUDES, and a seen-log (surfacedCount/lastSurfacedAt) the brief records. Five literals must hold,
# grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx):
#   (1) types.ts declares the status type, by 'export type TrendStatus';
#   (2) types.ts carries the seen-log field, by 'surfacedCount';
#   (3) store.ts owns the seen-log writer, by 'export function markSurfaced';
#   (4) brief.ts excludes handled trends, by 'effectiveStatus' (the filter is wired, not doc'd);
#   (5) cli.ts exposes the lifecycle verb, by 'command === "act"'.
# Non-vacuity self-test mirrors Section 16j: the brief-filter predicate must accept a probe carrying
# the effectiveStatus pointer and reject one without it. Placed after Section 16j / before Section 18
# (anti-erosion must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward
# ASSERT_BASELINE_FLOOR.
LIFECYCLE_STATUS_LIT='export type TrendStatus'
LIFECYCLE_SEEN_LIT='surfacedCount'
LIFECYCLE_MARK_LIT='export function markSurfaced'
LIFECYCLE_FILTER_LIT='effectiveStatus'
LIFECYCLE_VERB_LIT='command === "act"'

I16K_SELFTEST_OK=1
if ! echo 'rankForBrief drops a trend when effectiveStatus(trend) !== "new"' | grep -qF "$LIFECYCLE_FILTER_LIT"; then
  I16K_SELFTEST_OK=0; echo "  non-vacuity FAIL: a wired status-filter probe was not detected"
fi
if echo 'the brief shows every record regardless of status' | grep -qF "$LIFECYCLE_FILTER_LIT"; then
  I16K_SELFTEST_OK=0; echo "  false-positive FAIL: an unwired probe matched the status-filter pointer"
fi
if [ "$I16K_SELFTEST_OK" -eq 1 ]; then
  pass "trends-lifecycle self-test: status-filter predicate detects wiring, rejects the unfiltered form"
else
  fail "trends-lifecycle self-test failed — the lifecycle-wiring lint is vacuous or over-eager"
fi

if grep -qF "$LIFECYCLE_STATUS_LIT" scripts/trends/src/types.ts; then
  pass "types.ts declares the lifecycle status type ('$LIFECYCLE_STATUS_LIT')"
else
  fail "types.ts has no TrendStatus — add '$LIFECYCLE_STATUS_LIT' (RE-R3b lifecycle)"
fi

if grep -qF "$LIFECYCLE_SEEN_LIT" scripts/trends/src/types.ts; then
  pass "types.ts carries the seen-log field ('$LIFECYCLE_SEEN_LIT')"
else
  fail "types.ts does not carry the seen-log — add '$LIFECYCLE_SEEN_LIT' to TrendRecord (RE-R3b schema v4)"
fi

if grep -qF "$LIFECYCLE_MARK_LIT" scripts/trends/src/store.ts; then
  pass "store.ts owns the seen-log writer ('$LIFECYCLE_MARK_LIT')"
else
  fail "store.ts has no markSurfaced — add '$LIFECYCLE_MARK_LIT' (RE-R3b seen-log)"
fi

if grep -qF "$LIFECYCLE_FILTER_LIT" scripts/trends/src/brief.ts; then
  pass "brief.ts excludes handled trends ('$LIFECYCLE_FILTER_LIT')"
else
  fail "brief.ts does not exclude handled trends — wire '$LIFECYCLE_FILTER_LIT' into rankForBrief (RE-R3b)"
fi

if grep -qF "$LIFECYCLE_VERB_LIT" scripts/trends/src/cli.ts; then
  pass "cli.ts exposes the lifecycle verb ('$LIFECYCLE_VERB_LIT')"
else
  fail "cli.ts has no act/skip/reset — add '$LIFECYCLE_VERB_LIT' (RE-R3b lifecycle verbs)"
fi

echo ""

# --- Section 16l: Trends Scheduler / Headless Wiring (research-engine RE-R3c) ---
echo "--- Trends Scheduler / Headless Wiring ---"

# RE-R3c adds the autonomous trigger + headless entry: a `schedule` CLI verb emitting a launchd plist /
# cron line (print-first), and a bash wrapper that runs the DETERMINISTIC brief headless. Five literals
# must hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx):
#   (1) schedule.ts emits the launchd plist, by 'export function launchdPlist';
#   (2) schedule.ts emits the cron line, by 'export function crontabLine';
#   (3) cli.ts exposes the schedule verb, by 'command === "schedule"';
#   (4) run-daily.sh runs the deterministic brief, by 'cli.ts" brief' (the wrapper owns the subcommand);
#   (5) run-daily.sh uses the data-path twin seam, by 'LINKEDIN_STUDIO_DATA:-'.
# Non-vacuity self-test mirrors Section 16k: a probe carrying the launchdPlist pointer is accepted and
# one without it rejected. Placed after Section 16k / before Section 18 (anti-erosion must run last so it
# sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR.
SCHED_PLIST_LIT='export function launchdPlist'
SCHED_CRON_LIT='export function crontabLine'
SCHED_VERB_LIT='command === "schedule"'
SCHED_BRIEF_LIT='cli.ts" brief'
SCHED_DATA_LIT='LINKEDIN_STUDIO_DATA:-'

I16L_SELFTEST_OK=1
if ! echo 'a wired scheduler declares: export function launchdPlist(spec)' | grep -qF "$SCHED_PLIST_LIT"; then
  I16L_SELFTEST_OK=0; echo "  non-vacuity FAIL: a wired scheduler-emit probe was not detected"
fi
if echo 'an unwired module emits no artifact at all' | grep -qF "$SCHED_PLIST_LIT"; then
  I16L_SELFTEST_OK=0; echo "  false-positive FAIL: an unwired probe matched the scheduler-emit pointer"
fi
if [ "$I16L_SELFTEST_OK" -eq 1 ]; then
  pass "trends-scheduler self-test: the emit pointer is detected, the no-emit form rejected"
else
  fail "trends-scheduler self-test failed — the scheduler-wiring lint is vacuous or over-eager"
fi

if grep -qF "$SCHED_PLIST_LIT" scripts/trends/src/schedule.ts; then
  pass "schedule.ts emits the launchd plist ('$SCHED_PLIST_LIT')"
else
  fail "schedule.ts has no launchd emitter — add '$SCHED_PLIST_LIT' (RE-R3c scheduler)"
fi

if grep -qF "$SCHED_CRON_LIT" scripts/trends/src/schedule.ts; then
  pass "schedule.ts emits the cron line ('$SCHED_CRON_LIT')"
else
  fail "schedule.ts has no cron-line emitter — add '$SCHED_CRON_LIT' (RE-R3c scheduler)"
fi

if grep -qF "$SCHED_VERB_LIT" scripts/trends/src/cli.ts; then
  pass "cli.ts exposes the schedule verb ('$SCHED_VERB_LIT')"
else
  fail "cli.ts has no schedule verb — add '$SCHED_VERB_LIT' (RE-R3c trigger)"
fi

if grep -qF "$SCHED_BRIEF_LIT" scripts/trends/run-daily.sh; then
  pass "run-daily.sh runs the deterministic brief ('$SCHED_BRIEF_LIT')"
else
  fail "run-daily.sh does not run the brief — wire '$SCHED_BRIEF_LIT' (RE-R3c headless entry)"
fi

if grep -qF "$SCHED_DATA_LIT" scripts/trends/run-daily.sh; then
  pass "run-daily.sh uses the data-path twin seam ('$SCHED_DATA_LIT')"
else
  fail "run-daily.sh has no data-path seam — add '$SCHED_DATA_LIT' (RE-R3c fourth twin)"
fi

echo ""

# --- Section 16m: Trends Temporal Overlay (research-engine RE-R3d) ---
echo "--- Trends Temporal Overlay ---"

# RE-R3d adds the DERIVED temporal overlay (first-mover + saturation) to the morning-brief ranking:
# a pure temporalSignal in brief.ts, a new cmp key, and two tunable CLI flags. Five literals must
# hold, grepped EXACT (grep -F), deps-absent-safe (pure grep, no tsx); ASCII-only (bash 3.2 set -u):
#   (1) brief.ts exports the signal, by 'export function temporalSignal';
#   (2) brief.ts ranks on it, by 'b.temporal.rank' (the cmp key);
#   (3) brief.ts declares the first-mover tier, by '"first-mover"';
#   (4) cli.ts exposes the first-mover threshold flag, by 'first-mover-days';
#   (5) cli.ts exposes the saturation threshold flag, by 'saturation-at'.
# Non-vacuity self-test mirrors Section 16l. Placed after Section 16l / before Section 18 (anti-erosion
# must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR.
TEMP_SIGNAL_LIT='export function temporalSignal'
TEMP_RANK_LIT='b.temporal.rank'
TEMP_TIER_LIT='"first-mover"'
TEMP_FMDAYS_LIT='first-mover-days'
TEMP_SATAT_LIT='saturation-at'

I16M_SELFTEST_OK=1
if ! echo 'a wired overlay declares: export function temporalSignal(ageDays)' | grep -qF "$TEMP_SIGNAL_LIT"; then
  I16M_SELFTEST_OK=0; echo "  non-vacuity FAIL: a wired temporal-overlay probe was not detected"
fi
if echo 'an unwired module derives no temporal signal at all' | grep -qF "$TEMP_SIGNAL_LIT"; then
  I16M_SELFTEST_OK=0; echo "  false-positive FAIL: an unwired probe matched the temporal-overlay pointer"
fi
if [ "$I16M_SELFTEST_OK" -eq 1 ]; then
  pass "trends-temporal self-test: the signal pointer is detected, the no-signal form rejected"
else
  fail "trends-temporal self-test failed — the temporal-overlay lint is vacuous or over-eager"
fi

if grep -qF "$TEMP_SIGNAL_LIT" scripts/trends/src/brief.ts; then
  pass "brief.ts derives the temporal signal ('$TEMP_SIGNAL_LIT')"
else
  fail "brief.ts has no temporal signal — add '$TEMP_SIGNAL_LIT' (RE-R3d overlay)"
fi

if grep -qF "$TEMP_RANK_LIT" scripts/trends/src/brief.ts; then
  pass "brief.ts ranks on the temporal overlay ('$TEMP_RANK_LIT')"
else
  fail "brief.ts cmp does not use the temporal rank — add '$TEMP_RANK_LIT' (RE-R3d ranking)"
fi

if grep -qF "$TEMP_TIER_LIT" scripts/trends/src/brief.ts; then
  pass "brief.ts declares the first-mover tier ('$TEMP_TIER_LIT')"
else
  fail "brief.ts has no first-mover tier — add '$TEMP_TIER_LIT' (RE-R3d tiers)"
fi

if grep -qF "$TEMP_FMDAYS_LIT" scripts/trends/src/cli.ts; then
  pass "cli.ts exposes the first-mover-days flag ('$TEMP_FMDAYS_LIT')"
else
  fail "cli.ts has no first-mover-days flag — add '$TEMP_FMDAYS_LIT' (RE-R3d brief flag)"
fi

if grep -qF "$TEMP_SATAT_LIT" scripts/trends/src/cli.ts; then
  pass "cli.ts exposes the saturation-at flag ('$TEMP_SATAT_LIT')"
else
  fail "cli.ts has no saturation-at flag — add '$TEMP_SATAT_LIT' (RE-R3d brief flag)"
fi

echo ""

# --- Section 16n: Trends Brief History / Diff (research-engine RE-R3e) ---
echo "--- Trends Brief History / Diff ---"

# RE-R3e adds the day-over-day brief diff: a 'surfaced:' frontmatter record + three pure helpers
# (diffSurfaced / parseSurfacedFrontmatter / selectPriorBriefFile) in brief.ts, wired at the cli.ts
# edge, rendering a 'Nytt siden sist' section. Five literals must hold, grepped EXACT (grep -F),
# deps-absent-safe (pure grep, no tsx); ASCII-only (bash 3.2 set -u — the section's emoji is NEVER
# grepped, only its ASCII tail 'Nytt siden sist'):
#   (1) brief.ts exports the diff, by 'export function diffSurfaced';
#   (2) brief.ts parses the prior record, by 'parseSurfacedFrontmatter';
#   (3) brief.ts renders the section, by 'Nytt siden sist' (the ASCII header tail);
#   (4) cli.ts wires prior-discovery, by 'selectPriorBriefFile';
#   (5) brief.ts emits the membership record, by 'surfaced: ' (the frontmatter line).
# Non-vacuity self-test mirrors Section 16m. Placed after Section 16m / before Section 18 (anti-erosion
# must run last so it sees every prior check). UNCONDITIONAL (no tsx) -> counts toward ASSERT_BASELINE_FLOOR.
DIFF_FN_LIT='export function diffSurfaced'
DIFF_PARSE_LIT='parseSurfacedFrontmatter'
DIFF_SECTION_LIT='Nytt siden sist'
DIFF_SELECT_LIT='selectPriorBriefFile'
DIFF_SURFACED_LIT='surfaced: '

I16N_SELFTEST_OK=1
if ! echo 'a wired diff declares: export function diffSurfaced(currentIds, priorIds)' | grep -qF "$DIFF_FN_LIT"; then
  I16N_SELFTEST_OK=0; echo "  non-vacuity FAIL: a wired brief-diff probe was not detected"
fi
if echo 'an unwired module computes no day-over-day diff at all' | grep -qF "$DIFF_FN_LIT"; then
  I16N_SELFTEST_OK=0; echo "  false-positive FAIL: an unwired probe matched the brief-diff pointer"
fi
if [ "$I16N_SELFTEST_OK" -eq 1 ]; then
  pass "trends-brief-diff self-test: the diff pointer is detected, the no-diff form rejected"
else
  fail "trends-brief-diff self-test failed — the brief-diff lint is vacuous or over-eager"
fi

if grep -qF "$DIFF_FN_LIT" scripts/trends/src/brief.ts; then
  pass "brief.ts exports the day-over-day diff ('$DIFF_FN_LIT')"
else
  fail "brief.ts has no diff — add '$DIFF_FN_LIT' (RE-R3e brief history)"
fi

if grep -qF "$DIFF_PARSE_LIT" scripts/trends/src/brief.ts; then
  pass "brief.ts parses the prior surfaced record ('$DIFF_PARSE_LIT')"
else
  fail "brief.ts cannot read a prior brief — add '$DIFF_PARSE_LIT' (RE-R3e)"
fi

if grep -qF "$DIFF_SECTION_LIT" scripts/trends/src/brief.ts; then
  pass "brief.ts renders the diff section ('$DIFF_SECTION_LIT')"
else
  fail "brief.ts has no diff section — add '$DIFF_SECTION_LIT' (RE-R3e section header)"
fi

if grep -qF "$DIFF_SELECT_LIT" scripts/trends/src/cli.ts; then
  pass "cli.ts wires prior-brief discovery ('$DIFF_SELECT_LIT')"
else
  fail "cli.ts does not discover the prior brief — add '$DIFF_SELECT_LIT' (RE-R3e wiring)"
fi

if grep -qF "$DIFF_SURFACED_LIT" scripts/trends/src/brief.ts; then
  pass "brief.ts emits the surfaced: membership record ('$DIFF_SURFACED_LIT')"
else
  fail "brief.ts records no membership — add the 'surfaced:' frontmatter line (RE-R3e)"
fi

echo ""

# --- Section 16o: Voice Chain — chronicle-voice-profile binding (N8 / C-1,C-2,C-3,A2-F1,A2-F2) ---
echo "--- Voice Chain (N8) ---"

# N8 binds the long-form VOICE before prose — the same discipline the line already applies to
# SOURCES (validate-binding), its strongest link. Five invariants must hold in the .md/.json surfaces:
#  (C-1/A2-F1) voice-trainer.md distils a chronicle-voice-profile from the PUBLISHED gold corpus, and
#              newsletter.md binds drafting (Step 0/3a/3b) to THAT profile — not to the English short-form
#              authentic-voice-samples corpus the engine itself calls degrading for chronicle voice.
#  (C-2)       Step 3a READS the chronicle-voice-drift-log as a Pass-0 negative list (today write-only).
#  (C-3)       Step 1 RESOLVES + persists the review `language`, making the edition-state doc-string's
#              "Resolved at Step 1" claim true, so the Norwegian axis fires when content IS Norwegian.
#  (A2-F2)     Step 8 lock appends the locked edition to the gold corpus (ingest/published,
#              provenance=published) so the corpus the profile distils from grows automatically.
# All are UNCONDITIONAL .md/.json greps (no TS deps → they raise the deps-absent floor). Auto-gull needs
# BOTH gold-corpus literals; a non-vacuity self-test guards that compound predicate (mirrors Section 16c).
CVP_LIT='chronicle-voice-profile'
DRIFTLOG_LIT='chronicle-voice-drift-log'
LANG_RESOLVE_LIT='Resolve the review language'
GOLD_PATH_LIT='ingest/published'
GOLD_PROV_LIT='provenance=published'

autogull_wired() {  # $1 = text; wired iff BOTH gold-corpus literals present (echo twice — grep consumes stdin)
  echo "$1" | grep -qF "$GOLD_PATH_LIT" && echo "$1" | grep -qF "$GOLD_PROV_LIT"
}

VC_SELFTEST_OK=1
if ! autogull_wired "lock appends the edition to ingest/published with provenance=published"; then
  VC_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired auto-gull probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if autogull_wired "$probe"; then
    VC_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired auto-gull probe accepted -> $probe"
  fi
done <<'NEGATIVE16O'
lock appends the edition to ingest/published/ but never tags provenance
marks provenance=published but never names the gold-corpus path
NEGATIVE16O
if [ "$VC_SELFTEST_OK" -eq 1 ]; then
  pass "voice-chain self-test: auto-gull predicate needs BOTH gold-corpus literals (1 accepted, 2 under-wired rejected)"
else
  fail "voice-chain self-test failed — the auto-gull lint is vacuous or over-eager"
fi

NL="commands/newsletter.md"
VT_N8="agents/voice-trainer.md"

# (C-1/A2-F1) chronicle-voice-profile artifact generated + bound
if grep -qF "$CVP_LIT" "$VT_N8"; then
  pass "voice-trainer.md distils the $CVP_LIT (per-language gold-corpus voice artifact)"
else
  fail "voice-trainer.md missing '$CVP_LIT' — the chronicle voice artifact is never generated (C-1/A2-F1)"
fi
if grep -qF "$CVP_LIT" "$NL"; then
  pass "newsletter.md binds long-form drafting to the $CVP_LIT (C-1)"
else
  fail "newsletter.md missing '$CVP_LIT' — drafting still binds only to the short-form corpus (C-1)"
fi

# (C-2) drift-log READ (Pass 0 negative list), not just written
if grep -qF "$DRIFTLOG_LIT" "$NL"; then
  pass "newsletter.md reads the $DRIFTLOG_LIT as a Pass-0 negative list (C-2)"
else
  fail "newsletter.md missing '$DRIFTLOG_LIT' — the drift log is still write-only (C-2)"
fi

# (C-3) language resolved + persisted at Step 1
if grep -qF "$LANG_RESOLVE_LIT" "$NL"; then
  pass "newsletter.md Step 1 resolves + persists the review language ('$LANG_RESOLVE_LIT', C-3)"
else
  fail "newsletter.md missing the Step 1 language-resolution instruction ('$LANG_RESOLVE_LIT', C-3)"
fi

# (A2-F2) auto-gull at Step 8 lock — the gold corpus grows on its own
if autogull_wired "$(cat "$NL")"; then
  pass "newsletter.md Step 8 auto-gull: locked edition appended to the gold corpus ('$GOLD_PATH_LIT' + '$GOLD_PROV_LIT', A2-F2)"
else
  fail "newsletter.md missing auto-gull — lock does not grow the gold corpus (needs '$GOLD_PATH_LIT' + '$GOLD_PROV_LIT', A2-F2)"
fi

echo ""

# --- Section 16p: Randsone-Gates - feed-facing surfaces + deterministic fact-recheck + WebFetch (N9 / C-4,C-6,C-7,C-9) ---
echo "--- Randsone Gates (N9) ---"

# N9 closes the border-zone gap: the hook (delingstekst), the samle post, the carousel slide text and the
# edition title reach the feed today with NO fact/language gate (Step 5/6.5 run on the article BODY;
# carousel is authored in Step 7.5 AFTER the last content gate; Step 9 judged only the hook's click-worth).
# Four invariants must hold in the .md surfaces:
#  (C-4/C-9) Step 9's post-lock gate is extended with a scoped fact-reviewer + language-reviewer pass over
#            all four distribution surfaces (delingstekst hook, samle post, carousel slide text, edition
#            title candidates). Step 9 is the first phase where all four exist, and it already operates on
#            post-lock distribution copy the gate may revise without reopening the locked body.
#  (C-6)     any post-factcheck fold-in whose diff touches a sentence with numbers, names or quotes fires a
#            mandatory scoped fact-reviewer mini-recheck - a DETERMINISTIC trigger ("not a judgment call"),
#            which also covers the pivot-heuristic blind spot (a swapped number no >20% rule would catch).
#  (C-7)     both fact agents (fact-checker, fact-reviewer) carry WebFetch - quote precision needs the
#            verbatim source text, not just a search snippet.
# The randsone predicate is compound (both reviewers AND both new surfaces), so a non-vacuity self-test
# guards it (mirrors Section 16o). All checks are UNCONDITIONAL .md greps -> they raise the deps-absent floor.
NL_N9="commands/newsletter.md"
# Slice the Step 9 block (## Step 9: up to ## Step 10:) so the gate literals are scoped to that phase.
STEP9_BLOCK=$(awk '/^## Step 9:/{f=1} /^## Step 10:/{f=0} f' "$NL_N9")

randsone_gated() {  # $1 = text; wired iff BOTH reviewers AND BOTH new surfaces are named
  echo "$1" | grep -qF "fact-reviewer" \
    && echo "$1" | grep -qF "language-reviewer" \
    && echo "$1" | grep -qF "carousel slide text" \
    && echo "$1" | grep -qF "edition title"
}

RZ_SELFTEST_OK=1
if ! randsone_gated "scoped fact-reviewer + language-reviewer over the carousel slide text and edition title candidates"; then
  RZ_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired randsone-gate probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if randsone_gated "$probe"; then
    RZ_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired randsone-gate probe accepted -> $probe"
  fi
done <<'NEGATIVE16P'
language-reviewer over the carousel slide text and edition title but no fact reviewer here
fact-reviewer + language-reviewer over the edition title only, no carousel surface
fact-reviewer + language-reviewer over the carousel slide text only, no title surface
NEGATIVE16P
if [ "$RZ_SELFTEST_OK" -eq 1 ]; then
  pass "randsone-gate self-test: predicate needs BOTH fact+language reviewers AND both new surfaces (1 accepted, 3 under-wired rejected)"
else
  fail "randsone-gate self-test failed - the randsone lint is vacuous or over-eager"
fi

# (C-4/C-9) Step 9 gates all four distribution surfaces through fact + language reviewers
if randsone_gated "$STEP9_BLOCK"; then
  pass "newsletter.md Step 9 runs the scoped fact-reviewer + language-reviewer randsone gate over carousel + title (C-4/C-9)"
else
  fail "newsletter.md Step 9 missing the randsone fact+language gate - hook/samle/carousel/title still reach the feed ungated (C-4/C-9)"
fi

# (C-9) the edition's own title now faces a strength gate at Step 9 (today only the hook's two lines were judged)
if echo "$STEP9_BLOCK" | grep -qF "title-strength"; then
  pass "newsletter.md Step 9 gates the edition title-strength, not only the hook (C-9)"
else
  fail "newsletter.md Step 9 missing the title-strength gate - the edition title still ships unjudged (C-9)"
fi

# (C-6) deterministic post-factcheck fact-recheck trigger (nouns + mechanism + determinism marker)
if grep -qF "numbers, names, or quotes" "$NL_N9"; then
  pass "newsletter.md declares the deterministic recheck trigger nouns ('numbers, names, or quotes', C-6)"
else
  fail "newsletter.md missing the recheck trigger nouns 'numbers, names, or quotes' (C-6)"
fi
if grep -qF "scoped fact-reviewer" "$NL_N9"; then
  pass "newsletter.md routes the post-factcheck recheck to a scoped fact-reviewer (C-6)"
else
  fail "newsletter.md missing 'scoped fact-reviewer' - the recheck has no mechanism (C-6)"
fi
if grep -qF "not a judgment call" "$NL_N9"; then
  pass "newsletter.md marks the recheck trigger deterministic ('not a judgment call', C-6)"
else
  fail "newsletter.md missing the determinism marker 'not a judgment call' - the trigger is still discretionary (C-6)"
fi

# (C-7) both fact agents carry WebFetch (verbatim source text for quote precision)
if grep -qF "WebFetch" agents/fact-checker.md; then
  pass "fact-checker.md carries WebFetch (verbatim source text for quote precision, C-7)"
else
  fail "fact-checker.md missing WebFetch in tools - quote precision has no verbatim-source fetch (C-7)"
fi
if grep -qF "WebFetch" agents/fact-reviewer.md; then
  pass "fact-reviewer.md carries WebFetch (verbatim source text for quote precision, C-7)"
else
  fail "fact-reviewer.md missing WebFetch in tools - cold re-verification has no verbatim-source fetch (C-7)"
fi

echo ""

# --- Section 16q: Configurable Target-Level Span (N10 / C-8) ---
echo "--- Configurable Target-Level Span (N10) ---"

# N10 turns "practically usable at all levels" into a CONFIGURABLE gate axis and removes the
# hardcoded primary persona. Six .md/.json surfaces must hold the invariants (all UNCONDITIONAL
# greps -> they raise the deps-absent floor):
#  (C-8 absence) the hardcoded primary "non-technical line manager" is GONE from references/ + config/
#                — the quality-rule now points at the edition's CONFIGURED target-level span instead.
#  (C-8 span-ref) references/longform-quality-rules.md names the "target-level span" (domain-general),
#                 not a single baked-in reader role.
#  (C-8 obligatorisk-sekundær) config/personas.template.md's «primær trumfer» rule now REQUIRES a
#                 mandatory secondary at the opposite end of the span; a secondary NO may be waived
#                 as «signal, not failure» ONLY with an explicit ceiling justification.
#  (C-8 tekniker-seed) the seed library gains a technical-end persona (today all three seeds are leaders).
#  (C-8 edition-state) config/edition-state.template.json carries an additive per-article targetLevel slot.
#  (C-8 persist) commands/newsletter.md persists the resolved targetLevel to articles.NN.targetLevel.
#  (C-8 consume) agents/persona-reviewer.md READS articles.NN.targetLevel and places personas on the span
#                — a compound predicate (targetLevel field + edition-state read + span placement), so a
#                non-vacuity self-test guards it (mirrors Section 16p).
PR_N10="agents/persona-reviewer.md"

targetlevel_consumed() {  # $1 = text; wired iff it reads targetLevel FROM edition-state AND places on the span
  echo "$1" | grep -qF "targetLevel" \
    && echo "$1" | grep -qF "articles." \
    && echo "$1" | grep -qF "target-level span"
}

TL_SELFTEST_OK=1
if ! targetlevel_consumed "read articles.NN.targetLevel and place the personas on the target-level span"; then
  TL_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired targetLevel-consumption probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if targetlevel_consumed "$probe"; then
    TL_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired targetLevel probe accepted -> $probe"
  fi
done <<'NEGATIVE16Q'
place the personas on the target-level span but never read the field
read articles.NN.targetLevel but do not mention the span placement here
the targetLevel matters for the target-level span without an edition-state read
NEGATIVE16Q
if [ "$TL_SELFTEST_OK" -eq 1 ]; then
  pass "targetLevel-consumption self-test: predicate needs targetLevel + edition-state read (articles.) + span placement (1 accepted, 3 under-wired rejected)"
else
  fail "targetLevel-consumption self-test failed - the N10 consumption lint is vacuous or over-eager"
fi

# (C-8 absence) the hardcoded primary persona is gone from the shippable surfaces
if grep -rn "non-technical line manager" references/ config/ >/dev/null 2>&1; then
  fail "N10: hardcoded 'non-technical line manager' still present in references/ or config/ — the primary persona is not de-hardcoded (C-8)"
else
  pass "N10: no hardcoded 'non-technical line manager' in references/ or config/ (C-8 absence)"
fi

# (C-8 span-ref) the quality rule points at the configured span, not a baked-in role
if grep -qF "target-level span" references/longform-quality-rules.md; then
  pass "references/longform-quality-rules.md names the configured 'target-level span' (C-8 span-ref)"
else
  fail "references/longform-quality-rules.md missing 'target-level span' — the served-reader rule is still role-hardcoded (C-8)"
fi

# (C-8 obligatorisk-sekundær) the «primær trumfer» rule requires a mandatory secondary at the opposite end
if grep -qF "opposite end of the target-level span" config/personas.template.md; then
  pass "config/personas.template.md requires a mandatory secondary at the opposite end of the span (C-8 obligatorisk-sekundær)"
else
  fail "config/personas.template.md missing the 'opposite end of the target-level span' requirement — one reader can still self-certify all levels (C-8)"
fi

# (C-8 ceiling-begrunnelse) a secondary NO may only be waived with an explicit ceiling justification
if grep -qF "explicit ceiling justification" config/personas.template.md; then
  pass "config/personas.template.md gates the secondary-NO waiver behind an explicit ceiling justification (C-8 ceiling-begrunnelse)"
else
  fail "config/personas.template.md missing 'explicit ceiling justification' — a secondary NO can still be waved away as 'signal, not failure' unproven (C-8)"
fi

# (C-8 tekniker-seed) the seed library gains a technical-end persona (today all three seeds are leaders)
if grep -qF "Technical end of the target-level span" config/personas.template.md; then
  pass "config/personas.template.md seeds a technical-end persona opposite the leader personas (C-8 tekniker-seed)"
else
  fail "config/personas.template.md missing a 'Technical end of the target-level span' seed — the library is still leader-only (C-8)"
fi

# (C-8 edition-state) the per-article targetLevel SLOT exists (additive, no schemaVersion bump).
# Grep the slot's default literal `"targetLevel": null`, NOT the bare word — the latter already occurs
# inside the sourceTrendId doc-string ("angle / targetLevel / key-points"), which would match vacuously.
if grep -qF '"targetLevel": null' config/edition-state.template.json; then
  pass "config/edition-state.template.json carries the additive articles.NN.targetLevel slot (default null, C-8 edition-state)"
else
  fail "config/edition-state.template.json missing the '\"targetLevel\": null' slot — persona-reviewer would consume a phantom field (C-8)"
fi

# (C-8 persist) newsletter.md persists the resolved targetLevel to the per-article slot
if grep -qF "articles.NN.targetLevel" commands/newsletter.md; then
  pass "commands/newsletter.md persists the resolved targetLevel to articles.NN.targetLevel (C-8 persist)"
else
  fail "commands/newsletter.md never persists articles.NN.targetLevel — the slot stays null and consumption is vacuous (C-8)"
fi

# (C-8 consume) persona-reviewer reads targetLevel from edition-state and places personas on the span
PR_CONTENT=$(cat "$PR_N10")
if targetlevel_consumed "$PR_CONTENT"; then
  pass "agents/persona-reviewer.md reads articles.NN.targetLevel and places personas on the target-level span (C-8 consume)"
else
  fail "agents/persona-reviewer.md does not consume articles.NN.targetLevel onto the span — targetLevel is set but never read (C-8)"
fi

echo ""

# --- Section 16r: Series-Level Protection - distillate + usage log (N11 / C-5) ---
echo "--- Series-Level Protection (N11) ---"

# N11 makes "the reader has heard this before" visible BEFORE the skeleton is approved.
# Every other gate sees ONE edition; at series cadence the fastest-growing defect is a
# REPEATED edition, and the specifics-bank dedupe actively encourages re-surfacing material.
# Two stores, two grains, both UNCONDITIONAL greps (they raise the deps-absent floor):
#  (C-5 series grain)  scripts/editions - what THIS SERIES already published (anecdote/argument/hook)
#  (C-5 material grain) specifics-bank usedIn - which editions consumed a given lived specific
#  (C-5 Step 2.5) the destillat-sjekk feeds the annotation gate BEFORE prose - a compound predicate
#                 (distil-check call + the series-scoped store + the advisory REUSE verdict), so a
#                 non-vacuity self-test guards it (mirrors Sections 16p/16q).
#  (C-5 Step 8)   lock writes both stores - published, never merely considered.
NL_N11="commands/newsletter.md"
DIST_SRC="scripts/editions/src/distillate.ts"

distillate_gated() {  # $1 = text; wired iff it CALLS the check, names the series-scoped store AND carries the advisory verdict
  echo "$1" | grep -qF "distil-check" \
    && echo "$1" | grep -qF "series-distillate.json" \
    && echo "$1" | grep -qF "REUSE"
}

DG_SELFTEST_OK=1
if ! distillate_gated "run distil-check --distillate <abs>/linkedin/series-distillate.json; on REUSE fold the finding into the annotation page"; then
  DG_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired destillat-sjekk probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if distillate_gated "$probe"; then
    DG_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired destillat probe accepted -> $probe"
  fi
done <<'NEGATIVE16R'
run distil-check against the store and act on REUSE without naming the file
the series-distillate.json store exists and REUSE is possible, but nothing calls the check
call distil-check on series-distillate.json but never say what a hit means
NEGATIVE16R
if [ "$DG_SELFTEST_OK" -eq 1 ]; then
  pass "destillat-gate self-test: predicate needs distil-check + series-distillate.json + REUSE verdict (1 accepted, 3 under-wired rejected)"
else
  fail "destillat-gate self-test failed - the N11 wiring lint is vacuous or over-eager"
fi

# (C-5 Step 2.5) the destillat-sjekk runs in the skeleton gate, BEFORE prose
STEP25_BLOCK=$(awk '/^## Step 2.5:/{f=1} /^## Step 3a:/{f=0} f' "$NL_N11")
if distillate_gated "$STEP25_BLOCK"; then
  pass "newsletter Step 2.5 runs the destillat-sjekk against the series store with an advisory REUSE verdict (C-5 Step 2.5)"
else
  fail "newsletter Step 2.5 does not run the destillat-sjekk - series-level re-use stays invisible before prose (C-5)"
fi

# (C-5 Step 8 distillate) lock folds the spent units into the series distillate
STEP8_BLOCK=$(awk '/^## Step 8:/{f=1} /^## Step 9:/{f=0} f' "$NL_N11")
if echo "$STEP8_BLOCK" | grep -qF "distil-append"; then
  pass "newsletter Step 8 lock writes the series distillate (distil-append) (C-5 Step 8)"
else
  fail "newsletter Step 8 never calls distil-append - nothing feeds the next edition's Step 2.5 check (C-5)"
fi

# (C-5 Step 8 usage) lock stamps the consumed lived material
if echo "$STEP8_BLOCK" | grep -qF "record-usage"; then
  pass "newsletter Step 8 lock stamps consumed specifics (record-usage) (C-5 Step 8)"
else
  fail "newsletter Step 8 never calls record-usage - bank re-use across editions stays invisible (C-5)"
fi

# (C-5 material grain) the bank record carries the usage log
if grep -qF "usedIn" scripts/specifics-bank/src/types.ts; then
  pass "specifics-bank Specific declares usedIn - the cross-edition usage log exists (C-5 material grain)"
else
  fail "specifics-bank/src/types.ts has no usedIn field - «used in edition NN» is unrecordable (C-5)"
fi

if grep -qF "export function recordUsage" scripts/specifics-bank/src/bank.ts; then
  pass "specifics-bank exports recordUsage (C-5 material grain)"
else
  fail "specifics-bank/src/bank.ts does not export recordUsage (C-5)"
fi

if grep -qF "export function boundSpecificIds" scripts/specifics-bank/src/binding.ts; then
  pass "specifics-bank exports boundSpecificIds - only specific-bound slots count as consumption (C-5)"
else
  fail "specifics-bank/src/binding.ts does not export boundSpecificIds (C-5)"
fi

# (C-5 series grain) the distillate module exists with its deterministic check
if grep -qF "export function checkSkeleton" "$DIST_SRC"; then
  pass "scripts/editions exports checkSkeleton - the deterministic series check exists (C-5 series grain)"
else
  fail "$DIST_SRC does not export checkSkeleton (C-5)"
fi

# (C-5 placement) the distillate lives in the SERIES root beside edition-state, not the data dir
if grep -qF "series-distillate.json" "$DIST_SRC" && grep -qF '"linkedin"' "$DIST_SRC"; then
  pass "series distillate resolves under <serie>/linkedin/ beside edition-state (C-5 placement)"
else
  fail "$DIST_SRC does not resolve the distillate under the series root - series-scoped state placed wrong (C-5)"
fi

echo ""

# --- Section 16s: Editions Register + phaseLog Telemetry (N12 / A1-11, A1-12) ---
echo "--- Editions Register (N12) ---"

# N12 makes two structurally invisible things visible: WHICH editions are in flight (until
# now readable only by opening every series folder's edition-state.json) and HOW LONG an
# edition actually takes (until now not recorded at all). The register is a MIRROR - only
# edition-state.json drives resumption - so the binding worth linting is that EVERY phase
# transition writes both the phaseLog entry and the register row, from ONE call:
#  (A1-12 protocol)  the transition protocol is defined once and is fully wired - a compound
#                    predicate (verb + logged field + mirrored store), so a non-vacuity
#                    self-test guards it (mirrors Sections 16p/16q/16r).
#  (A1-12 coverage)  every canonical phase calls it - a phase with no call is a silent hole
#                    in the lead-time log, which is exactly how telemetry dies.
#  (A1-11 store)     the register module + its data-dir placement exist.
#  (A1-12 schema)    edition-state carries the phaseLog slot the CLI appends to.
NL_N12="commands/newsletter.md"
REG_SRC="scripts/editions/src/register.ts"
EST_SRC="scripts/editions/src/editionState.ts"
EST_TPL="config/edition-state.template.json"
BT='`'

transition_protocol() {  # $1 = text; wired iff it CALLS the verb, names the field it logs AND the store it mirrors to
  echo "$1" | grep -qF "register-upsert" \
    && echo "$1" | grep -qF "phaseLog" \
    && echo "$1" | grep -qF "editions/register.json"
}

TP_SELFTEST_OK=1
if ! transition_protocol "run register-upsert --edition-state <path>; it appends articles.NN.phaseLog and mirrors the row into <data>/editions/register.json"; then
  TP_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired transition-protocol probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if transition_protocol "$probe"; then
    TP_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired transition probe accepted -> $probe"
  fi
done <<'NEGATIVE16S'
run register-upsert at every transition and let phaseLog grow, store left unspecified
append articles.NN.phaseLog and mirror into editions/register.json, but nothing calls the verb
run register-upsert and write editions/register.json without logging the phase
NEGATIVE16S
if [ "$TP_SELFTEST_OK" -eq 1 ]; then
  pass "transition-protocol self-test: predicate needs register-upsert + phaseLog + editions/register.json (1 accepted, 3 under-wired rejected)"
else
  fail "transition-protocol self-test failed - the N12 wiring lint is vacuous or over-eager"
fi

# (A1-12 protocol) the protocol is defined once, in the resumption area, fully wired
PROTO_BLOCK=$(awk '/^### Phase-transition protocol/{f=1} /^## Step 1:/{f=0} f' "$NL_N12")
if transition_protocol "$PROTO_BLOCK"; then
  pass "newsletter defines a wired phase-transition protocol (register-upsert appends phaseLog + mirrors the register) (A1-11/A1-12)"
else
  fail "newsletter has no wired phase-transition protocol - phase telemetry falls back to the model remembering (A1-12)"
fi

# (A1-11 invariant) the mirror rule is stated where the protocol is defined - a register read
# as source-of-truth would break deterministic resumption, the one thing edition-state owns
if echo "$PROTO_BLOCK" | grep -qF "MIRROR, never a source of truth"; then
  pass "newsletter states the mirror invariant - resumption reads edition-state only (A1-11)"
else
  fail "newsletter does not state that the register is a mirror - it can drift into a second source of truth (A1-11)"
fi

# (A1-12 coverage) every canonical phase transition calls the protocol
NL_FLAT=$(tr '\n' ' ' < "$NL_N12" | tr -s ' ')
MISSING_PHASES=""
for phase in lived-specifics research skeleton-pitch spine-prose draft consistency-quality \
             contract-gate factcheck-sweep editorial-review persona-sweep-prelock \
             headless-review annotation visual-assets lock-delivery hook-conversion-gate scheduling \
             retro; do
  if ! printf '%s' "$NL_FLAT" | grep -qF "run the protocol (Step 0) for ${BT}${phase}${BT}"; then
    MISSING_PHASES="$MISSING_PHASES $phase"
  fi
done
if [ -z "$MISSING_PHASES" ]; then
  pass "all 17 canonical phases carry a transition call - the lead-time log has no holes (A1-12)"
else
  fail "newsletter phases with no transition call:$MISSING_PHASES - lead time silently undercounts them (A1-12)"
fi

# (A1-12 close) Step 10 closes the row - without it every edition stays 'in flight' forever
STEP10_BLOCK=$(awk '/^## Step 10:/{f=1} /^## Distribution channel/{f=0} f' "$NL_N12")
if echo "$STEP10_BLOCK" | grep -qF "register-complete"; then
  pass "newsletter Step 10 closes the register row (register-complete) - lead time becomes a measured number (A1-12)"
else
  fail "newsletter Step 10 never calls register-complete - editions never leave the in-flight view (A1-12)"
fi

# (A1-11 store) the register module exists with its mutations
if grep -qF "export function upsertEdition" "$REG_SRC"; then
  pass "scripts/editions exports upsertEdition - the deterministic register write exists (A1-11)"
else
  fail "$REG_SRC does not export upsertEdition (A1-11)"
fi

if grep -qF "export function completeEdition" "$REG_SRC" && grep -qF "export function listEditions" "$REG_SRC"; then
  pass "scripts/editions exports completeEdition + listEditions - the WIP view and its close verb exist (A1-11)"
else
  fail "$REG_SRC does not export both completeEdition and listEditions (A1-11)"
fi

# (A1-11 placement) the register lives in the per-user data dir (M0 convention), not the plugin
if grep -qF "LINKEDIN_STUDIO_DATA" "$REG_SRC" && grep -qF '"editions"' "$REG_SRC" && grep -qF '"register.json"' "$REG_SRC"; then
  pass "editions register resolves under the per-user data dir - it survives plugin reinstalls (M0/A1-11)"
else
  fail "$REG_SRC does not resolve the register under the data dir - WIP state placed wrong (M0/A1-11)"
fi

# (A1-12 telemetry) the phase log is appended by code, not by hand
if grep -qF "export function appendPhase" "$EST_SRC"; then
  pass "scripts/editions exports appendPhase - phaseLog entries are written deterministically (A1-12)"
else
  fail "$EST_SRC does not export appendPhase (A1-12)"
fi

# (A1-12 schema) edition-state documents AND carries the phaseLog slot
if grep -qF '"phaseLog": "Per-article phase-transition log' "$EST_TPL"; then
  pass "edition-state template documents phaseLog (A1-12 schema)"
else
  fail "$EST_TPL does not document phaseLog - the telemetry field is undefined (A1-12)"
fi

if grep -qF '"phaseLog": []' "$EST_TPL"; then
  pass "edition-state template carries the per-article phaseLog slot (A1-12 schema)"
else
  fail "$EST_TPL has no per-article phaseLog slot - new editions start unmeasurable (A1-12)"
fi

echo ""

# --- Section 16t: Publishing Slots + WIP Surfacing (N13 / A1-10) ---
echo "--- Publishing Slots (N13) ---"

# N13 answers the question a queue cannot: is the WEEK covered? The grid of publishing
# slots is operator config, coverage is queue + editions register, and the surfaces that
# report it (session start, router, calendar) plus the one that consumes it (newsletter
# Step 10 slot default) must all read the SAME module - two implementations of "next free
# slot" would disagree within a week, and the operator would learn to trust neither.
#  (A1-10 wiring)     a WIP surface loads slots.mjs and asks it for BOTH vacancy and
#                     in-flight editions - a compound predicate, so a non-vacuity
#                     self-test guards it (mirrors Sections 16p/16q/16r/16s).
#  (A1-10 consume)    Step 10 defaults to the next free slot AND claims it on the row.
#  (A1-10 placement)  the grid + register resolve under the per-user data dir (M0).
#  (D-5)              the shipped scheduling times are marked low/directional and defer
#                     to the operator's own grid.
#  (de-niche)         the plugin ships NO publishing times in the slot code path and no
#                     operator grid - the times are user config, always.
SLOTS_SRC="hooks/scripts/slots.mjs"
SS_SRC="hooks/scripts/session-start.mjs"
SLOTS_TPL="config/publishing-slots.template.json"
NL_N13="commands/newsletter.md"
SCHED_REF="references/scheduling-strategy.md"

slots_surface_wired() {  # $1 = text; wired iff it LOADS the shared module and asks for BOTH vacancy and in-flight
  echo "$1" | grep -qF "slots.mjs" \
    && echo "$1" | grep -qE "slotVacancies|nextFreeSlot" \
    && echo "$1" | grep -qF "activeEditions"
}

SL_SELFTEST_OK=1
if ! slots_surface_wired "import { slotVacancies, activeEditions } from hooks/scripts/slots.mjs and print both"; then
  SL_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired slot-surface probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if slots_surface_wired "$probe"; then
    SL_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired slot-surface probe accepted -> $probe"
  fi
done <<'NEGATIVE16T'
import activeEditions from slots.mjs and list what is in production
call slotVacancies and activeEditions from somewhere, no module named
import slotVacancies from slots.mjs and report open slots only
NEGATIVE16T
if [ "$SL_SELFTEST_OK" -eq 1 ]; then
  pass "slot-surface self-test: predicate needs slots.mjs + vacancy + activeEditions (1 accepted, 3 under-wired rejected)"
else
  fail "slot-surface self-test failed - the N13 wiring lint is vacuous or over-eager"
fi

# (A1-10 wiring) the two WIP surfaces report capacity, not just a queue
if slots_surface_wired "$(cat commands/linkedin.md)"; then
  pass "router surfaces work-in-progress + open slots from the shared module (A1-10)"
else
  fail "commands/linkedin.md does not surface in-flight editions and open slots - the week stays invisible (A1-10)"
fi

if slots_surface_wired "$(cat commands/calendar.md)"; then
  pass "calendar surfaces the slot grid + editions in flight from the shared module (A1-10)"
else
  fail "commands/calendar.md does not read slots.mjs for vacancy + in-flight editions (A1-10)"
fi

# (A1-10 opt-in) the grid is offered, never written for the user
if grep -qF "publishing-slots.template.json" commands/calendar.md; then
  pass "calendar documents the opt-in grid copy - the plugin imposes no schedule (A1-10/de-niche)"
else
  fail "commands/calendar.md never points at the slot-grid template - the feature is undiscoverable (A1-10)"
fi

# (A1-10 session start) both blocks render at session start, from the same module
if grep -qF "from './slots.mjs'" "$SS_SRC" \
  && grep -qF "## Editions in Flight" "$SS_SRC" \
  && grep -qF "## Publishing Slots" "$SS_SRC"; then
  pass "session start renders in-flight editions + publishing slots from slots.mjs (A1-10)"
else
  fail "$SS_SRC does not render the week picture from slots.mjs - the operator must open files again (A1-10)"
fi

# (A1-10 coverage) vacancy counts BOTH short-form queue and long-form register
if grep -qF "export function coverageByDate" "$SLOTS_SRC" && grep -qF "readRegisterRows" "$SLOTS_SRC"; then
  pass "slot coverage counts the queue AND the editions register - long-form cannot be double-booked (A1-10)"
else
  fail "$SLOTS_SRC does not derive coverage from both the queue and the editions register (A1-10)"
fi

# (A1-10 placement) grid + register resolve under the per-user data dir (M0 convention)
if grep -qF "getDataRoot('profile')" "$SLOTS_SRC" && grep -qF "getDataRoot('editions')" "$SLOTS_SRC"; then
  pass "slot grid + register resolve under the per-user data dir - they survive reinstalls (M0/A1-10)"
else
  fail "$SLOTS_SRC does not resolve the grid and register under the data dir (M0/A1-10)"
fi

# (A1-10 consume) Step 10 defaults to the next free slot instead of interviewing
STEP10_N13=$(awk '/^## Step 10:/{f=1} /^## Distribution channel/{f=0} f' "$NL_N13")
if echo "$STEP10_N13" | grep -qF "nextFreeSlot"; then
  pass "newsletter Step 10 defaults to the next free slot - scheduling is one confirmation (A1-10)"
else
  fail "newsletter Step 10 still asks for the slot from scratch - the grid buys nothing (A1-10)"
fi

if echo "$STEP10_N13" | grep -qF -- "--slot"; then
  pass "newsletter Step 10 claims the slot on the register row - the next session stops offering it (A1-10)"
else
  fail "newsletter Step 10 never writes --slot - a scheduled edition still reads as an open slot (A1-10)"
fi

# (D-5) the shipped times are marked as what they are, and defer to the operator's grid
if grep -qF "low / directional" "$SCHED_REF"; then
  pass "scheduling reference carries the low/directional epistemic marking (D-5)"
else
  fail "$SCHED_REF presents practitioner lore as fact - mark it low / directional (D-5)"
fi

if grep -qF "publishing-slots.json" "$SCHED_REF"; then
  pass "scheduling reference defers to the operator's own grid (D-5)"
else
  fail "$SCHED_REF does not point at the operator grid - the tables read as the authority (D-5)"
fi

# (de-niche) the template exists and states that nothing is written for the user
if [ -f "$SLOTS_TPL" ] && grep -qF '"schemaVersion": 1' "$SLOTS_TPL" && grep -qF "OPT-IN" "$SLOTS_TPL"; then
  pass "slot-grid template ships with a schema and an explicit opt-in (A1-10/de-niche)"
else
  fail "$SLOTS_TPL missing, unversioned, or does not state the opt-in (A1-10/de-niche)"
fi

# (de-niche) no publishing times hardcoded in the slot code path, no operator grid committed
SLOT_TIME_LITERALS=$(grep -cE "['\"][0-2][0-9]:[0-5][0-9]['\"]" "$SLOTS_SRC" "$SS_SRC" 2>/dev/null | awk -F: '{s+=$2} END {print s+0}')
COMMITTED_GRIDS=$(find . -name "publishing-slots.json" -not -path "*/node_modules/*" 2>/dev/null | wc -l | tr -d ' ')
if [ "$SLOT_TIME_LITERALS" -eq 0 ] && [ "$COMMITTED_GRIDS" -eq 0 ]; then
  pass "no publishing times hardcoded in the slot path and no operator grid committed (de-niche)"
else
  fail "hardcoded slot times in the code path ($SLOT_TIME_LITERALS) or a committed operator grid ($COMMITTED_GRIDS) - times are user config (de-niche)"
fi

echo ""

# --- Section 16u: Fold-In Loop + Retro Step (N14 / A2-F6,F7,F8,F9, C-10) ---
echo "--- Fold-In Loop + Retro (N14) ---"

# N14 closes the loop the whole pipeline was missing: a correction the operator makes in
# edition N is ENFORCED in edition N+1 instead of being re-discovered. The machinery already
# existed (foldIns schema + promote->ratify); what was absent was the wiring - nothing wrote
# to the queue and no step ever emptied it. So the bindings worth linting are:
#  (A2-F7 capture)  every step where the operator corrects prose appends to the queue - a
#                   compound predicate (queue named + left open + routed), so a non-vacuity
#                   self-test guards it (mirrors Sections 16p/16q/16r/16s/16t).
#  (A2-F8 retro)    Step 11 exists and does all three jobs: promote the queue, summarize the
#                   measured effort (phaseLog, N12), ask ONE friction question.
#  (A2-F6 headless) the cold package runs as a background agent writing to a file - the
#                   maximum-independence path with no manual copy-paste seam.
#  (C-10 language)  confirmed language corrections accumulate in a USER file the reviewer
#                   and the drafting step both read - the shipped ban-list stays the baseline.
#  (A2-F9 adopter)  the fang->klassifiser->promoter->handhev loop is documented in-tree, so
#                   an adopter without the author's contract still has the whole loop.
NL_N14="commands/newsletter.md"
HR_N14="commands/headless-review.md"
LR_N14="agents/language-reviewer.md"
EST_N14="config/edition-state.template.json"
LANG_TPL="config/language-rules.template.md"
FOLDIN_REF="references/fold-in-loop.md"

foldin_capture_wired() {  # $1 = text; wired iff it NAMES the queue, leaves it OPEN, and ROUTES it
  echo "$1" | grep -qF "foldIns" \
    && echo "$1" | grep -qF "pending" \
    && echo "$1" | grep -qF "Step 11"
}

FI_SELFTEST_OK=1
if ! foldin_capture_wired "append the correction to articles.NN.foldIns with decision pending; Step 11 routes it"; then
  FI_SELFTEST_OK=0; echo "  non-vacuity FAIL: a fully-wired fold-in-capture probe was not detected"
fi
while IFS= read -r probe; do
  [ -z "$probe" ] && continue
  if foldin_capture_wired "$probe"; then
    FI_SELFTEST_OK=0; echo "  false-positive FAIL: under-wired fold-in-capture probe accepted -> $probe"
  fi
done <<'NEGATIVE16U'
append the correction to articles.NN.foldIns with decision pending, routed by nobody
capture the correction as pending and let Step 11 route it, queue left unnamed
append it to articles.NN.foldIns and Step 11 promotes it, never marked open
NEGATIVE16U
if [ "$FI_SELFTEST_OK" -eq 1 ]; then
  pass "fold-in-capture self-test: predicate needs foldIns + pending + Step 11 (1 accepted, 3 under-wired rejected)"
else
  fail "fold-in-capture self-test failed - the N14 wiring lint is vacuous or over-eager"
fi

# (A2-F7 capture) each of the four fold-in steps feeds the queue - a step that folds a
# correction in without capturing it is exactly how a rule gets re-discovered next edition
STEP25_N14=$(awk '/^## Step 2.5:/{f=1} /^## Step 3a:/{f=0} f' "$NL_N14")
STEP3A_N14=$(awk '/^## Step 3a:/{f=1} /^## Step 3b:/{f=0} f' "$NL_N14")
STEP55_N14=$(awk '/^## Step 5.5:/{f=1} /^## Step 6:/{f=0} f' "$NL_N14")
STEP65_N14=$(awk '/^## Step 6.5:/{f=1} /^## Step 7:/{f=0} f' "$NL_N14")
STEP11_N14=$(awk '/^## Step 11:/{f=1} /^## Distribution channel/{f=0} f' "$NL_N14")

if foldin_capture_wired "$STEP25_N14"; then
  pass "Step 2.5 captures operator corrections into the fold-in queue (A2-F7)"
else
  fail "newsletter Step 2.5 folds annotations in without capturing them - the rule is lost (A2-F7)"
fi

if foldin_capture_wired "$STEP3A_N14"; then
  pass "Step 3a captures operator corrections into the fold-in queue (A2-F7)"
else
  fail "newsletter Step 3a folds annotations in without capturing them - the rule is lost (A2-F7)"
fi

if foldin_capture_wired "$STEP55_N14"; then
  pass "Step 5.5 captures approved editorial flags into the fold-in queue (A2-F7)"
else
  fail "newsletter Step 5.5 folds flags in without capturing them - the rule is lost (A2-F7)"
fi

if foldin_capture_wired "$STEP65_N14"; then
  pass "Step 6.5 captures approved cold-review flags into the fold-in queue (A2-F7)"
else
  fail "newsletter Step 6.5 folds flags in without capturing them - the rule is lost (A2-F7)"
fi

# (A2-F8 retro) the step exists and empties the queue with an explicit operator verdict
if echo "$STEP11_N14" | grep -qF "foldIns" && echo "$STEP11_N14" | grep -qF "promoted"; then
  pass "Step 11 promotes the fold-in queue with an operator verdict (A2-F8)"
else
  fail "newsletter has no Step 11 fold-in promotion - the queue fills up and is never emptied (A2-F8)"
fi

# (A2-F8 effort) the effort summary reads the MEASURED log (N12), not the model's memory
if echo "$STEP11_N14" | grep -qF "phaseLog"; then
  pass "Step 11 summarizes effort from the measured phaseLog - no new measuring apparatus (A2-F8/A1-12)"
else
  fail "newsletter Step 11 does not read phaseLog - effort is re-estimated from memory (A2-F8)"
fi

# (A2-F8 friction) exactly one friction question, and the operator's own register stays theirs
if echo "$STEP11_N14" | grep -qF "process friction" && echo "$STEP11_N14" | grep -qF "never write"; then
  pass "Step 11 asks one friction question and never writes the operator's register (A2-F8)"
else
  fail "newsletter Step 11 lacks the single friction question or claims write access to the operator's register (A2-F8)"
fi

# (A2-F8 transition) retro is a real phase - logged like every other, and it re-closes the row
if echo "$STEP11_N14" | grep -qF "register-complete" && echo "$STEP11_N14" | grep -qF "${BT}retro${BT}"; then
  pass "Step 11 runs the retro transition and re-closes the register row (A1-11/A2-F8)"
else
  fail "newsletter Step 11 leaves the edition re-opened in the register or logs no retro phase (A1-11/A2-F8)"
fi

# (A2-F8 schema) edition-state documents AND carries the retro slot
if grep -qF '"retro": "Per-article retro record' "$EST_N14" && grep -qF '"retro": null' "$EST_N14"; then
  pass "edition-state documents and carries the per-article retro slot (A2-F8 schema)"
else
  fail "$EST_N14 has no documented retro slot - the retro leaves no durable record (A2-F8)"
fi

# (A2-F8 resumption) an edition aborted after scheduling resumes INTO the retro, not past it
RESUME_N14=$(awk '/^### Deterministic resumption/{f=1} /^### Phase-transition protocol/{f=0} f' "$NL_N14")
if echo "$RESUME_N14" | grep -qF "Step 11" && echo "$RESUME_N14" | grep -qF "${BT}retro${BT}"; then
  pass "resumption table routes scheduling -> Step 11 and retro -> complete (A2-F8)"
else
  fail "newsletter resumption table skips the retro - an aborted session drops the fold-in queue (A2-F8)"
fi

# (A2-F6 headless) the cold package can run as a background agent writing its report to a file
if grep -qF "run_in_background" "$HR_N14" && grep -qF -- "--background" "$HR_N14"; then
  pass "headless-review offers a background run - cold isolation without a fresh session (A2-F6)"
else
  fail "$HR_N14 has no background mode - maximum independence still costs a manual session switch (A2-F6)"
fi

if grep -qF "no copy-paste" "$HR_N14"; then
  pass "headless-review states the seam it removes - the report is read from disk, not pasted (A2-F6)"
else
  fail "$HR_N14 still relies on pasting the report back into the drafting session (A2-F6)"
fi

# (A2-F6 wiring) Step 6.5 actually offers that path instead of the paste-back one
if echo "$STEP65_N14" | grep -qF -- "--background"; then
  pass "newsletter Step 6.5 routes maximum-independence through the background run (A2-F6)"
else
  fail "newsletter Step 6.5 still sends the operator to a fresh session with a paste-back (A2-F6)"
fi

# (C-10 language) the accumulating user file is READ by the reviewer and by the drafting step
if grep -qF "language-rules/" "$LR_N14"; then
  pass "language-reviewer reads the accumulated user language rules (C-10)"
else
  fail "$LR_N14 grades only against the shipped list - confirmed corrections never bind (C-10)"
fi

STEP4_N14=$(awk '/^## Step 4: /{f=1} /^## Step 4.5:/{f=0} f' "$NL_N14")
if echo "$STEP4_N14" | grep -qF "language-rules/"; then
  pass "newsletter Step 4 reads the accumulated user language rules while drafting (C-10)"
else
  fail "newsletter Step 4 ignores the accumulated language rules - they only catch, never prevent (C-10)"
fi

if echo "$STEP11_N14" | grep -qF "language-rules/"; then
  pass "Step 11 appends confirmed language corrections to the user rules file (C-10)"
else
  fail "newsletter Step 11 never writes the language-rules file - judgment corrections evaporate (C-10)"
fi

# (C-10 opt-in) the file is offered as a template, never written for the user (slots pattern)
if [ -f "$LANG_TPL" ] && grep -qF "OPT-IN" "$LANG_TPL"; then
  pass "language-rules template ships with an explicit opt-in - nothing is imposed (C-10/de-niche)"
else
  fail "$LANG_TPL missing or does not state the opt-in (C-10/de-niche)"
fi

# (A2-F9 adopter) the whole loop is documented in-tree, all four stages named
FOLDIN_LOOP_OK=1
for stage in "Capture" "Classify" "Promote" "Enforce"; do
  grep -qF "$stage" "$FOLDIN_REF" 2>/dev/null || FOLDIN_LOOP_OK=0
done
if [ -f "$FOLDIN_REF" ] && [ "$FOLDIN_LOOP_OK" -eq 1 ]; then
  pass "fold-in loop documented in-tree with all four stages (A2-F9)"
else
  fail "$FOLDIN_REF missing or does not name capture/classify/promote/enforce (A2-F9)"
fi

# (A2-F9 self-contained) an adopter without the author's private contract still has the loop,
# and the four accumulation silos are named as one collection point
if grep -qF "not a gap" "$FOLDIN_REF" 2>/dev/null \
  && grep -qF "specifics-bank" "$FOLDIN_REF" 2>/dev/null \
  && grep -qF "ab-test" "$FOLDIN_REF" 2>/dev/null; then
  pass "fold-in loop is adopter-self-contained and names the accumulation silos (A2-F9)"
else
  fail "$FOLDIN_REF reads as requiring the author's private contract, or omits the silos (A2-F9)"
fi

echo ""

# --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
# The lint self-modifies its own checks, so a green run could mask a silently dropped
# assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
# may only grow (brief-reviewer assumption 3). History: 74 pre-M0; +1 for the SB-S0
# brain-suite floor (Section 16b) = 75; +3 for SB-S1's three UNCONDITIONAL Section-16c
# checks (published-only self-test + voice-trainer grep + contract-doc grep) = 78;
# +2 for SB-S3a's two UNCONDITIONAL Section-16d checks (profile-reader self-test +
# strategy-advisor wiring grep) = 80; +2 for SB-S3d's two UNCONDITIONAL Section-16e
# checks (ops-reader self-test + strategy-advisor ops-wiring grep) = 82; +2 for SB-S3e's
# two UNCONDITIONAL Section-16f checks (reconcile self-test + brain-CLI reconcile grep) = 84;
# +3 for RE-R1's three UNCONDITIONAL Section-16g checks (trends-scorer self-test + score.ts
# both-modes weight-set grep + trend-spotter scorer-pointer grep) = 87; +3 for RE-R2a's three
# UNCONDITIONAL Section-16h checks (trends-capture self-test + cli.ts capture-handler grep +
# trend-spotter capture-pointer grep) = 90; +4 for RE-R2b's four UNCONDITIONAL Section-16i checks
# (trends-brief self-test + cli.ts brief-handler grep + trend-spotter brief-pointer grep +
# session-start surfacing grep) = 94; +5 for RE-R3a's five UNCONDITIONAL Section-16j checks
# (trends-score self-test + score.ts TrendScore-iface grep + types.ts score-field grep +
# trend-spotter dimensions grep + brief.ts composite-rank grep) = 99; +6 for RE-R3b's six
# UNCONDITIONAL Section-16k checks (lifecycle self-test + types.ts TrendStatus grep + types.ts
# surfacedCount grep + store.ts markSurfaced grep + brief.ts effectiveStatus grep + cli.ts
# act-verb grep) = 105; +6 for RE-R3c's six UNCONDITIONAL Section-16l checks (scheduler self-test
# + schedule.ts launchdPlist grep + schedule.ts crontabLine grep + cli.ts schedule-verb grep +
# run-daily.sh brief-invocation grep + run-daily.sh data-twin grep) = 111; +6 for RE-R3d's six
# UNCONDITIONAL Section-16m checks (temporal self-test + brief.ts temporalSignal grep + brief.ts
# b.temporal.rank cmp grep + brief.ts "first-mover" tier grep + cli.ts first-mover-days flag grep +
# cli.ts saturation-at flag grep) = 117; +6 for RE-R3e's six UNCONDITIONAL Section-16n checks
# (brief-diff self-test + brief.ts diffSurfaced grep + brief.ts parseSurfacedFrontmatter grep +
# brief.ts 'Nytt siden sist' section grep + cli.ts selectPriorBriefFile wiring grep + brief.ts
# 'surfaced: ' frontmatter grep) = 123; +6 for N8's six UNCONDITIONAL Section-16o checks (voice-chain
# self-test + voice-trainer chronicle-voice-profile grep + newsletter chronicle-voice-profile grep +
# newsletter chronicle-voice-drift-log read grep + newsletter Step-1 language-resolve grep + newsletter
# Step-8 auto-gull compound grep) = 129; +8 for N9's eight UNCONDITIONAL Section-16p checks (randsone-gate
# self-test + Step-9 randsone_gated compound grep + Step-9 title-strength grep + newsletter C-6 trigger-nouns
# grep + newsletter C-6 scoped-fact-reviewer grep + newsletter C-6 determinism-marker grep + fact-checker
# WebFetch grep + fact-reviewer WebFetch grep) = 137; +9 for N10's nine UNCONDITIONAL Section-16q checks
# (targetLevel-consumption self-test + no-hardcoded-line-manager absence grep + references target-level-span
# grep + personas opposite-end obligatorisk-sekundær grep + personas explicit-ceiling-justification grep +
# personas technical-end seed grep + edition-state targetLevel-slot grep + newsletter articles.NN.targetLevel
# persist grep + persona-reviewer targetLevel-consumption compound grep) = 146; +9 for N11's nine
# UNCONDITIONAL Section-16r checks (destillat-gate self-test + Step-2.5 destillat_gated compound grep +
# Step-8 distil-append grep + Step-8 record-usage grep + types.ts usedIn grep + bank.ts recordUsage grep +
# binding.ts boundSpecificIds grep + distillate.ts checkSkeleton grep + distillate.ts series-root
# placement grep) = 155; +11 for N12's eleven UNCONDITIONAL Section-16s checks (transition-protocol
# self-test + protocol-block compound grep + mirror-invariant grep + 16-phase coverage sweep +
# Step-10 register-complete grep + register.ts upsertEdition grep + register.ts completeEdition/
# listEditions grep + register.ts data-dir placement grep + editionState.ts appendPhase grep +
# template phaseLog-doc grep + template phaseLog-slot grep) = 166; +13 for N13's thirteen
# UNCONDITIONAL Section-16t checks (slot-surface self-test + router wiring grep + calendar
# wiring grep + calendar opt-in grep + session-start two-block compound grep + coverage
# queue+register grep + data-dir placement grep + Step-10 nextFreeSlot grep + Step-10 --slot
# grep + scheduling-ref directional grep + scheduling-ref operator-grid grep + slot-template
# schema/opt-in grep + de-niche no-hardcoded-times/no-committed-grid check) = 179; +19 for
# N14's nineteen UNCONDITIONAL Section-16u checks (fold-in-capture self-test + four capture
# greps for Steps 2.5/3a/5.5/6.5 + Step-11 promotion grep + Step-11 phaseLog grep + Step-11
# friction/never-write grep + Step-11 retro-transition/re-close grep + edition-state retro
# doc+slot grep + resumption retro-routing grep + headless background-mode grep + headless
# no-copy-paste grep + Step-6.5 background-routing grep + language-reviewer rules-file grep +
# Step-4 rules-file grep + Step-11 rules-file append grep + language-rules template opt-in
# check + fold-in-loop four-stage check + fold-in-loop adopter/silos grep) = 198.
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop
# the count), so it is bumped only by UNCONDITIONAL new checks — NOT pinned to the
# deps-present TOTAL_CHECKS (that would zero the warn-skip margin and false-fail a fresh
# clone). Runs last so TOTAL_CHECKS sees every prior check.
ASSERT_BASELINE_FLOOR=198
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
