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

@ -40,6 +40,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Specifics-bank usage log (`usedIn`)** — the material grain of the same defect: `recordUsage` + the `record-usage` CLI verb stamp «used in edition NN» on the specifics an edition actually consumed (read from the bound slot-map via `boundSpecificIds`, so `abstrakt`/`ekstern` slots stamp nothing). Recorded at **lock**, so it means published rather than merely considered, and idempotent under a pivot re-lock. The bank's dedupe deliberately *encourages* re-surfacing the same material; this log is what makes that re-use visible at the next Step 1.5 instead of silent. Additive-optional — schema stays v1, existing banks load unchanged. - **Specifics-bank usage log (`usedIn`)** — the material grain of the same defect: `recordUsage` + the `record-usage` CLI verb stamp «used in edition NN» on the specifics an edition actually consumed (read from the bound slot-map via `boundSpecificIds`, so `abstrakt`/`ekstern` slots stamp nothing). Recorded at **lock**, so it means published rather than merely considered, and idempotent under a pivot re-lock. The bank's dedupe deliberately *encourages* re-surfacing the same material; this log is what makes that re-use visible at the next Step 1.5 instead of silent. Additive-optional — schema stays v1, existing banks load unchanged.
- Suites: new `editions` 27/0; specifics-bank 28 → 45; test-runner 163 → 173 (Section 16r: 9 unconditional greps + non-vacuity self-test; anti-erosion floor 146 → 155). `tsc --noEmit` clean in both packages. Domain-general throughout. - Suites: new `editions` 27/0; specifics-bank 28 → 45; test-runner 163 → 173 (Section 16r: 9 unconditional greps + non-vacuity self-test; anti-erosion floor 146 → 155). `tsc --noEmit` clean in both packages. Domain-general throughout.
### Added — editions register + phase telemetry: make the production line measurable (N12 / A1-11, A1-12)
- **Editions register (`scripts/editions`, new module)** — one row per edition in production in `${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/editions/register.json`: series, edition, title, series path, current phase, the one next action, and slot. `register-list` answers "what is in flight, and where does each edition stand?" without opening a single series folder — until now that answer required opening every `edition-state.json` by hand, which is why nobody had it. Data-dir placement (M0), because the register spans **all** series (the distillate is per-series and stays in the series root).
- **`articles.NN.phaseLog` — lead time becomes a measured number** — every phase transition appends `{phase, completedAt}` to the edition-state. Additive-optional (default `[]`), so schemaVersion stays **1** and pre-N12 editions load unchanged; their absence reads as "not measured", never as zero. Per-article rather than top-level, mirroring `articles.NN.phase`: lead time is a property of an edition.
- **One call per transition, both writes**`/linkedin:newsletter` gains a **phase-transition protocol** defined once next to the resumption table, and each of the **16 canonical phases** invokes it: `register-upsert --edition-state <path>` 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 (`register-complete`) and prints the measured lead time.
- **The register is a MIRROR, never a source of truth** — deterministic resumption still reads `edition-state.json` and only that. Delete the register and the next transition rebuilds the row; a failed `register-upsert` is reported plainly and never stops the pipeline. `startedAt` is the one value that cannot be recovered, 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** — every mutation takes `now` as an argument (`--at` / `--now` at the CLI edge), as the distillate takes `lockedAt`. Idempotent where a re-run is legitimate (a 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 rather than defaulted: a row naming the wrong phase is worse than no row.
- Suites: editions 27 → 72; test-runner 173 → 184 (Section 16s: 11 unconditional greps incl. a 16-phase coverage sweep + non-vacuity self-test; anti-erosion floor 155 → 166). `tsc --noEmit` clean. Domain-general throughout.
### Built feedback (awaiting consumer-side proof) ### Built feedback (awaiting consumer-side proof)
- **MR-F9** (demand-sweep «innenfra og ut») — built; the (a)/(b)/(c) evidence gate is a runtime demonstration, proven consumer-side (plugin agents don't resolve in the dev repo). - **MR-F9** (demand-sweep «innenfra og ut») — built; the (a)/(b)/(c) evidence gate is a runtime demonstration, proven consumer-side (plugin agents don't resolve in the dev repo).

View file

@ -13,6 +13,7 @@ Full-spectrum LinkedIn content engine — short-form feed posts, carousels, vide
- **Analytics:** CLI `scripts/analytics/` (TypeScript, needs `tsx` + `npm install`); data `assets/analytics/` (gitignored) - **Analytics:** CLI `scripts/analytics/` (TypeScript, needs `tsx` + `npm install`); data `assets/analytics/` (gitignored)
- **Specifics-bank:** `scripts/specifics-bank/` (TypeScript, needs `tsx` + `npm install`) — deterministic, topic-tagged store of the operator's lived specifics (real numbers, named cases, held opinions) in the per-user data dir; elicited/bound by `/linkedin:newsletter` Step 1.5 so drafts draw from real inventory, never invented filler. Consumption is logged back at Step 8 lock (`record-usage``usedIn`), so cross-edition re-use of the same material is visible rather than silently encouraged by the dedupe - **Specifics-bank:** `scripts/specifics-bank/` (TypeScript, needs `tsx` + `npm install`) — deterministic, topic-tagged store of the operator's lived specifics (real numbers, named cases, held opinions) in the per-user data dir; elicited/bound by `/linkedin:newsletter` Step 1.5 so drafts draw from real inventory, never invented filler. Consumption is logged back at Step 8 lock (`record-usage``usedIn`), so cross-edition re-use of the same material is visible rather than silently encouraged by the dedupe
- **Series distillate:** `scripts/editions/` (TypeScript, needs `tsx` + `npm install`) — series-level memory: each locked edition's spent narrative units (anecdotes/arguments/hooks) in `<serie>/linkedin/series-distillate.json`; written at `/linkedin:newsletter` Step 8 (`distil-append`), checked against the next skeleton at Step 2.5 (`distil-check`, advisory). Deterministic character-trigram similarity — AI extracts, code compares - **Series distillate:** `scripts/editions/` (TypeScript, needs `tsx` + `npm install`) — series-level memory: each locked edition's spent narrative units (anecdotes/arguments/hooks) in `<serie>/linkedin/series-distillate.json`; written at `/linkedin:newsletter` Step 8 (`distil-append`), checked against the next skeleton at Step 2.5 (`distil-check`, advisory). Deterministic character-trigram similarity — AI extracts, code compares
- **Editions register:** `scripts/editions/` (same package) — one row per edition in production in the per-user data dir (`editions/register.json`): series, edition, path, current phase, next action, slot, `startedAt`/`completedAt`. Each of the 16 canonical `/linkedin:newsletter` phase transitions runs `register-upsert --edition-state <path>`, which appends `articles.NN.phaseLog` (lead-time telemetry, additive — schemaVersion stays 1) **and** mirrors the row in one call; Step 10 runs `register-complete`. `register-list` is the work-in-progress view. The register is a **mirror** — deterministic resumption reads `edition-state.json` only, and a lost register is rebuilt by the next transition
- **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 - **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
- **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). - **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).

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` | | 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`) | | 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) | | 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, > **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 > 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 If the chronicle voice profile or persona library is missing, say so plainly and
continue on the Step-0 fallback — do not fabricate either. 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 ## Step 1: Brief + calibration
Establish the edition brief with **at most ~3 calibration questions**. Infer 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. doc-string's "Resolved at Step 1" claim true.
- Write `articles.NN.livedSpecifics` (the slot-map above, `status: "pending"`). - Write `articles.NN.livedSpecifics` (the slot-map above, `status: "pending"`).
- Set `currentPhase: "lived-specifics"` in `edition-state.json`. - 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 - Write a "lived-specifics bound → next: research (scoped by the binding)" line
to `<serie>/STATE.md` (overwrite). 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 "research complete → next: skeleton + section pitch (BEFORE prose)" next-step
line to `<serie>/STATE.md` (overwrite). If somehow no `edition-state.json` exists 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 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 first. **↳ Phase transition** — run the protocol (Step 0) for `research`,
session; otherwise Step 2.5 may run inline (it is short and operator-interactive). `--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. 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). resonance/conversion under the same `personaSweep` object).
- `currentPhase: "skeleton-pitch"` in `edition-state.json` (the marker that - `currentPhase: "skeleton-pitch"` in `edition-state.json` (the marker that
Step 2.5 is complete and the gate has passed). 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)" - A "skeleton + pitches PASS (primær JA) → next: Step 3a (spine prose)"
next-step line in `<serie>/STATE.md` (overwrite). next-step line in `<serie>/STATE.md` (overwrite).
@ -914,6 +972,8 @@ Typically ~2030 % of the edition's final length.
with the expanded prose). with the expanded prose).
- `currentPhase: "spine-prose"` in `edition-state.json` (the marker that 3a - `currentPhase: "spine-prose"` in `edition-state.json` (the marker that 3a
is complete and the gate has passed). 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 - A "spine prose JA → next: Step 3b (full prose expansion)" next-step line in
`<serie>/STATE.md` (overwrite). `<serie>/STATE.md` (overwrite).
@ -988,6 +1048,9 @@ turning-points the spine already named.
silently skip any draft without an `NN` prefix). Set silently skip any draft without an `NN` prefix). Set
`currentPhase: "draft"` in `edition-state.json`, and write a "draft `currentPhase: "draft"` in `edition-state.json`, and write a "draft
complete → next: consistency/quality" line to `<serie>/STATE.md` (overwrite). 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>). 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` After the pass, set `currentPhase: "consistency-quality"` in `edition-state.json`
and write a "quality pass complete → next: contract-gate" line to 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. Consistency + quality pass complete.
@ -1123,7 +1187,8 @@ mechanical noise.
3. **Persist + checkpoint state.** Set `currentPhase: "contract-gate"` in 3. **Persist + checkpoint state.** Set `currentPhase: "contract-gate"` in
`edition-state.json` and write a "contract-gate passed → next: fact-check sweep" `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. 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), 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: set `currentPhase: "factcheck-sweep"`, and write a "fact-check complete → next:
persona sweep (BEFORE lock)" line to `<serie>/STATE.md` (overwrite). 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. 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). machine-readable record — what was flagged, severity, fold-in decision).
- Set `currentPhase: "editorial-review"` in `edition-state.json` (the marker - Set `currentPhase: "editorial-review"` in `edition-state.json` (the marker
that Step 5.5 is complete and the operator-gate has passed). 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 - Write an "editorial review complete (craft clean) → next: persona sweep
(BEFORE lock)" line to `<serie>/STATE.md` (overwrite). (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. heuristic (Step 8 / `/linkedin:pivot`) compares against to catch a late pivot.
Set `currentPhase: "persona-sweep-prelock"`, and write a "persona sweep Set `currentPhase: "persona-sweep-prelock"`, and write a "persona sweep
PASS (primær JA) → next: headless adversarial review (Step 6.5, BEFORE lock)" 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). 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: summary, status}`, `consolidatedReport`, `foldedIn`/`waived`, `status:
"folded"`), set `currentPhase: "headless-review"`, and write a "headless review "folded"`), set `currentPhase: "headless-review"`, and write a "headless review
complete (cold, converged flags folded) → next: annotation/lock" line to 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. 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 4. **Persist.** Set `currentPhase: "annotation"` in `edition-state.json` and
write an "annotation rendered (optional) → next: visual assets" line to write an "annotation rendered (optional) → next: visual assets" line to
`<serie>/STATE.md` (overwrite). If skipped, note "annotation skipped" and move on. `<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). Annotation (optional).
@ -1722,6 +1797,8 @@ the deck is approved) and credit/caption are recorded:
`edition-state.json`. `edition-state.json`.
- Set `currentPhase: "visual-assets"` in `edition-state.json` (the marker that - Set `currentPhase: "visual-assets"` in `edition-state.json` (the marker that
Step 7.5 is complete and the gate has passed). 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: - Write a "visual assets approved (cover/figures or carousel) → next:
lock/delivery" line to `<serie>/STATE.md` (overwrite). 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 `<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 body is frozen — Step 9 may only adjust the distribution hook, never the
locked edition text. 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 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 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 + `articles.NN.randsoneGate` (per-surface fact/language verdicts + title-strength +
what was revised) and the conversion verdict in what was revised) and the conversion verdict in
`articles.NN.personaSweep.conversion`; set `currentPhase: "hook-conversion-gate"`. `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). 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 `edition-state.json`. Write a closing "edition scheduled → mark live via
`/linkedin:calendar` on <date>" line to `<serie>/STATE.md` (overwrite). The `/linkedin:calendar` on <date>" line to `<serie>/STATE.md` (overwrite). The
pipeline is complete. 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. 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}/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/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/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/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 - `${CLAUDE_PLUGIN_ROOT}/config/edition-delingstekst.template.md` — distribution-copy grammar (`## Del N —` / `## Samle`) — Steps 8/9

