feat(linkedin-studio): N12 — editions-register CLI + phaseLog-telemetri (TDD) [skip-docs]

Two things were structurally invisible: 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 two editions a week both are
load-bearing — a production line cannot be dimensioned on numbers never collected.

Register (scripts/editions, new module beside distillate.ts): one row per edition
in ${LINKEDIN_STUDIO_DATA}/editions/register.json — series, edition, title, series
path, current phase, next action, slot, startedAt/completedAt. Data-dir placement
(M0) because the register spans ALL series; the distillate is per-series and stays
in the series root. Verbs: register-upsert / register-list / register-complete.

phaseLog: articles.NN.phaseLog[{phase, completedAt}] in edition-state, additive so
schemaVersion stays 1 — pre-N12 editions load unchanged and their absence reads as
"not measured", never as zero. Per-article, mirroring articles.NN.phase: lead time
is a property of an edition.

One call per transition, both writes: newsletter.md gains a phase-transition
protocol defined once beside the resumption table, and all 16 canonical phases
invoke it. register-upsert appends the phase-log entry AND mirrors the register
row. Deliberately one command, not two — telemetry the command layer must remember
to write separately is incomplete inside a week, and an incomplete log measures
nothing. Step 10 closes the row and prints the measured lead time.

Mirror discipline: resumption still reads edition-state.json and only that. Delete
the register and the next transition rebuilds it; a failed upsert is reported, never
a reason to stop the pipeline. startedAt is the one unrecoverable value, so it never
moves — re-upserting a completed edition reactivates the same row (what
/linkedin:pivot does), keeping the clock on real elapsed production time.

Deterministic: no clock in the core (now passed in, --at/--now at the edge, as the
distillate takes lockedAt). Idempotent where a re-run is legitimate (repeated
transition logs once; completing twice keeps the first completedAt), not where it is
real work (a phase recurring after another one is logged again — a pivot back through
cleared gates is production time that happened). Missing facts are refused at the
edge, not defaulted: a row naming the wrong phase is worse than no row.

TDD (Iron Law): all three test files written first and verified red before any
implementation existed (register.test.ts + editionState.test.ts failed on missing
modules, cli-register.test.ts on "unknown command: register-upsert").

Suites: editions 27 -> 72 (floor raised) · test-runner 173 -> 184 (Section 16s: 11
unconditional greps incl. a 16-phase coverage sweep + non-vacuity self-test;
anti-erosion floor 155 -> 166) · trends 300/0 · brain 134/0 · specifics-bank 45/0 ·
hooks 140/0 · tests 35/0 · render 60/0. tsc --noEmit clean.

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 06:55:44 +02:00
commit 657539ef09
13 changed files with 1454 additions and 20 deletions

View file

