@letterstory/cli 0.65.0 → 0.69.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
@@ -369,14 +369,17 @@ letterstory admin tuning trial-run <trial> --repeats 2 # runs advance on the se
369
369
  letterstory admin tuning trial-save <trial> --note "…" # a trial as a candidate version (never live); then `admin tuning compare`
370
370
  letterstory admin tuning promote <experiment-id> --versions <service-kernel-id>:21 # from a concluded "ship" decision
371
371
  letterstory admin experiments list --q colons # what we've tried
372
+ letterstory admin experiments list --primitive write --stage rarity # what we learned about one stage
373
+ letterstory admin experiments new "A different writer, voice rewritten, rarity rewrite off" --primitive write # an investigation in Write's notebook
372
374
  letterstory admin experiments new "We use too many colons" --as "claude-code"
373
375
  letterstory admin experiments update <id> --plan-file plan.json --budget 120
374
- # ...a person approves the plan in Kernel Tuning -> Experiments...
376
+ # ...a person approves the plan on the investigation's page (approval gates spending a budget; a person's
377
+ # investigation needs none and is concluded straight away)...
375
378
  letterstory admin experiments spend <id> 54
376
379
  letterstory admin tuning-import lab/results/colons/publish/<brief>.json --experiment <id>
377
380
  letterstory admin experiments conclude <id> --decision drop \
378
381
  --conclusion "Colons fell 60%; P(machine) did not move; quality held." \
379
- --evidence lab:"kernels lab/results/colons/report.md"
382
+ --evidence lab:"kernels lab/results/colons/report.md" --stages rules
380
383
  ```
381
384
 
382
385
  The ledger behind Kernel Tuning's Experiments tab
@@ -728,8 +731,10 @@ AI visibility and page access — the Measure tabs' reads, for scripts and agent
728
731
  ```bash
729
732
  # The Measure Overview: how often AI answers name you and cite your pages (rates
730
733
  # with 95% intervals and gated trends), share of voice, search for your site and
731
- # the phantom network kept apart, and each ranking target's standing.
732
- letterstory insights summary [--period 14d|30d|90d]
734
+ # the phantom network kept apart, and each ranking target's standing. --part
735
+ # (repeatable) returns only those blocks: audience, ai, shareOfVoice, pageAccess,
736
+ # search, actions, targets — `ai` is the slow one on a large org.
737
+ letterstory insights summary [--period 14d|30d|90d] [--part <block>]...
733
738
 
734
739
  # The AI Visibility Bank: which questions named you, cited your pages, or both (a
735
740
  # breakthrough). All time unless --period; --source picks the phantom network
@@ -777,10 +782,17 @@ letterstory sequences list | get <id> | create | update <id> | pause <id> | resu
777
782
  letterstory sequences enroll <id> --list <list-id> # or --email a@x.com ...
778
783
  letterstory sequences review list | approve <item-id> | skip <item-id> | edit <item-id> --subject "…"
779
784
  letterstory sequences links create --days 2 # no-login phone review link, shown once
785
+ letterstory sequences update <id> --test-mode # internal accounts only: walks as live, sends nothing
786
+
787
+ # Dispatches (the review desks a sequence queues to)
788
+ letterstory dispatches list | create --name "Founders" | rename <id> --name "VIPs" | delete <id> --yes
780
789
 
781
790
  # Prospects
782
791
  letterstory prospects lists | ls --list <list-id> | get <id> | import --csv-file leads.csv --list-name "Q4"
783
792
  letterstory prospects source --description "heads of growth at seed-stage fintech" --list-name "Fintech"