View file

@ -25,6 +25,7 @@
"scheduling — register edition in plugin queue/state for native LinkedIn scheduling (Step 10)" "scheduling — register edition in plugin queue/state for native LinkedIn scheduling (Step 10)"
], ],
"articleStatusValues": ["pending", "in-progress", "locked", "scheduled"], "articleStatusValues": ["pending", "in-progress", "locked", "scheduled"],
"phaseLog": "Per-article phase-transition log (N12 / A1-12) — the raw material for measuring lead time. Additive-optional (default [], NO schemaVersion bump — same pattern as sourceTrendId/targetLevel/language; an edition produced before N12 simply has none and the register treats its absence as \"not measured\"). Shape: [ { phase, completedAt } ] in transition order, where phase is one of the canonical _doc.phases identifiers and completedAt is an ISO-8601 timestamp. Written DETERMINISTICALLY, never by hand: every /linkedin:newsletter phase transition runs `scripts/editions` (`node --import tsx src/cli.ts register-upsert --edition-state <path>`), which appends the entry for the just-written currentPhase AND mirrors the edition into the editions register (${LINKEDIN_STUDIO_DATA}/editions/register.json) in one call — the log and the register cannot drift apart because nothing writes one without the other. Idempotent against an immediately repeated transition (a re-run of the same step logs once), but a phase that recurs AFTER another one is logged again: /linkedin:pivot legitimately sends an edition back through cleared gates, and that second pass is real production time. Per-article rather than top-level because lead time is a property of an edition, mirroring articles.NN.phase. The register is a MIRROR of this file, never a source of truth — losing it costs telemetry, not an edition.",
"editorialReview": "Per-article editorial-review record written by Step 5.5 (editorial-review phase). Runs AFTER fact-check (Step 5) and BEFORE the persona sweep (Step 6): the editorial-reviewer agent judges CRAFT (prose-craft + narrative-architecture), not reader-response, mirroring the Maskinrommet skrivekontrakt §C2. The report (≤10 flags, each with kategori P1P5/A1A5, quote/line-ref, direction, severity BLOCK/REWORK/NICE) is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { reportPath, flagCount, byAxis: { prosa, arkitektur }, bySeverity: { block, rework, nice }, foldedIn, waived, status }. status ladder: pending → reviewed → folded. null until Step 5.5 runs. This is the craft companion to factcheckLog (truth) and personaSweep (response).", "editorialReview": "Per-article editorial-review record written by Step 5.5 (editorial-review phase). Runs AFTER fact-check (Step 5) and BEFORE the persona sweep (Step 6): the editorial-reviewer agent judges CRAFT (prose-craft + narrative-architecture), not reader-response, mirroring the Maskinrommet skrivekontrakt §C2. The report (≤10 flags, each with kategori P1P5/A1A5, quote/line-ref, direction, severity BLOCK/REWORK/NICE) is surfaced to the operator via SendUserFile; the operator decides which flags fold in. Shape: { reportPath, flagCount, byAxis: { prosa, arkitektur }, bySeverity: { block, rework, nice }, foldedIn, waived, status }. status ladder: pending → reviewed → folded. null until Step 5.5 runs. This is the craft companion to factcheckLog (truth) and personaSweep (response).",
"visualAssets": "Per-article visual-asset record written by Step 7.5 (visual-assets phase). Runs BEFORE lock because render/build-linkedin.mjs picks up linkedin/NN/cover.png + the edition-config credit/caption when it builds POST.html — generating images after lock would force a re-render. Shape: { format: \"standard\" | \"carousel\"; cover: { brief, route, candidates[], approved, status }; figures: [ { id, brief, placement, status } ]; carousel: null | { source, pdf, status } }. format \"standard\" = cover + optional inline figures (cover.png is mandatory per the KTG cover-directive); format \"carousel\" = typografisk deck via render/build-carousel.mjs instead of cover+inline (cover/figures stay empty). route: \"mcp-image\" (default, via mcp__mcp-image__generate_image) | \"external\" (DALL·E / Midjourney / photographer → linkedin/NN/cover-raw.png). status ladder: pending → briefed → generated → approved. candidates[] holds the cover-v<N>-kandidat.png attempts; approved is the fixed approved name (\"cover.png\") once the operator-gate passes. figures[].id = \"fig1\"..; placement = section reference in NN-utkast.md (figures are referenced in the draft via ![alt](linkedin/NN/figN.png) and uploaded manually in the LinkedIn editor — build-linkedin.mjs does NOT embed them). Naming convention: cover.png (approved, fixed — what build-linkedin.mjs reads) | cover-v<N>-kandidat.png (attempts) | cover-raw.png (optional external pre-edit source) | fig<N>.png (inline). credit + caption are recorded in <serie>/linkedin/image-credit-caption.md and flow into edition-config.json coverCredit + captions[NN].", "visualAssets": "Per-article visual-asset record written by Step 7.5 (visual-assets phase). Runs BEFORE lock because render/build-linkedin.mjs picks up linkedin/NN/cover.png + the edition-config credit/caption when it builds POST.html — generating images after lock would force a re-render. Shape: { format: \"standard\" | \"carousel\"; cover: { brief, route, candidates[], approved, status }; figures: [ { id, brief, placement, status } ]; carousel: null | { source, pdf, status } }. format \"standard\" = cover + optional inline figures (cover.png is mandatory per the KTG cover-directive); format \"carousel\" = typografisk deck via render/build-carousel.mjs instead of cover+inline (cover/figures stay empty). route: \"mcp-image\" (default, via mcp__mcp-image__generate_image) | \"external\" (DALL·E / Midjourney / photographer → linkedin/NN/cover-raw.png). status ladder: pending → briefed → generated → approved. candidates[] holds the cover-v<N>-kandidat.png attempts; approved is the fixed approved name (\"cover.png\") once the operator-gate passes. figures[].id = \"fig1\"..; placement = section reference in NN-utkast.md (figures are referenced in the draft via ![alt](linkedin/NN/figN.png) and uploaded manually in the LinkedIn editor — build-linkedin.mjs does NOT embed them). Naming convention: cover.png (approved, fixed — what build-linkedin.mjs reads) | cover-v<N>-kandidat.png (attempts) | cover-raw.png (optional external pre-edit source) | fig<N>.png (inline). credit + caption are recorded in <serie>/linkedin/image-credit-caption.md and flow into edition-config.json coverCredit + captions[NN].",
"livedSpecifics": "Per-article lived-specifics binding (Fix #2 slice 2, kilde-så-draft). A slot-map binding each LOAD-BEARING key-point (or, later, section) of this edition to either a real specific from the specifics-bank OR an explicit escape decision — so a draft starts grounded in the operator's real material instead of inventing plausible filler. Resolved at Step 1.5 (lived-specifics extraction, BETWEEN brief (Step 1) and research (Step 2); wired in slice 3) via a guided interview that REFUSES vague answers. THE GATE (drømme-spec G4, «vaghet avvises», enforced at the Step 2.5 skeleton-gate): every slot must resolve to \"specific\" (grounded), \"abstrakt\" (deliberately abstract here, with a rationale), or \"ekstern\" (backed by research, not lived material) — a slot left \"unresolved\" is the vagueness that BLOCKs. Numbers stay guilty-until-checked: a slot backed by an UNVERIFIED number is allowed but WARNED (regel 6/7), so Step 2 research verifies it before the draft asserts it. The deterministic validator + the rendered NN-kilder.md artifact live in scripts/specifics-bank (binding.ts/kilder.ts; `node --import tsx src/cli.ts validate-binding|render-kilder --edition <edition-state.json>`). Shape: { slots: [ { slotId, kind: \"key-point\" | \"section\", label, binding: ({ type: \"specific\", specificId } | { type: \"abstrakt\", rationale } | { type: \"ekstern\", source? } | { type: \"unresolved\" }) } ], status: \"pending\" | \"bound\" }. Default { slots: [], status: \"pending\" }: populated on first Step 1.5. Companion artifact: <serie>/linkedin/NN-kilder.md, rendered from this + the bank (regenerated, never hand-edited). This is the per-edition consumer of the global specifics-bank.", "livedSpecifics": "Per-article lived-specifics binding (Fix #2 slice 2, kilde-så-draft). A slot-map binding each LOAD-BEARING key-point (or, later, section) of this edition to either a real specific from the specifics-bank OR an explicit escape decision — so a draft starts grounded in the operator's real material instead of inventing plausible filler. Resolved at Step 1.5 (lived-specifics extraction, BETWEEN brief (Step 1) and research (Step 2); wired in slice 3) via a guided interview that REFUSES vague answers. THE GATE (drømme-spec G4, «vaghet avvises», enforced at the Step 2.5 skeleton-gate): every slot must resolve to \"specific\" (grounded), \"abstrakt\" (deliberately abstract here, with a rationale), or \"ekstern\" (backed by research, not lived material) — a slot left \"unresolved\" is the vagueness that BLOCKs. Numbers stay guilty-until-checked: a slot backed by an UNVERIFIED number is allowed but WARNED (regel 6/7), so Step 2 research verifies it before the draft asserts it. The deterministic validator + the rendered NN-kilder.md artifact live in scripts/specifics-bank (binding.ts/kilder.ts; `node --import tsx src/cli.ts validate-binding|render-kilder --edition <edition-state.json>`). Shape: { slots: [ { slotId, kind: \"key-point\" | \"section\", label, binding: ({ type: \"specific\", specificId } | { type: \"abstrakt\", rationale } | { type: \"ekstern\", source? } | { type: \"unresolved\" }) } ], status: \"pending\" | \"bound\" }. Default { slots: [], status: \"pending\" }: populated on first Step 1.5. Companion artifact: <serie>/linkedin/NN-kilder.md, rendered from this + the bank (regenerated, never hand-edited). This is the per-edition consumer of the global specifics-bank.",
@ -50,6 +51,7 @@
"title": "<Article 1 title>", "title": "<Article 1 title>",
"phase": "load-context", "phase": "load-context",
"status": "pending", "status": "pending",
"phaseLog": [],
"personas": [], "personas": [],
"livedSpecifics": { "livedSpecifics": {
"slots": [], "slots": [],

View file

@ -1,4 +1,10 @@
# editions — series-level memory for long-form (N11, serie-nivå-vern) # 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- 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 growing defect class is not a bad edition — it is a **repeated** one: the anecdote, the
@ -67,6 +73,61 @@ unlike the binding gate's BLOCK. `2` on usage error.
- **A missing distillate is not an error.** A series' first edition compares against - **A missing distillate is not an error.** A series' first edition compares against
nothing and is CLEAR. 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 07:45"] [--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 ## Test
```bash ```bash

View file

@ -1,18 +1,32 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* CLI for the series distillate (N11 serie-nivå-vern). * CLI for series-level edition memory: the distillate (N11 serie-nivå-vern)
* and the editions register (N12 A1-11 / A1-12).
* *
* node --import tsx src/cli.ts distil-append --distillate <path> --series <slug> --extract <extract.json> * node --import tsx src/cli.ts distil-append --distillate <path> --series <slug> --extract <extract.json>
* node --import tsx src/cli.ts distil-check --distillate <path> --skeleton <skeleton.json> * node --import tsx src/cli.ts distil-check --distillate <path> --skeleton <skeleton.json>
* [--threshold <0..1>] [--json] * [--threshold <0..1>] [--json]
* node --import tsx src/cli.ts register-upsert --edition-state <path> [--next <text>] [--slot <text>]
* [--path <series-root>] [--register <path>] [--at <ISO>]
* node --import tsx src/cli.ts register-list [--all] [--json] [--register <path>] [--now <ISO>]
* node --import tsx src/cli.ts register-complete --series <slug> --edition <NN>
* [--register <path>] [--at <ISO>]
* *
* `distil-append` runs at Step 8 lock: the command layer writes the AI extract * `distil-append` runs at Step 8 lock: the command layer writes the AI extract
* (the anecdotes/arguments/hooks the edition actually spent) to a JSON file, and * (the anecdotes/arguments/hooks the edition actually spent) to a JSON file, and
* this folds it in deterministically. `distil-check` runs at Step 2.5, before * this folds it in deterministically. `distil-check` runs at Step 2.5, before
* prose, and reports re-use into the annotation gate. * prose, and reports re-use into the annotation gate.
* *
* `register-upsert` runs at EVERY phase transition it appends the `phaseLog`
* entry to the edition-state AND mirrors the row into the register in one call,
* so the telemetry cannot drift out of step with the state it describes.
* `register-complete` runs at Step 10 when the edition is scheduled.
*
* Clock at the edge only: `--at` / `--now` override it so runs are reproducible.
*
* Exit code: 0 on success (INCLUDING a REUSE verdict the check is advisory by * Exit code: 0 on success (INCLUDING a REUSE verdict the check is advisory by
* design, unlike the binding gate's BLOCK), 2 on usage error. * design, unlike the binding gate's BLOCK), 1 on a failed operation, 2 on usage
* error.
*/ */
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
@ -24,7 +38,23 @@ import {
loadDistillate, loadDistillate,
saveDistillate, saveDistillate,
} from "./distillate.js"; } from "./distillate.js";
import type { DistillateEntry, SkeletonCandidate } from "./types.js"; import {
appendPhase,
editionFacts,
readEditionState,
saveEditionState,
seriesRootFromStatePath,
} from "./editionState.js";
import {
completeEdition,
defaultRegisterPath,
elapsedDays,
listEditions,
loadRegister,
saveRegister,
upsertEdition,
} from "./register.js";
import type { DistillateEntry, EditionRow, SkeletonCandidate } from "./types.js";
function parseFlags(args: string[]): Record<string, string> { function parseFlags(args: string[]): Record<string, string> {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
@ -48,12 +78,22 @@ function usage(msg: string): never {
console.error(`error: ${msg}`); console.error(`error: ${msg}`);
console.error( console.error(
"usage:\n" + "usage:\n" +
" distil-append --distillate <path> --series <slug> --extract <extract.json>\n" + " distil-append --distillate <path> --series <slug> --extract <extract.json>\n" +
" distil-check --distillate <path> --skeleton <skeleton.json> [--threshold <0..1>] [--json]", " distil-check --distillate <path> --skeleton <skeleton.json> [--threshold <0..1>] [--json]\n" +
" register-upsert --edition-state <path> [--next <text>] [--slot <text>] [--path <series-root>]\n" +
" [--register <path>] [--at <ISO>]\n" +
" register-list [--all] [--json] [--register <path>] [--now <ISO>]\n" +
" register-complete --series <slug> --edition <NN> [--register <path>] [--at <ISO>]",
); );
process.exit(2); process.exit(2);
} }
/** A failed operation (as opposed to a malformed invocation). */
function fail(msg: string): never {
console.error(`error: ${msg}`);
process.exit(1);
}
/** A flag that was given a real value (not absent, not a bare boolean flag). */ /** A flag that was given a real value (not absent, not a bare boolean flag). */
function value(flags: Record<string, string>, key: string): string | undefined { function value(flags: Record<string, string>, key: string): string | undefined {
const v = flags[key]; const v = flags[key];
@ -92,6 +132,28 @@ function toEntry(raw: unknown): DistillateEntry {
}; };
} }
/**
* One edition as the operator reads it: what it is, where it stands, what the
* one next move is, and how long it has been running. This block IS the WIP
* answer if it needs a follow-up file to be useful, the register has failed.
*/
function renderRow(row: EditionRow, now: string): string {
const days = elapsedDays(row.startedAt, row.completedAt ?? now);
const age =
days === null
? "age unknown"
: row.status === "complete"
? `lead time ${days} day(s)`
: `${days} day(s) in flight`;
return (
`\n · ${row.series}/${row.editionId}${row.title ? ` — "${row.title}"` : ""}\n` +
` phase: ${row.currentPhase} → next: ${row.nextAction ?? "—"}\n` +
` ${age} · slot: ${row.slot ?? "—"}\n` +
` ${row.path}`
);
}
function main(): void { function main(): void {
const [command, ...rest] = process.argv.slice(2); const [command, ...rest] = process.argv.slice(2);
const flags = parseFlags(rest); const flags = parseFlags(rest);
@ -161,6 +223,99 @@ function main(): void {
return; return;
} }
if (command === "register-upsert") {
const statePath = value(flags, "edition-state");
if (!statePath) usage("register-upsert needs --edition-state <path>");
const at = value(flags, "at") ?? new Date().toISOString();
const registerPath = value(flags, "register") ?? defaultRegisterPath();
let state;
try {
state = readEditionState(statePath);
} catch (err) {
usage(`could not read edition-state at ${statePath}: ${(err as Error).message}`);
}
let facts;
try {
facts = editionFacts(state);
} catch (err) {
usage((err as Error).message);
}
// Log first, mirror second: the state file is the source of truth, and a
// register row for a transition that was never logged would be a lie.
const logged = appendPhase(state, facts.editionId, facts.currentPhase, at);
if (logged.appended) saveEditionState(statePath, logged.state);
const { register, row, created } = upsertEdition(
loadRegister(registerPath),
{
series: facts.series,
editionId: facts.editionId,
title: facts.title,
path: value(flags, "path") ?? seriesRootFromStatePath(statePath),
currentPhase: facts.currentPhase,
nextAction: value(flags, "next"),
slot: value(flags, "slot"),
},
at,
);
saveRegister(registerPath, register);
console.log(
`${created ? "Registered" : "Updated"} ${row.series}/${row.editionId} at phase ${row.currentPhase} ` +
`(${logged.appended ? "phase logged" : "phase already logged"}) — ${registerPath}`,
);
return;
}
if (command === "register-list") {
const registerPath = value(flags, "register") ?? defaultRegisterPath();
const includeComplete = flags.all === "true";
const now = value(flags, "now") ?? new Date().toISOString();
const rows = listEditions(loadRegister(registerPath), { includeComplete });
if (asJson) {
console.log(JSON.stringify(rows, null, 2));
return;
}
if (rows.length === 0) {
console.log(includeComplete ? "No editions in the register." : "No editions in flight.");
return;
}
console.log(
includeComplete
? `${rows.length} edition(s) in the register:`
: `${rows.length} edition(s) in flight:`,
);
for (const row of rows) console.log(renderRow(row, now));
return;
}
if (command === "register-complete") {
const series = value(flags, "series");
const editionId = value(flags, "edition");
if (!series || !editionId) usage("register-complete needs --series <slug> and --edition <NN>");
const at = value(flags, "at") ?? new Date().toISOString();
const registerPath = value(flags, "register") ?? defaultRegisterPath();
const { register, row } = completeEdition(loadRegister(registerPath), { series, editionId }, at);
if (!row) fail(`no register row for ${series}/${editionId} — nothing to complete`);
saveRegister(registerPath, register);
const lead = elapsedDays(row.startedAt, row.completedAt ?? at);
console.log(
`Completed ${row.series}/${row.editionId}` +
(lead === null ? "" : ` — lead time ${lead} day(s)`) +
`${registerPath}`,
);
return;
}
usage(command ? `unknown command: ${command}` : "no command given"); usage(command ? `unknown command: ${command}` : "no command given");
} }

View file

@ -0,0 +1,106 @@
/**
* The edition-state side of the register (N12 A1-12).
*
* Two jobs, both deterministic:
* 1. read the handful of facts the register mirrors (series, edition, title,
* phase) refusing to guess when any of them is missing;
* 2. append the `phaseLog` entry that makes lead time measurable.
*
* Why code and not the command layer: the phase log is only useful if it is
* complete, and "remember to append the right JSON object at all ~17 transitions"
* is exactly the discipline a model quietly drops mid-pipeline. One CLI call at
* each transition writes both the log and the register, or neither.
*
* The state file is read and written whole this package touches `phaseLog` and
* nothing else. `edition-state.json` stays owned by /linkedin:newsletter.
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import type { EditionFacts, EditionStateFile, PhaseLogEntry } from "./types.js";
/** Read the state file. A missing file throws — the register mirrors state, it never invents it. */
export function readEditionState(path: string): EditionStateFile {
return JSON.parse(readFileSync(path, "utf8")) as EditionStateFile;
}
/** Write the state back, preserving everything this package did not touch. */
export function saveEditionState(path: string, state: EditionStateFile): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(state, null, 2) + "\n", "utf8");
}
/**
* Extract what the register mirrors. Every missing field is an error rather than
* a default: a row naming the wrong phase is worse than no row, because the
* whole point is that the operator trusts the list without opening files.
* The title is the one exception it is telemetry, not a gate.
*/
export function editionFacts(state: EditionStateFile): EditionFacts {
const series = state.series?.slug;
if (typeof series !== "string" || series.trim().length === 0) {
throw new Error("edition-state is missing series.slug");
}
const editionId = state.currentArticle;
if (typeof editionId !== "string" || editionId.trim().length === 0) {
throw new Error("edition-state is missing currentArticle");
}
const article = state.articles?.[editionId];
if (!article || typeof article !== "object") {
throw new Error(`edition-state has no articles entry for currentArticle ${editionId}`);
}
const currentPhase = state.currentPhase;
if (typeof currentPhase !== "string" || currentPhase.trim().length === 0) {
throw new Error("edition-state is missing currentPhase");
}
return { series, editionId, title: typeof article.title === "string" ? article.title : "", currentPhase };
}
export interface AppendPhaseResult {
state: EditionStateFile;
/** false iff the same transition was already the newest entry. */
appended: boolean;
}
/**
* Append one completed phase to the article's log.
*
* Idempotent against an immediately repeated transition (a re-run of the same
* step), but NOT against a phase that recurs after another one: `/linkedin:pivot`
* legitimately sends an edition back through cleared gates, and that second pass
* is real production time collapsing it would understate the lead time of
* exactly the editions that cost the most.
*/
export function appendPhase(
state: EditionStateFile,
editionId: string,
phase: string,
at: string,
): AppendPhaseResult {
const article = state.articles?.[editionId];
if (!article || typeof article !== "object") {
throw new Error(`edition-state has no articles entry for ${editionId}`);
}
const log: PhaseLogEntry[] = Array.isArray(article.phaseLog) ? article.phaseLog : [];
article.phaseLog = log;
if (log.length > 0 && log[log.length - 1].phase === phase) return { state, appended: false };
log.push({ phase, completedAt: at });
return { state, appended: true };
}
/**
* Derive the series root from the conventional
* `<serie>/linkedin/edition-state.json` layout, so a transition does not have to
* repeat a path the state file's own location already carries.
*/
export function seriesRootFromStatePath(statePath: string): string {
return dirname(dirname(statePath));
}

