linkedin-studio/scripts/editions/README.md
Kjell Tore Guttormsen 677eab9294 feat(linkedin-studio): N13 — publiserings-slots + vacancy-varsel + WIP-roll-up + slot-default [skip-docs]
A1-10 + D-5. The queue answers "what is scheduled"; nothing answered "is the week
covered, and what is still being written". N13 makes both readable at session start.

- Publishing slots are operator config: profile/publishing-slots.json in the per-user
  data dir, schema + OPT-IN template in config/publishing-slots.template.json. The
  plugin ships no publishing times and writes the grid for nobody; absent config means
  every slot surface stays silent (same never-nag discipline as the trend/brain nudges).
  Data dir rather than the state file, which is machine-written and whose frontmatter
  reader does not parse lists.
- hooks/scripts/slots.mjs (new): one implementation behind four surfaces. Zero-dep (a
  SessionStart hook must not spawn tsx), pure core (dates in as strings, no clock),
  imported by the commands exactly as queue-manager.mjs already is — so session-start's
  vacancy warning and Step 10's slot default cannot drift apart.
- Coverage counts the short-form queue (scheduled/published; cancelled frees the slot)
  AND editions-register rows claiming that date, counted per date, so long-form and
  short-form cannot be double-booked and a two-slot day needs two posts.
- Session start gains "## Editions in Flight" (series, phase, next action, claimed slot,
  days in flight) and "## Publishing Slots" (next open slot, open count in 14 days, and
  how to fill it when the gap is <=3 days). The existing discovery nudge is untouched.
- /linkedin router + /linkedin:calendar surface the same roll-up; calendar documents the
  opt-in copy. /linkedin:newsletter Step 10 defaults to the next open slot (one
  confirmation, not an interview) and writes --slot onto the register row.
- D-5: references/scheduling-strategy.md marked "confidence: low / directional" per the
  algorithm reference's own standard, deferring to the operator's grid and their own
  analytics. Neutral example time replaces the operator-specific one in the N12 docs.

TDD: red proven first (module absent; 5 session-start behaviours failing).
hooks 140 -> 174 · test-runner 184 -> 197 (Section 16t: 13 unconditional greps incl. a
de-niche check that no publishing time is hardcoded in the slot path and no operator grid
is committed, + non-vacuity self-test; anti-erosion floor 166 -> 179).
Suites green: trends 300/0 · brain 134/0 · editions 72/0 · specifics-bank 45/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
2026-07-25 07:28:48 +02:00