793
+ letterstory prospects openers <list-id> --new-group "Fintech openers" # print an opening line per row
794
+ letterstory prospects columns add "Cake image" | columns delete <column-id> --yes
795
+ letterstory prospects delete <prospect-id>... --yes
784
796
  ```
785
797
 
786
798
  Connecting a bring-your-own Gmail mailbox needs a browser (Google consent), so it is done in the
package/lib/cli.mjs CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  cmdLede,
45
45
  cmdLandings,
46
46
  cmdSequences,
47
+ cmdDispatches,
47
48
  cmdProspects,
48
49
  cmdMail,
49
50
  cmdMailers,
@@ -55,7 +56,7 @@ import {
55
56
  } from "./commands.mjs";
56
57
 
57
58
  // Keep in sync with cli/package.json.
58
- export const VERSION = "0.65.0";
59
+ export const VERSION = "0.69.0";
59
60
 
60
61
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
61
62
  // can't accidentally swallow the id as --json's value.
@@ -549,16 +550,28 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
549
550
  read-only SQL across every org; --sql prints
550
551
  the queries behind the answer. Needs the
551
552
  fleet:query capability on the staff key.
552
- admin experiments list [--q colons] [--status proposed] | show <id>
553
- | new "<question>" [--hypothesis …] [--plan-file p.json] [--tags a,b] [--as "<agent>"]
554
- | update <id> [--plan-file …] [--result-file …] [--evidence kind:ref,…] [--budget N]
553
+ admin replays [--org <uuid>] [--target <uuid>] [--status captured|not_reproduced|queued|running|unsupported|failed] [--limit <n>]
554
+ Live answer replays: breakthroughs re-asked in ChatGPT /
555
+ Gemini / Google / Perplexity / Claude, with screenshots.
556
+ Omit --org for the whole fleet. Needs fleet:read.
557
+ admin replay --org <uuid> [<response-id> | --target <uuid>] [--surface openai|anthropic|google-aio|google-gemini|perplexity]
558
+ Replay a breakthrough live now (background; poll
559
+ admin replays). With neither, the client's newest
560
+ breakthrough. Needs fleet:replay.
561
+ admin experiments list [--q colons] [--status proposed] [--primitive write] [--stage rarity] | show <id>
562
+ | new "<what you are investigating>" [--primitive write|research|shred|enhance] [--stages a,b]
563
+ [--hypothesis …] [--plan-file p.json] [--tags a,b] [--as "<agent>"]
564
+ | update <id> [--plan-file …] [--result-file …] [--evidence kind:ref,…] [--stages a,b] [--budget N]
555
565
  | note|started <id> "<text>" | spend <id> <runs>
556
- | conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "…" [--evidence …]
566
+ | conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "…" [--stages a,b] [--evidence …]
557
567
  | abandon <id> --reason "…"
558
- The kernel experiment ledger: what we tried,
559
- how, what it found and why. Approving a plan
560
- stays with a signed-in person. Needs the
561
- tuning:experiment capability.
568
+ Each primitive's notebook of investigations:
569
+ what we tried, how, what it found and why,
570
+ and the pipeline stages it is about. A plan
571
+ is approved by a signed-in person before a
572
+ budget is spent; concluding needs evidence,
573
+ not approval. "ship" hands off: a promoter
574
+ makes it live. Needs tuning:experiment.
562
575
  admin tuning-import <brief-results.json> [--experiment <id>]
563
576
  One brief's kernels lab results as a console
564
577
  session (a trial per arm), linked to its
@@ -622,7 +635,11 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
622
635
  candidate, compare, comparisons, picks, pick,
623
636
  reading-list, pair, side-by-side, promote,
624
637
  sessions, session-new, propose, trial-add,
625
- trial-run, trial-save, and the arena, shredder
638
+ trial-run, trial-save, the Write input bank
639
+ (inputs, input, inputs-harvest, input-save),
640
+ screens (screen-new, screens, screen,
641
+ screen-advance),
642
+ and the arena, shredder
626
643
  and enhancer twins (every flag is listed in
627
644
  cli/lib/commands/admin.mjs).
628
645
  admin create-org --domain <bare-domain> [--name "Acme"] [--test] [--no-key]
@@ -808,13 +825,14 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
808
825
  sequences get <sequence-id> Flow, enrollments, who replied at each step
809
826
  sequences create --name <n> (--flow '<json>'|--flow-file <path>) [--mailer <mailer-id>]...
810
827
  [--[no-]opt-out-footer] [--[no-]postal-address-footer] [--[no-]unsubscribe-links] [--[no-]detect-reply-opt-outs]
811
- [--send-window-start <HH:MM>] [--send-window-end <HH:MM>] [--send-window-timezone <IANA tz>]
828
+ [--[no-]test-mode] [--send-window-start <HH:MM>] [--send-window-end <HH:MM>] [--send-window-timezone <IANA tz>]
812
829
  New draft sequence (nothing sends yet; rails default on, opt-out line off;
813
830
  sends 9:00-17:00 in each recipient's inferred timezone, else the sender's)
814
831
  sequences update <sequence-id> [--name <n>] [--flow '<json>'|--flow-file <path>]
815
832
  [--status draft|active|paused|archived] [--mailer <mailer-id>]...
816
833
  [--[no-]opt-out-footer] [--[no-]postal-address-footer] [--[no-]unsubscribe-links] [--[no-]detect-reply-opt-outs]
817
- [--send-window-start <HH:MM>] [--send-window-end <HH:MM>] [--send-window-timezone <IANA tz>]
834
+ [--[no-]test-mode] [--send-window-start <HH:MM>] [--send-window-end <HH:MM>] [--send-window-timezone <IANA tz>]
835
+ --test-mode (internal accounts only): walks as live, sends nothing
818
836
  sequences pause|resume <sequence-id> Stop / restart sending
819
837
  sequences enroll <sequence-id> (--list <list-id> | --email <addr>... | --recipients '<json>'|--recipients-file <path>)
820
838
  sequences stop <sequence-id> <enrollment-id> Stop one recipient
@@ -825,6 +843,12 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
825
843
  sequences links create --days <1..3> Mint a no-login review link (shown once)
826
844
  sequences links revoke <link-id> --yes
827
845
 
846
+ Dispatches (the named review desks a sequence's review stage queues to):
847
+ dispatches list Desks + how many emails each has queued
848
+ dispatches create --name <n>
849
+ dispatches rename <dispatch-id> --name <n>
850
+ dispatches delete <dispatch-id> --yes Queued emails move to the default desk
851
+
828
852
  Prospects (who you write to):
829
853
  prospects lists Your lists + counts
830
854
  prospects list <list-id> One list
@@ -837,6 +861,11 @@ Prospects (who you write to):
837
861
  --enrich starts enrichment as soon as the pull lands
838
862
  prospects enrich [--list <list-id>] [--status <s>] [--q <search>]
839
863
  Funding + competitors for un-enriched rows
864
+ prospects openers <list-id> [--group <ledes-group-id>|--new-group <name>]
865
+ Print an opening line for every row without one (background)
866
+ prospects delete <prospect-id>... --yes Permanently delete prospects
867
+ prospects columns add <label> Add a custom column (key returned)
868
+ prospects columns delete <column-id> --yes
840
869
  prospects update <prospect-id> [--status new|contacted|replied|unsubscribed|archived] [--name <n>]
841
870
  [--first-name] [--last-name] [--title] [--company] [--website] [--linkedin] [--school]
842
871
  [--city] [--country] [--years] [--notes] [--custom key=value]...
@@ -862,7 +891,7 @@ Mail:
862
891
  mail postal-address "<address>" Set the org's CAN-SPAM mailing address (email footers)
863
892
 
864
893
  Insights:
865
- insights summary [--period 14d|30d|90d] Measure Overview: AI named/cited rates, share of voice, search, targets
894
+ insights summary [--period …] [--part <block>]... Measure Overview: AI named/cited rates, share of voice, search, targets
866
895
  insights site [--period 14d|30d|90d] [--collection <uuid>]
867
896
  insights post <article-id> [--period 14d|30d|90d]
868
897
  insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
@@ -960,6 +989,7 @@ const CLIENT_COMMANDS = {
960
989
  ledes: cmdLede,
961
990
  landings: cmdLandings,
962
991
  sequences: cmdSequences,
992
+ dispatches: cmdDispatches,
963
993
  prospects: cmdProspects,
964
994
  mail: cmdMail,
965
995
  mailers: cmdMailers,
@@ -10,6 +10,8 @@
10
10
  // admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|promote … (tuning:experiment)
11
11
  // admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
12
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)
13
+ // admin replays [--org <id>] [--target <id>] [--status …] [--limit n] (fleet:read)
14
+ // admin replay --org <id> [<response-id> | --target <id>] [--surface …] (fleet:replay)
13
15
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
14
16
  // [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
15
17
  // [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
@@ -266,7 +268,8 @@ function evidenceFrom(flags) {
266
268
 
267
269
  function experimentLine(e) {
268
270
  const decision = e.decision ? ` → ${e.decision}` : "";
269
- return `${e.id} [${e.status}${decision}] ${e.question}${e.gist ? `\n ${e.gist}` : ""}`;
271
+ const where = e.primitive ? `${e.primitive}${e.stages?.length ? `: ${e.stages.join(", ")}` : ""} ` : "";
272
+ return `${e.id} [${e.status}${decision}] ${where}${e.question}${e.gist ? `\n ${e.gist}` : ""}`;
270
273
  }
271
274
 
272
275
  /**
@@ -291,7 +294,13 @@ async function cmdAdminExperiments(ctx) {
291
294
  switch (sub) {
292
295
  case "list": {
293
296
  const params = new URLSearchParams(
294
- compact({ q: flagStr(flags.q), status: flagStr(flags.status), limit: flagStr(flags.limit) })
297
+ compact({
298
+ q: flagStr(flags.q),
299
+ status: flagStr(flags.status),
300
+ primitive: flagStr(flags.primitive),
301
+ stage: flagStr(flags.stage),
302
+ limit: flagStr(flags.limit),
303
+ })
295
304
  );
296
305
  const result = await staffRequest(ctx, "GET", `${EXPERIMENTS}?${params}`);
297
306
  return out(result, (r) =>
@@ -304,9 +313,13 @@ async function cmdAdminExperiments(ctx) {
304
313
  case "new": {
305
314
  const question = [id, ...rest].filter(Boolean).join(" ").trim();
306
315
  if (question.length < 3)
307
- throw new CliError('Usage: admin experiments new "<question>" [--plan-file plan.json] [--tags a,b]');
316
+ throw new CliError(
317
+ 'Usage: admin experiments new "<what you are investigating>" [--primitive write|research|shred|enhance] [--stages write,rarity] [--plan-file plan.json] [--tags a,b]'
318
+ );
308
319
  const body = compact({
309
320
  question,
321
+ primitive: flagStr(flags.primitive),
322
+ stages: flagList(flags.stages),
310
323
  restated: flagStr(flags.hypothesis),
311
324
  plan: jsonFile(flags, "plan"),
312
325
  method: flagStr(flags.method),
@@ -329,6 +342,7 @@ async function cmdAdminExperiments(ctx) {
329
342
  result: jsonFile(flags, "result"),
330
343
  method: flagStr(flags.method),
331
344
  tags: flagList(flags.tags),
345
+ stages: flagList(flags.stages),
332
346
  budget_runs: flagNum(flags.budget),
333
347
  engine_build: flagStr(flags.build),
334
348
  add_evidence: evidenceFrom(flags),
@@ -355,12 +369,13 @@ async function cmdAdminExperiments(ctx) {
355
369
  const conclusion = flagStr(flags.conclusion);
356
370
  if (!id || !decision || !conclusion)
357
371
  throw new CliError(
358
- 'Usage: admin experiments conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "<what we learned and why>" [--evidence kind:ref,…] [--result-file r.json]'
372
+ 'Usage: admin experiments conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "<what we learned and why>" [--stages write,rarity] [--evidence kind:ref,…] [--result-file r.json]'
359
373
  );
360
374
  const body = compact({
361
375
  action: "conclude",
362
376
  decision,
363
377
  conclusion,
378
+ stages: flagList(flags.stages),
364
379
  result: jsonFile(flags, "result"),
365
380
  add_evidence: evidenceFrom(flags),
366
381
  actor: actorFrom(flags),
@@ -441,13 +456,28 @@ async function cmdAdminTuningContext(ctx) {
441
456
  * admin tuning base-kernels the kernels a session can run on (ids for --kernel)
442
457
  * admin tuning base-versions <kernel-id> the versions a session on it can run against
443
458
  * admin tuning sessions | session <id> [--texts] list / one session: trials, runs and their numbers (--texts adds each trial's latest text)
444
- * admin tuning session-new --title "…" --kernel <id|name> (--input-file f.md | --input "…") [--kind article|outline|brief]
459
+ * admin tuning session-new --title "…" --kernel <id|name> (--input-file f.md | --input "…" | --bank <input-id>) [--kind article|outline|brief]
445
460
  * [--version N] [--writers echo/echo,…] [--trials-from <session-id>]
461
+ * (--bank: a bank input's text, and its request's constraints under every trial)
446
462
  * admin tuning propose <session-id> --text "Echo with rarity on; live with the shred after" [--create]
447
463
  * admin tuning trial-add <session-id> [--name "…"] [--patch '{…}'] [--options '{…}'] [--from <trial-id>]
448
464
  * admin tuning trial-run <trial-id> [--repeats 2] queue runs (guardrails apply)
449
465
  * admin tuning trial-save <trial-id> [--note "…"] save a trial as a candidate version (never live)
450
466
  *
467
+ * The Write input bank: real kernel requests (above all the arena outlines Auto-Pen sends), as sent.
468
+ * admin tuning inputs [--filter "origin=arena_outline;material=thin,typical"] [--q "…"] list by tag, with coverage per bucket
469
+ * admin tuning input <input-id> one input in full: text, constraints, tags, run defaults, origin
470
+ * admin tuning inputs-harvest [--n 40] [--pool 400] [--callers auto-pen,endpoint] [--since-days 21] [--min-per-bucket 3]
471
+ * freeze recent production runs, stratified; idempotent by key
472
+ * admin tuning input-save <session-id> [--note "…"] save a session's pasted input or arena outline to the bank
473
+ *
474
+ * Screens: "is this candidate worth a full comparison?", on a slice of the bank (it does not decide).
475
+ * admin tuning screen-new --title "…" (--from <session-id> | --kernel <kernel-id> --versions 23,29) [--version N]
476
+ * [--n 8] [--filter "origin=arena_outline;material=thin"] [--inputs <id>,…] [--repeats 1] [--lift-budget]
477
+ * one session per input with the same trials; runs queue as the console's caps allow
478
+ * admin tuning screens | screen <id> list / one screen: per candidate, each measure against the Base, with its noise
479
+ * admin tuning screen-advance <id> queue what the caps allow now (the cron does it every minute)
480
+ *
451
481
  * Research (Phase 3): our arena's versions as files, research comparisons, the deploy split.
452
482
  * admin tuning arenas | arena <version-id> our arena's versions / one version's file and diff
453
483
  * admin tuning arena-candidate --patch '{…}' | --file f.json [--of <version-id>] [--note "…"] save a candidate
@@ -490,6 +520,19 @@ function jsonFlag(raw, name) {
490
520
  }
491
521
  }
492
522
 
523
+ /**
524
+ * `--filter "origin=arena_outline;material=thin,typical"` → the bank's query params (one comma list
525
+ * per tag dimension; lib/tuning/input-tags.ts `parseTagFilter` reads the same form).
526
+ */
527
+ function tagFilterParams(raw) {
528
+ const out = {};
529
+ for (const part of (raw ?? "").split(";")) {
530
+ const [k, v] = part.split("=");
531
+ if (k?.trim() && v?.trim()) out[k.trim()] = v.trim();
532
+ }
533
+ return out;
534
+ }
535
+
493
536
  /**
494
537
  * A session as an agent reads it: each trial's changes and runs with their headline numbers, and
495
538
  * (with `texts`) the latest finished text per trial. The full session (traces, records) is `--json`.
@@ -768,9 +811,11 @@ async function cmdAdminTuning(ctx) {
768
811
  throw new CliError(`--input-file ${path}: ${err.message}`);
769
812
  }
770
813
  }
771
- if (!title || !kernel || !input)
814
+ // A bank input instead of a paste: its text, and its request's constraints under every trial.
815
+ const bank = flagStr(flags.bank);
816
+ if (!title || !kernel || (!input && !bank))
772
817
  throw new CliError(
773
- 'Usage: admin tuning session-new --title "…" --kernel <id|name> (--input-file f.md | --input "…") [--kind article|outline|brief] [--version N] [--writers a,b] [--versions 23,29] [--trials-from <session-id>]'
818
+ 'Usage: admin tuning session-new --title "…" --kernel <id|name> (--input-file f.md | --input "…" | --bank <input-id>) [--kind article|outline|brief] [--version N] [--writers a,b] [--versions 23,29] [--trials-from <session-id>]'
774
819
  );
775
820
  let kernelId = kernel;
776
821
  if (!/^[0-9a-f-]{36}$/i.test(kernel)) {
@@ -786,8 +831,9 @@ async function cmdAdminTuning(ctx) {
786
831
  T,
787
832
  compact({
788
833
  title,
789
- input_text: input,
834
+ input_text: bank ? undefined : input,
790
835
  input_kind: flagStr(flags.kind) ?? "unknown",
836
+ material: bank ? { source: "bank", input_id: bank } : undefined,
791
837
  base_kernel_id: kernelId,
792
838
  base_version: flagNum(flags.version),
793
839
  trials_from_session_id: flagStr(flags["trials-from"]),
@@ -869,6 +915,167 @@ async function cmdAdminTuning(ctx) {
869
915
  `${r.kernel}: saved as candidate v${r.version}${r.baseVersion ? ` (from v${r.baseVersion})` : ""}${r.leftBehind.length ? `; per-run, not saved: ${r.leftBehind.join(", ")}` : ""}`
870
916
  );
871
917
  }
918
+ // --- The Write input bank: real kernel requests a session can start from ---
919
+ case "inputs": {
920
+ const q = new URLSearchParams(
921
+ compact({ ...tagFilterParams(flagStr(flags.filter)), q: flagStr(flags.q), limit: flagStr(flags.limit) })
922
+ );
923
+ const r = await staffRequest(ctx, "GET", `${T}/inputs${q.size ? `?${q}` : ""}`);
924
+ if (json) return printResult(io, flags, r);
925
+ const coverage = Object.entries(r.coverage)
926
+ .map(
927
+ ([dim, buckets]) =>
928
+ ` ${dim.padEnd(11)} ${Object.entries(buckets)
929
+ .map(([b, n]) => `${b} ${n}`)
930
+ .join(" · ")}`
931
+ )
932
+ .join("\n");
933
+ return io.log(
934
+ [
935
+ `bank ${r.set}: ${r.inputs.length} of ${r.total} input(s)`,
936
+ ...r.inputs.map(
937
+ (i) =>
938
+ `${i.id} ${i.source.padEnd(10)} ${String(i.tags.origin ?? "").padEnd(13)} ${String(i.tags.material?.bucket ?? "").padEnd(7)} ${String(i.tags.shape?.bucket ?? "").padEnd(5)} ${i.title}`
939
+ ),
940
+ "coverage (the whole bank):",
941
+ coverage,
942
+ ].join("\n")
943
+ );
944
+ }
945
+ case "input": {
946
+ if (!id) throw new CliError("Usage: admin tuning input <input-id>");
947
+ return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${T}/inputs/${id}`));
948
+ }
949
+ case "inputs-harvest": {
950
+ const r = await staffRequest(
951
+ ctx,
952
+ "POST",
953
+ `${T}/inputs/harvest`,
954
+ compact({
955
+ n: flagNum(flags.n),
956
+ pool: flagNum(flags.pool),
957
+ callers: flagStr(flags.callers)
958
+ ?.split(",")
959
+ .map((c) => c.trim())
960
+ .filter(Boolean),
961
+ since_days: flagNum(flags["since-days"]),
962
+ min_per_bucket: flagNum(flags["min-per-bucket"]),
963
+ })
964
+ );
965
+ if (json) return printResult(io, flags, r);
966
+ return io.log(
967
+ [
968
+ `added ${r.added.length} input(s) to ${r.set} from a pool of ${r.pool} run(s); ${r.alreadyHeld} already held`,
969
+ ...r.added.map((a) => ` ${a.key} (${a.reason})`),
970
+ ...Object.entries(r.skipped).map(([why, n]) => ` skipped ${n}: ${why}`),
971
+ ].join("\n")
972
+ );
973
+ }
974
+ case "input-save": {
975
+ if (!id) throw new CliError('Usage: admin tuning input-save <session-id> [--note "…"]');
976
+ const r = await staffRequest(
977
+ ctx,
978
+ "POST",
979
+ `${T}/sessions/${id}/bank`,
980
+ compact({ note: flagStr(flags.note) })
981
+ );
982
+ return json ? printResult(io, flags, r) : io.log(`bank input ${r.input.id} (${r.input.key})`);
983
+ }
984
+ // --- Screens: candidates across a slice of the bank, read against the Base, beside the noise ---
985
+ case "screen-new": {
986
+ const title = flagStr(flags.title);
987
+ const from = flagStr(flags.from);
988
+ const versions = (flagStr(flags.versions) ?? "")
989
+ .split(",")
990
+ .map((v) => Number(v.trim()))
991
+ .filter((v) => Number.isInteger(v) && v > 0);
992
+ if (!title || (!from && !(flagStr(flags.kernel) && versions.length)))
993
+ throw new CliError(
994
+ 'Usage: admin tuning screen-new --title "…" (--from <session-id> | --kernel <kernel-id> --versions 23,29) [--version N] [--n 8] [--filter "origin=arena_outline;material=thin"] [--inputs <id>,…] [--repeats 1] [--lift-budget]'
995
+ );
996
+ const filter = Object.fromEntries(
997
+ Object.entries(tagFilterParams(flagStr(flags.filter))).map(([k, v]) => [
998
+ k,
999
+ v
1000
+ .split(",")
1001
+ .map((x) => x.trim())
1002
+ .filter(Boolean),
1003
+ ])
1004
+ );
1005
+ const inputIds = (flagStr(flags.inputs) ?? "")
1006
+ .split(",")
1007
+ .map((x) => x.trim())
1008
+ .filter(Boolean);
1009
+ const r = await staffRequest(
1010
+ ctx,
1011
+ "POST",
1012
+ `${T}/screens`,
1013
+ compact({
1014
+ title,
1015
+ from_session_id: from,
1016
+ base_kernel_id: flagStr(flags.kernel),
1017
+ base_version: flagNum(flags.version),
1018
+ versions: versions.length ? versions : undefined,
1019
+ n: flagNum(flags.n),
1020
+ filter: Object.keys(filter).length ? filter : undefined,
1021
+ input_ids: inputIds.length ? inputIds : undefined,
1022
+ repeats: flagNum(flags.repeats),
1023
+ lift_budget: flagBool(flags["lift-budget"]) || undefined,
1024
+ })
1025
+ );
1026
+ if (json) return printResult(io, flags, r);
1027
+ return io.log(
1028
+ `screen ${r.screen.id}: ${r.screen.inputs} input(s), ${r.aggregate.columns.length} column(s); ${r.screen.pending} run(s) waiting${r.screen.waiting ? ` (${r.screen.waiting})` : ""} → ${ctx.client.url}/superadmin/tuning/screens/${r.screen.id}`
1029
+ );
1030
+ }
1031
+ case "screens": {
1032
+ const r = await staffRequest(ctx, "GET", `${T}/screens?limit=${flagNum(flags.limit) ?? 30}`);
1033
+ if (json) return printResult(io, flags, r);
1034
+ return io.log(
1035
+ r.screens
1036
+ .map(
1037
+ (x) =>
1038
+ `${x.id} ${x.status.padEnd(7)} ${x.title} · ${x.inputs} inputs${x.pending ? ` · ${x.pending} waiting` : ""}`
1039
+ )
1040
+ .join("\n") || "(no screens)"
1041
+ );
1042
+ }
1043
+ case "screen": {
1044
+ if (!id) throw new CliError("Usage: admin tuning screen <screen-id>");
1045
+ const r = await staffRequest(ctx, "GET", `${T}/screens/${id}`);
1046
+ if (json) return printResult(io, flags, r);
1047
+ // The reading as an agent reads it: per candidate, each measure's difference from the Base and its noise.
1048
+ return printResult(
1049
+ io,
1050
+ { ...flags, json: true },
1051
+ {
1052
+ screen: r.screen,
1053
+ note: r.aggregate.note,
1054
+ columns: r.aggregate.columns,
1055
+ measures: r.aggregate.measures.map((m) => ({
1056
+ measure: m.label,
1057
+ judge: m.judgeId,
1058
+ standing: m.standing,
1059
+ sigma: m.sigma,
1060
+ per_column: m.perTrial.map((p) =>
1061
+ compact({
1062
+ column: p.column,
1063
+ n: p.n,
1064
+ mean: p.mean,
1065
+ delta: p.delta ?? undefined,
1066
+ })
1067
+ ),
1068
+ })),
1069
+ }
1070
+ );
1071
+ }
1072
+ case "screen-advance": {
1073
+ if (!id) throw new CliError("Usage: admin tuning screen-advance <screen-id>");
1074
+ const r = await staffRequest(ctx, "POST", `${T}/screens/${id}/advance`, {});
1075
+ return json
1076
+ ? printResult(io, flags, r)
1077
+ : io.log(`queued ${r.queued} run(s)${r.waiting ? `; waiting: ${r.waiting}` : ""}`);
1078
+ }
872
1079
  // --- Research: our arena's versions, research comparisons, the deploy split ---
