@letterstory/cli 0.57.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -360,6 +360,7 @@ letterstory admin tuning comparison <id> # per-measure verdicts (b
360
360
  letterstory admin tuning picks <id> # the blind queue; then: admin tuning pick <id> --cell <key> --pick left|right|tie
361
361
  letterstory admin tuning reading-list <id> --n 10 # your next blind pairs as links (Text A / Text B), to read off the screen; pick in the console
362
362
  letterstory admin tuning pair <id> --cell <key> # one pair named: live and candidate, both texts, the measures per arm (a key reads every pair)
363
+ letterstory admin tuning side-by-side <id>,<id>,<id> [--cell <key>] # several writers as one board: each one's measures against live; with --cell, every writer's text on that input
363
364
  letterstory admin tuning candidates --kernels all --patch '{"writer":"echo/echo"}' --note "Echo" # the same change as a candidate of each kernel
364
365
  letterstory admin tuning session-new --title "Echo round 1" --kernel Hazel --input-file brief.md --kind outline [--version 22]
365
366
  letterstory admin tuning propose <session> --text "Echo with rarity on; live with the shred after" --create # trials from words, over the dials
@@ -595,6 +596,23 @@ it to `0` in CI so a test does not wait on a real interval.
595
596
  `collection run --topic "…" --collection <uuid>` runs topic → draft → publish → rebuild
596
597
  end to end, with `collection run-status <job-id>` to follow it.
597
598
 
599
+ ### Enhancing
600
+
601
+ The passes that run after the writer — visuals, internal links, external links (citations),
602
+ metadata — as one versioned file per collection (models and settings), each run recorded.
603
+
604
+ ```bash
605
+ letterstory enhance run <article-id> --pass internal_links # run one pass on a post now
606
+ letterstory enhance runs --article <article-id> # what each pass did (also --auto-pen, --outcome skipped)
607
+ letterstory enhance get <run-id> # one run: version, models, what it added, cost
608
+ letterstory enhance file <collection-id> > enhancer.json # the file the collection's passes run
609
+ letterstory enhance file <collection-id> --patch '{"internalLinks":{"maxPerPost":3}}'
610
+ letterstory enhance versions <collection-id> # your copy's versions and our releases
611
+ letterstory enhance pin <collection-id> <version-id|none> # pin one of our releases, or follow live
612
+ letterstory enhance offer <collection-id> [--accept] # our newer version, over your own changes
613
+ letterstory autopen run --article <id> --skip finish,metadata # leave passes out of one run
614
+ ```
615
+
598
616
  ## Seers — event-driven signals
599
617
 
