@letterstory/cli 0.57.0 → 0.58.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
@@ -595,6 +595,23 @@ it to `0` in CI so a test does not wait on a real interval.
595
595
  `collection run --topic "…" --collection <uuid>` runs topic → draft → publish → rebuild
596
596
  end to end, with `collection run-status <job-id>` to follow it.
597
597
 
598
+ ### Enhancing
599
+
600
+ The passes that run after the writer — visuals, internal links, external links (citations),
601
+ metadata — as one versioned file per collection (models and settings), each run recorded.
602
+
603
+ ```bash
604
+ letterstory enhance run <article-id> --pass internal_links # run one pass on a post now
605
+ letterstory enhance runs --article <article-id> # what each pass did (also --auto-pen, --outcome skipped)
606
+ letterstory enhance get <run-id> # one run: version, models, what it added, cost
607
+ letterstory enhance file <collection-id> > enhancer.json # the file the collection's passes run
608
+ letterstory enhance file <collection-id> --patch '{"internalLinks":{"maxPerPost":3}}'
609
+ letterstory enhance versions <collection-id> # your copy's versions and our releases
610
+ letterstory enhance pin <collection-id> <version-id|none> # pin one of our releases, or follow live
611
+ letterstory enhance offer <collection-id> [--accept] # our newer version, over your own changes
612
+ letterstory autopen run --article <id> --skip finish,metadata # leave passes out of one run
613
+ ```
614
+
598
615
  ## Seers — event-driven signals
599
616
 
600
617
  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.58.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,
@@ -463,6 +463,13 @@ async function cmdAdminTuningContext(ctx) {
463
463
  * admin tuning shredders | shredder <version-id> our shredder's versions / one version's file and diff
464
464
  * admin tuning shredder-candidate --patch '{…}' | --file f.json [--of <version-id>] [--note "…"] save a candidate
465
465
  * admin tuning promote-shredder <experiment-id> --version <shredder-version-id> promote our shredder from a "ship" decision (blind picks required)
466
+ *
467
+ * Enhance (Phase 5): our enhancer's versions as files, enhance comparisons, the deploy split.
468
+ * admin tuning enhancers | enhancer <version-id> our enhancer's versions / one version's file and diff
469
+ * admin tuning enhancer-candidate --patch '{…}' | --file f.json [--of <version-id>] [--note "…"] save a candidate
470
+ * admin tuning enhance-compare --title "…" (--candidate <version-id> | --patch '{…}' | --null-check) [--keys …]
471
+ * admin tuning enhance-inputs [--set v1] | enhance-split [--since-hours 72] [--at <iso>,…] | enhance-standing
472
+ * admin tuning promote-enhancer <experiment-id> --version <enhancer-version-id> promote our enhancer from a "ship" decision (picks when it changes visuals)
466
473
  */
467
474
  /** `--flag '<json>'` → the parsed object, or undefined when the flag is absent. */
468
475
  function jsonFlag(raw, name) {
@@ -1011,6 +1018,106 @@ async function cmdAdminTuning(ctx) {
1011
1018
  )
1012
1019
  );
1013
1020
  }