873
1080
  case "arenas": {
874
1081
  const r = await staffRequest(ctx, "GET", `${T}/arena-files`);
@@ -1926,8 +2133,79 @@ async function cmdAdminEvals(ctx) {
1926
2133
  }
1927
2134
  }
1928
2135
 
2136
+ const REPLAY_SURFACES = ["openai", "anthropic", "google-aio", "google-gemini", "perplexity"];
2137
+ const REPLAY_STATUSES = ["queued", "running", "captured", "not_reproduced", "unsupported", "failed"];
2138
+ const REPLAYS = "/api/superadmin/answer-replays";
2139
+
2140
+ function oneOf(flags, name, values) {
2141
+ const v = flagStr(flags[name]);
2142
+ if (v !== undefined && !values.includes(v)) throw new CliError(`--${name} must be one of: ${values.join(", ")}`);
2143
+ return v;
2144
+ }
2145
+
2146
+ function formatReplays(r) {
2147
+ const rows = r.replays ?? [];
2148
+ if (!rows.length) return "No answer replays.";
2149
+ return rows
2150
+ .map((x) => {
2151
+ const head = `${x.id} ${x.surfaceLabel} ${x.status} ${x.attemptsMade}/${x.plannedAttempts} asks ${x.createdAt}`;
2152
+ const lines = [head];
2153
+ if (x.capture) {
2154
+ lines.push(
2155
+ ` captured on ask ${x.capture.step + 1}: "${x.capture.query}" (web search ${x.capture.webSearch})`
2156
+ );
2157
+ for (const img of x.capture.images ?? []) lines.push(` ${img.kind}: ${img.url}`);
2158
+ }
2159
+ if (x.error) lines.push(` ${x.error}`);
2160
+ return lines.join("\n");
2161
+ })
2162
+ .join("\n");
2163
+ }
2164
+
2165
+ /** `admin replays` / `admin replay` — live answer replays (staff only). */
2166
+ async function cmdAdminReplays(ctx, sub) {
2167
+ const { flags, positionals, io } = ctx;
2168
+ if (sub === "replays") {
2169
+ const params = new URLSearchParams(
2170
+ compact({
2171
+ org_id: flagStr(flags.org),
2172
+ target_id: flagStr(flags.target),
2173
+ status: oneOf(flags, "status", REPLAY_STATUSES),
2174
+ limit: flagNum(flags.limit)?.toString(),
2175
+ })
2176
+ );
2177
+ const result = await staffRequest(ctx, "GET", `${REPLAYS}${params.size ? `?${params}` : ""}`);
2178
+ printResult(io, flags, result, formatReplays);
2179
+ return 0;
2180
+ }
2181
+ const org = flagStr(flags.org);
2182
+ const responseId = positionals[0];
2183
+ const target = flagStr(flags.target);
2184
+ if (!org) throw new CliError("admin replay needs --org <org-id>");
2185
+ const result = await staffPost(
2186
+ ctx,
2187
+ REPLAYS,
2188
+ compact({
2189
+ org_id: org,
2190
+ response_id: responseId,
2191
+ target_id: target,
2192
+ surface: oneOf(flags, "surface", REPLAY_SURFACES),
2193
+ })
2194
+ );
2195
+ ok(
2196
+ ctx,
2197
+ result.already_queued
2198
+ ? `Already replaying: ${result.replay_id}`
2199
+ : `Replay ${result.replay_id} is ${result.status}. Poll: ${ctx.bin} admin replays --org ${org}`
2200
+ );
2201
+ printResult(io, flags, result);
2202
+ return 0;
2203
+ }
2204
+
1929
2205
  export async function cmdAdmin(ctx) {
1930
2206
  const sub = ctx.positionals[0];
2207
+ if (sub === "replays" || sub === "replay")
2208
+ return cmdAdminReplays({ ...ctx, positionals: ctx.positionals.slice(1) }, sub);
1931
2209
  if (sub === "crm") return cmdAdminCrm({ ...ctx, positionals: ctx.positionals.slice(1) });
1932
2210
  if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
1933
2211
  if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
@@ -1937,6 +2215,6 @@ export async function cmdAdmin(ctx) {
1937
2215
  if (sub === "tuning") return cmdAdminTuning({ ...ctx, positionals: ctx.positionals.slice(1) });
1938
2216
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
1939
2217
  throw new CliError(
1940
- 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin evals … | admin tuning … | admin tuning-context (see --help)'
2218
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin evals … | admin tuning … | admin tuning-context | admin replays … (see --help)'
1941
2219
  );
1942
2220
  }
@@ -21,6 +21,7 @@ export const ARGS = {
21
21
  },
22
22
  get_measure_summary: {
23
23
  period: { flag: "period", enum: PERIODS },
24
+ parts: { flag: "part", type: "list" },
24
25
  },
25
26
  };