600
618
  A **seer** watches an external source (a GitHub repo, a Notion workspace, a spec URL, a
package/lib/cli.mjs CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  cmdTemplates,
41
41
  cmdCompositions,
42
42
  cmdShredder,
43
+ cmdEnhance,
43
44
  cmdLede,
44
45
  cmdLandings,
45
46
  cmdSequences,
@@ -54,7 +55,7 @@ import {
54
55
  } from "./commands.mjs";
55
56
 
56
57
  // Keep in sync with cli/package.json.
57
- export const VERSION = "0.57.0";
58
+ export const VERSION = "0.59.0";
58
59
 
59
60
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
60
61
  // can't accidentally swallow the id as --json's value.
@@ -936,6 +937,8 @@ const CLIENT_COMMANDS = {
936
937
  templates: cmdTemplates,
937
938
  compositions: cmdCompositions,
938
939
  shredder: cmdShredder,
940
+ enhance: cmdEnhance,
941
+ enhancer: cmdEnhance,
939
942
  lede: cmdLede,
940
943
  ledes: cmdLede,
941
944
  landings: cmdLandings,
@@ -430,6 +430,7 @@ async function cmdAdminTuningContext(ctx) {
430
430
  * admin tuning pick <comparison-id> --cell <key> --pick left|right|tie record a blind pick
431
431
  * admin tuning reading-list <comparison-id> [--n 10] your next blind pairs as links, to read off the screen
432
432
  * admin tuning pair <comparison-id> --cell <key> one pair named: live and candidate, both texts, the measures per arm
433
+ * admin tuning side-by-side <id>,<id>[,…] [--cell <key>] several comparisons as one board: each writer's measures against live, and one input's texts
433
434
  * admin tuning candidates --kernels Hazel,Aspen|all --patch '{…}' [--base N] [--note "…"] the same changes as a candidate of each kernel
434
435
  * admin tuning promote <experiment-id> --versions <service-kernel-id>:<version>,… promote from a "ship" decision
435
436
  *
@@ -463,6 +464,13 @@ async function cmdAdminTuningContext(ctx) {
463
464
  * admin tuning shredders | shredder <version-id> our shredder's versions / one version's file and diff
464
465
  * admin tuning shredder-candidate --patch '{…}' | --file f.json [--of <version-id>] [--note "…"] save a candidate
465
466
  * admin tuning promote-shredder <experiment-id> --version <shredder-version-id> promote our shredder from a "ship" decision (blind picks required)
467
+ *
468
+ * Enhance (Phase 5): our enhancer's versions as files, enhance comparisons, the deploy split.
469
+ * admin tuning enhancers | enhancer <version-id> our enhancer's versions / one version's file and diff
470
+ * admin tuning enhancer-candidate --patch '{…}' | --file f.json [--of <version-id>] [--note "…"] save a candidate
471
+ * admin tuning enhance-compare --title "…" (--candidate <version-id> | --patch '{…}' | --null-check) [--keys …]
472
+ * admin tuning enhance-inputs [--set v1] | enhance-split [--since-hours 72] [--at <iso>,…] | enhance-standing
473
+ * admin tuning promote-enhancer <experiment-id> --version <enhancer-version-id> promote our enhancer from a "ship" decision (picks when it changes visuals)
466
474
  */
467
475
  /** `--flag '<json>'` → the parsed object, or undefined when the flag is absent. */
468
476
  function jsonFlag(raw, name) {
@@ -633,6 +641,34 @@ async function cmdAdminTuning(ctx) {
633
641
  await staffRequest(ctx, "GET", `${T}/comparisons/${id}/pair?${new URLSearchParams({ cell })}`)
634
642
  );
635
643
  }
644
+ case "side-by-side": {
645
+ const ids = (id ?? "")
646
+ .split(",")
647
+ .map((x) => x.trim())
648
+ .filter(Boolean);
649
+ if (!ids?.length) throw new CliError("Usage: admin tuning side-by-side <id>,<id>[,…] [--cell <key>]");
650
+ const cell = flagStr(flags.cell);
651
+ const r = await staffRequest(
652
+ ctx,
653
+ "GET",
654
+ `${T}/comparisons/side-by-side?${new URLSearchParams(compact({ ids: ids.join(","), cell }))}`
655
+ );
656
+ if (json) return printResult(io, flags, r);
657
+ for (const w of r.writers) {
658
+ const m = (measure) => w.measures.find((x) => x.measure === measure);
659
+ const q = m("quality");
660
+ io.log(
661
+ `${w.label} (${w.writers.join(", ") || "—"}, ${w.settled_pairs} pairs, ${w.status}): quality ${q?.mean_delta ?? "—"} [${q?.ci95?.join(" … ") ?? "—"}] ${q?.verdict ?? ""} · seconds ${m("latency_s")?.mean_candidate ?? "—"} vs ${m("latency_s")?.mean_control ?? "—"} · picks ${w.picks.candidate}–${w.picks.control}`
662
+ );
663
+ }
664
+ if (r.input) {
665
+ for (const t of [r.input.live, ...r.input.texts].filter(Boolean))
666
+ io.log(
667
+ `\n=== ${t.label} ${t.writer ?? ""}${t.version ? ` v${t.version}` : ""} (${t.access})\n${t.text ?? "(no text)"}`
668
+ );
669
+ } else io.log(`\ninputs: ${r.inputs.map((i) => i.key).join(", ")} (add --cell <key> for the texts)`);
670
+ return;
671
+ }
636
672
  case "candidates": {
637
673
  const patch = jsonFlag(flags.patch, "--patch");
638
674
  const wanted = flagList(flags.kernels);
@@ -1011,6 +1047,106 @@ async function cmdAdminTuning(ctx) {
1011
1047
  )
1012
1048
  );
1013
1049
  }
1050
+ // --- Enhance: our enhancer's versions as files, comparisons, the deploy split ---
1051
+ case "enhance-compare": {
1052
+ const title = flagStr(flags.title);
1053
+ const versionId = flagStr(flags.candidate);
1054
+ const patch = jsonFlag(flags.patch, "--patch");
1055
+ const nullCheck = flagBool(flags["null-check"]);
1056
+ const named = [versionId !== undefined, patch !== undefined, nullCheck === true].filter(Boolean).length;
1057
+ if (!title || named !== 1)
1058
+ throw new CliError(
1059
+ "Usage: admin tuning enhance-compare --title \"…\" (--candidate <version-id> | --patch '{…}' | --null-check) [--keys k1,k2] [--concurrency 6] [--experiment <id>]"
1060
+ );
1061
+ const candidate = versionId ? { version_id: versionId } : patch ? { patch } : { null_check: true };
1062
+ const r = await staffRequest(
1063
+ ctx,
1064
+ "POST",
1065
+ `${T}/comparisons`,
1066
+ compact({
1067
+ primitive: "enhance",
1068
+ title,
1069
+ candidate,
1070
+ keys: flagList(flags.keys),
1071
+ concurrency: flagNum(flags.concurrency),
1072
+ experiment_id: flagStr(flags.experiment),
1073
+ })
1074
+ );
1075
+ return json ? printResult(io, flags, r) : io.log(`enhance comparison ${r.comparison?.id ?? ""} started`);
1076
+ }
1077
+ case "enhance-inputs": {
1078
+ const set = flagStr(flags.set);
1079
+ return printResult(
1080
+ io,
1081
+ { ...flags, json: true },
1082
+ await staffRequest(ctx, "GET", `${T}/enhance/inputs${set ? `?${new URLSearchParams({ set })}` : ""}`)
1083
+ );
1084
+ }
1085
+ case "enhance-split": {
1086
+ const q = new URLSearchParams(
1087
+ compact({
1088
+ since_hours: flagStr(flags["since-hours"]),
1089
+ window_hours: flagStr(flags["window-hours"]),
1090
+ at: flagStr(flags.at),
1091
+ })
1092
+ );
1093
+ return printResult(
1094
+ io,
1095
+ { ...flags, json: true },
1096
+ await staffRequest(ctx, "GET", `${T}/enhance/deploy-split${q.size ? `?${q}` : ""}`)
1097
+ );
1098
+ }
1099
+ case "enhance-standing":
1100
+ return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${T}/enhance/standing`));
1101
+ case "enhancers": {
1102
+ const r = await staffRequest(ctx, "GET", `${T}/enhancer-files`);
1103
+ if (json) return printResult(io, flags, r);
1104
+ return io.log(
1105
+ r.versions
1106
+ .map((v) => `${v.label.padEnd(18)} ${v.status.padEnd(9)} ${v.id}${v.note ? ` ${v.note}` : ""}`)
1107
+ .join("\n") || "(no versions)"
1108
+ );
1109
+ }
1110
+ case "enhancer": {
1111
+ if (!id) throw new CliError("Usage: admin tuning enhancer <version-id>");
1112
+ return printResult(
1113
+ io,
1114
+ { ...flags, json: true },
1115
+ await staffRequest(ctx, "GET", `${T}/enhancer-files/${id}`)
1116
+ );
1117
+ }
1118
+ case "enhancer-candidate": {
1119
+ const patch = jsonFlag(flags.patch, "--patch");
1120
+ const file = jsonFile({ "enhancer-file": flags.file }, "enhancer");
1121
+ if ((patch === undefined) === (file === undefined))
1122
+ throw new CliError(
1123
+ 'Usage: admin tuning enhancer-candidate --patch \'{"internalLinks":{"maxPerPost":3}}\' | --file enhancer.json [--of <version-id>] [--note "…"]'
1124
+ );
1125
+ const r = await staffRequest(
1126
+ ctx,
1127
+ "POST",
1128
+ `${T}/enhancer-files`,
1129
+ compact({ patch, file, patch_of: flagStr(flags.of), note: flagStr(flags.note) })
1130
+ );
1131
+ return json ? printResult(io, flags, r) : io.log(`saved candidate ${r.version.label} (${r.version.id})`);
1132
+ }
1133
+ case "promote-enhancer": {
1134
+ const version = flagStr(flags.version);
1135
+ if (!id || !version)
1136
+ throw new CliError(
1137
+ "Usage: admin tuning promote-enhancer <experiment-id> --version <enhancer-version-id>"
1138
+ );
1139
+ return printResult(
1140
+ io,
1141
+ { ...flags, json: true },
1142
+ await staffRequest(
1143
+ ctx,
1144
+ "POST",
1145
+ `${EXPERIMENTS}/${id}/promote`,
1146
+ compact({ enhancer_version_id: version, actor: actorFrom(flags) })
1147
+ )
1148
+ );
1149
+ }
1014
1150
  case "promote": {
1015
1151
  const list = flagList(flags.versions);
1016
1152
  if (!id || !list?.length)
@@ -1034,7 +1170,7 @@ async function cmdAdminTuning(ctx) {
1034
1170
  }
1035
1171
  default:
1036
1172
  throw new CliError(
1037
- "Usage: admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|reading-list|pair|candidates|base-kernels|base-versions|sessions|session|session-new|propose|trial-add|trial-run|trial-save|promote|arenas|arena|arena-candidate|research-compare|research-inputs|research-split|research-standing|promote-arena|shred-compare|shred-inputs|shred-split|shred-standing|shredders|shredder|shredder-candidate|promote-shredder (see --help)"
1173
+ "Usage: admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|reading-list|pair|side-by-side|candidates|base-kernels|base-versions|sessions|session|session-new|propose|trial-add|trial-run|trial-save|promote|arenas|arena|arena-candidate|research-compare|research-inputs|research-split|research-standing|promote-arena|shred-compare|shred-inputs|shred-split|shred-standing|shredders|shredder|shredder-candidate|promote-shredder|enhance-compare|enhance-inputs|enhance-split|enhance-standing|enhancers|enhancer|enhancer-candidate|promote-enhancer (see --help)"
1038
1174
  );
1039
1175
  }
1040
1176
  }
@@ -18,6 +18,8 @@ export const ARGS = {
18
18
  run_auto_pen: {
19
19
  article_id: { flag: "article", required: true },
20
20
  from_scratch: { flag: "from-scratch", type: "bool" },
21
+ // --skip finish,metadata: leave those steps out of this run only.
22
+ skip_steps: { flag: "skip", type: "list" },
21
23
  },
22
24
  };
23
25
 
@@ -0,0 +1,152 @@
1
+ // `enhance` — the passes that run after the writer: visuals, internal links, external links
2
+ // (citations) and metadata. Run one on a post now, read what each run did, and read or change the
3
+ // enhance file a collection's passes run (its models and settings), the same versioned-file pattern
4
+ // as a kernel, an arena and a shredder. Every command is a projection of one tool (docs/api).
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { CliError } from "../client.mjs";
8
+ import { flagStr, printResult, ok, toolField, argsFromFlags } from "./shared.mjs";
9
+
10
+ const PASSES = ["visuals", "internal_links", "external_links", "metadata"];
11
+ const OUTCOMES = ["added", "none", "skipped", "failed"];
12
+
13
+ /** `--file <path>` → the file's parsed JSON, or undefined when the flag is not set. */
14
+ function readJsonFile(flags) {
15
+ const path = flagStr(flags.file);
16
+ if (path === undefined) return undefined;
17
+ try {
18
+ return JSON.parse(readFileSync(path, "utf8"));
19
+ } catch (error) {
20
+ throw new CliError(`--file ${path}: ${error instanceof Error ? error.message : String(error)}`);
21
+ }
22
+ }
23
+
24
+ /** `--patch '<json>'` → a JSON object, or undefined. */
25
+ function readPatch(flags) {
26
+ const inline = flagStr(flags.patch);
27
+ if (inline === undefined) return undefined;
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(inline);
31
+ } catch (err) {
32
+ throw new CliError(`--patch is not valid JSON: ${err.message}`);
33
+ }
34
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
35
+ throw new CliError("--patch must be a JSON object");
36
+ return parsed;
37
+ }
38
+
39
+ /**
40
+ * How every command sources its tool's inputs (shared.mjs `argsFromFlags`; scripts/parity-check.ts
41
+ * diffs these keys against each tool's published schema).
42
+ */
43
+ export const ARGS = {
44
+ run_enhance_pass: {
45
+ article_id: { positional: 0, label: "article-id", required: true },
46
+ pass: { flag: "pass", required: true, enum: PASSES },
47
+ },
48
+ get_enhance_run: {
49
+ run_id: { positional: 0, label: "run-id", required: true },
50
+ },
51
+ list_enhance_runs: {
52
+ article_id: "article",
53
+ auto_pen_id: "auto-pen",
54
+ collection_id: "collection",
55
+ pass: { flag: "pass", enum: PASSES },
56
+ outcome: { flag: "outcome", enum: OUTCOMES },
57
+ since: "since",
58
+ limit: { flag: "limit", type: "num" },
59
+ },
60
+ get_enhancer_file: {
61
+ collection_id: { positional: 0, label: "collection-id", required: true },
62
+ },
63
+ save_enhancer_file: {
64
+ collection_id: { positional: 0, label: "collection-id", required: true },
65
+ file: { value: (flags) => readJsonFile(flags) },
66
+ patch: { value: (flags) => readPatch(flags) },
67
+ note: "note",
68
+ },
69
+ list_enhancer_versions: {
70
+ collection_id: { positional: 0, label: "collection-id", required: true },
71
+ },
72
+ pin_enhancer_version: {
73
+ collection_id: { positional: 0, label: "collection-id", required: true },
74
+ version_id: {
75
+ value: (_flags, positionals) => {
76
+ const v = positionals[1];
77
+ if (v === undefined) throw new CliError("Missing required <version-id|none>");
78
+ return v === "none" ? null : v;
79
+ },
80
+ },
81
+ },
82
+ accept_enhancer_offer: {
83
+ collection_id: { positional: 0, label: "collection-id", required: true },
84
+ },
85
+ };
86
+
87
+ export async function cmdEnhance(ctx) {
88
+ const sub = ctx.positionals[0];
89
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
90
+ switch (sub) {
91
+ case "run":
92
+ return enhanceTool(rest, "run_enhance_pass");
93
+ case "runs":
94
+ return enhanceTool(rest, "list_enhance_runs");
95
+ case "get":
96
+ case "show":
97
+ return enhanceTool(rest, "get_enhance_run");
98
+ case "file":
99
+ return enhanceFile(rest);
100
+ case "versions":
101
+ return enhanceTool(rest, "list_enhancer_versions");
102
+ case "pin":
103
+ return enhanceTool(rest, "pin_enhancer_version");
104
+ case "offer":
105
+ return rest.flags.accept === true ? enhanceTool(rest, "accept_enhancer_offer") : enhanceOffer(rest);
106
+ default:
107
+ throw new CliError(
108
+ `Unknown enhance subcommand: ${sub ?? "(none)"}. Try: run <article-id> --pass <pass>, runs, get <run-id>, file <collection-id>, versions, pin, offer`
109
+ );
110
+ }
111
+ }
112
+
113
+ async function enhanceTool(ctx, tool) {
114
+ const result = await ctx.client.callTool(tool, argsFromFlags(ARGS[tool], ctx.flags, ctx.positionals));
115
+ printResult(ctx.io, ctx.flags, result);
116
+ return 0;
117
+ }
118
+
119
+ async function enhanceFile(ctx) {
120
+ const { client, flags, io } = ctx;
121
+ const saving = flagStr(flags.file) !== undefined || flagStr(flags.patch) !== undefined;
122
+ if (!saving) {
123
+ const result = await client.callTool(
124
+ "get_enhancer_file",
125
+ argsFromFlags(ARGS.get_enhancer_file, flags, ctx.positionals)
126
+ );
127
+ printResult(io, flags, result, (r) => JSON.stringify(r?.file ?? r, null, 2));
128
+ return 0;
129
+ }
130
+ const result = await client.callTool(
131
+ "save_enhancer_file",
132
+ argsFromFlags(ARGS.save_enhancer_file, flags, ctx.positionals)
133
+ );
134
+ ok(
135
+ ctx,
136
+ `Saved ${toolField(result, "version")?.label ?? "a new version"}; the collection's passes run it from now on.`
137
+ );
138
+ printResult(io, flags, result);
139
+ return 0;
140
+ }
141
+
142
+ async function enhanceOffer(ctx) {
143
+ const { client, flags, io } = ctx;
144
+ const result = await client.callTool(
145
+ "get_enhancer_file",
146
+ argsFromFlags(ARGS.get_enhancer_file, flags, ctx.positionals)
147
+ );
148
+ const offer = toolField(result, "offer");
149
+ if (!offer) ok(ctx, "No offer: the collection runs our latest version, or a copy based on it.");
150
+ printResult(io, flags, result, (r) => JSON.stringify(r?.offer ?? null, null, 2));
151
+ return 0;
152
+ }
@@ -16,7 +16,7 @@
16
16
  import { CliError } from "../client.mjs";
17
17
  import { flagStr, readBodyInput, printResult, ok, argsFromFlags } from "./shared.mjs";
18
18
 
19
- const STAGES = ["research", "brief", "links", "mentions", "accuracy", "pipeline", "shred"];
19
+ const STAGES = ["research", "brief", "links", "mentions", "accuracy", "pipeline", "shred", "enhance"];
20
20
  const STATUSES = ["proposed", "open", "guarding", "retired", "rejected"];
21
21
 
22
22
  /** `--keys a,b,c` (or repeated `--keys`) → ["a", "b", "c"]; undefined when unset. */
package/lib/commands.mjs CHANGED
@@ -32,6 +32,7 @@ export * from "./commands/lede.mjs";
32
32
  export * from "./commands/onboard.mjs";
33
33
  export * from "./commands/seers.mjs";
34
34
  export * from "./commands/shredder.mjs";
35
+ export * from "./commands/enhance.mjs";
35
36
  export * from "./commands/landings.mjs";
36
37
  export * from "./commands/outbound.mjs";
37
38
  export * from "./commands/offerings.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.57.0",
3
+ "version": "0.59.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",