1021
+ // --- Enhance: our enhancer's versions as files, comparisons, the deploy split ---
1022
+ case "enhance-compare": {
1023
+ const title = flagStr(flags.title);
1024
+ const versionId = flagStr(flags.candidate);
1025
+ const patch = jsonFlag(flags.patch, "--patch");
1026
+ const nullCheck = flagBool(flags["null-check"]);
1027
+ const named = [versionId !== undefined, patch !== undefined, nullCheck === true].filter(Boolean).length;
1028
+ if (!title || named !== 1)
1029
+ throw new CliError(
1030
+ "Usage: admin tuning enhance-compare --title \"…\" (--candidate <version-id> | --patch '{…}' | --null-check) [--keys k1,k2] [--concurrency 6] [--experiment <id>]"
1031
+ );
1032
+ const candidate = versionId ? { version_id: versionId } : patch ? { patch } : { null_check: true };
1033
+ const r = await staffRequest(
1034
+ ctx,
1035
+ "POST",
1036
+ `${T}/comparisons`,
1037
+ compact({
1038
+ primitive: "enhance",
1039
+ title,
1040
+ candidate,
1041
+ keys: flagList(flags.keys),
1042
+ concurrency: flagNum(flags.concurrency),
1043
+ experiment_id: flagStr(flags.experiment),
1044
+ })
1045
+ );
1046
+ return json ? printResult(io, flags, r) : io.log(`enhance comparison ${r.comparison?.id ?? ""} started`);
1047
+ }
1048
+ case "enhance-inputs": {
1049
+ const set = flagStr(flags.set);
1050
+ return printResult(
1051
+ io,
1052
+ { ...flags, json: true },
1053
+ await staffRequest(ctx, "GET", `${T}/enhance/inputs${set ? `?${new URLSearchParams({ set })}` : ""}`)
1054
+ );
1055
+ }
1056
+ case "enhance-split": {
1057
+ const q = new URLSearchParams(
1058
+ compact({
1059
+ since_hours: flagStr(flags["since-hours"]),
1060
+ window_hours: flagStr(flags["window-hours"]),
1061
+ at: flagStr(flags.at),
1062
+ })
1063
+ );
1064
+ return printResult(
1065
+ io,
1066
+ { ...flags, json: true },
1067
+ await staffRequest(ctx, "GET", `${T}/enhance/deploy-split${q.size ? `?${q}` : ""}`)
1068
+ );
1069
+ }
1070
+ case "enhance-standing":
1071
+ return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${T}/enhance/standing`));
1072
+ case "enhancers": {
1073
+ const r = await staffRequest(ctx, "GET", `${T}/enhancer-files`);
1074
+ if (json) return printResult(io, flags, r);
1075
+ return io.log(
1076
+ r.versions
1077
+ .map((v) => `${v.label.padEnd(18)} ${v.status.padEnd(9)} ${v.id}${v.note ? ` ${v.note}` : ""}`)
1078
+ .join("\n") || "(no versions)"
1079
+ );
1080
+ }
1081
+ case "enhancer": {
1082
+ if (!id) throw new CliError("Usage: admin tuning enhancer <version-id>");
1083
+ return printResult(
1084
+ io,
1085
+ { ...flags, json: true },
1086
+ await staffRequest(ctx, "GET", `${T}/enhancer-files/${id}`)
1087
+ );
1088
+ }
1089
+ case "enhancer-candidate": {
1090
+ const patch = jsonFlag(flags.patch, "--patch");
1091
+ const file = jsonFile({ "enhancer-file": flags.file }, "enhancer");
1092
+ if ((patch === undefined) === (file === undefined))
1093
+ throw new CliError(
1094
+ 'Usage: admin tuning enhancer-candidate --patch \'{"internalLinks":{"maxPerPost":3}}\' | --file enhancer.json [--of <version-id>] [--note "…"]'
1095
+ );
1096
+ const r = await staffRequest(
1097
+ ctx,
1098
+ "POST",
1099
+ `${T}/enhancer-files`,
1100
+ compact({ patch, file, patch_of: flagStr(flags.of), note: flagStr(flags.note) })
1101
+ );
1102
+ return json ? printResult(io, flags, r) : io.log(`saved candidate ${r.version.label} (${r.version.id})`);
1103
+ }
1104
+ case "promote-enhancer": {
1105
+ const version = flagStr(flags.version);
1106
+ if (!id || !version)
1107
+ throw new CliError(
1108
+ "Usage: admin tuning promote-enhancer <experiment-id> --version <enhancer-version-id>"
1109
+ );
1110
+ return printResult(
1111
+ io,
1112
+ { ...flags, json: true },
1113
+ await staffRequest(
1114
+ ctx,
1115
+ "POST",
1116
+ `${EXPERIMENTS}/${id}/promote`,
1117
+ compact({ enhancer_version_id: version, actor: actorFrom(flags) })
1118
+ )
1119
+ );
1120
+ }
1014
1121
  case "promote": {
1015
1122
  const list = flagList(flags.versions);
1016
1123
  if (!id || !list?.length)
@@ -1034,7 +1141,7 @@ async function cmdAdminTuning(ctx) {
1034
1141
  }
1035
1142
  default:
1036
1143
  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)"
1144
+ "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|enhance-compare|enhance-inputs|enhance-split|enhance-standing|enhancers|enhancer|enhancer-candidate|promote-enhancer (see --help)"
1038
1145
  );
1039
1146
  }
1040
1147
  }
@@ -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.58.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",