#!/usr/bin/env node /** * 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 --series --extract * node --import tsx src/cli.ts distil-check --distillate --skeleton * [--threshold <0..1>] [--json] * node --import tsx src/cli.ts register-upsert --edition-state [--next ] [--slot ] * [--path ] [--register ] [--at ] * node --import tsx src/cli.ts register-list [--all] [--json] [--register ] [--now ] * node --import tsx src/cli.ts register-complete --series --edition * [--register ] [--at ] * * `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 * this folds it in deterministically. `distil-check` runs at Step 2.5, before * 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 * design, unlike the binding gate's BLOCK), 1 on a failed operation, 2 on usage * error. */ import { readFileSync } from "node:fs"; import { DEFAULT_THRESHOLD, appendEntry, checkSkeleton, loadDistillate, saveDistillate, } from "./distillate.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 { const out: Record = {}; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = args[i + 1]; if (next === undefined || next.startsWith("--")) { out[key] = "true"; } else { out[key] = next; i++; } } } return out; } function usage(msg: string): never { console.error(`error: ${msg}`); console.error( "usage:\n" + " distil-append --distillate --series --extract \n" + " distil-check --distillate --skeleton [--threshold <0..1>] [--json]\n" + " register-upsert --edition-state [--next ] [--slot ] [--path ]\n" + " [--register ] [--at ]\n" + " register-list [--all] [--json] [--register ] [--now ]\n" + " register-complete --series --edition [--register ] [--at ]", ); 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). */ function value(flags: Record, key: string): string | undefined { const v = flags[key]; return v === undefined || v === "true" ? undefined : v; } function readJson(path: string, what: string): unknown { try { return JSON.parse(readFileSync(path, "utf8")); } catch (err) { usage(`could not read ${what} at ${path}: ${(err as Error).message}`); } } function stringList(raw: unknown): string[] { if (!Array.isArray(raw)) return []; return raw.filter((x): x is string => typeof x === "string" && x.trim().length > 0); } /** Validate the AI extract at the edge — a malformed extract must not become a silent empty entry. */ function toEntry(raw: unknown): DistillateEntry { const o = (raw ?? {}) as Record; for (const field of ["editionId", "title", "lockedAt"]) { const v = o[field]; if (typeof v !== "string" || v.trim().length === 0) { usage(`extract is missing required string field: ${field}`); } } return { editionId: o.editionId as string, title: o.title as string, lockedAt: o.lockedAt as string, anecdotes: stringList(o.anecdotes), arguments: stringList(o.arguments), hooks: stringList(o.hooks), }; } /** * 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 { const [command, ...rest] = process.argv.slice(2); const flags = parseFlags(rest); const distillatePath = value(flags, "distillate"); const asJson = flags.json === "true"; if (command === "distil-append") { if (!distillatePath) usage("distil-append needs --distillate "); const series = value(flags, "series"); if (!series) usage("distil-append needs --series "); const extractPath = value(flags, "extract"); if (!extractPath) usage("distil-append needs --extract "); const entry = toEntry(readJson(extractPath, "extract")); const res = appendEntry(loadDistillate(distillatePath, series), entry); saveDistillate(distillatePath, res.distillate); const units = entry.anecdotes.length + entry.arguments.length + entry.hooks.length; console.log( `${res.replaced ? "Replaced" : "Appended"} edition ${entry.editionId} (${units} narrative unit(s)) ` + `in ${distillatePath} — ${res.distillate.editions.length} edition(s) in the series distillate`, ); return; } if (command === "distil-check") { if (!distillatePath) usage("distil-check needs --distillate "); const skeletonPath = value(flags, "skeleton"); if (!skeletonPath) usage("distil-check needs --skeleton "); const rawThreshold = value(flags, "threshold"); let threshold = DEFAULT_THRESHOLD; if (rawThreshold !== undefined) { threshold = Number(rawThreshold); if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) { usage("--threshold must be a number in (0, 1]"); } } const candidate = (readJson(skeletonPath, "skeleton") ?? {}) as SkeletonCandidate; const report = checkSkeleton(loadDistillate(distillatePath, value(flags, "series") ?? ""), candidate, { threshold, }); if (asJson) { console.log(JSON.stringify(report, null, 2)); return; } if (report.verdict === "CLEAR") { console.log( `CLEAR — nothing in this skeleton resembles the ${report.comparedAgainst} locked edition(s) ` + `(threshold ${report.threshold}).`, ); return; } console.log( `REUSE — ${report.hits.length} unit(s) resemble earlier editions (threshold ${report.threshold}, ` + `${report.comparedAgainst} edition(s) checked):`, ); for (const hit of report.hits) { console.log(`\n · ${hit.kind} — ${(hit.score * 100).toFixed(0)}% like edition ${hit.editionId}`); console.log(` new: ${hit.candidate}`); console.log(` then: ${hit.matched}`); } console.log("\nAdvisory: a deliberate callback is fine — an unnoticed retread is not."); return; } if (command === "register-upsert") { const statePath = value(flags, "edition-state"); if (!statePath) usage("register-upsert needs --edition-state "); 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 and --edition "); 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"); } main();