feat(linkedin-studio): N15 — do-next-kontrakt (recordDoNext + skrivere/lesere + forrige-utgave-kalibrering) [skip-docs]

Måling som ikke endrer neste utgave er teater: report/analyze/ab-test/48h-monitor
endte alle i chat (grep do.next = 0 i hele repoet). N15 gjør kanalen til en kontrakt.

- recordDoNext i hooks/scripts/state-updater.mjs (TDD, 17 nye tester): seksjonen
  Do-Next Directives, rader "[dato] (kilde) direktiv — evidence: peker".
  Levetid = erstatt-per-kilde + 60-dagers aldersgulv, maks 3 per skriv, additiv
  scalar last_donext_date, $-sikre replacement-funksjoner. CLI: --record-do-next.
- Skrivere: report Step 7b, analyze Step 6b, ab-test 2c.6 (kun Adopt),
  post-feedback-monitor (kun 48h-sjekken); analytics-interpreter emitterer nå
  anbefalinger på do-next-form (imperativ + evidens).
- Lesere: post/quick/batch/create Step 0 + newsletter Step 1, som i tillegg
  kalibrerer på forrige utgaves MÅLTE tall via queue-id-match (queue.json →
  analytics/posts) med eksplisitt honest-miss — aldri estimert.
- Guards: test-runner Section 16v (15 ubetingede sjekker, floor 198 → 213),
  $-safety-batteriet dekker recordDoNext (coverage-gate fanget den nye eksporten).

Suiter: test-runner 232/0, hooks 191/0, trends 300/0, brain 134/0, editions 72/0,
specifics-bank 45/0, contract-gate 33/0, tests 35/0, render 60/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
This commit is contained in:
Kjell Tore Guttormsen 2026-07-25 15:23:35 +02:00
commit b45fdad911
16 changed files with 713 additions and 6 deletions

View file

@ -17,6 +17,7 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide
- **Publishing slots:** `hooks/scripts/slots.mjs` — the weekly production grid. Slots are **operator config** in the per-user data dir (`profile/publishing-slots.json`, schema + opt-in template in `config/publishing-slots.template.json`); absent config ⇒ every slot surface stays silent, and the plugin hardcodes no publishing times. Coverage of a slot counts the short-form queue (`scheduled`/`published`) **and** editions-register rows claiming that date, so long-form and short-form cannot be double-booked. Zero-dep (SessionStart may not spawn tsx) and imported identically by the hook and by the commands (`queue-manager.mjs` pattern), so session-start's vacancy warning and `/linkedin:newsletter` Step 10's slot default are the same computation
- **Contract-gate:** `scripts/contract-gate/` (TypeScript, needs `tsx` + `npm install`) — deterministic §B/§C1 rule-gate on the full draft (`/linkedin:newsletter` Step 4.5, before the AI sweeps); ratifies `rules.ts` against the edition's §E-manifest, then gates with BLOCK/WARN
- **Fold-in loop:** capture → classify → promote → enforce, documented domain-generally in `references/fold-in-loop.md`. The four `/linkedin:newsletter` steps where operator judgment enters the text (2.5, 3a, 5.5, 6.5) append corrections to `articles.NN.foldIns[]` (`decision: "pending"`); **Step 11 (retro)** empties the queue with an explicit operator JA/NEI — mechanical → an atomic contract-gate promotion (only counts when `--ratify` is green), judgment → the operator's opt-in language-rules file (`${DATA}/language-rules/<lang>.md`, template `config/language-rules.template.md`), which Step 4 reads while drafting (prevent) and `language-reviewer` reads at Step 6.5 (catch). Rejected rows are kept, never deleted. Step 11 also reports effort from the **measured** `phaseLog` and asks one friction question whose answer goes back to the operator — the plugin never writes their register
- **Do-next contract:** `recordDoNext()` in `hooks/scripts/state-updater.mjs` — the one channel from measurement to creation. `/linkedin:report`, `/linkedin:analyze`, an `/linkedin:ab-test` **Adopt** verdict and the `post-feedback-monitor`'s 48h learning each persist 23 directives (imperative + evidence pointer, tagged with their source) to `## Do-Next Directives` in the state file; the create surfaces (`post`/`quick`/`batch`/`create` Step 0, `newsletter` Step 1) read them before drafting, so a recommendation changes the next piece instead of dying in chat. Lifetime is **replace-by-source + a 60-day age floor**: each writer supersedes only its own rows, so the section is a live steering signal, never a backlog. `newsletter` Step 1 additionally calibrates on the **previous edition's measured numbers** via the queue-id match (`drafts/queue.json``analytics/posts/*.json` by publish date), with an explicit honest-miss branch — an unmeasured edition is stated as such, never estimated
- **Analytics metrics (S16):** parsed CSV columns + an optional, manually-entered `saves` count (count-only in native LinkedIn analytics since ~Sept 2025, no CSV export; the Marketing API exposes `POST_SAVE` on `/memberCreatorPostAnalytics` from v202604, but access is partner-gated — so manual entry remains the right UX). `parseOptionalCount()`: blank / non-numeric / negative → `undefined` (`unknown`, never 0), a genuine `0` is kept; saves surfaced per-post + as `totalSaves`, but **not** folded into `engagementRate`. `dwell` stays **explicitly unmeasurable** (internal to LinkedIn, no export/API). All analytics I/O routes through the `getAnalyticsRoot()` seam (M0 per-user data-dir).
## Hooks

View file