@ -112,7 +112,7 @@ split; v3.1 / Endring 9 on adversarial independence + framing-bias).
| 7.5 | **Visual assets — BEFORE lock** | cover (+ optional inline figures) or carousel deck: behov → per-image brief → generate (coded `build-figur.mjs` primary for data figures / mcp-image for illustrative / external `cover-raw.png`) → operator-gate (`SendUserFile`) → approve to `cover.png` → credit/caption. Runs before lock so the renderer picks the cover up. | `render/build-figur.mjs` (data figures) + `mcp__mcp-image__generate_image` + `SendUserFile` + (carousel) `render/build-carousel.mjs` |
| 8 | **LOCK → delivery** | POST.html "all in one place"; auto-gull (gold corpus) + **serie-minne** (series distillate + specifics-bank usage log) | `render/build-linkedin.mjs` + `scripts/editions` (`distil-append`) + `scripts/specifics-bank` (`record-usage`) |
| 9 | **Hook / conversion gate** | persona gate on the distribution text post-lock: "would YOU click?" | **`persona-reviewer`** (conversion mode) |
| 10 | **Scheduling** | register the edition in the plugin queue/state for native scheduling | `hooks/scripts/queue-manager.mjs` |
| 10 | **Scheduling** | register the edition in the plugin queue/state for native scheduling; close the editions-register row (measured lead time) | `hooks/scripts/queue-manager.mjs` + `scripts/editions` (`register-complete`) |
> **Build status:** all 18 phases (Steps 0, 1, 1.5, 2, 2.5, 3a, 3b, 4, 4.5, 5,
> 5.5, 6, 6.5, 7, 7.5, 810) are implemented below. This command takes an edition
@ -280,6 +280,58 @@ Persona library: <N> personas loaded (active set chosen in Step 1)
If the chronicle voice profile or persona library is missing, say so plainly and
continue on the Step-0 fallback — do not fabricate either.
### Phase-transition protocol — one deterministic call per transition
Every Step below ends by writing `currentPhase` to `edition-state.json`. That
write is a **phase transition**, and every transition runs exactly one
deterministic command — no per-step variation, no hand-written telemetry:
```bash
node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-upsert \
--edition-state "<serie>/linkedin/edition-state.json" \
--next "<the one next concrete step>"
```
Run it **after** `currentPhase` has been written, never before — the command
reads the phase out of the state file. It does two things in one call:
1. **appends** `{phase, completedAt}` to `articles.NN.phaseLog` — the lead-time
telemetry (A1-12); and
2. **mirrors** the edition into the editions register at
`${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json`
— series, edition, title, series path, phase, next action, slot (A1-11).
One call, both writes: a log the command layer had to remember to append
separately would be incomplete within a week, and an incomplete log measures
nothing. Add `--slot "<YYYY-MM-DD HH:MM>"` once a publishing slot is claimed, and
`--path <series-root>` when the state file is not at the conventional
`<serie>/linkedin/edition-state.json` location.
The week's work-in-progress is then one command away, with no series folder
opened:
```bash
node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-list
```
**At Step 10 only**, once the edition is scheduled, close its row (this is what
turns `startedAt` into a measured lead time):
```bash
node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-complete \
--series "<slug>" --edition "<NN>"
```
> **The register is a MIRROR, never a source of truth.** Resumption reads
> `edition-state.json` and nothing else (the table above). The register is a
> read-only view for humans; if it is deleted or drifts, the next transition
> repairs it and nothing about the edition is lost. So a failing `register-upsert`
> is a telemetry problem to report plainly — never a reason to stop the pipeline
> or to re-do the phase.
Each Step below marks its transition **↳ Phase transition** — that marker means
"run the protocol above for this phase".
## Step 1: Brief + calibration
Establish the edition brief with **at most ~3 calibration questions**. Infer
@ -487,6 +539,8 @@ Edition brief
doc-string's "Resolved at Step 1" claim true.
- Write `articles.NN.livedSpecifics` (the slot-map above, `status: "pending"`).
- Set `currentPhase: "lived-specifics"` in `edition-state.json`.
- **↳ Phase transition** — run the protocol (Step 0) for `lived-specifics`,
`--next "Step 2 — research (scoped by the binding)"`.
- Write a "lived-specifics bound → next: research (scoped by the binding)" line
to `<serie>/STATE.md` (overwrite).
@ -593,8 +647,10 @@ Next: Step 2 — Research (scoped by the binding: fill ekstern/unresolved, verif
"research complete → next: skeleton + section pitch (BEFORE prose)" next-step
line to `<serie>/STATE.md` (overwrite). If somehow no `edition-state.json` exists
yet (Step 1.5 was a no-op for an adopter), initialize it from the template schema
first. Stop cleanly here if context budget is tight — Step 2.5 begins in the next
session; otherwise Step 2.5 may run inline (it is short and operator-interactive).
first. **↳ Phase transition** — run the protocol (Step 0) for `research`,
`--next "Step 2.5 — skeleton + section pitch"`. Stop cleanly here if context
budget is tight — Step 2.5 begins in the next session; otherwise Step 2.5 may
run inline (it is short and operator-interactive).
```
Research phase complete.
@ -806,6 +862,8 @@ Next: Step 2.5 — Skeleton + section pitch (operator + persona gate BEFORE pros
resonance/conversion under the same `personaSweep` object).
- `currentPhase: "skeleton-pitch"` in `edition-state.json` (the marker that
Step 2.5 is complete and the gate has passed).
- **↳ Phase transition** — run the protocol (Step 0) for `skeleton-pitch`,
`--next "Step 3a — spine prose"`.
- A "skeleton + pitches PASS (primær JA) → next: Step 3a (spine prose)"
next-step line in `<serie>/STATE.md` (overwrite).
@ -914,6 +972,8 @@ Typically ~2030 % of the edition's final length.
with the expanded prose).
- `currentPhase: "spine-prose"` in `edition-state.json` (the marker that 3a
is complete and the gate has passed).
- **↳ Phase transition** — run the protocol (Step 0) for `spine-prose`,
`--next "Step 3b — full prose expansion"`.
- A "spine prose JA → next: Step 3b (full prose expansion)" next-step line in
`<serie>/STATE.md` (overwrite).
@ -988,6 +1048,9 @@ turning-points the spine already named.
silently skip any draft without an `NN` prefix). Set
`currentPhase: "draft"` in `edition-state.json`, and write a "draft
complete → next: consistency/quality" line to `<serie>/STATE.md` (overwrite).
**↳ Phase transition** — run the protocol (Step 0) for `draft`,
`--next "Step 4 — consistency + quality"`. Run it only when the draft is
COMPLETE, not at an interim cursor save: the log measures finished phases.
```
Full prose expansion — complete (or: partial — resumes at section <X>).
@ -1052,7 +1115,8 @@ non-self-certified — that verdict stays with the Step 6 persona sweep / operat
After the pass, set `currentPhase: "consistency-quality"` in `edition-state.json`
and write a "quality pass complete → next: contract-gate" line to
`<serie>/STATE.md` (overwrite).
`<serie>/STATE.md` (overwrite). **↳ Phase transition** — run the protocol
(Step 0) for `consistency-quality`, `--next "Step 4.5 — contract-gate"`.
```
Consistency + quality pass complete.
@ -1123,7 +1187,8 @@ mechanical noise.
3. **Persist + checkpoint state.** Set `currentPhase: "contract-gate"` in
`edition-state.json` and write a "contract-gate passed → next: fact-check sweep"
line to `<serie>/STATE.md` (overwrite).
line to `<serie>/STATE.md` (overwrite). **↳ Phase transition** — run the
protocol (Step 0) for `contract-gate`, `--next "Step 5 — fact-check sweep"`.
```
Contract-gate complete.
@ -1198,6 +1263,8 @@ because it "feels" right or because it sits in your own research notes.
record of what was checked — this is where the old HANDOVER §4 log now lives),
set `currentPhase: "factcheck-sweep"`, and write a "fact-check complete → next:
persona sweep (BEFORE lock)" line to `<serie>/STATE.md` (overwrite).
**↳ Phase transition** — run the protocol (Step 0) for `factcheck-sweep`,
`--next "Step 5.5 — editorial review"`.
```
Fact-check sweep complete.
@ -1319,6 +1386,8 @@ and `persona-reviewer` never flags em-dash density (that is this step).
machine-readable record — what was flagged, severity, fold-in decision).
- Set `currentPhase: "editorial-review"` in `edition-state.json` (the marker
that Step 5.5 is complete and the operator-gate has passed).
- **↳ Phase transition** — run the protocol (Step 0) for `editorial-review`,
`--next "Step 6 — persona sweep (pre-lock)"`.
- Write an "editorial review complete (craft clean) → next: persona sweep
(BEFORE lock)" line to `<serie>/STATE.md` (overwrite).
@ -1393,7 +1462,9 @@ reopening locked texts — the biggest single process error of the series (plan
heuristic (Step 8 / `/linkedin:pivot`) compares against to catch a late pivot.
Set `currentPhase: "persona-sweep-prelock"`, and write a "persona sweep
PASS (primær JA) → next: headless adversarial review (Step 6.5, BEFORE lock)"
line to `<serie>/STATE.md` (overwrite).
line to `<serie>/STATE.md` (overwrite). **↳ Phase transition** — run the
protocol (Step 0) for `persona-sweep-prelock`,
`--next "Step 6.5 — headless adversarial review"`.
```
Persona sweep complete (BEFORE lock).
@ -1507,7 +1578,8 @@ command runs — see `commands/headless-review.md` for the full cold contract):
summary, status}`, `consolidatedReport`, `foldedIn`/`waived`, `status:
"folded"`), set `currentPhase: "headless-review"`, and write a "headless review
complete (cold, converged flags folded) → next: annotation/lock" line to
`<serie>/STATE.md` (overwrite).
`<serie>/STATE.md` (overwrite). **↳ Phase transition** — run the protocol
(Step 0) for `headless-review`, `--next "Step 7/7.5 — annotation + visual assets"`.
```
Headless adversarial review (cold, BEFORE lock) — complete.
@ -1565,6 +1637,9 @@ editor is satisfied with the in-session draft. It does not gate lock.
4. **Persist.** Set `currentPhase: "annotation"` in `edition-state.json` and
write an "annotation rendered (optional) → next: visual assets" line to
`<serie>/STATE.md` (overwrite). If skipped, note "annotation skipped" and move on.
**↳ Phase transition** — run the protocol (Step 0) for `annotation`,
`--next "Step 7.5 — visual assets"`. If the step was skipped, skip the
transition too: an unrun phase must not appear in the lead-time log.
```
Annotation (optional).
@ -1722,6 +1797,8 @@ the deck is approved) and credit/caption are recorded:
`edition-state.json`.
- Set `currentPhase: "visual-assets"` in `edition-state.json` (the marker that
Step 7.5 is complete and the gate has passed).
- **↳ Phase transition** — run the protocol (Step 0) for `visual-assets`,
`--next "Step 8 — LOCK + delivery"`.
- Write a "visual assets approved (cover/figures or carousel) → next:
lock/delivery" line to `<serie>/STATE.md` (overwrite).
@ -1815,6 +1892,8 @@ produces the editor's single delivery artifact — `POST.html`, the
`<serie-mappe>/linkedin/NN/POST.html` as a `file://` link. From here the
body is frozen — Step 9 may only adjust the distribution hook, never the
locked edition text.
**↳ Phase transition** — run the protocol (Step 0) for `lock-delivery`,
`--next "Step 9 — hook/conversion gate"`.
5. **Auto-gull — grow the gold corpus (A2-F2).** A locked edition is now
published-grade voice signal, so append it to the gold corpus the chronicle
@ -1974,6 +2053,8 @@ lock invariant without breaking phase order.
`articles.NN.randsoneGate` (per-surface fact/language verdicts + title-strength +
what was revised) and the conversion verdict in
`articles.NN.personaSweep.conversion`; set `currentPhase: "hook-conversion-gate"`.
**↳ Phase transition** — run the protocol (Step 0) for `hook-conversion-gate`,
`--next "Step 10 — scheduling"`.
```
Randsone + conversion gate (post-lock, distribution copy only).
@ -2038,6 +2119,20 @@ now a first-class scheduled post.
`edition-state.json`. Write a closing "edition scheduled → mark live via
`/linkedin:calendar` on <date>" line to `<serie>/STATE.md` (overwrite). The
pipeline is complete.
**↳ Phase transition** — run the protocol (Step 0) for `scheduling`,
`--next "published — mark live via /linkedin:calendar"`, and then **close the
register row** (this step alone does):
```bash
node --import tsx "${CLAUDE_PLUGIN_ROOT}/scripts/editions/src/cli.ts" register-complete \
--series "<slug>" --edition "<NN>"
```
It prints the measured lead time (first transition → completion) — the A1-12
number the cadence work is calibrated on. An edition re-opened later by
`/linkedin:pivot` simply re-activates the same row on its next transition; the
original `startedAt` is kept, so the clock reflects the real elapsed production
time rather than restarting.
```
Scheduling.
@ -2118,7 +2213,7 @@ the honest decision surface; it sells nothing.
- `${CLAUDE_PLUGIN_ROOT}/config/edition-state.template.json` — edition-state schema (18 phases including Fix #2 lived-specifics extraction (Step 1.5) + `articles.NN.livedSpecifics`, the deterministic §B/§C1 contract-gate (Step 4.5), v2.1 skeleton + spine-prose gates, v2.3 visual-assets, v2.4 editorial-review, and v3.1 headless-review + per-article `personas` + `pivots`)
- `${CLAUDE_PLUGIN_ROOT}/scripts/specifics-bank/` — lived-specifics store + per-edition binding (Fix #2, kilde-så-draft): `query`/`add` (Step 1.5), `validate-binding` (Step 2.5 gate), `render-kilder` (`NN-kilder.md` ledger), `record-usage` (Step 8 lock — «used in edition NN»). Design record: `docs/fix2/slice2-binding.md`
- `${CLAUDE_PLUGIN_ROOT}/scripts/editions/` — series-level memory (N11): `distil-append` writes what a locked edition spent to `<serie>/linkedin/series-distillate.json` (Step 8), `distil-check` gates the next skeleton against it (Step 2.5). Deterministic character-trigram similarity; advisory, never blocking
- `${CLAUDE_PLUGIN_ROOT}/scripts/editions/` — series-level memory (N11): `distil-append` writes what a locked edition spent to `<serie>/linkedin/series-distillate.json` (Step 8), `distil-check` gates the next skeleton against it (Step 2.5). Deterministic character-trigram similarity; advisory, never blocking. **Editions register (N12):** `register-upsert` at every phase transition (appends `articles.NN.phaseLog` + mirrors the row into `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json`), `register-list` for the work-in-progress view, `register-complete` at Step 10 (measured lead time). The register mirrors state — resumption never reads it
- `${CLAUDE_PLUGIN_ROOT}/config/edition-config.template.json` — static delivery metadata schema (calendar, freshness, credit, captions) — Step 8
- `${CLAUDE_PLUGIN_ROOT}/config/image-credit-caption.template.md` — cover motif + credit + caption table (honest-about-AI credit) — Step 7.5
- `${CLAUDE_PLUGIN_ROOT}/config/edition-delingstekst.template.md` — distribution-copy grammar (`## Del N —` / `## Samle`) — Steps 8/9