/** * 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 * `/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)); }