@ -343,9 +343,9 @@ Based on this analysis, your unique advantages are:
- Followers: [↑/↓/→] [net change]
## Recommendations for Next Week
1. [Most impactful action]
2. [Second priority]
3. [Experiment to try]
1. [Most impactful action] — evidence: [the measured number it rests on]
2. [Second priority] — evidence: [measured number]
3. [Experiment to try] — evidence: [measured number]
## Content Plan Adjustment
Based on this week's data:
@ -354,6 +354,14 @@ Based on this week's data:
- Start: [new experiment]
```
**Write recommendations in do-next form — imperative + evidence pointer.** The calling command
persists the top 23 verbatim as do-next directives (`recordDoNext`, source `report`), and the
create surfaces read them at Step 0 before drafting. So each one must be actionable by someone
who never saw this report: "Plan 2 AI-agent posts this week" (imperative, checkable), not
"AI content is performing well" (an observation nobody can execute). The evidence pointer is
what lets the next session judge how much weight a directive carries — always name the measured
number and the sample it came from.
### Monthly Report Additions
For monthly reports, also include:

View file

@ -287,6 +287,33 @@ Output a structured intervention plan using this format:
- [Actionable change to try next time]
```
### Persist the 48h learning as a do-next directive
At the **final check (48h / Resurrection Window)** — not at the intermediate check-ins, where
the numbers are still moving — write the one or two learnings that should change the next post
into the shared do-next channel. This is the same channel `/linkedin:report`,
`/linkedin:analyze` and `/linkedin:ab-test` write to, and the one every create surface reads at
its Step 0. Without it the 48h learning lives only in this session's chat and is re-learned
from scratch next week:
```bash
node --input-type=module -e "
import { writeState, recordDoNext } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs';
writeState(content => recordDoNext(content, {
recordDate: 'YYYY-MM-DD',
source: '48h-monitor',
directives: [
{ directive: 'Put the concrete number in the first line, not the third', evidence: '48h on 2026-05-28 post: velocity 82/100, golden hour 3x the account average' }
]
}));
"
```
Write only what a *future draft* can act on — a per-post tactic ("reply to every comment inside
the first hour") belongs in the report to the operator, not in the channel that shapes the next
draft. `recordDoNext` replaces this source's previous row on each write, so the most recent 48h
learning is the one in force.
## Step 7: Follow-Up Scheduling
Based on current performance, suggest:

View file

@ -342,6 +342,32 @@ handful of posts per variant a creator realistically gathers (well under the
Update the test file status from ACTIVE to COMPLETED. Add the conclusion and recommended actions to the file.
### 2c.6: Persist an Adopt verdict as a do-next directive
An **Adopt** verdict (and only Adopt — "Keep A", "Inconclusive" and "Extend test" change nothing
about the next draft) is a decision the next post must inherit. Write it to the same do-next
channel `/linkedin:report` and `/linkedin:analyze` use, so every create surface picks it up at
its Step 0 instead of the test result sitting unread in a file:
```bash
node --input-type=module -e "
import { writeState, recordDoNext } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs';
writeState(content => recordDoNext(content, {
recordDate: 'YYYY-MM-DD',
source: 'ab-test',
directives: [
{ directive: 'Open with a question hook by default', evidence: 'test hook-form (6 posts): B +38% comments, directional-strong' }
]
}));
"
```
Carry the **directional** framing into the evidence pointer (see 2c.4 — organic volume rarely
reaches significance); a directive that reads as proven when the signal was weak is how one
outlier becomes a permanent rule. `recordDoNext` replaces this source's previous adopt row, so
a newer verdict supersedes an older one automatically — and the `report`/`analyze` slots are
untouched.
---
## Step 2d: Review Test History

View file

@ -220,6 +220,31 @@ Quick triage if a full audit can wait:
- [ ] Monitor metrics closely
- [ ] Continue high engagement activity
## Step 6b: Persist the plan as do-next directives (the measurement→creation contract)
The recovery plan above is worth nothing if the next drafting session never sees it. Turn the
**23 load-bearing corrections** into do-next directives — the same channel `/linkedin:report`,
`/linkedin:ab-test` and the 48h monitor write to, and the one every create surface reads at its
Step 0:
```bash
node --input-type=module -e "
import { writeState, recordDoNext } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs';
writeState(content => recordDoNext(content, {
recordDate: 'YYYY-MM-DD',
source: 'analyze',
directives: [
{ directive: 'Keep every post inside the 3 core pillars for 14 days', evidence: 'diagnosis: profile-content mismatch, reach -55% since off-topic run' },
{ directive: 'No external links in the post body until reach recovers', evidence: 'diagnosis: link-in-body correlates with the drop window' }
]
}));
"
```
Write the corrections that change the NEXT draft — not the whole checklist. `recordDoNext`
replaces this source's previous rows and expires anything older than 60 days, so a superseded
diagnosis cannot keep steering drafts. Confirm in one line what was persisted.
## Step 7: Timeline Expectations
Set realistic expectations:

View file

@ -24,6 +24,10 @@ You are a LinkedIn batch content creator. Help the user create an entire week's
Load state and personalization:
- Read `~/.claude/linkedin-studio.local.md` for recent topics and weekly goals
- **Read `## Do-Next Directives` in the same file** — the last measurement pass's directives
(report / analyze / A/B Adopt / 48h monitor) with their evidence pointers. A batch is where
they bite hardest: apply them across the week's posts and name which post carries which
directive in the plan. Never edit or clear the section (lifetime is managed per source)
- Read `${CLAUDE_PLUGIN_ROOT}/skills/linkedin-studio/SKILL.md` for profile and preferences
- Check `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/plans/` for existing weekly plan
- Read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/templates/my-post-templates.md` for proven templates — vary templates across the batch for format diversity

View file

@ -26,6 +26,10 @@ If `~/.claude/linkedin-studio.local.md` exists, you may glance at the planned ne
topic and recent pillars to make a smarter suggestion — but keep it to one line and
do not block on it.
If `## Do-Next Directives` in that file is non-empty, mention the top directive in that same
line ("last report says: lead with the number") — it often decides which format to route to.
You do not apply it here; the command you route to reads the section itself at its Step 0.
## Step 1: Identify what they want to create
If the user's message already names a format (e.g. "a carousel about X", "react to

View file

@ -371,6 +371,39 @@ what genuinely changes the work.
> discovery→production loop automatically at Step 10 (scheduling flips the trend to
> `acted`).
> **Calibration from measurement (A2-F14 — the do-next contract).** Two reads, both
> cheap, both BEFORE the dimensions below. Neither asks the operator anything.
>
> 1. **`## Do-Next Directives` in `~/.claude/linkedin-studio.local.md`.** What the last
> measurement pass (`/linkedin:report`, `/linkedin:analyze`, an A/B Adopt verdict, the
> 48h monitor) decided the next piece should do differently, each row carrying its
> evidence pointer. Apply the ones that apply to long-form and **name them in the brief**
> — a directive that shaped this edition must be visible in the brief, not silently
> absorbed. Never edit or clear the section here (each source manages its own rows).
> 2. **The previous edition's MEASURED numbers — queue-id match.** The prior edition of
> this series was registered at its Step 10 under the stable queue id
> `<series-slug>-<NN1>`. Resolve its numbers:
> - read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json`,
> take the entry whose `id` is `<series-slug>-<NN1>` → its `scheduled_date`,
> `hook_preview` and `status`;
> - read the analytics batches in
> `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/analytics/posts/*.json` and
> select the `.posts[]` row whose `publishedDate` equals that `scheduled_date`
> (a rescheduled edition may be a day or two off — widen to ±2 days and disambiguate
> on a `title` that matches `hook_preview`);
> - carry forward `impressions`, `engagementRate`, `comments` and `saves` (when present).
>
> Read them as calibration, not as a verdict: **low impressions** points at hook /
> topic-relevance (a Step 9 problem for this edition), **decent impressions with weak
> comments** points at resonance (a Step 6 persona problem), and a strong prior edition
> says keep the structure that worked. State the comparison in one line in the brief.
>
> **Honest miss.** No queue entry, no analytics match, or the edition simply is not measured
> yet (`status: scheduled`, or the import has not been run) → say so in one line — *"forrige
> utgave er ikke målt ennå; kalibrerer på skjelett og persona alene"* — and continue.
> **Never estimate** the previous edition's numbers, and never let a missing measurement
> block the brief. This is edition NN=1 of a new series' normal state.
Settle these dimensions (most should come from context, not questions):
- **Angle** — the one premise this edition argues.

View file

@ -26,6 +26,14 @@ First, load persistent state and personalization:
- Read `~/.claude/linkedin-studio.local.md` for posting state (streak, weekly progress, recent topics)
- Read `skills/linkedin-studio/SKILL.md` for user profile, voice settings, and preferences
**Read `## Do-Next Directives` in the state file and apply them to this draft.** This is what the
last measurement pass (`/linkedin:report`, `/linkedin:analyze`, an A/B Adopt verdict, the 48h
monitor) decided the next post should do differently — each row carries its evidence pointer.
Apply the ones that fit this post, and **name the applied directive in one line** when you
present the draft ("applied: lead with the number — report 2026-W22"). A directive that does not
fit this topic is skipped silently, not argued with. Directives are already lifetime-managed
(each source replaces its own; >60 days expire) — never edit or clear the section here.
Check state for topic planning:
- Compare intended topic against "Recent Posts" in state file
- If a similar topic was posted in the last 7 days, suggest a different angle or topic

View file

@ -27,6 +27,10 @@ Read `~/.claude/linkedin-studio.local.md` for:
- Weekly posting progress (show "X/Y posts this week")
- Recent topics (avoid repetition within 7 days)
- Current streak status
- **`## Do-Next Directives`** — what the last measurement pass (report / analyze / A/B Adopt /
48h monitor) decided the next post should do differently, each with its evidence pointer.
Apply the ones that fit and name the applied directive in one line with the draft. Never edit
or clear the section (each source manages its own rows; rows older than 60 days expire).
Read `skills/linkedin-studio/SKILL.md` for:
- User's voice profile and tone preferences

View file

@ -336,6 +336,40 @@ Delegate interpretation to the `analytics-interpreter` agent (report mode) — i
3. **Test carousel format**: Your one carousel got 3x more shares than text posts. Create a carousel for your top-performing topic this week.
```
## Step 7b: Persist them as do-next directives (the measurement→creation contract)
A recommendation that only lands in chat dies there — the next post is drafted in a fresh
session that never saw it. **Persist the 23 recommendations above as do-next directives**, so
`/linkedin:post`, `/linkedin:quick`, `/linkedin:batch`, `/linkedin:create` and
`/linkedin:newsletter` read them at their Step 0 and the next piece is actually shaped by this
report. This step is not optional when a report produced recommendations.
Each directive is an **imperative the next draft can act on** plus an **evidence pointer**
the measured number it came from, so the create surface can tell a directive backed by 12 posts
from one backed by 2:
```bash
node --input-type=module -e "
import { writeState, recordDoNext } from '${CLAUDE_PLUGIN_ROOT}/hooks/scripts/state-updater.mjs';
writeState(content => recordDoNext(content, {
recordDate: 'YYYY-MM-DD',
source: 'report',
directives: [
{ directive: 'Plan 2 AI-agent posts this week', evidence: 'weekly 2026-W22: 3 AI posts avg 2400 impressions vs 1200 baseline (n=12)' },
{ directive: 'Move Tuesday posts to 08:00', evidence: 'weekly 2026-W22: Tuesday -40% impressions' }
]
}));
"
```
**Lifetime (do not hand-manage it):** `recordDoNext` replaces this source's own previous rows on
every write and drops any row older than 60 days, so the section stays a live steering signal
rather than a growing backlog. Directives from other sources (`ab-test`, `analyze`,
`48h-monitor`) are left alone — never clear the section by hand.
Then confirm in one line what was persisted, e.g. "2 do-next directives written to state — the
next post/newsletter will read them at Step 0."
## Step 8: Offer Deep Dive Options
After presenting the report, ask:

View file

@ -40,6 +40,9 @@ firsthour_active: false # true while a first-hour loop is in progress
last_outreach_date: null # "YYYY-MM-DD HH:MM" of the most recent outreach contact
outreach_active: false # true while an outreach pipeline is being worked
# Measurement -> creation (do-next contract)
last_donext_date: null # YYYY-MM-DD of the most recent do-next directive write
# Profile
expertise_areas:
- "general"
@ -77,3 +80,9 @@ expertise_areas:
<!-- Outreach contacts / pipeline rows, newest first. Written by /linkedin:outreach. -->
<!-- Format: ### [YYYY-MM-DD HH:MM] partner — track -->
## Do-Next Directives
<!-- Measurement -> next action. Written by /linkedin:report, /linkedin:analyze, /linkedin:ab-test (Adopt) and the 48h post-feedback-monitor; read by the create surfaces at Step 0. -->
<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->
<!-- Lifetime: each source replaces its OWN rows on its next write; rows older than 60 days drop. Max 3 per write. -->

View file

@ -1,6 +1,6 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact } from '../state-updater.mjs';
import { updatePostTracking, pruneContentHistory, updateFollowerCount, recordFirstHourPlan, recordOutreachContact, recordDoNext } from '../state-updater.mjs';
const SAMPLE_STATE = `---
last_post_date: "2026-04-05"
@ -693,3 +693,218 @@ describe('recordOutreachContact', () => {
assert.ok(result.content.includes('[2026-05-30 14:00]'));
});
});
// Mirrors the ## Do-Next Directives block config/state-file.template.md ships
// (own fixture rather than an edit to TEMPLATE_STATE, so the recordOutreachContact
// "entry lands inside the last section" assertion keeps testing exactly what it
// tested before). Every template-initialized state file therefore hits the
// section-APPEND branch; SAMPLE_STATE (no section) exercises section-CREATE.
const DONEXT_TEMPLATE_STATE = `---
last_post_date: "2026-04-05"
last_post_topic: "AI strategy"
follower_count: 850
---
# LinkedIn Session State
## Recent Posts
- [2026-04-05] "AI governance is not about..." (1450) - AI strategy
## Do-Next Directives
<!-- Measurement -> next action. Written by /linkedin:report, /linkedin:analyze, /linkedin:ab-test (Adopt) and the 48h post-feedback-monitor; read by the create surfaces at Step 0. -->
<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->
`;
describe('recordDoNext', () => {
// "Now" for the write tests: days — not months — after the 2026-05-3x record dates,
// so the age floor is NOT what those assertions end up measuring. (Reusing
// FIXED_TODAY here made the supersede test pass for the wrong reason: every row
// was already past the 60-day floor, so "replaced" and "expired" were
// indistinguishable.) The prune tests below deliberately use FIXED_TODAY with
// dates computed relative to it.
const DONEXT_TODAY = new Date('2026-06-10T00:00:00Z');
const REPORT_OPTS = {
recordDate: '2026-05-30',
source: 'report',
directives: [
{ directive: 'Double down on AI-agent posts', evidence: 'weekly 2026-W22: 3 posts avg 2400 impressions vs 1200 baseline' },
{ directive: 'Move Tuesday posts to 08:00', evidence: 'weekly 2026-W22: Tuesday -40% impressions' }
],
today: DONEXT_TODAY
};
test('creates a non-R-initial Do-Next Directives section when absent', () => {
assert.ok(!SAMPLE_STATE.includes('## Do-Next Directives'));
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.ok(result.content.includes('## Do-Next Directives'));
});
test('records date, source tag, directive text and evidence pointer in each row', () => {
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.match(result.content, /^- \[2026-05-30\] \(report\) Double down on AI-agent posts — evidence: weekly 2026-W22: 3 posts avg 2400 impressions vs 1200 baseline$/m);
assert.match(result.content, /^- \[2026-05-30\] \(report\) Move Tuesday posts to 08:00 — evidence: weekly 2026-W22: Tuesday -40% impressions$/m);
});
test('accepts a plain-string directive and marks the missing evidence pointer as none', () => {
const result = recordDoNext(SAMPLE_STATE, {
recordDate: '2026-05-30',
source: 'report',
directives: ['Ship one carousel this week'],
today: DONEXT_TODAY
});
assert.notEqual(result, null);
assert.match(result.content, /^- \[2026-05-30\] \(report\) Ship one carousel this week — evidence: _\(none\)_$/m);
});
test('caps a single write at 3 directives (2-3 is the contract; a report dump is not a backlog)', () => {
const result = recordDoNext(SAMPLE_STATE, {
recordDate: '2026-05-30',
source: 'report',
directives: ['one', 'two', 'three', 'four', 'five'],
today: DONEXT_TODAY
});
assert.notEqual(result, null);
const rows = result.content.match(/^- \[2026-05-30\] \(report\)/gm) || [];
assert.equal(rows.length, 3, 'at most 3 rows per write');
assert.ok(!result.content.includes('(report) four'));
});
test('replaces the SAME source on the next write — directives have a lifetime, they do not accumulate', () => {
const first = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
const second = recordDoNext(first.content, {
recordDate: '2026-06-06',
source: 'report',
directives: ['Test a poll format'],
today: DONEXT_TODAY
});
assert.notEqual(second, null);
assert.ok(!second.content.includes('Double down on AI-agent posts'), 'superseded report directive must be gone');
assert.ok(!second.content.includes('Move Tuesday posts to 08:00'), 'superseded report directive must be gone');
assert.match(second.content, /^- \[2026-06-06\] \(report\) Test a poll format /m);
});
test('leaves OTHER sources untouched when one source rewrites its slot', () => {
const withAb = recordDoNext(SAMPLE_STATE, {
recordDate: '2026-05-28',
source: 'ab-test',
directives: [{ directive: 'Adopt question hooks', evidence: 'test hook-form: B +38% comments' }],
today: DONEXT_TODAY
});
const withReport = recordDoNext(withAb.content, REPORT_OPTS);
assert.notEqual(withReport, null);
assert.ok(withReport.content.includes('(ab-test) Adopt question hooks'), 'a report write must not evict the A/B slot');
assert.ok(withReport.content.includes('(report) Double down on AI-agent posts'));
});
test('prunes rows older than maxAgeDays regardless of source (bounded section)', () => {
const stale = daysBeforeFixedToday(90);
const fresh = daysBeforeFixedToday(10);
const seeded = SAMPLE_STATE.replace(
'## Milestone Log',
`## Do-Next Directives\n\n- [${stale}] (48h-monitor) Old learning — evidence: post 2026-01\n- [${fresh}] (48h-monitor) Recent learning — evidence: post 2026-12\n\n## Milestone Log`
);
const result = recordDoNext(seeded, { ...REPORT_OPTS, recordDate: daysBeforeFixedToday(0), maxAgeDays: 60, today: FIXED_TODAY });
assert.notEqual(result, null);
assert.ok(!result.content.includes('Old learning'), 'row past the age window must be dropped');
assert.ok(result.content.includes('Recent learning'), 'row inside the age window must survive');
});
test('prunes stale rows even when the write itself carries no directives', () => {
const stale = daysBeforeFixedToday(120);
const seeded = SAMPLE_STATE.replace(
'## Milestone Log',
`## Do-Next Directives\n\n- [${stale}] (report) Ancient directive — evidence: none\n\n## Milestone Log`
);
const result = recordDoNext(seeded, { recordDate: daysBeforeFixedToday(0), source: 'report', directives: [], maxAgeDays: 60, today: FIXED_TODAY });
assert.notEqual(result, null);
assert.ok(!result.content.includes('Ancient directive'));
});
test('returns null when there is nothing to record and nothing to prune', () => {
const result = recordDoNext(SAMPLE_STATE, { recordDate: '2026-05-30', source: 'report', directives: [], today: FIXED_TODAY });
assert.equal(result, null);
});
test('is additive — inserts last_donext_date when the field is absent', () => {
assert.ok(!/^last_donext_date:/m.test(SAMPLE_STATE));
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.match(result.content, /^last_donext_date: "2026-05-30"$/m);
});
test('updates an existing last_donext_date without duplicating it', () => {
const withField = SAMPLE_STATE.replace(
'last_post_date: "2026-04-05"',
'last_post_date: "2026-04-05"\nlast_donext_date: null'
);
const result = recordDoNext(withField, { ...REPORT_OPTS, recordDate: '2026-06-06' });
assert.notEqual(result, null);
const hits = result.content.match(/^last_donext_date:/gm) || [];
assert.equal(hits.length, 1, 'field must not be duplicated');
assert.match(result.content, /^last_donext_date: "2026-06-06"$/m);
});
test('no-anchor fall-through: scalar not written, not reported; section still written', () => {
assert.ok(!/^last_post_date:/m.test(NO_ANCHOR_STATE));
const result = recordDoNext(NO_ANCHOR_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.ok(!/^last_donext_date:/m.test(result.content), 'scalar must not be inserted without an anchor');
assert.ok(!result.changes.some((c) => /last_donext_date/.test(c)), 'no date-scalar change entry without an anchor');
assert.ok(result.changes.some((c) => /^Do-Next Directives/.test(c)), 'section write must still be recorded');
assert.ok(result.content.includes('(report) Double down on AI-agent posts'));
});
test('writes into the pre-existing section (production path) without duplicating it or dropping its format comments', () => {
assert.ok(DONEXT_TEMPLATE_STATE.includes('## Do-Next Directives'));
const result = recordDoNext(DONEXT_TEMPLATE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
const headings = result.content.match(/^## Do-Next Directives$/gm) || [];
assert.equal(headings.length, 1, 'section must not be duplicated');
assert.ok(result.content.includes('<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->'), 'template format comment survives');
const section = result.content.slice(result.content.indexOf('## Do-Next Directives'));
assert.ok(section.includes('(report) Double down on AI-agent posts'), 'rows must land inside the section');
});
test('records $-bearing directive/evidence verbatim — special replacement patterns are not interpreted', () => {
const result = recordDoNext(DONEXT_TEMPLATE_STATE, {
recordDate: '2026-05-30',
source: 'report',
directives: [{ directive: 'Price the $&-offer at $99', evidence: 'ref $1, spend $$40' }],
today: DONEXT_TODAY
});
assert.notEqual(result, null);
assert.ok(result.content.includes('Price the $&-offer at $99'), 'directive with $-tokens verbatim');
assert.ok(result.content.includes('ref $1, spend $$40'), 'evidence with $-tokens verbatim');
const headings = result.content.match(/^## Do-Next Directives$/gm) || [];
assert.equal(headings.length, 1, 'section must not be re-injected by a $1/$& expansion');
});
test('leaves existing fields and sections untouched (round-trip)', () => {
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.match(result.content, /^last_post_date: "2026-04-05"$/m);
assert.match(result.content, /^follower_count: 850$/m);
assert.ok(result.content.includes('- [2026-04-05] "AI governance is not about..."'));
assert.ok(result.content.includes('## Milestone Log'));
});
test('returns a changes array describing what changed', () => {
const result = recordDoNext(SAMPLE_STATE, REPORT_OPTS);
assert.notEqual(result, null);
assert.ok(Array.isArray(result.changes));
assert.ok(result.changes.some((c) => /Do-Next Directives/.test(c)));
});
test('a do-next section does not break pruneContentHistory (non-R-initial section boundary)', () => {
// pruneContentHistory captures "## Recent Posts ... (?=\n## [^R])": the do-next
// heading must terminate that window, not extend it into the directive rows.
const withDoNext = recordDoNext(SAMPLE_STATE, REPORT_OPTS).content;
const pruned = pruneContentHistory(withDoNext, 30, FIXED_TODAY);
assert.notEqual(pruned, null, 'old Recent Posts entries must still be prunable');
assert.ok(pruned.content.includes('(report) Double down on AI-agent posts'), 'directives must survive a content-history prune');
});
});

View file

@ -343,6 +343,112 @@ export function recordOutreachContact(stateContent, { contactDate, track = '', p
return { content, changes };
}
/**
* Record 2-3 "do next" directives deterministically the measurementcreation
* contract. A recommendation that only ever lands in chat dies there; a directive
* written HERE is read by the create surfaces at their Step 0, so the next post is
* actually shaped by the last report.
*
* Lifetime is REPLACE-BY-SOURCE plus an age floor, not append-forever:
* - each writer (report / analyze / ab-test / 48h-monitor) owns its own slot, so a
* new report supersedes the previous report's directives but never evicts an
* A/B verdict that landed in between;
* - rows older than `maxAgeDays` are dropped on every write regardless of source,
* so a writer that goes quiet cannot leave stale directives steering drafts
* forever (pruneContentHistory's bounded-section pattern);
* - at most 3 rows per write this is a steering signal, not a backlog.
*
* Additive by contract, like recordFirstHourPlan/recordOutreachContact: a missing
* scalar is inserted, a missing section created, no existing field touched. The
* section name is deliberately non-`R`-initial ("Do-Next …") so it falls outside
* pruneContentHistory's `## Recent Posts … (?=\n## [^R])` capture window.
*
* @param {string} stateContent - Full state file content
* @param {{ recordDate: string, source?: string, directives?: Array<string|{directive: string, evidence?: string}>, maxAgeDays?: number, today?: Date }} opts
* @returns {{ content: string, changes: string[] } | null}
*/
export function recordDoNext(stateContent, { recordDate, source = 'report', directives = [], maxAgeDays = 60, today = new Date() }) {
let content = stateContent;
const changes = [];
const MAX_PER_WRITE = 3;
const cutoff = new Date(today);
cutoff.setDate(cutoff.getDate() - maxAgeDays);
const cutoffStr = cutoff.toISOString().slice(0, 10);
// 1. Build this write's rows (capped; string and {directive, evidence} both accepted)
const rows = directives.slice(0, MAX_PER_WRITE).map((d) => {
const text = (typeof d === 'string' ? d : d && d.directive) || '';
const evidence = (typeof d === 'string' ? '' : (d && d.evidence) || '').trim();
return `- [${recordDate}] (${source}) ${String(text).trim()} — evidence: ${evidence || '_(none)_'}`;
}).filter((r) => !/\) +— evidence:/.test(r)); // drop rows with an empty directive
// 2. Rebuild the existing section body: drop this source's rows (superseded) and
// every row past the age floor (any source). Non-row lines (format comments,
// blank lines) are kept verbatim.
// No /m flag — same trap pruneContentHistory documents: with /m the `$` alternative
// in the lookahead matches the end of EVERY line, so the lazy body capture stops
// after the first line and older rows below it are never scanned (never superseded,
// never pruned). Without /m, `$` is end-of-string and the capture spans the section.
const sectionRe = /(## Do-Next Directives\n\n?)([\s\S]*?)(?=\n## |$)/;
const existing = content.match(sectionRe);
let dropped = 0;
if (existing) {
// The section's leading `<!-- … -->` format comments are documentation, not data:
// they stay directly under the heading, above the rows (a row prepended above them
// would push the format contract to the bottom of the section every write).
const lines = existing[2].split('\n');
let cut = 0;
while (cut < lines.length && !/^- \[/.test(lines[cut]) && (lines[cut].trim() === '' || lines[cut].trim().startsWith('<!--'))) cut++;
const preamble = lines.slice(0, cut).join('\n').replace(/\s+$/, '');
const kept = lines.slice(cut).filter((line) => {
if (line.trim() === '') return false; // rebuilt below, never accumulated
const row = line.match(/^- \[(\d{4}-\d{2}-\d{2})[^\]]*\] \(([^)]*)\)/);
if (!row) return true; // unexpected user text: keep verbatim
if (row[2] === source) { dropped++; return false; } // superseded by this write
if (row[1] < cutoffStr) { dropped++; return false; } // past the age floor
return true;
});
// Newest first: this write's rows go directly under the heading + comments.
const parts = [];
if (preamble) parts.push(preamble, '');
parts.push(...rows, ...kept);
const body = `${parts.join('\n')}\n`;
// Replacement FUNCTION, not string: rows embed untrusted directive/evidence text —
// `$&`/`$1`/`$$` must be inserted verbatim (same class as the other section writes).
content = content.replace(sectionRe, () => `${existing[1]}${body}`);
} else if (rows.length) {
const trimmed = content.replace(/\s*$/, '');
const header = '## Do-Next Directives\n\n<!-- Measurement -> next action. Written by /linkedin:report, /linkedin:analyze, /linkedin:ab-test (Adopt) and the 48h post-feedback-monitor; read by the create surfaces at Step 0. -->\n<!-- Format: - [YYYY-MM-DD] (source) directive - evidence: pointer -->\n\n';
content = `${trimmed}\n\n${header}${rows.join('\n')}\n`;
}
if (rows.length === 0 && dropped === 0) return null;
// 3. last_donext_date — only when something was actually recorded. Replace in place,
// else insert after the nearest existing date anchor (additive). Report the change
// only inside the branch that writes it: with no anchor the scalar is not inserted.
if (rows.length) {
if (/^last_donext_date: .*/m.test(content)) {
content = replaceField(content, 'last_donext_date', `"${recordDate}"`);
changes.push(`last_donext_date → ${recordDate}`);
} else {
const anchor = ['last_outreach_date', 'last_firsthour_date', 'last_post_date'].find((f) => new RegExp(`^${f}: .*`, 'm').test(content));
if (anchor) {
content = content.replace(new RegExp(`^(${anchor}: .*)$`, 'm'), (m) => `${m}\nlast_donext_date: "${recordDate}"`); // function, not string: keeps recordDate `$`-safe by construction
changes.push(`last_donext_date → ${recordDate}`);
}
}
changes.push(`Do-Next Directives ← ${rows.length} from ${source} (${recordDate})`);
}
if (dropped) changes.push(`Do-Next Directives: ${dropped} superseded/expired row(s) dropped`);
if (content === stateContent) return null;
return { content, changes };
}
/**
* I/O wrapper: read state file, apply update function, write atomically.
* @param {function(string): {content: string}|null} updateFn - Pure update function
@ -405,6 +511,19 @@ if (import.meta.url === `file://${process.argv[1]}`) {
nextAction: getArg('--next') || '',
dueDate: getArg('--due') || ''
}));
} else if (args.includes('--record-do-next')) {
const getArg = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : ''; };
// Rows are `;`-separated; each row is `directive :: evidence` (evidence optional).
const parseDirectives = (s) => (s ? s.split(';').map((row) => {
const [directive, ...rest] = row.split('::');
return { directive: (directive || '').trim(), evidence: rest.join('::').trim() };
}).filter((d) => d.directive) : []);
writeState(content => recordDoNext(content, {
recordDate: getArg('--date') || new Date().toISOString().slice(0, 10),
source: getArg('--source') || 'report',
directives: parseDirectives(getArg('--directives')),
maxAgeDays: parseInt(getArg('--max-age') || '60', 10)
}));
} else {
console.log('Usage:');
console.log(' node state-updater.mjs --update-post --date YYYY-MM-DD --topic "topic" --hook "Hook text" --chars 1500 --format post');
@ -412,5 +531,6 @@ if (import.meta.url === `file://${process.argv[1]}`) {
console.log(' node state-updater.mjs --update-followers --count 920 --month 2026-04');
console.log(' node state-updater.mjs --record-firsthour --date "YYYY-MM-DD HH:MM" --topic "topic" --targets "a;b" --comments "c;d" --plan "e;f"');
console.log(' node state-updater.mjs --record-outreach --date "YYYY-MM-DD HH:MM" --track collab --partner "@name" --stage pitched --next "follow up" --due YYYY-MM-DD');
console.log(' node state-updater.mjs --record-do-next --date YYYY-MM-DD --source report --directives "do X :: evidence;do Y :: evidence" [--max-age 60]');
}
}

View file

@ -209,6 +209,45 @@ const BATTERY = [
},
],
},
{
fn: "recordDoNext",
cases: [
{
path: "section CREATE, free-text directive/evidence",
run: () => mod.recordDoNext(SAMPLE, {
recordDate: "2026-05-30", source: "report",
directives: [{ directive: PAYLOAD, evidence: PAYLOAD }],
}),
expect: [`(report) ${PAYLOAD} — evidence: ${PAYLOAD}`],
once: [/^## Do-Next Directives$/gm],
},
{
path: "section REWRITE (supersede/prune path) — the kept + new rows are re-emitted",
run: () => {
// Two writes: the second rebuilds the section body, so a `$`-bearing row
// written by ANOTHER source has to survive being re-emitted verbatim.
const first = mod.recordDoNext(SAMPLE, {
recordDate: "2026-05-28", source: "ab-test",
directives: [{ directive: PAYLOAD, evidence: PAYLOAD }],
today: new Date("2026-06-10T00:00:00Z"),
});
return mod.recordDoNext(first.content, {
recordDate: "2026-05-30", source: "report",
directives: [{ directive: PAYLOAD, evidence: PAYLOAD }],
today: new Date("2026-06-10T00:00:00Z"),
});
},
expect: [`(ab-test) ${PAYLOAD} — evidence: ${PAYLOAD}`, `(report) ${PAYLOAD} — evidence: ${PAYLOAD}`],
once: [/^## Do-Next Directives$/gm],
},
{
path: "date additive-insert via last_post_date anchor — recordDate fuzzed",
run: () => mod.recordDoNext(SAMPLE, { recordDate: PAYLOAD, source: "report", directives: ["d"] }),
expect: [`last_donext_date: "${PAYLOAD}"`],
once: [/^last_donext_date:/gm],
},
],
},
];
// --- Backstop 1: coverage completeness -------------------------------------------

View file

@ -2392,6 +2392,151 @@ fi
echo ""
# --- Section 16v: Do-Next Contract (N15 / A2-F10,F12,F13,F14) ---
echo "--- Do-Next Contract (N15) ---"
# Measurement that does not change the next piece is theatre. Before N15 every
# analytics surface ENDED in chat: report/analyze printed recommendations, ab-test
# printed a verdict, the 48h monitor printed a learning - and the next drafting
# session, a fresh context, never saw any of it (grep do.next across the repo = 0).
# N15 makes the channel a contract, so what is worth linting is BOTH ends of it plus
# the lifetime rule that keeps it honest:
# (A2-F10 writer) every measurement surface persists via recordDoNext with a source
# tag and an evidence pointer - a compound predicate (mutator named +
# source declared + evidence carried), so a non-vacuity self-test
# guards it (mirrors Sections 16p-16u).
# (A2-F12/F13) the A/B Adopt verdict and the 48h learning route in the SAME
# channel - a second private channel would re-fragment the loop.
# (reader) all four create surfaces read the section at Step 0, else the
# directives are written into a file nobody opens.
# (A2-F14) newsletter Step 1 calibrates on the PREVIOUS edition's measured
# numbers via the queue-id match, with an explicit honest-miss branch
# (an unmeasured edition must never be estimated).
# (lifetime) replace-by-source + age floor, in code - a section that only grows
# stops being a steering signal and becomes a backlog.
SU_N15="hooks/scripts/state-updater.mjs"
RPT_N15="commands/report.md"
ANL_N15="commands/analyze.md"
ABT_N15="commands/ab-test.md"
PFM_N15="agents/post-feedback-monitor.md"
AIN_N15="agents/analytics-interpreter.md"
NL_N15="commands/newsletter.md"
STATE_TPL_N15="config/state-file.template.md"
donext_write_wired() { # $1 = text; wired iff it NAMES the mutator, DECLARES a source, and CARRIES evidence
echo "$1" | grep -qF "recordDoNext" \
&& echo "$1" | grep -qF "source:" \
&& echo "$1" | grep -qF "evidence"
}
DN_SELFTEST_OK=1
if ! donext_write_wired "call recordDoNext with source: 'report' and directives carrying an evidence pointer"; then
DN_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired do-next write probe was not detected"
fi
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if donext_write_wired "$probe"; then
DN_SELFTEST_OK=0; echo " false-positive FAIL: under-wired do-next write probe accepted -> $probe"
fi
done <<'NEGATIVE16V'
call recordDoNext with source: 'report' and two directives, no pointer to what was measured
persist the directives with an evidence pointer under source: 'report', mutator left unnamed
call recordDoNext with an evidence pointer for each directive, source never declared
NEGATIVE16V
if [ "$DN_SELFTEST_OK" -eq 1 ]; then
pass "do-next write self-test: predicate needs recordDoNext + source + evidence (1 accepted, 3 under-wired rejected)"
else
fail "do-next write self-test failed - the N15 wiring lint is vacuous or over-eager"
fi
# (A2-F10 writers) the two analytics front-doors persist instead of printing-and-forgetting
if donext_write_wired "$(cat "$RPT_N15" 2>/dev/null)"; then
pass "/linkedin:report persists its recommendations as do-next directives (A2-F10)"
else
fail "report.md still ends its recommendations in chat - nothing reaches the next draft (A2-F10)"
fi
if donext_write_wired "$(cat "$ANL_N15" 2>/dev/null)"; then
pass "/linkedin:analyze persists its recovery plan as do-next directives (A2-F10)"
else
fail "analyze.md diagnoses without persisting the corrections - re-diagnosed next session (A2-F10)"
fi
# (A2-F12) an Adopt verdict is a decision the next post inherits, not a filed test result
if donext_write_wired "$(cat "$ABT_N15" 2>/dev/null)" && grep -qF "Adopt" "$ABT_N15" 2>/dev/null; then
pass "/linkedin:ab-test routes an Adopt verdict into the same do-next channel (A2-F12)"
else
fail "ab-test.md leaves an Adopt verdict in the test file only (A2-F12)"
fi
# (A2-F13) the 48h learning is the one measurement taken closest to the next post
if donext_write_wired "$(cat "$PFM_N15" 2>/dev/null)" && grep -qF "48h-monitor" "$PFM_N15" 2>/dev/null; then
pass "post-feedback-monitor routes its 48h learning into the same do-next channel (A2-F13)"
else
fail "post-feedback-monitor.md keeps the 48h learning in chat (A2-F13)"
fi
# (A2-F10 shape) the interpreter must EMIT directives the caller can persist verbatim
if grep -qF "do-next" "$AIN_N15" 2>/dev/null && grep -qF "evidence" "$AIN_N15" 2>/dev/null; then
pass "analytics-interpreter emits recommendations in do-next form (imperative + evidence)"
else
fail "analytics-interpreter.md emits observations, not persistable directives (A2-F10)"
fi
# (reader) all four create surfaces read the section - a write nobody reads is still theatre
for READER_N15 in commands/post.md commands/quick.md commands/batch.md commands/create.md; do
if grep -qF "Do-Next Directives" "$READER_N15" 2>/dev/null; then
pass "$(basename "$READER_N15" .md) reads the do-next directives at Step 0"
else
fail "$READER_N15 drafts without reading the do-next directives - the loop is open"
fi
done
# (A2-F14) long-form calibrates on the previous edition's MEASURED numbers, and says so
# out loud when there are none rather than estimating them
STEP1_N15=$(awk '/^## Step 1: Brief/{f=1} /^## Step 1.5:/{f=0} f' "$NL_N15")
if echo "$STEP1_N15" | grep -qF "Do-Next Directives" \
&& echo "$STEP1_N15" | grep -qF "queue.json" \
&& echo "$STEP1_N15" | grep -qF "analytics/posts" \
&& echo "$STEP1_N15" | grep -qF "Never estimate"; then
pass "newsletter Step 1 calibrates on the previous edition via queue-id match, with an honest-miss branch (A2-F14)"
else
fail "newsletter Step 1 does not read the previous edition's measured numbers (or may estimate them) (A2-F14)"
fi
# (contract) the mutator exists and is reachable from a command line
if grep -qF "export function recordDoNext" "$SU_N15" 2>/dev/null; then
pass "state-updater exports recordDoNext (deterministic, testable, no AI in the write)"
else
fail "$SU_N15 has no recordDoNext export - the channel has no writer"
fi
if grep -qF -- "--record-do-next" "$SU_N15" 2>/dev/null; then
pass "state-updater exposes the --record-do-next standalone verb"
else
fail "$SU_N15 has no --record-do-next CLI verb"
fi
# (lifetime) both halves in code: a source replaces its OWN rows, and every row expires.
# Only one half and the section either grows forever or evicts other sources' directives.
if grep -qF "superseded by this write" "$SU_N15" 2>/dev/null \
&& grep -qF "past the age floor" "$SU_N15" 2>/dev/null; then
pass "do-next lifetime is replace-by-source AND age-bounded (the section cannot become a backlog)"
else
fail "$SU_N15 do-next lifetime rule is incomplete - a growing or self-evicting section"
fi
# (contract, shipped) a fresh install carries the section and the scalar, so the very
# first report writes into a documented shape instead of inventing one
if grep -qF "## Do-Next Directives" "$STATE_TPL_N15" 2>/dev/null \
&& grep -qF "last_donext_date" "$STATE_TPL_N15" 2>/dev/null; then
pass "state-file template ships the Do-Next Directives section + last_donext_date scalar"
else
fail "$STATE_TPL_N15 does not ship the do-next section/scalar (fresh installs start off-contract)"
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
@ -2450,12 +2595,17 @@ echo ""
# 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.
# check + fold-in-loop four-stage check + fold-in-loop adopter/silos grep) = 198; +15 for
# N15's fifteen UNCONDITIONAL Section-16v checks (do-next write self-test + report writer +
# analyze writer + ab-test Adopt writer + 48h-monitor writer + analytics-interpreter
# directive-shape grep + four create-surface reader greps + newsletter Step-1 queue-id/
# honest-miss compound grep + recordDoNext export grep + --record-do-next CLI verb grep +
# lifetime replace-by-source/age-floor grep + state-template section/scalar grep) = 213.
# 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
ASSERT_BASELINE_FLOOR=213
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"