View file

@ -0,0 +1,171 @@
/**
* The editions register (N12 A1-11 / A1-12).
*
* One row per edition in production, so the week's work-in-progress is readable
* without opening a single `edition-state.json`, and lead time (first transition
* completion) is measured rather than assumed.
*
* Pure where it matters: every mutation takes `now` as an argument, exactly like
* the distillate takes `lockedAt`. No clock in the core means no run-to-run
* variance in the tests and no hidden timezone behaviour in the data.
*
* Mirror discipline: nothing here is authoritative. Rows are derived from
* edition-state files at phase transitions; `startedAt` is the only value that
* cannot be recovered by re-running one, which is why it never moves.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { REGISTER_SCHEMA_VERSION } from "./types.js";
import type { EditionRegister, EditionRow, EditionStatus, UpsertInput } from "./types.js";
export { REGISTER_SCHEMA_VERSION } from "./types.js";
const MS_PER_DAY = 86_400_000;
export function emptyRegister(): EditionRegister {
return { schemaVersion: REGISTER_SCHEMA_VERSION, editions: [] };
}
/** Read the register; a missing file is an empty one (the first edition is not an error). */
export function loadRegister(path: string): EditionRegister {
if (!existsSync(path)) return emptyRegister();
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<EditionRegister>;
return {
schemaVersion: parsed.schemaVersion ?? REGISTER_SCHEMA_VERSION,
editions: Array.isArray(parsed.editions) ? parsed.editions : [],
};
}
/** Write as pretty JSON with a trailing newline, creating the parent dir if needed. */
export function saveRegister(path: string, register: EditionRegister): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(register, null, 2) + "\n", "utf8");
}
export interface UpsertResult {
register: EditionRegister;
row: EditionRow;
/** true iff the edition had no row yet. */
created: boolean;
}
/**
* Mirror one phase transition into the register.
*
* Omitted optionals are preserved: a transition reports the phase it just
* completed, and it must not blank the slot or the title just because it had no
* reason to mention them. Re-upserting a completed edition REACTIVATES its row
* `/linkedin:pivot` re-opens an edition, and the lead-time clock it started
* should keep running rather than fork a second row.
*/
export function upsertEdition(register: EditionRegister, input: UpsertInput, now: string): UpsertResult {
const at = register.editions.findIndex(
(e) => e.series === input.series && e.editionId === input.editionId,
);
if (at < 0) {
const row: EditionRow = {
series: input.series,
editionId: input.editionId,
title: input.title ?? "",
path: input.path,
currentPhase: input.currentPhase,
nextAction: input.nextAction ?? null,
slot: input.slot ?? null,
startedAt: now,
updatedAt: now,
completedAt: null,
status: "active",
};
register.editions.push(row);
return { register, row, created: true };
}
const existing = register.editions[at];
const row: EditionRow = {
...existing,
title: input.title ?? existing.title,
path: input.path,
currentPhase: input.currentPhase,
nextAction: input.nextAction ?? existing.nextAction,
slot: input.slot ?? existing.slot,
updatedAt: now,
completedAt: null,
status: "active",
};
register.editions[at] = row;
return { register, row, created: false };
}
export interface CompleteResult {
register: EditionRegister;
/** null iff no row matched — completing an edition that was never registered is a caller error. */
row: EditionRow | null;
}
/**
* Close an edition. Idempotent by design: a second call keeps the FIRST
* `completedAt`, so re-running Step 10 cannot quietly stretch the measured lead
* time of an edition that was already done.
*/
export function completeEdition(
register: EditionRegister,
key: { series: string; editionId: string },
now: string,
): CompleteResult {
const at = register.editions.findIndex((e) => e.series === key.series && e.editionId === key.editionId);
if (at < 0) return { register, row: null };
const existing = register.editions[at];
if (existing.status === "complete") return { register, row: existing };
const row: EditionRow = { ...existing, status: "complete", completedAt: now, updatedAt: now };
register.editions[at] = row;
return { register, row };
}
export interface ListOptions {
/** Include finished editions — the history behind the in-flight view. */
includeComplete?: boolean;
}
const STATUS_ORDER: Record<EditionStatus, number> = { active: 0, complete: 1 };
/** Deterministic order: in flight first, then by series, then by edition id. */
export function listEditions(register: EditionRegister, options: ListOptions = {}): EditionRow[] {
const rows = options.includeComplete
? [...register.editions]
: register.editions.filter((e) => e.status === "active");
return rows.sort(
(a, b) =>
STATUS_ORDER[a.status] - STATUS_ORDER[b.status] ||
a.series.localeCompare(b.series) ||
a.editionId.localeCompare(b.editionId),
);
}
/**
* Days between two ISO stamps, to two decimals the lead-time primitive.
* Sub-day precision matters: at two editions a week, "1 day" and "0.5 days" are
* a different production reality. An unparseable stamp yields null, never NaN.
*/
export function elapsedDays(from: string, to: string): number | null {
const a = Date.parse(from);
const b = Date.parse(to);
if (Number.isNaN(a) || Number.isNaN(b)) return null;
return Math.round(((b - a) / MS_PER_DAY) * 100) / 100;
}
/**
* Default register path under the per-user data dir (M0 data-path convention),
* so the WIP view survives plugin upgrades/reinstalls. `LINKEDIN_STUDIO_DATA`
* overrides the root; otherwise `~/.claude/linkedin-studio`.
*/
export function defaultRegisterPath(): string {
const root = process.env.LINKEDIN_STUDIO_DATA ?? join(homedir(), ".claude", "linkedin-studio");
return join(root, "editions", "register.json");
}