136 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# editions — series-level memory for long-form
Two stores, one package, both about what a *series* knows rather than what an edition
knows: the **distillate** (N11) — what the reader has already been told — and the
**register** (N12) — what is in production right now and how long it is taking.
## 1. Series distillate (N11, serie-nivå-vern)
Every long-form gate agent sees **one** edition. At two editions a week the fastest-
growing defect class is not a bad edition — it is a **repeated** one: the anecdote, the
argument or the hook the reader already met three editions ago. That failure is
structurally invisible today, and the specifics-bank dedupe actively *encourages*
re-surfacing the same material.
So each locked edition leaves behind a **distillate** of the narrative units it spent,
and the Step 2.5 skeleton gate checks the next skeleton against it — **before prose
exists**, the only point where changing course is still cheap.
## Where the distillate lives
`<serie>/linkedin/series-distillate.json` — beside `edition-state.json` in the **series
root**, not in the per-user data dir. It is series-scoped state, the series folder is
already where the edition's state lives, and putting it there means it travels with the
series and needs no slug→path map.
Series-scoped is the point: this answers *"has this reader heard it?"*. Cross-series
re-use of the operator's raw material is a different grain, tracked by the
specifics-bank's `usedIn` log.
## Setup
```bash
cd scripts/editions && npm install
```
## Use
```bash
# Step 8 (lock): fold the edition's spent units into the series distillate
node --import tsx src/cli.ts distil-append \
--distillate "<serie>/linkedin/series-distillate.json" \
--series seres --extract "<abs>/extract.json"
# Step 2.5 (before prose): check the proposed skeleton against everything published
node --import tsx src/cli.ts distil-check \
--distillate "<serie>/linkedin/series-distillate.json" \
--skeleton "<abs>/skeleton.json" [--threshold 0.4] [--json]
```
`extract.json` = `{editionId, title, lockedAt, anecdotes[], arguments[], hooks[]}` — the
AI extract from the locked edition. `skeleton.json` = `{anecdotes?, arguments?, hooks?}`.
Exit codes: `0` on success — **including a REUSE verdict**. The check is advisory by
design (it reports into the annotation gate the operator already reads at Step 2.5),
unlike the binding gate's BLOCK. `2` on usage error.
## Model
- **Extraction is AI, comparison is code.** The command layer distils the units from the
locked prose; the store, the similarity and the verdict are plain deterministic code —
no model in the loop, no run-to-run variance.
- **Similarity = character-trigram Jaccard**, not word overlap. The plugin is
language-general and the languages it is used in are inflection-heavy: *migrere* /
*migreringen* are the same unit to a reader but different word tokens. Calibrated
against real paraphrase pairs (see `tests/distillate.test.ts`): retellings score
0.440.55, unrelated material 0.040.06, same-topic-different-story 0.24 — so the
default threshold sits in the gap at **0.40**. Word-Jaccard scored a shortened hook
paraphrase at 0.11 and would have let it through.
- **Units are compared within their kind.** An argument re-used as a hook is a new move,
not a retread.
- **A re-locked edition replaces its entry**, never duplicates it — `/linkedin:pivot`
re-opens and re-locks the same edition, and the reader still only met it once.
- **A missing distillate is not an error.** A series' first edition compares against
nothing and is CLEAR.
## 2. Editions register (N12, A1-11 / A1-12)
Two things were structurally invisible before this: **which editions are in flight**
(readable only by opening every series folder's `edition-state.json` by hand) and **how
long an edition takes** (not recorded anywhere at all). At a two-editions-a-week cadence
both are load-bearing — you cannot dimension a production line on numbers you never
collected.
So every phase transition in `/linkedin:newsletter` runs **one** command that does two
writes: it appends `{phase, completedAt}` to `articles.NN.phaseLog` in the edition-state,
and mirrors the edition's row into the register.
```bash
# at EVERY phase transition (after currentPhase has been written)
node --import tsx src/cli.ts register-upsert \
--edition-state "<serie>/linkedin/edition-state.json" \
--next "Step 3a — spine prose" [--slot "2026-07-28 08:30"] [--path <series-root>]
# the week's work-in-progress, without opening a single series folder
node --import tsx src/cli.ts register-list [--all] [--json]
# at Step 10, once the edition is scheduled — prints the measured lead time
node --import tsx src/cli.ts register-complete --series seres --edition 05
```
### Where the register lives
`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json` — the
per-user data dir (M0 convention), because it spans *all* series and must survive plugin
upgrades and reinstalls. Contrast the distillate above, which is per-series and therefore
lives in the series root.
### Model
- **The register is a MIRROR, never a source of truth.** Every row is derived from an
`edition-state.json` at a transition. Resumption reads edition-state and nothing else;
delete the register and the next transition rebuilds the row. Losing it costs telemetry,
not an edition — so a failed `register-upsert` is reported, never a reason to stop the
pipeline.
- **One call, both writes.** A phase log the command layer had to remember to append
separately would be incomplete inside a week, and an incomplete log measures nothing.
- **No clock in the core.** Every mutation takes `now` as an argument (`--at` / `--now` at
the CLI edge), exactly like the distillate takes `lockedAt`. Deterministic tests, no
hidden timezone behaviour.
- **`startedAt` never moves.** It is the lead-time anchor: re-upserting a completed
edition *reactivates* the same row (that is what `/linkedin:pivot` does), so the measured
time reflects real elapsed production rather than restarting the clock.
- **Idempotent where a re-run is legitimate, not where it is real work.** Repeating the
same transition logs once; a phase that recurs *after* another one is logged again,
because a pivot back through cleared gates is production time that actually happened.
Completing twice keeps the first `completedAt`.
- **Missing facts are errors, not defaults.** A row naming the wrong phase is worse than
no row — the whole point is that the list can be trusted without opening files. Only the
title is allowed to be empty; it is telemetry, not a gate.
## Test
```bash
npm test # node:test, deterministic (callers supply lockedAt)
npm run build # tsc — must stay clean
```