@letterstory/cli 0.49.2 → 0.52.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
@@ -352,6 +352,13 @@ long-running agent can watch the fleet on a schedule without a browser.
352
352
  export LETTERSTORY_STAFF_API_KEY=<your staff key with tuning:experiment>
353
353
 
354
354
  letterstory admin tuning-context --q "colons" # what the agent knows (JSON)
355
+ letterstory admin tuning kernels # every catalog kernel: live version, writer, candidates
356
+ letterstory admin tuning kernel <service-kernel-id> --version live # one version's kernel file (JSON)
357
+ letterstory admin tuning candidate <service-kernel-id> --file hazel.json --note "Echo as the writer"
358
+ letterstory admin tuning compare --title "Echo vs live" --candidates Hazel:21,Aspen:20
359
+ letterstory admin tuning comparison <id> # per-measure verdicts (better / worse / within noise / refused)
360
+ letterstory admin tuning picks <id> # the blind queue; then: admin tuning pick <id> --cell <key> --pick left|right|tie
361
+ letterstory admin tuning promote <experiment-id> --versions <service-kernel-id>:21 # from a concluded "ship" decision
355
362
  letterstory admin experiments list --q colons # what we've tried
356
363
  letterstory admin experiments new "We use too many colons" --as "claude-code"
357
364
  letterstory admin experiments update <id> --plan-file plan.json --budget 120
@@ -491,11 +498,25 @@ letterstory arena run-status <run-id> # status: awaiting_direct
491
498
  letterstory arena direction <run-id> --choice 1
492
499
  letterstory arena direction <run-id> --custom "Lead with the hidden costs"
493
500
  letterstory arena direction <run-id> --skip # let the agents decide
501
+ letterstory arena run --topic "payroll solutions" --direction "Lead with the hidden costs" # no gate, no vote
502
+
503
+ # Watching runs (what an agent monitoring the account polls):
504
+ letterstory arena runs --status awaiting_direction # the gates waiting on you
505
+ letterstory arena runs --since 2026-10-02T00:00:00Z --limit 50
506
+ letterstory arena runs --before <next_cursor> # the next (older) page
507
+ letterstory arena latest --article <uuid> # the post's latest run, whoever started it
508
+
509
+ # What research decided each site is — and your call on one (research never overrides it):
510
+ letterstory arena roles --rival # competitors research found that you never listed
511
+ letterstory arena role set reviewsite.com --role context
512
+ letterstory arena role set rival.com --role company --company "Rival" --rival
513
+ letterstory arena role reset rival.com # hand it back to research
494
514
 
495
515
  # Arenas are callable endpoints with one-file defaults (the org's default arena is "Letterstory"):
496
516
  letterstory arena list
497
517
  letterstory arena endpoint create --name "Docs research" --defaults '{"depth":"barebones"}'
498
518
  letterstory arena endpoint config <arena-id> [--file cfg.json]
519
+ letterstory arena endpoint update <arena-id> --name "Docs research v2" # creator or org admin only
499
520
  letterstory arena endpoint delete <arena-id> --yes
500
521
 
501
522
  # Ledes: a prospect's context in, ready-to-send opening lines out (chosen from a
package/lib/cli.mjs CHANGED
@@ -54,7 +54,7 @@ import {
54
54
  } from "./commands.mjs";
55
55
 
56
56
  // Keep in sync with cli/package.json.
57
- export const VERSION = "0.49.2";
57
+ export const VERSION = "0.52.0";
58
58
 
59
59
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
60
60
  // can't accidentally swallow the id as --json's value.
@@ -7,6 +7,7 @@
7
7
  // admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
8
8
  // admin tuning-context [--q "<question>"] (tuning:experiment)
9
9
  // admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
10
+ // admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|promote … (tuning:experiment)
10
11
  // admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
11
12
  // admin crm import|capture|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
12
13
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
@@ -412,6 +413,144 @@ async function cmdAdminTuningContext(ctx) {
412
413
  );
413
414
  }
414
415
 