View file

@ -69,6 +69,97 @@ export interface ReuseHit {
export type ReuseVerdict = "CLEAR" | "REUSE"; export type ReuseVerdict = "CLEAR" | "REUSE";
/* ------------------------------------------------------------------ *
* Editions register (N12 A1-11 / A1-12)
*
* A second, orthogonal grain of series memory: the distillate above answers
* "has the reader heard this before?", the register answers "what is in flight
* right now, and how long has it taken?". At two editions a week the expensive
* blind spot is not content it is that the only record of an in-flight edition
* lives inside its own `edition-state.json`, so seeing the week means opening
* every series folder by hand, and lead time is never measured at all.
*
* The register is a MIRROR, never a source of truth: every row is derived from
* an `edition-state.json` at a phase transition. Losing it costs telemetry, not
* an edition rebuild by re-running the transitions.
* ------------------------------------------------------------------ */
export const REGISTER_SCHEMA_VERSION = 1;
export type EditionStatus = "active" | "complete";
/** One in-flight (or finished) edition, keyed by `(series, editionId)`. */
export interface EditionRow {
/** Series slug — `series.slug` in edition-state.json. */
series: string;
/** Edition id within the series — `currentArticle`, e.g. "05". */
editionId: string;
title: string;
/** Absolute path to the series root holding this edition. */
path: string;
/** Last completed phase, mirrored from `currentPhase`. */
currentPhase: string;
/** The one next concrete step, in the operator's words. */
nextAction: string | null;
/** Publishing slot, once one is claimed (N13 wires the slot layer). */
slot: string | null;
/** First transition seen — the lead-time anchor. Never moves. */
startedAt: string;
updatedAt: string;
completedAt: string | null;
status: EditionStatus;
}
export interface EditionRegister {
schemaVersion: number;
editions: EditionRow[];
}
/** What a phase transition supplies. Omitted optionals are PRESERVED, not blanked. */
export interface UpsertInput {
series: string;
editionId: string;
path: string;
currentPhase: string;
title?: string;
nextAction?: string;
slot?: string;
}
/* ---- edition-state.json: the mirrored source of truth ---- */
/** One completed phase transition — the raw material for lead-time measurement. */
export interface PhaseLogEntry {
phase: string;
/** ISO timestamp. Supplied by the caller (CLI edge) — no clock in the pure core. */
completedAt: string;
}
/** Structurally open: this package reads a few fields and must preserve the rest verbatim. */
export interface EditionArticle {
title?: string;
phase?: string;
phaseLog?: PhaseLogEntry[];
[key: string]: unknown;
}
export interface EditionStateFile {
schemaVersion?: number;
series?: { slug?: string; title?: string };
currentArticle?: string;
currentPhase?: string;
articles: Record<string, EditionArticle>;
[key: string]: unknown;
}
/** The fields the register mirrors out of an edition-state file. */
export interface EditionFacts {
series: string;
editionId: string;
title: string;
currentPhase: string;
}
/** /**
* Advisory by design this reports into the annotation gate the operator * Advisory by design this reports into the annotation gate the operator
* already reads at Step 2.5; it never blocks. Re-use is sometimes the right * already reads at Step 2.5; it never blocks. Re-use is sometimes the right

View file

@ -0,0 +1,194 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
// Resolve the package root (scripts/editions) so the subprocess `src/cli.ts` path +
// the `tsx` loader resolve regardless of the runner's cwd.
const editionsDir = fileURLToPath(new URL("..", import.meta.url));
function run(args: string[], dataRoot?: string): { status: number | null; stdout: string; stderr: string } {
const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], {
encoding: "utf8",
cwd: editionsDir,
env: dataRoot ? { ...process.env, LINKEDIN_STUDIO_DATA: dataRoot } : process.env,
});
return { status: res.status, stdout: res.stdout, stderr: res.stderr };
}
const T1 = "2026-07-21T09:30:00.000Z";
const T2 = "2026-07-24T09:30:00.000Z";
/** A scratch series root with a realistic edition-state.json, plus a scratch data dir. */
function fixture(): { dir: string; statePath: string; dataRoot: string; seriesRoot: string } {
const dir = mkdtempSync(join(tmpdir(), "editions-cli-register-"));
const seriesRoot = join(dir, "series", "seres");
const statePath = join(seriesRoot, "linkedin", "edition-state.json");
mkdirSync(join(seriesRoot, "linkedin"), { recursive: true });
writeFileSync(
statePath,
JSON.stringify(
{
schemaVersion: 1,
series: { slug: "seres", title: "Seres" },
currentArticle: "05",
currentPhase: "skeleton-pitch",
articles: { "05": { title: "Da pipelinen brøt sammen", phase: "skeleton-pitch", phaseLog: [] } },
},
null,
2,
) + "\n",
"utf8",
);
return { dir, statePath, dataRoot: join(dir, "data"), seriesRoot };
}
describe("editions CLI — register upsert/list/complete round-trip (N12 AC)", () => {
test("upsert -> list -> complete round-trips through a real register in a scratch data dir", () => {
const { dir, statePath, dataRoot, seriesRoot } = fixture();
try {
// 1. a phase transition mirrors the edition into the register
const up = run(
["register-upsert", "--edition-state", statePath, "--next", "Step 3a — spine prose", "--at", T1],
dataRoot,
);
assert.equal(up.status, 0, up.stderr);
const registerPath = join(dataRoot, "editions", "register.json");
assert.ok(existsSync(registerPath), "register-upsert must create the register under ${DATA}/editions/");
// 2. list surfaces it without opening a single edition-state.json
const listed = run(["register-list", "--json"], dataRoot);
assert.equal(listed.status, 0, listed.stderr);
const rows = JSON.parse(listed.stdout);
assert.equal(rows.length, 1);
assert.equal(rows[0].series, "seres");
assert.equal(rows[0].editionId, "05");
assert.equal(rows[0].title, "Da pipelinen brøt sammen");
assert.equal(rows[0].currentPhase, "skeleton-pitch");
assert.equal(rows[0].nextAction, "Step 3a — spine prose");
assert.equal(rows[0].path, seriesRoot, "the series root is derived from the state-file path");
assert.equal(rows[0].startedAt, T1);
assert.equal(rows[0].status, "active");
// 3. complete closes the row and takes it out of the in-flight view
const done = run(["register-complete", "--series", "seres", "--edition", "05", "--at", T2], dataRoot);
assert.equal(done.status, 0, done.stderr);
assert.equal(JSON.parse(run(["register-list", "--json"], dataRoot).stdout).length, 0);
const all = JSON.parse(run(["register-list", "--json", "--all"], dataRoot).stdout);
assert.equal(all.length, 1);
assert.equal(all[0].status, "complete");
assert.equal(all[0].completedAt, T2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("the same upsert also appends the phaseLog entry — telemetry is not a separate step", () => {
const { dir, statePath, dataRoot } = fixture();
try {
assert.equal(run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot).status, 0);
const state = JSON.parse(readFileSync(statePath, "utf8"));
assert.deepEqual(state.articles["05"].phaseLog, [{ phase: "skeleton-pitch", completedAt: T1 }]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a repeated transition is idempotent in both the log and the register", () => {
const { dir, statePath, dataRoot } = fixture();
try {
run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot);
run(["register-upsert", "--edition-state", statePath, "--at", T2], dataRoot);
const state = JSON.parse(readFileSync(statePath, "utf8"));
assert.equal(state.articles["05"].phaseLog.length, 1);
const rows = JSON.parse(run(["register-list", "--json"], dataRoot).stdout);
assert.equal(rows.length, 1);
assert.equal(rows[0].startedAt, T1, "lead time is anchored at the first transition");
assert.equal(rows[0].updatedAt, T2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("the human view shows phase, next action and days in flight — the WIP answer without opening files", () => {
const { dir, statePath, dataRoot } = fixture();
try {
run(["register-upsert", "--edition-state", statePath, "--next", "Step 3a — spine prose", "--at", T1], dataRoot);
const out = run(["register-list", "--now", T2], dataRoot);
assert.equal(out.status, 0, out.stderr);
assert.match(out.stdout, /seres/);
assert.match(out.stdout, /05/);
assert.match(out.stdout, /skeleton-pitch/);
assert.match(out.stdout, /Step 3a — spine prose/);
assert.match(out.stdout, /3(\.0)? day/, "days in flight is the lead-time signal (T1 -> T2 = 3 days)");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("an empty register says so plainly instead of printing nothing", () => {
const { dir, dataRoot } = fixture();
try {
const out = run(["register-list"], dataRoot);
assert.equal(out.status, 0, out.stderr);
assert.match(out.stdout, /no editions in flight/i);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("--register overrides the data-dir default", () => {
const { dir, statePath, dataRoot } = fixture();
try {
const elsewhere = join(dir, "elsewhere", "register.json");
assert.equal(
run(["register-upsert", "--edition-state", statePath, "--register", elsewhere, "--at", T1], dataRoot).status,
0,
);
assert.ok(existsSync(elsewhere));
assert.ok(!existsSync(join(dataRoot, "editions", "register.json")));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("register-upsert without --edition-state is a usage error (exit 2)", () => {
const { dir, dataRoot } = fixture();
try {
const out = run(["register-upsert", "--next", "Step 3a"], dataRoot);
assert.equal(out.status, 2);
assert.match(out.stderr, /--edition-state/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("register-complete on an unknown edition fails loudly instead of silently succeeding", () => {
const { dir, statePath, dataRoot } = fixture();
try {
run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot);
const out = run(["register-complete", "--series", "seres", "--edition", "99", "--at", T2], dataRoot);
assert.notEqual(out.status, 0);
assert.match(out.stderr, /99/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a malformed edition-state is refused at the edge, not mirrored", () => {
const { dir, statePath, dataRoot } = fixture();
try {
writeFileSync(statePath, JSON.stringify({ schemaVersion: 1, series: { slug: "seres" } }), "utf8");
const out = run(["register-upsert", "--edition-state", statePath, "--at", T1], dataRoot);
assert.equal(out.status, 2);
assert.match(out.stderr, /currentArticle/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,139 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
appendPhase,
editionFacts,
readEditionState,
saveEditionState,
seriesRootFromStatePath,
} from "../src/editionState.js";
import type { EditionStateFile } from "../src/types.js";
const tmp = () => mkdtempSync(join(tmpdir(), "editions-state-"));
const AT = "2026-07-21T09:30:00.000Z";
function state(): EditionStateFile {
return {
schemaVersion: 1,
series: { slug: "seres", title: "Seres" },
currentArticle: "05",
currentPhase: "skeleton-pitch",
articles: {
"04": { title: "Forrige", phase: "scheduling", phaseLog: [{ phase: "scheduling", completedAt: AT }] },
"05": { title: "Da pipelinen brøt sammen", phase: "skeleton-pitch", phaseLog: [] },
},
} as unknown as EditionStateFile;
}
describe("editionState — facts", () => {
test("reads series, edition id, title and phase from the state file", () => {
const facts = editionFacts(state());
assert.deepEqual(facts, {
series: "seres",
editionId: "05",
title: "Da pipelinen brøt sammen",
currentPhase: "skeleton-pitch",
});
});
test("a missing currentArticle is an error, not a guess", () => {
const broken = { ...state(), currentArticle: undefined } as unknown as EditionStateFile;
assert.throws(() => editionFacts(broken), /currentArticle/);
});
test("a currentArticle with no matching articles entry is an error", () => {
const broken = { ...state(), currentArticle: "99" } as unknown as EditionStateFile;
assert.throws(() => editionFacts(broken), /99/);
});
test("a missing currentPhase is an error — the register must never mirror a guessed phase", () => {
const broken = { ...state(), currentPhase: undefined } as unknown as EditionStateFile;
assert.throws(() => editionFacts(broken), /currentPhase/);
});
test("an untitled article still resolves — the title is telemetry, not a gate", () => {
const s = state();
delete (s.articles["05"] as Record<string, unknown>).title;
assert.equal(editionFacts(s).title, "");
});
});
describe("editionState — phaseLog", () => {
test("appends {phase, completedAt} to the article's log", () => {
const { state: next, appended } = appendPhase(state(), "05", "skeleton-pitch", AT);
assert.equal(appended, true);
assert.deepEqual(next.articles["05"].phaseLog, [{ phase: "skeleton-pitch", completedAt: AT }]);
});
test("re-running the same transition does not double-log", () => {
const once = appendPhase(state(), "05", "skeleton-pitch", AT).state;
const { state: twice, appended } = appendPhase(once, "05", "skeleton-pitch", "2026-07-21T10:00:00.000Z");
assert.equal(appended, false);
assert.equal(twice.articles["05"].phaseLog?.length, 1);
assert.equal(twice.articles["05"].phaseLog?.[0].completedAt, AT, "the first stamp is the real one");
});
test("a phase repeated AFTER another phase is logged again — /linkedin:pivot re-runs cleared gates", () => {
let s = appendPhase(state(), "05", "factcheck-sweep", AT).state;
s = appendPhase(s, "05", "editorial-review", "2026-07-21T11:00:00.000Z").state;
const { state: pivoted, appended } = appendPhase(s, "05", "factcheck-sweep", "2026-07-22T08:00:00.000Z");
assert.equal(appended, true);
assert.equal(pivoted.articles["05"].phaseLog?.length, 3);
assert.deepEqual(
pivoted.articles["05"].phaseLog?.map((e) => e.phase),
["factcheck-sweep", "editorial-review", "factcheck-sweep"],
);
});
test("an article written before phaseLog existed gets the field created", () => {
const s = state();
delete (s.articles["05"] as Record<string, unknown>).phaseLog;
const { state: next } = appendPhase(s, "05", "research", AT);
assert.deepEqual(next.articles["05"].phaseLog, [{ phase: "research", completedAt: AT }]);
});
test("other articles are left alone", () => {
const next = appendPhase(state(), "05", "research", AT).state;
assert.equal(next.articles["04"].phaseLog?.length, 1);
assert.equal(next.articles["04"].phaseLog?.[0].phase, "scheduling");
});
test("an unknown article is an error, not a silently created one", () => {
assert.throws(() => appendPhase(state(), "99", "research", AT), /99/);
});
});
describe("editionState — io", () => {
test("save then read round-trips, pretty-printed with a trailing newline", () => {
const dir = tmp();
try {
const path = join(dir, "linkedin", "edition-state.json");
saveEditionState(path, appendPhase(state(), "05", "research", AT).state);
const raw = readFileSync(path, "utf8");
assert.ok(raw.endsWith("\n"));
assert.ok(raw.includes('\n "schemaVersion"'), "must stay human-diffable (2-space indent)");
const back = readEditionState(path);
assert.equal(back.articles["05"].phaseLog?.[0].phase, "research");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("reading a missing state file is an error — the register mirrors state, it never invents it", () => {
const dir = tmp();
try {
assert.throws(() => readEditionState(join(dir, "absent.json")));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("the series root is derived from the conventional <serie>/linkedin/edition-state.json path", () => {
assert.equal(seriesRootFromStatePath("/series/seres/linkedin/edition-state.json"), "/series/seres");
});
});

View file

@ -0,0 +1,277 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir, homedir } from "node:os";
import {
REGISTER_SCHEMA_VERSION,
completeEdition,
defaultRegisterPath,
elapsedDays,
emptyRegister,
listEditions,
loadRegister,
saveRegister,
upsertEdition,
} from "../src/register.js";
import type { EditionRegister } from "../src/types.js";
const tmp = () => mkdtempSync(join(tmpdir(), "editions-register-"));
const T0 = "2026-07-20T08:00:00.000Z";
const T1 = "2026-07-21T08:00:00.000Z";
const T2 = "2026-07-24T20:00:00.000Z";
function seed(): EditionRegister {
return upsertEdition(
emptyRegister(),
{
series: "seres",
editionId: "05",
title: "Da pipelinen brøt sammen",
path: "/series/seres",
currentPhase: "research",
nextAction: "Step 2.5 — skeleton",
},
T0,
).register;
}
describe("register — store shape", () => {
test("emptyRegister carries the schema version and no rows", () => {
const reg = emptyRegister();
assert.equal(reg.schemaVersion, REGISTER_SCHEMA_VERSION);
assert.deepEqual(reg.editions, []);
});
test("a missing register file loads as an empty one — the first edition is not an error", () => {
const dir = tmp();
try {
const reg = loadRegister(join(dir, "nope", "register.json"));
assert.equal(reg.schemaVersion, REGISTER_SCHEMA_VERSION);
assert.deepEqual(reg.editions, []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("save creates the parent directory and round-trips the rows", () => {
const dir = tmp();
try {
const path = join(dir, "editions", "register.json");
saveRegister(path, seed());
assert.ok(existsSync(path), "saveRegister must create the parent dir");
assert.ok(readFileSync(path, "utf8").endsWith("\n"), "file must end with a newline");
const back = loadRegister(path);
assert.equal(back.editions.length, 1);
assert.equal(back.editions[0].editionId, "05");
assert.equal(back.editions[0].startedAt, T0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("a malformed register degrades to empty rows instead of throwing", () => {
const dir = tmp();
try {
const path = join(dir, "register.json");
writeFileSync(path, JSON.stringify({ schemaVersion: 1, editions: "not-an-array" }), "utf8");
assert.deepEqual(loadRegister(path).editions, []);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("register — upsert", () => {
test("a new edition is created as active, with startedAt == updatedAt", () => {
const { register, row, created } = upsertEdition(
emptyRegister(),
{ series: "seres", editionId: "05", title: "T", path: "/p", currentPhase: "load-context" },
T0,
);
assert.equal(created, true);
assert.equal(register.editions.length, 1);
assert.equal(row.status, "active");
assert.equal(row.startedAt, T0);
assert.equal(row.updatedAt, T0);
assert.equal(row.completedAt, null);
});
test("the same (series, editionId) is updated in place — never duplicated", () => {
const { register, row, created } = upsertEdition(
seed(),
{ series: "seres", editionId: "05", title: "T", path: "/p", currentPhase: "skeleton-pitch" },
T1,
);
assert.equal(created, false);
assert.equal(register.editions.length, 1);
assert.equal(row.currentPhase, "skeleton-pitch");
assert.equal(row.startedAt, T0, "startedAt is the lead-time anchor — it must survive every update");
assert.equal(row.updatedAt, T1);
});
test("omitted nextAction/slot/title are PRESERVED, not blanked", () => {
const withSlot = upsertEdition(
seed(),
{ series: "seres", editionId: "05", path: "/p", currentPhase: "draft", slot: "2026-07-28 07:45" },
T1,
).register;
const { row } = upsertEdition(
withSlot,
{ series: "seres", editionId: "05", path: "/p", currentPhase: "consistency-quality" },
T2,
);
assert.equal(row.slot, "2026-07-28 07:45");
assert.equal(row.nextAction, "Step 2.5 — skeleton");
assert.equal(row.title, "Da pipelinen brøt sammen");
});
test("supplied nextAction/slot overwrite the previous values", () => {
const { row } = upsertEdition(
seed(),
{
series: "seres",
editionId: "05",
path: "/p",
currentPhase: "draft",
nextAction: "Step 4 — consistency",
slot: "2026-07-28 07:45",
},
T1,
);
assert.equal(row.nextAction, "Step 4 — consistency");
assert.equal(row.slot, "2026-07-28 07:45");
});
test("two editions of the same series are two rows", () => {
const { register } = upsertEdition(
seed(),
{ series: "seres", editionId: "06", title: "Neste", path: "/series/seres", currentPhase: "load-context" },
T1,
);
assert.equal(register.editions.length, 2);
});
test("the same edition number in a different series is a different row", () => {
const { register } = upsertEdition(
seed(),
{ series: "annen", editionId: "05", title: "Annen 05", path: "/series/annen", currentPhase: "load-context" },
T1,
);
assert.equal(register.editions.length, 2);
assert.equal(register.editions[1].series, "annen");
});
test("re-upserting a completed edition reactivates it — a pivot re-opens the row it already has", () => {
const done = completeEdition(seed(), { series: "seres", editionId: "05" }, T1).register;
const { register, row } = upsertEdition(
done,
{ series: "seres", editionId: "05", path: "/p", currentPhase: "factcheck-sweep" },
T2,
);
assert.equal(register.editions.length, 1);
assert.equal(row.status, "active");
assert.equal(row.completedAt, null);
assert.equal(row.startedAt, T0);
});
});
describe("register — complete", () => {
test("complete stamps completedAt and flips the status", () => {
const { register, row } = completeEdition(seed(), { series: "seres", editionId: "05" }, T1);
assert.equal(row?.status, "complete");
assert.equal(row?.completedAt, T1);
assert.equal(register.editions[0].updatedAt, T1);
});
test("completing twice keeps the FIRST completedAt — lead time must not drift on a re-run", () => {
const once = completeEdition(seed(), { series: "seres", editionId: "05" }, T1).register;
const { row } = completeEdition(once, { series: "seres", editionId: "05" }, T2);
assert.equal(row?.completedAt, T1);
});
test("completing an unknown edition returns null and leaves the register untouched", () => {
const reg = seed();
const { register, row } = completeEdition(reg, { series: "seres", editionId: "99" }, T1);
assert.equal(row, null);
assert.equal(register.editions[0].status, "active");
});
});
describe("register — list", () => {
function twoActiveOneDone(): EditionRegister {
let reg = seed();
reg = upsertEdition(
reg,
{ series: "annen", editionId: "02", title: "A", path: "/p", currentPhase: "draft" },
T1,
).register;
reg = upsertEdition(
reg,
{ series: "seres", editionId: "04", title: "Eldre", path: "/p", currentPhase: "scheduling" },
T1,
).register;
return completeEdition(reg, { series: "seres", editionId: "04" }, T2).register;
}
test("list shows only the editions in flight by default", () => {
const rows = listEditions(twoActiveOneDone());
assert.equal(rows.length, 2);
assert.ok(rows.every((r) => r.status === "active"));
});
test("includeComplete lists the whole history", () => {
assert.equal(listEditions(twoActiveOneDone(), { includeComplete: true }).length, 3);
});
test("ordering is deterministic — active first, then series, then edition id", () => {
const rows = listEditions(twoActiveOneDone(), { includeComplete: true });
assert.deepEqual(
rows.map((r) => `${r.status}:${r.series}/${r.editionId}`),
["active:annen/02", "active:seres/05", "complete:seres/04"],
);
});
});
describe("register — lead time", () => {
test("elapsedDays measures the span between two ISO stamps", () => {
assert.equal(elapsedDays(T0, T1), 1);
assert.equal(elapsedDays(T0, T0), 0);
});
test("elapsedDays keeps sub-day precision — a two-editions-a-week cadence is measured in hours", () => {
assert.equal(elapsedDays("2026-07-20T00:00:00.000Z", "2026-07-20T12:00:00.000Z"), 0.5);
});
test("an unparseable stamp yields null rather than NaN", () => {
assert.equal(elapsedDays("ikke-en-dato", T1), null);
});
});
describe("register — default path (M0 data-path convention)", () => {
test("LINKEDIN_STUDIO_DATA relocates the register", () => {
const prev = process.env.LINKEDIN_STUDIO_DATA;
process.env.LINKEDIN_STUDIO_DATA = "/scratch/data";
try {
assert.equal(defaultRegisterPath(), join("/scratch/data", "editions", "register.json"));
} finally {
if (prev === undefined) delete process.env.LINKEDIN_STUDIO_DATA;
else process.env.LINKEDIN_STUDIO_DATA = prev;
}
});
test("without the env-var it falls back to the per-user data dir", () => {
const prev = process.env.LINKEDIN_STUDIO_DATA;
delete process.env.LINKEDIN_STUDIO_DATA;
try {
assert.equal(
defaultRegisterPath(),
join(homedir(), ".claude", "linkedin-studio", "editions", "register.json"),
);
} finally {
if (prev !== undefined) process.env.LINKEDIN_STUDIO_DATA = prev;
}
});
});

View file

@ -722,7 +722,7 @@ if [ -x "$ED_DIR/node_modules/.bin/tsx" ]; then
ED_OUT=$( set +e; (cd "$ED_DIR" && npm test) 2>&1; echo "ED_EXIT:$?" ) ED_OUT=$( set +e; (cd "$ED_DIR" && npm test) 2>&1; echo "ED_EXIT:$?" )
ED_EXIT=$(echo "$ED_OUT" | grep -oE 'ED_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1) ED_EXIT=$(echo "$ED_OUT" | grep -oE 'ED_EXIT:[0-9]+' | grep -oE '[0-9]+' | head -1)
ED_TESTS=$(echo "$ED_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1) ED_TESTS=$(echo "$ED_OUT" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | tail -1)
EDITIONS_TESTS_FLOOR=27 EDITIONS_TESTS_FLOOR=72
if [ "$ED_EXIT" = "0" ] && [ -n "$ED_TESTS" ] && [ "$ED_TESTS" -ge "$EDITIONS_TESTS_FLOOR" ]; then if [ "$ED_EXIT" = "0" ] && [ -n "$ED_TESTS" ] && [ "$ED_TESTS" -ge "$EDITIONS_TESTS_FLOOR" ]; then
pass "editions suite green: $ED_TESTS tests pass (floor $EDITIONS_TESTS_FLOOR)" pass "editions suite green: $ED_TESTS tests pass (floor $EDITIONS_TESTS_FLOOR)"
else else
@ -1928,6 +1928,135 @@ fi
echo "" echo ""
# --- Section 16s: Editions Register + phaseLog Telemetry (N12 / A1-11, A1-12) ---
echo "--- Editions Register (N12) ---"
# N12 makes two structurally invisible things visible: WHICH editions are in flight (until
# now readable only by opening every series folder's edition-state.json) and HOW LONG an
# edition actually takes (until now not recorded at all). The register is a MIRROR - only
# edition-state.json drives resumption - so the binding worth linting is that EVERY phase
# transition writes both the phaseLog entry and the register row, from ONE call:
# (A1-12 protocol) the transition protocol is defined once and is fully wired - a compound
# predicate (verb + logged field + mirrored store), so a non-vacuity
# self-test guards it (mirrors Sections 16p/16q/16r).
# (A1-12 coverage) every canonical phase calls it - a phase with no call is a silent hole
# in the lead-time log, which is exactly how telemetry dies.
# (A1-11 store) the register module + its data-dir placement exist.
# (A1-12 schema) edition-state carries the phaseLog slot the CLI appends to.
NL_N12="commands/newsletter.md"
REG_SRC="scripts/editions/src/register.ts"
EST_SRC="scripts/editions/src/editionState.ts"
EST_TPL="config/edition-state.template.json"
BT='`'
transition_protocol() { # $1 = text; wired iff it CALLS the verb, names the field it logs AND the store it mirrors to
echo "$1" | grep -qF "register-upsert" \
&& echo "$1" | grep -qF "phaseLog" \
&& echo "$1" | grep -qF "editions/register.json"
}
TP_SELFTEST_OK=1
if ! transition_protocol "run register-upsert --edition-state <path>; it appends articles.NN.phaseLog and mirrors the row into <data>/editions/register.json"; then
TP_SELFTEST_OK=0; echo " non-vacuity FAIL: a fully-wired transition-protocol probe was not detected"
fi
while IFS= read -r probe; do
[ -z "$probe" ] && continue
if transition_protocol "$probe"; then
TP_SELFTEST_OK=0; echo " false-positive FAIL: under-wired transition probe accepted -> $probe"
fi
done <<'NEGATIVE16S'
run register-upsert at every transition and let phaseLog grow, store left unspecified
append articles.NN.phaseLog and mirror into editions/register.json, but nothing calls the verb
run register-upsert and write editions/register.json without logging the phase
NEGATIVE16S
if [ "$TP_SELFTEST_OK" -eq 1 ]; then
pass "transition-protocol self-test: predicate needs register-upsert + phaseLog + editions/register.json (1 accepted, 3 under-wired rejected)"
else
fail "transition-protocol self-test failed - the N12 wiring lint is vacuous or over-eager"
fi
# (A1-12 protocol) the protocol is defined once, in the resumption area, fully wired
PROTO_BLOCK=$(awk '/^### Phase-transition protocol/{f=1} /^## Step 1:/{f=0} f' "$NL_N12")
if transition_protocol "$PROTO_BLOCK"; then
pass "newsletter defines a wired phase-transition protocol (register-upsert appends phaseLog + mirrors the register) (A1-11/A1-12)"
else
fail "newsletter has no wired phase-transition protocol - phase telemetry falls back to the model remembering (A1-12)"
fi
# (A1-11 invariant) the mirror rule is stated where the protocol is defined - a register read
# as source-of-truth would break deterministic resumption, the one thing edition-state owns
if echo "$PROTO_BLOCK" | grep -qF "MIRROR, never a source of truth"; then
pass "newsletter states the mirror invariant - resumption reads edition-state only (A1-11)"
else
fail "newsletter does not state that the register is a mirror - it can drift into a second source of truth (A1-11)"
fi
# (A1-12 coverage) every canonical phase transition calls the protocol
NL_FLAT=$(tr '\n' ' ' < "$NL_N12" | tr -s ' ')
MISSING_PHASES=""
for phase in lived-specifics research skeleton-pitch spine-prose draft consistency-quality \
contract-gate factcheck-sweep editorial-review persona-sweep-prelock \
headless-review annotation visual-assets lock-delivery hook-conversion-gate scheduling; do
if ! printf '%s' "$NL_FLAT" | grep -qF "run the protocol (Step 0) for ${BT}${phase}${BT}"; then
MISSING_PHASES="$MISSING_PHASES $phase"
fi
done
if [ -z "$MISSING_PHASES" ]; then
pass "all 16 canonical phases carry a transition call - the lead-time log has no holes (A1-12)"
else
fail "newsletter phases with no transition call:$MISSING_PHASES - lead time silently undercounts them (A1-12)"
fi
# (A1-12 close) Step 10 closes the row - without it every edition stays 'in flight' forever
STEP10_BLOCK=$(awk '/^## Step 10:/{f=1} /^## Distribution channel/{f=0} f' "$NL_N12")
if echo "$STEP10_BLOCK" | grep -qF "register-complete"; then
pass "newsletter Step 10 closes the register row (register-complete) - lead time becomes a measured number (A1-12)"
else
fail "newsletter Step 10 never calls register-complete - editions never leave the in-flight view (A1-12)"
fi
# (A1-11 store) the register module exists with its mutations
if grep -qF "export function upsertEdition" "$REG_SRC"; then
pass "scripts/editions exports upsertEdition - the deterministic register write exists (A1-11)"
else
fail "$REG_SRC does not export upsertEdition (A1-11)"
fi
if grep -qF "export function completeEdition" "$REG_SRC" && grep -qF "export function listEditions" "$REG_SRC"; then
pass "scripts/editions exports completeEdition + listEditions - the WIP view and its close verb exist (A1-11)"
else
fail "$REG_SRC does not export both completeEdition and listEditions (A1-11)"
fi
# (A1-11 placement) the register lives in the per-user data dir (M0 convention), not the plugin
if grep -qF "LINKEDIN_STUDIO_DATA" "$REG_SRC" && grep -qF '"editions"' "$REG_SRC" && grep -qF '"register.json"' "$REG_SRC"; then
pass "editions register resolves under the per-user data dir - it survives plugin reinstalls (M0/A1-11)"
else
fail "$REG_SRC does not resolve the register under the data dir - WIP state placed wrong (M0/A1-11)"
fi
# (A1-12 telemetry) the phase log is appended by code, not by hand
if grep -qF "export function appendPhase" "$EST_SRC"; then
pass "scripts/editions exports appendPhase - phaseLog entries are written deterministically (A1-12)"
else
fail "$EST_SRC does not export appendPhase (A1-12)"
fi
# (A1-12 schema) edition-state documents AND carries the phaseLog slot
if grep -qF '"phaseLog": "Per-article phase-transition log' "$EST_TPL"; then
pass "edition-state template documents phaseLog (A1-12 schema)"
else
fail "$EST_TPL does not document phaseLog - the telemetry field is undefined (A1-12)"
fi
if grep -qF '"phaseLog": []' "$EST_TPL"; then
pass "edition-state template carries the per-article phaseLog slot (A1-12 schema)"
else
fail "$EST_TPL has no per-article phaseLog slot - new editions start unmeasurable (A1-12)"
fi
echo ""
# --- Section 18: Assertion-Count Anti-Erosion (SC6) --- # --- Section 18: Assertion-Count Anti-Erosion (SC6) ---
# The lint self-modifies its own checks, so a green run could mask a silently dropped # 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 # assertion. Pin the total pass()+fail() invocations as a monotonic floor; the count
@ -1970,12 +2099,16 @@ echo ""
# UNCONDITIONAL Section-16r checks (destillat-gate self-test + Step-2.5 destillat_gated compound grep + # UNCONDITIONAL Section-16r checks (destillat-gate self-test + Step-2.5 destillat_gated compound grep +
# Step-8 distil-append grep + Step-8 record-usage grep + types.ts usedIn grep + bank.ts recordUsage grep + # Step-8 distil-append grep + Step-8 record-usage grep + types.ts usedIn grep + bank.ts recordUsage grep +
# binding.ts boundSpecificIds grep + distillate.ts checkSkeleton grep + distillate.ts series-root # binding.ts boundSpecificIds grep + distillate.ts checkSkeleton grep + distillate.ts series-root
# placement grep) = 155. # placement grep) = 155; +11 for N12's eleven UNCONDITIONAL Section-16s checks (transition-protocol
# self-test + protocol-block compound grep + mirror-invariant grep + 16-phase coverage sweep +
# Step-10 register-complete grep + register.ts upsertEdition grep + register.ts completeEdition/
# listEditions grep + register.ts data-dir placement grep + editionState.ts appendPhase grep +
# template phaseLog-doc grep + template phaseLog-slot grep) = 166.
# NB: the floor tracks the deps-absent MINIMUM (conditional TS suites warn-skip and drop # 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 # 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 # 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. # clone). Runs last so TOTAL_CHECKS sees every prior check.
ASSERT_BASELINE_FLOOR=155 ASSERT_BASELINE_FLOOR=166
TOTAL_CHECKS=$((PASS + FAIL)) TOTAL_CHECKS=$((PASS + FAIL))
if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then if [ "$TOTAL_CHECKS" -ge "$ASSERT_BASELINE_FLOOR" ]; then
pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR" pass "assertion-count anti-erosion: $TOTAL_CHECKS checks >= baseline floor $ASSERT_BASELINE_FLOOR"