26
27
 
@@ -59,6 +59,8 @@ const SEQUENCE_RAIL_ARGS = {
59
59
  postal_address_footer: { flag: "postal-address-footer", type: "bool", negate: "no-postal-address-footer" },
60
60
  unsubscribe_links: { flag: "unsubscribe-links", type: "bool", negate: "no-unsubscribe-links" },
61
61
  detect_reply_opt_outs: { flag: "detect-reply-opt-outs", type: "bool", negate: "no-detect-reply-opt-outs" },
62
+ // Staff-only to turn on (the server refuses it for anyone else): walks as live, sends nothing.
63
+ test_mode: { flag: "test-mode", type: "bool", negate: "no-test-mode" },
62
64
  };
63
65
 
64
66
  // A send-window edge from --<flag> HH:MM (or a raw minute of day) → the
@@ -158,6 +160,17 @@ export const ARGS = {
158
160
  item_id: { positional: 0, label: "item-id", required: true },
159
161
  action: { flag: "action", required: true, enum: ["approve", "skip", "delete"] },
160
162
  },
163
+ list_dispatches: {},
164
+ create_dispatch: {
165
+ name: { flag: "name", required: true },
166
+ },
167
+ update_dispatch: {
168
+ dispatch_id: { positional: 0, label: "dispatch-id", required: true },
169
+ name: { flag: "name", required: true },
170
+ },
171
+ delete_dispatch: {
172
+ dispatch_id: { positional: 0, label: "dispatch-id", required: true },
173
+ },
161
174
  list_dispatch_links: {},
162
175
  create_dispatch_link: {
163
176
  days: { flag: "days", type: "num", required: true },
@@ -210,6 +223,26 @@ export const ARGS = {
210
223
  status: { flag: "status", enum: PROSPECT_STATUSES },
211
224
  q: "q",
212
225
  },
226
+ delete_prospects: {
227
+ prospect_ids: {
228
+ value: (flags, positionals) => {
229
+ const ids = [...positionals, ...flagList(flags.id)];
230
+ if (!ids.length) throw new CliError("Missing <prospect-id>... (or --id <prospect-id>, repeatable)");
231
+ return ids;
232
+ },
233
+ },
234
+ },
235
+ create_prospect_column: {
236
+ label: { positional: 0, label: "label", required: true },
237
+ },
238
+ delete_prospect_column: {
239
+ column_id: { positional: 0, label: "column-id", required: true },
240
+ },
241
+ generate_prospect_openers: {
242
+ list_id: { positional: 0, label: "list-id", required: true },
243
+ group_id: "group",
244
+ new_group_name: "new-group",
245
+ },
213
246
  update_prospect: {
214
247
  prospect_id: { positional: 0, label: "prospect-id", required: true },
215
248
  status: { flag: "status", enum: PROSPECT_STATUSES },
@@ -292,6 +325,7 @@ const VALUE_FLAGS = {
292
325
  update_sequence: ["flow", "flow-file", "mailer", "send-window-start", "send-window-end"],
293
326
  enroll_in_sequence: ["recipients", "recipients-file", "email"],
294
327
  update_review_item: ["body", "file"],
328
+ delete_prospects: ["id"],
295
329
  import_prospects_csv: ["csv", "csv-file"],
296
330
  update_prospect: ["custom"],
297
331
  set_mailer_forwarding: ["to", "clear"],
@@ -306,6 +340,37 @@ async function run(ctx, tool, message) {
306
340
  }
307
341
 
308
342
  const SEQUENCES_USAGE = `Usage: sequences <list|get|create|update|pause|resume|enroll|stop|review|links>`;
343
+ const DISPATCHES_USAGE = `Usage: dispatches <list|create|rename|delete>`;
344
+
345
+ // Dispatches: the named review desks a sequence's review stage queues to.
346
+ export async function cmdDispatches(ctx) {
347
+ const sub = ctx.positionals[0];
348
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
349
+ switch (sub) {
350
+ case "list":
351
+ case "ls":
352
+ case undefined:
353
+ return run(rest, "list_dispatches");
354
+ case "create":
355
+ case "new":
356
+ return run(rest, "create_dispatch", "Dispatch created.");
357
+ case "rename":
358
+ case "update":
359
+ return run(rest, "update_dispatch", "Dispatch renamed.");
360
+ case "delete": {
361
+ if (!ctx.flags.yes) {
362
+ const id = flagStr(rest.positionals[0]) ?? "<dispatch-id>";
363
+ ctx.io.error(
364
+ `Its queued emails move to the default desk. Re-run with --yes to confirm: ${ctx.bin} dispatches delete ${id} --yes`
365
+ );
366
+ return 1;
367
+ }
368
+ return run(rest, "delete_dispatch", "Dispatch deleted; its queued emails moved to the default desk.");
369
+ }
370
+ default:
371
+ throw new CliError(`Unknown dispatches subcommand: ${sub}. ${DISPATCHES_USAGE}`);
372
+ }
373
+ }
309
374
 
310
375
  export async function cmdSequences(ctx) {
311
376
  const sub = ctx.positionals[0];
@@ -395,7 +460,7 @@ async function sequencesLinks(ctx, sub) {
395
460
  }
396
461
  }
397
462
 
398
- const PROSPECTS_USAGE = `Usage: prospects <lists|list|ls|get|create-list|delete-list|import|source|enrich|update>`;
463
+ const PROSPECTS_USAGE = `Usage: prospects <lists|list|ls|get|create-list|delete-list|import|source|enrich|openers|update|delete|columns>`;
399
464
 
400
465
  export async function cmdProspects(ctx) {
401
466
  const sub = ctx.positionals[0];
@@ -424,13 +489,46 @@ export async function cmdProspects(ctx) {
424
489
  );
425
490
  case "enrich":
426
491
  return run(rest, "enrich_prospects", "Enrichment queued.");
492
+ case "openers":
493
+ return run(rest, "generate_prospect_openers", "Printing openers; they fill in as each one lands.");
427
494
  case "update":
428
495
  return run(rest, "update_prospect", "Updated prospect.");
496
+ case "delete":
497
+ case "rm": {
498
+ if (!ctx.flags.yes) {
499
+ ctx.io.error(`Deleting prospects cannot be undone. Re-run with --yes to confirm.`);
500
+ return 1;
501
+ }
502
+ return run(rest, "delete_prospects", "Deleted.");
503
+ }
504
+ case "columns":
505
+ return prospectsColumns(rest, rest.positionals[0]);
429
506
  default:
430
507
  throw new CliError(`Unknown prospects subcommand: ${sub ?? "(none)"}. ${PROSPECTS_USAGE}`);
431
508
  }
432
509
  }
433
510
 
511
+ async function prospectsColumns(ctx, sub) {
512
+ const inner = { ...ctx, positionals: ctx.positionals.slice(1) };
513
+ switch (sub) {
514
+ case "add":
515
+ return run(inner, "create_prospect_column", "Column added.");
516
+ case "delete":
517
+ case "rm": {
518
+ if (!ctx.flags.yes) {
519
+ const id = flagStr(inner.positionals[0]) ?? "<column-id>";
520
+ ctx.io.error(`Re-run with --yes to confirm: ${ctx.bin} prospects columns delete ${id} --yes`);
521
+ return 1;
522
+ }
523
+ return run(inner, "delete_prospect_column", "Column removed.");
524
+ }
525
+ default:
526
+ throw new CliError(
527
+ `Unknown columns subcommand: ${sub ?? "(none)"}. Try: add <label>, delete <column-id> --yes`
528
+ );
529
+ }
530
+ }
531
+
434
532
  const MAILERS_USAGE = `Usage: mailers <list|domain|messages|message|forward|warmup|limit|retire|suppressions>`;
435
533
 
436
534
  export async function cmdMailers(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.65.0",
3
+ "version": "0.69.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",