416
+ /* ------------------------------------------------------------ the tuning console's Write area */
417
+
418
+ /**
419
+ * `admin tuning …` — kernel versions as files, comparisons of a candidate against live, blind picks
420
+ * and promotion from the ledger (docs/architecture/tuning-foundation.md): the headless half of the
421
+ * tuning console's Write › Kernels and Compare tabs. Needs `tuning:experiment` on the key.
422
+ *
423
+ * admin tuning kernels every catalog kernel, live version, writer, candidates
424
+ * admin tuning kernel <service-kernel-id> [--version N|live] one version's file (JSON)
425
+ * admin tuning candidate <service-kernel-id> --file f.json [--note "…"] save a file as a candidate version
426
+ * admin tuning compare --title "…" --candidates Hazel:21,Aspen:20 [--experiment <id>] start a comparison
427
+ * admin tuning comparisons | comparison <id> list / read one (per-measure verdicts)
428
+ * admin tuning picks <comparison-id> the blind queue (next pairs)
429
+ * admin tuning pick <comparison-id> --cell <key> --pick left|right|tie record a blind pick
430
+ * admin tuning promote <experiment-id> --versions <service-kernel-id>:<version>,… promote from a "ship" decision
431
+ */
432
+ async function cmdAdminTuning(ctx) {
433
+ const [sub, id] = ctx.positionals;
434
+ const { flags, io } = ctx;
435
+ const json = flagBool(flags.json);
436
+ const T = "/api/superadmin/tuning";
437
+ switch (sub) {
438
+ case "kernels": {
439
+ const r = await staffRequest(ctx, "GET", `${T}/kernel-files`);
440
+ if (json) return printResult(io, flags, r);
441
+ return io.log(
442
+ r.kernels
443
+ .map((k) => {
444
+ const c = k.versions
445
+ .filter((v) => v.status === "candidate")
446
+ .map((v) => `v${v.version} ${v.writer}`);
447
+ return `${k.kernel.padEnd(12)} live v${k.live} ${k.writer} ${k.serviceKernelId}${c.length ? `\n candidates: ${c.join(", ")}` : ""}`;
448
+ })
449
+ .join("\n") || "(no kernels)"
450
+ );
451
+ }
452
+ case "kernel": {
453
+ if (!id) throw new CliError("Usage: admin tuning kernel <service-kernel-id> [--version N|live]");
454
+ const version = flagStr(flags.version) ?? "live";
455
+ return printResult(
456
+ io,
457
+ { ...flags, json: true },
458
+ await staffRequest(ctx, "GET", `${T}/kernel-files/${id}/versions/${version}`)
459
+ );
460
+ }
461
+ case "candidate": {
462
+ const path = flagStr(flags.file);
463
+ let file;
464
+ if (path) {
465
+ try {
466
+ file = JSON.parse(readFileSync(path, "utf8"));
467
+ } catch (err) {
468
+ throw new CliError(`--file ${path}: ${err.message}`);
469
+ }
470
+ }
471
+ if (!id || !file)
472
+ throw new CliError('Usage: admin tuning candidate <service-kernel-id> --file kernel.json [--note "…"]');
473
+ const r = await staffRequest(
474
+ ctx,
475
+ "POST",
476
+ `${T}/kernel-files/${id}/versions`,
477
+ compact({ file, note: flagStr(flags.note) })
478
+ );
479
+ return json ? printResult(io, flags, r) : io.log(`saved candidate v${r.version}`);
480
+ }
481
+ case "compare": {
482
+ const title = flagStr(flags.title);
483
+ const list = flagList(flags.candidates);
484
+ if (!title || !list?.length)
485
+ throw new CliError(
486
+ 'Usage: admin tuning compare --title "…" --candidates Hazel:21,Aspen:20 [--experiment <id>]'
487
+ );
488
+ const candidates = list.map((item) => {
489
+ const at = item.lastIndexOf(":");
490
+ const version = Number(item.slice(at + 1));
491
+ if (at < 1 || !Number.isInteger(version))
492
+ throw new CliError(`--candidates items are kernel:version, got "${item}"`);
493
+ return { kernel: item.slice(0, at), candidate_version: version };
494
+ });
495
+ const r = await staffRequest(
496
+ ctx,
497
+ "POST",
498
+ `${T}/comparisons`,
499
+ compact({ title, candidates, experiment_id: flagStr(flags.experiment) })
500
+ );
501
+ return json ? printResult(io, flags, r) : io.log(`comparison ${r.comparison?.id ?? r.id ?? ""} started`);
502
+ }
503
+ case "comparisons":
504
+ return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${T}/comparisons`));
505
+ case "comparison":
506
+ if (!id) throw new CliError("Usage: admin tuning comparison <id>");
507
+ return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${T}/comparisons/${id}`));
508
+ case "picks":
509
+ if (!id) throw new CliError("Usage: admin tuning picks <comparison-id>");
510
+ return printResult(
511
+ io,
512
+ { ...flags, json: true },
513
+ await staffRequest(ctx, "GET", `${T}/comparisons/${id}/picks`)
514
+ );
515
+ case "pick": {
516
+ const cell = flagStr(flags.cell);
517
+ const pick = flagStr(flags.pick);
518
+ if (!id || !cell || !["left", "right", "tie"].includes(pick ?? ""))
519
+ throw new CliError("Usage: admin tuning pick <comparison-id> --cell <key> --pick left|right|tie");
520
+ return printResult(
521
+ io,
522
+ flags,
523
+ await staffRequest(ctx, "POST", `${T}/comparisons/${id}/picks`, { cell_key: cell, outcome: pick })
524
+ );
525
+ }
526
+ case "promote": {
527
+ const list = flagList(flags.versions);
528
+ if (!id || !list?.length)
529
+ throw new CliError(
530
+ "Usage: admin tuning promote <experiment-id> --versions <service-kernel-id>:<version>,…"
531
+ );
532
+ const versions = list.map((item) => {
533
+ const at = item.lastIndexOf(":");
534
+ return { service_kernel_id: item.slice(0, at), version: Number(item.slice(at + 1)) };
535
+ });
536
+ return printResult(
537
+ io,
538
+ { ...flags, json: true },
539
+ await staffRequest(
540
+ ctx,
541
+ "POST",
542
+ `${EXPERIMENTS}/${id}/promote`,
543
+ compact({ versions, actor: actorFrom(flags) })
544
+ )
545
+ );
546
+ }
547
+ default:
548
+ throw new CliError(
549
+ "Usage: admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|promote (see --help)"
550
+ );
551
+ }
552
+ }
553
+
415
554
  /* ------------------------------------------------------------ the staff CRM */
416
555
 
417
556
  const CRM = "/api/superadmin/crm";
@@ -1078,8 +1217,9 @@ export async function cmdAdmin(ctx) {
1078
1217
  if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
1079
1218
  if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
1080
1219
  if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
1220
+ if (sub === "tuning") return cmdAdminTuning({ ...ctx, positionals: ctx.positionals.slice(1) });
1081
1221
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
1082
1222
  throw new CliError(
1083
- 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin evals … | admin tuning-context (see --help)'
1223
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin evals … | admin tuning … | admin tuning-context (see --help)'
1084
1224
  );
1085
1225
  }
@@ -68,6 +68,8 @@ const RUN_OPTIONS = {
68
68
  // --no-verify turns the fact-check off; nothing sent otherwise (the default is on).
69
69
  verify: { value: (flags) => (flags["no-verify"] === true ? false : undefined) },
70
70
  mention_degree: { flag: "mention", enum: ["off", "subtle", "woven"] },
71
+ // What the fact-check does with a claim it can neither fix nor stand behind: flag or remove it.
72
+ unresolved_claims: { flag: "unresolved", enum: ["comment", "drop"] },
71
73
  // Positioning as data — a file, not flags: `--context-file ctx.json` of
72
74
  // {manifesto, stances[], priority_topics[], avoid_topics[], identity{is[],is_not[],one_liner}}.
73
75
  context: { value: (flags) => readJsonObjectFlag(flags, "context") },
@@ -116,6 +118,8 @@ export const ARGS = {
116
118
  },
117
119
  },
118
120
  existing_content: { value: (flags) => flagStr(flags["existing-content"]) },
121
+ // Your own thesis: adopted as the run's direction, so a gate never pauses for it.
122
+ direction: { flag: "direction" },
119
123
  article_id: { flag: "article" },
120
124
  ...RUN_OPTIONS,
121
125
  },
@@ -124,8 +128,32 @@ export const ARGS = {
124
128
  },
125
129
  list_arena_runs: {
126
130
  arena_id: { flag: "arena" },
131
+ article_id: { flag: "article" },
132
+ status: { flag: "status", type: "list" },
133
+ since: { flag: "since" },
134
+ before: { flag: "before" },
127
135
  limit: { flag: "limit", type: "num" },
128
136
  },
137
+ get_latest_arena_run: {
138
+ article_id: { flag: "article", required: true },
139
+ },
140
+ // What research decided each site is, and your call on one (never overridden by the model).
141
+ list_source_roles: {
142
+ role: { flag: "role", enum: ["authority", "company", "context"] },
143
+ rival: { flag: "rival", type: "bool" },
144
+ decided_by: { flag: "decided-by", enum: ["model", "customer", "staff"] },
145
+ limit: { flag: "limit", type: "num" },
146
+ },
147
+ set_source_role: {
148
+ site: { positional: 1, label: "site", required: true },
149
+ role: { flag: "role", enum: ["authority", "company", "context"], required: true },
150
+ company: { flag: "company" },
151
+ rival: { flag: "rival", type: "bool" },
152
+ note: { flag: "note" },
153
+ },
154
+ reset_source_role: {
155
+ site: { positional: 1, label: "site", required: true },
156
+ },
129
157
  submit_arena_direction: {
130
158
  run_id: { positional: 0, label: "run-id", required: true },
131
159
  choice: { flag: "choice", type: "num" },
@@ -144,6 +172,11 @@ export const ARGS = {
144
172
  delete_arena: {
145
173
  arena_id: { positional: 1, label: "arena-id", required: true },
146
174
  },
175
+ update_arena: {
176
+ arena_id: { positional: 1, label: "arena-id", required: true },
177
+ name: { flag: "name" },
178
+ defaults: { value: (flags) => readJsonObjectFlag(flags, "defaults") },
179
+ },
147
180
  // The arena's configuration as ONE file: `arena endpoint config <id>` prints it,
148
181
  // `arena endpoint config <id> --file cfg.json` replaces it with the file's document.
149
182
  get_arena_config: {
@@ -167,6 +200,12 @@ export async function cmdArena(ctx) {
167
200
  return arenaRunStatus(rest);
168
201
  case "runs":
169
202
  return arenaRuns(rest);
203
+ case "latest":
204
+ return arenaLatest(rest);
205
+ case "roles":
206
+ return arenaRoles(rest);
207
+ case "role":
208
+ return arenaRole(rest);
170
209
  case "direction":
171
210
  return arenaDirection(rest);
172
211
  case "cancel":
@@ -181,7 +220,7 @@ export async function cmdArena(ctx) {
181
220
  return arenaEndpoint(rest);
182
221
  default:
183
222
  throw new CliError(
184
- `Unknown arena subcommand: ${sub ?? "(none)"}. Try: run, run-status, runs, direction, cancel, balance, list, endpoint`
223
+ `Unknown arena subcommand: ${sub ?? "(none)"}. Try: run, run-status, runs, latest, direction, cancel, balance, list, endpoint, roles, role`
185
224
  );
186
225
  }
187
226
  }
@@ -262,6 +301,47 @@ async function arenaRuns(ctx) {
262
301
  return 0;
263
302
  }
264
303
 
304
+ async function arenaLatest(ctx) {
305
+ const { client, flags, io } = ctx;
306
+ const args = argsFromFlags(ARGS.get_latest_arena_run, flags, ctx.positionals);
307
+ const result = await client.callTool("get_latest_arena_run", args);
308
+ printResult(io, flags, result);
309
+ return 0;
310
+ }
311
+
312
+ async function arenaRoles(ctx) {
313
+ const { client, flags, io } = ctx;
314
+ const result = await client.callTool(
315
+ "list_source_roles",
316
+ argsFromFlags(ARGS.list_source_roles, flags, ctx.positionals)
317
+ );
318
+ printResult(io, flags, result);
319
+ return 0;
320
+ }
321
+
322
+ /** `arena role set <site> --role …` / `arena role reset <site>` — your call on one site. */
323
+ async function arenaRole(ctx) {
324
+ const { client, positionals, flags, io } = ctx;
325
+ const action = positionals[0];
326
+ if (action === "set") {
327
+ const args = argsFromFlags(ARGS.set_source_role, flags, positionals);
328
+ const result = await client.callTool("set_source_role", args);
329
+ ok(ctx, `${result?.role?.site ?? args.site} is now ${args.role}${args.rival ? " (a rival)" : ""}.`);
330
+ printResult(io, flags, result);
331
+ return 0;
332
+ }
333
+ if (action === "reset") {
334
+ const result = await client.callTool(
335
+ "reset_source_role",
336
+ argsFromFlags(ARGS.reset_source_role, flags, positionals)
337
+ );
338
+ ok(ctx, result?.reset ? "Handed back to research." : "Research had not recorded that site.");
339
+ printResult(io, flags, result);
340
+ return 0;
341
+ }
342
+ throw new CliError(`Unknown arena role action: ${action ?? "(none)"}. Try: set, reset`);
343
+ }
344
+
265
345
  async function arenaDirection(ctx) {
266
346
  const { client, flags, io } = ctx;
267
347
  const runId = requirePositional(ctx.positionals, 0, "run-id");
@@ -327,6 +407,14 @@ async function arenaEndpoint(ctx) {
327
407
  printResult(io, flags, result);
328
408
  return 0;
329
409
  }
410
+ if (action === "update" || action === "edit") {
411
+ const id = requirePositional(positionals, 1, "arena-id");
412
+ const args = argsFromFlags(ARGS.update_arena, flags, positionals);
413
+ const result = await client.callTool("update_arena", args);
414
+ ok(ctx, `Updated arena ${id}.`);
415
+ printResult(io, flags, result);
416
+ return 0;
417
+ }
330
418
  if (action === "config") {
331
419
  const id = requirePositional(positionals, 1, "arena-id");
332
420
  if (flagStr(flags.file) === undefined) {
@@ -344,5 +432,5 @@ async function arenaEndpoint(ctx) {
344
432
  printResult(io, flags, result);
345
433
  return 0;
346
434
  }
347
- throw new CliError(`Unknown arena endpoint action: ${action ?? "(none)"}. Try: create, delete, config`);
435
+ throw new CliError(`Unknown arena endpoint action: ${action ?? "(none)"}. Try: create, update, delete, config`);
348
436
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.49.2",
3
+ "version": "0.52.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",