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:
parent
f0532dce3f
commit
b45fdad911
16 changed files with 713 additions and 6 deletions
|
|
@ -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 2–3 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
|
||||
|
|
|
|||
|
|
@ -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 2–3 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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
**2–3 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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>-<NN−1>`. Resolve its numbers:
|
||||
> - read `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/drafts/queue.json`,
|
||||
> take the entry whose `id` is `<series-slug>-<NN−1>` → 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 2–3 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:
|
||||
|
|
|
|||
|
|
@ -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. -->
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -343,6 +343,112 @@ export function recordOutreachContact(stateContent, { contactDate, track = '', p
|
|||
return { content, changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record 2-3 "do next" directives deterministically — the measurement→creation
|
||||
* 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]');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue