@letterstory/cli 0.53.0 → 0.55.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
@@ -359,6 +359,7 @@ letterstory admin tuning compare --title "Echo vs live" --candidates Hazel:21,As
359
359
  letterstory admin tuning comparison <id> # per-measure verdicts (better / worse / within noise / refused)
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
+ letterstory admin tuning pair <id> --cell <key> # one pair named: live and candidate, both texts, the measures per arm (a key reads every pair)
362
363
  letterstory admin tuning promote <experiment-id> --versions <service-kernel-id>:21 # from a concluded "ship" decision
363
364
  letterstory admin experiments list --q colons # what we've tried
364
365
  letterstory admin experiments new "We use too many colons" --as "claude-code"
@@ -520,6 +521,14 @@ letterstory arena endpoint config <arena-id> [--file cfg.json]
520
521
  letterstory arena endpoint update <arena-id> --name "Docs research v2" # creator or org admin only
521
522
  letterstory arena endpoint delete <arena-id> --yes
522
523
 
524
+ # The research agent as ONE versioned file (like a kernel file; schema at /api/schema/arena):
525
+ letterstory arena file <arena-id> > arena.json # the file the arena runs now
526
+ letterstory arena file <arena-id> --patch '{"rules":{"context-figures":"measure"}}' --note "try it"
527
+ letterstory arena file <arena-id> --file arena.json # save a whole file as the next version
528
+ letterstory arena versions <arena-id> # your copy's versions and our releases
529
+ letterstory arena pin <arena-id> <version-id|none> # pin one of our releases, or follow live
530
+ letterstory arena offer <arena-id> [--accept] # our newer version, over your own changes
531
+
523
532
  # Ledes: a prospect's context in, ready-to-send opening lines out (chosen from a
524
533
  # hand-approved library, never generated; the top choice is flagged). Synchronous.
525
534
  letterstory lede create --name "Clay outbound table"
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.53.0";
57
+ export const VERSION = "0.55.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.
@@ -428,8 +428,31 @@ async function cmdAdminTuningContext(ctx) {
428
428
  * admin tuning picks <comparison-id> the blind queue (next pairs)
429
429
  * admin tuning pick <comparison-id> --cell <key> --pick left|right|tie record a blind pick
430
430
  * admin tuning reading-list <comparison-id> [--n 10] your next blind pairs as links, to read off the screen
431
+ * admin tuning pair <comparison-id> --cell <key> one pair named: live and candidate, both texts, the measures per arm
431
432
  * admin tuning promote <experiment-id> --versions <service-kernel-id>:<version>,… promote from a "ship" decision
433
+ *
434
+ * Research (Phase 3): our arena's versions as files, research comparisons, the deploy split.
435
+ * admin tuning arenas | arena <version-id> our arena's versions / one version's file and diff
436
+ * admin tuning arena-candidate --patch '{…}' | --file f.json [--of <version-id>] [--note "…"] save a candidate
437
+ * admin tuning research-compare --title "…" (--candidate <version-id> | --patch '{…}' | --null-check) [--keys …]
438
+ * admin tuning research-inputs [--set v1] research's fixed inputs
439
+ * admin tuning research-split [--since-hours 72] [--at <iso>,…] what each deploy did to research in production
440
+ * admin tuning research-standing live's level on the fixed inputs, week by week
441
+ * admin tuning promote-arena <experiment-id> --version <arena-version-id> promote our arena from a "ship" decision
432
442
  */
443
+ /** `--flag '<json>'` → the parsed object, or undefined when the flag is absent. */
444
+ function jsonFlag(raw, name) {
445
+ const text = flagStr(raw);
446
+ if (text === undefined) return undefined;
447
+ try {
448
+ const v = JSON.parse(text);
449
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not an object");
450
+ return v;
451
+ } catch (err) {
452
+ throw new CliError(`${name} is not a JSON object: ${err.message}`);
453
+ }
454
+ }
455
+
433
456
  async function cmdAdminTuning(ctx) {
434
457
  const [sub, id] = ctx.positionals;
435
458
  const { flags, io } = ctx;
@@ -534,6 +557,109 @@ async function cmdAdminTuning(ctx) {
534
557
  );
535
558
  return json ? printResult(io, flags, r) : io.log(r.text);
536
559
  }
560
+ case "pair": {
561
+ const cell = flagStr(flags.cell);
562
+ if (!id || !cell) throw new CliError("Usage: admin tuning pair <comparison-id> --cell <key>");
563
+ return printResult(
564
+ io,
565
+ { ...flags, json: true },
566
+ await staffRequest(ctx, "GET", `${T}/comparisons/${id}/pair?${new URLSearchParams({ cell })}`)
567
+ );
568
+ }
569
+ // --- Research: our arena's versions, research comparisons, the deploy split ---
570
+ case "arenas": {
571
+ const r = await staffRequest(ctx, "GET", `${T}/arena-files`);
572
+ if (json) return printResult(io, flags, r);
573
+ return io.log(
574
+ r.versions
575
+ .map((v) => `${v.label.padEnd(18)} ${v.status.padEnd(9)} ${v.id}${v.note ? ` ${v.note}` : ""}`)
576
+ .join("\n") || "(no versions)"
577
+ );
578
+ }
579
+ case "arena": {
580
+ if (!id) throw new CliError("Usage: admin tuning arena <version-id>");
581
+ return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${T}/arena-files/${id}`));
582
+ }
583
+ case "arena-candidate": {
584
+ const patch = jsonFlag(flags.patch, "--patch");
585
+ const file = jsonFile({ "arena-file": flags.file }, "arena");
586
+ if ((patch === undefined) === (file === undefined))
587
+ throw new CliError(
588
+ 'Usage: admin tuning arena-candidate --patch \'{"rules":{"context-figures":"off"}}\' | --file arena.json [--of <version-id>] [--note "…"]'
589
+ );
590
+ const r = await staffRequest(
591
+ ctx,
592
+ "POST",
593
+ `${T}/arena-files`,
594
+ compact({ patch, file, patch_of: flagStr(flags.of), note: flagStr(flags.note) })
595
+ );
596
+ return json ? printResult(io, flags, r) : io.log(`saved candidate ${r.version.label} (${r.version.id})`);
597
+ }
598
+ case "research-compare": {
599
+ const title = flagStr(flags.title);
600
+ const versionId = flagStr(flags.candidate);
601
+ const patch = jsonFlag(flags.patch, "--patch");
602
+ const nullCheck = flagBool(flags["null-check"]);
603
+ const named = [versionId !== undefined, patch !== undefined, nullCheck === true].filter(Boolean).length;
604
+ if (!title || named !== 1)
605
+ throw new CliError(
606
+ "Usage: admin tuning research-compare --title \"…\" (--candidate <version-id> | --patch '{…}' | --null-check) [--keys k1,k2] [--concurrency 6] [--experiment <id>]"
607
+ );
608
+ const candidate = versionId ? { version_id: versionId } : patch ? { patch } : { null_check: true };
609
+ const r = await staffRequest(
610
+ ctx,
611
+ "POST",
612
+ `${T}/comparisons`,
613
+ compact({
614
+ primitive: "research",
615
+ title,
616
+ candidate,
617
+ keys: flagList(flags.keys),
618
+ concurrency: flagNum(flags.concurrency),
619
+ experiment_id: flagStr(flags.experiment),
620
+ })
621
+ );
622
+ return json ? printResult(io, flags, r) : io.log(`research comparison ${r.comparison?.id ?? ""} started`);
623
+ }
624
+ case "research-inputs": {
625
+ const set = flagStr(flags.set);
626
+ return printResult(
627
+ io,
628
+ { ...flags, json: true },
629
+ await staffRequest(ctx, "GET", `${T}/research/inputs${set ? `?${new URLSearchParams({ set })}` : ""}`)
630
+ );
631
+ }
632
+ case "research-split": {
633
+ const q = new URLSearchParams(
634
+ compact({
635
+ since_hours: flagStr(flags["since-hours"]),
636
+ window_hours: flagStr(flags["window-hours"]),
637
+ at: flagStr(flags.at),
638
+ })
639
+ );
640
+ return printResult(
641
+ io,
642
+ { ...flags, json: true },
643
+ await staffRequest(ctx, "GET", `${T}/research/deploy-split${q.size ? `?${q}` : ""}`)
644
+ );
645
+ }
646
+ case "research-standing":
647
+ return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${T}/research/standing`));
648
+ case "promote-arena": {
649
+ const version = flagStr(flags.version);
650
+ if (!id || !version)
651
+ throw new CliError("Usage: admin tuning promote-arena <experiment-id> --version <arena-version-id>");
652
+ return printResult(
653
+ io,
654
+ { ...flags, json: true },
655
+ await staffRequest(
656
+ ctx,
657
+ "POST",
658
+ `${EXPERIMENTS}/${id}/promote`,
659
+ compact({ arena_version_id: version, actor: actorFrom(flags) })
660
+ )
661
+ );
662
+ }
537
663
  case "promote": {
538
664
  const list = flagList(flags.versions);
539
665
  if (!id || !list?.length)
@@ -557,7 +683,7 @@ async function cmdAdminTuning(ctx) {
557
683
  }
558
684
  default:
559
685
  throw new CliError(
560
- "Usage: admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|reading-list|promote (see --help)"
686
+ "Usage: admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|reading-list|pair|promote|arenas|arena|arena-candidate|research-compare|research-inputs|research-split|research-standing|promote-arena (see --help)"
561
687
  );
562
688
  }
563
689
  }
@@ -186,6 +186,33 @@ export const ARGS = {
186
186
  arena_id: { positional: 1, label: "arena-id", required: true },
187
187
  config: { value: (flags) => readJsonFileFlag(flags, "file") },
188
188
  },
189
+ // The arena's research agent as a versioned file (the kernel file's twin): `arena file <id>`
190
+ // prints it, `--file f.json` saves a whole file, `--patch '{…}'` only the fields to change.
191
+ get_arena_file: {
192
+ arena_id: { positional: 0, label: "arena-id", required: true },
193
+ },
194
+ save_arena_file: {
195
+ arena_id: { positional: 0, label: "arena-id", required: true },
196
+ file: { value: (flags) => readJsonFileFlag(flags, "file") },
197
+ patch: { value: (flags) => readJsonObjectFlag(flags, "patch") },
198
+ note: "note",
199
+ },
200
+ list_arena_versions: {
201
+ arena_id: { positional: 0, label: "arena-id", required: true },
202
+ },
203
+ pin_arena_version: {
204
+ arena_id: { positional: 0, label: "arena-id", required: true },
205
+ version_id: {
206
+ value: (_flags, positionals) => {
207
+ const v = positionals[1];
208
+ if (v === undefined) throw new CliError("Missing required <version-id|none>");
209
+ return v === "none" ? null : v;
210
+ },
211
+ },
212
+ },
213
+ accept_arena_offer: {
214
+ arena_id: { positional: 0, label: "arena-id", required: true },
215
+ },
189
216
  };
190
217
 
191
218
  export async function cmdArena(ctx) {
@@ -218,9 +245,17 @@ export async function cmdArena(ctx) {
218
245
  return arenaList(rest);
219
246
  case "endpoint":
220
247
  return arenaEndpoint(rest);
248
+ case "file":
249
+ return arenaFile(rest);
250
+ case "versions":
251
+ return arenaTool(rest, "list_arena_versions");
252
+ case "pin":
253
+ return arenaTool(rest, "pin_arena_version");
254
+ case "offer":
255
+ return rest.flags.accept === true ? arenaTool(rest, "accept_arena_offer") : arenaOffer(rest);
221
256
  default:
222
257
  throw new CliError(
223
- `Unknown arena subcommand: ${sub ?? "(none)"}. Try: run, run-status, runs, latest, direction, cancel, balance, list, endpoint, roles, role`
258
+ `Unknown arena subcommand: ${sub ?? "(none)"}. Try: run, run-status, runs, latest, direction, cancel, balance, list, endpoint, file, versions, pin, offer, roles, role`
224
259
  );
225
260
  }
226
261
  }
@@ -386,6 +421,51 @@ async function arenaList(ctx) {
386
421
  * `arena endpoint create|delete|config` — the arena's own CRUD, and its configuration as
387
422
  * one file (the same document `get_arena_config` returns and `set_arena_config` accepts).
388
423
  */
424
+ /**
425
+ * `arena file <arena-id>` prints the file the arena runs (the file itself, so `> f.json` round-trips);
426
+ * `arena file <arena-id> --file f.json | --patch '{…}' [--note …]` saves a new live version of the
427
+ * arena's own copy.
428
+ */
429
+ async function arenaFile(ctx) {
430
+ const { client, flags, io } = ctx;
431
+ const saving =
432
+ flagStr(flags.file) !== undefined ||
433
+ flagStr(flags.patch) !== undefined ||
434
+ flagStr(flags["patch-file"]) !== undefined;
435
+ if (!saving) {
436
+ const result = await client.callTool(
437
+ "get_arena_file",
438
+ argsFromFlags(ARGS.get_arena_file, flags, ctx.positionals)
439
+ );
440
+ printResult(io, flags, result, (r) => JSON.stringify(r?.file ?? r, null, 2));
441
+ return 0;
442
+ }
443
+ const result = await client.callTool(
444
+ "save_arena_file",
445
+ argsFromFlags(ARGS.save_arena_file, flags, ctx.positionals)
446
+ );
447
+ ok(ctx, `Saved ${toolField(result, "version")?.label ?? "a new version"}; the arena runs it from now on.`);
448
+ printResult(io, flags, result);
449
+ return 0;
450
+ }
451
+
452
+ /** `arena offer <arena-id>`: what our newer version would change; `--accept` takes it. */
453
+ async function arenaOffer(ctx) {
454
+ const { client, flags, io } = ctx;
455
+ const result = await client.callTool("get_arena_file", argsFromFlags(ARGS.get_arena_file, flags, ctx.positionals));
456
+ const offer = toolField(result, "offer");
457
+ if (!offer) ok(ctx, "No offer: your arena runs our latest version, or a copy based on it.");
458
+ printResult(io, flags, result, (r) => JSON.stringify(r?.offer ?? null, null, 2));
459
+ return 0;
460
+ }
461
+
462
+ /** One arena-file tool, its arguments from ARGS. */
463
+ async function arenaTool(ctx, tool) {
464
+ const result = await ctx.client.callTool(tool, argsFromFlags(ARGS[tool], ctx.flags, ctx.positionals));
465
+ printResult(ctx.io, ctx.flags, result);
466
+ return 0;
467
+ }
468
+
389
469
  async function arenaEndpoint(ctx) {
390
470
  const { client, positionals, flags, io } = ctx;
391
471
  const action = positionals[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.53.0",
3
+ "version": "0.55.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",