@letterstory/cli 0.70.2 → 0.71.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
@@ -362,6 +362,12 @@ letterstory admin tuning comparison <id> # per-measure verdicts (b
362
362
  letterstory admin tuning picks <id> # the blind queue; then: admin tuning pick <id> --cell <key> --pick left|right|tie
363
363
  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
364
364
  letterstory admin tuning pair <id> --cell <key> # one pair named: live and candidate, both texts, the measures per arm (a key reads every pair)
365
+ letterstory admin tuning bank # the eval bank: every eval (rules and drafts), instances, labels
366
+ letterstory admin tuning bank-pool <eval-id> # draw an eval's pool from our shipped runs and the human corpus
367
+ letterstory admin tuning bank-judge <eval-id> --prompt-file judge.md --mode score --threshold 4 # a judge version (or --live: the rule's own)
368
+ letterstory admin tuning bank-score <eval-id> --version 2 # score it over the pool; prints agreement on held-out labels
369
+ letterstory admin tuning bank-try <eval-id> --version 2 # the judge as a trial rule in candidates, and a comparison against live
370
+ letterstory admin tuning invite --email editor@example.com # invite an editor (Pick and Eval bank only); `people`, `revoke <id>`
365
371
  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
366
372
  letterstory admin tuning candidates --kernels all --patch '{"writer":"echo/echo"}' --note "Echo" # the same change as a candidate of each kernel
367
373
  letterstory admin tuning session-new --title "Echo round 1" --kernel Hazel --input-file brief.md --kind outline [--version 22] [--versions 23,29] # --versions: saved candidates, a trial each
package/lib/cli.mjs CHANGED
@@ -56,7 +56,7 @@ import {
56
56
  } from "./commands.mjs";
57
57
 
58
58
  // Keep in sync with cli/package.json.
59
- export const VERSION = "0.70.2";
59
+ export const VERSION = "0.71.0";
60
60
 
61
61
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
62
62
  // can't accidentally swallow the id as --json's value.
@@ -293,7 +293,7 @@ Connectors:
293
293
  [--confirm-missing-target] re-publish even though
294
294
  the item was deleted/unpublished on the provider
295
295
  connectors status --connector <connector> --publish-id <id>
296
- connectors pull [--collection <uuid>] [--provider <webflow|framer|sanity|contentful>]
296
+ connectors pull [--collection <uuid>] [--provider <webflow|framer|sanity|contentful|github>]
297
297
  Pull existing posts FROM a connected CMS into a
298
298
  collection, reconciling (adopt/update/create/orphan).
299
299
  --collection for a bound collection, --provider to
@@ -302,7 +302,10 @@ Connectors:
302
302
  Save + validate CMS credentials. framer also needs
303
303
  --project-url (+ [--no-auto-deploy]); sanity needs
304
304
  --project-id [--dataset]; contentful needs --space-id
305
- [--environment].
305
+ [--environment]; github takes a personal access
306
+ token (Contents + Pull requests read/write) — its
307
+ "mappings" are publish targets (repo, branch, path)
308
+ and a publish opens a PR (live once merged).
306
309
  connectors disconnect --provider <p> Forget a CMS connection (keeps imported posts)
307
310
  connectors connections Show connection status for every provider
308
311
  connectors mappings --provider <p> List the org's field mappings for a provider
@@ -505,7 +508,7 @@ Collection onboarding (your OWN blog, on your org's domain — no provisioning):
505
508
  Import the existing blog from its sitemap
506
509
  (auto-discovered from the org's domain unless
507
510
  --sitemap pins one) OR from a connected CMS with
508
- --cms <webflow|framer|sanity|contentful>, then
511
+ --cms <webflow|framer|sanity|contentful|github>, then
509
512
  derive content areas + series, build the topic
510
513
  map, bind SEO demand, and stock Headlines & Queue.
511
514
  Omit --collection to create a fresh one (--name,
@@ -634,7 +637,10 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
634
637
  registry the Pipeline tab draws (JSON).
635
638
  admin tuning <sub> … The console's loop over a staff key: kernels,
636
639
  candidate, compare, comparisons, picks, pick,
637
- reading-list, pair, side-by-side, promote,
640
+ ledger, reading-list, finalists,
641
+ finalists-queue, pair, side-by-side,
642
+ evals, eval-add, eval-set, proposals,
643
+ propose-eval, capture-eval, reject-eval, promote,
638
644
  sessions, session-new, propose, trial-add,
639
645
  trial-run, trial-save, the Write input bank
640
646
  (inputs, input, inputs-harvest, input-save),
@@ -7,7 +7,9 @@
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
+ // admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|criteria|promote … (tuning:experiment)
11
+ // admin tuning evals|eval-add|eval-set|proposals|propose-eval|capture-eval|reject-eval … (tuning:experiment)
12
+ // (propose-eval --fix "…" files the reader's rewrite; the diff is classified to a lever, or to a schema gap: kind lever)
11
13
  // admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
12
14
  // 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
15
  // admin replays [--org <id>] [--target <id>] [--status …] [--limit n] (fleet:read)
@@ -40,7 +42,7 @@ import { flagStr, flagNum, flagBool, flagList, printResult, compact, ok } from "
40
42
  import { watchDomainJob } from "./onboard.mjs";
41
43
 
42
44
  const SEER_MODES = ["suggest", "skip"];
43
- const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
45
+ const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful", "github"];
44
46
 
45
47
  function staffKeyFrom(flags) {
46
48
  const key = flagStr(flags["staff-key"]) || process.env.LETTERSTORY_STAFF_API_KEY || null;
@@ -439,15 +441,38 @@ async function cmdAdminTuningContext(ctx) {
439
441
  * admin tuning kernels every catalog kernel, live version, writer, candidates
440
442
  * admin tuning kernel <service-kernel-id> [--version N|live] one version's file (JSON)
441
443
  * admin tuning candidate <service-kernel-id> --file f.json [--note "…"] save a file as a candidate version
442
- * admin tuning compare --title "…" --candidates Hazel:21,Aspen:20 [--experiment <id>] start a comparison
444
+ * admin tuning compare --title "…" --candidates Hazel:21,Aspen:20 [--experiment <id>] [--pairs 20] start a comparison (--pairs caps it: breadth at 10–20, depth at the full 66)
443
445
  * (Hazel:23@22 reads v23 against the test version v22 instead of live; it cannot promote)
444
446
  * admin tuning comparisons | comparison <id> list / read one (per-measure verdicts)
445
- * admin tuning picks <comparison-id> the blind queue (next pairs)
446
- * admin tuning pick <comparison-id> --cell <key> --pick left|right|tie record a blind pick
447
+ * admin tuning picks <comparison-id> [--criterion opening] the blind queue (next pairs); with a criterion, the pairs not yet read for it (Eye Test mode)
448
+ * admin tuning pick <comparison-id> --cell <key> --pick left|right|tie [--criterion opening] [--note "why"] record a blind pick (the note is the reader's reason; a criterion pick steers the next arm, never promotion)
449
+ * admin tuning criteria the Eye Test's criteria: id, label and the question each asks of a pair
450
+ * admin tuning loop where the loop stands: what waits at each station, and where to go next
451
+ * admin tuning ledger <comparison-id> the defect ledger: per arm, what the diagnosis read named, the floor and the ceiling, the candidate's next arm; then the reader edits per arm by lever, and the schema gaps
452
+ * admin tuning evals the eval list: every defect category with its status and the picks' agreement (n, agreement, suggestion)
453
+ * admin tuning eval-add --id … --label … --how … --layer … --lever … --locality local|structural --axis … [--example "span"] add a category (starts unvetted)
454
+ * admin tuning eval-set <id> [--status confirmed|rejected|unvetted] [--example "span" [--fix "…"]] confirm / reject (a person's call), or add an example
455
+ * admin tuning proposals [--status proposed|captured|rejected] the eval proposals: spans readers marked in a pair's text with their words, and what became of each
456
+ * admin tuning propose-eval --span "…" --comment "…" [--fix "…"] [--comparison <id> --cell <key> --side control|candidate] [--kind narrow|wide] mark a span as an eval (no --side from a blind read); --fix is the reader's rewrite, classified to a lever (or a schema gap: kind lever)
457
+ * admin tuning capture-eval <proposal-id> --as category --id … --label … --how … --layer … --lever … --locality local|structural --axis … capture it into the eval list, the span (and its fix) as the category's example (appends when the id exists)
458
+ * admin tuning capture-eval <proposal-id> --as rule file it as narrow; the rule itself is written in the kernels rule lab
459
+ * admin tuning capture-eval <proposal-id> --as lever [--note "…"] file it as a schema gap: an edit no kernel-file lever can produce, for the kernels repo
460
+ * admin tuning reject-eval <proposal-id> a span that was not an eval after all
447
461
  * admin tuning reading-list <comparison-id> [--n 10] your next blind pairs as links, to read off the screen
462
+ * admin tuning finalists [--limit 10] the finalists feed: done, gates clean, no craft loss, not yet decided by the picks; ranked by the objective's order
463
+ * admin tuning finalists-queue [--n 10] your next blind pairs round-robin across the finalists (a key reads the queue of the person who owns it; recording a pick still needs a person)
448
464
  * admin tuning archive <comparison-id> [--undo] take a read comparison out of the working list (or back); the record stays
449
465
  * admin tuning comparisons [--archived only|all] the list; archived ones only when asked
450
466
  * admin tuning pair <comparison-id> --cell <key> one pair named: live and candidate, both texts, the measures per arm
467
+ * admin tuning bank the eval bank: every eval (rules and drafts), instances, labels
468
+ * admin tuning bank-eval <eval-id> one eval: its standing, judge versions and the report (JSON)
469
+ * admin tuning bank-draft --name "…" --shape '<regex>' [--flags i] a draft eval, not a rule yet
470
+ * admin tuning bank-shape <eval-id> --shape '<regex>'|--rule [--flags i] widen or narrow the pool's shape (--rule: back to the rule's own)
471
+ * admin tuning bank-pool <eval-id> [--per-source 150] [--days 14] draw the pool from our shipped runs and the human corpus
472
+ * admin tuning bank-judge <eval-id> --live | --prompt-file f.md [--model m] [--mode score|same] [--threshold 4] [--context paragraph|sentence] [--examples id,id]
473
+ * admin tuning bank-score <eval-id> --version N score a judge version over the pool (call again for more than 600)
474
+ * admin tuning bank-try <eval-id> --version N [--kernels Hazel,Aspen] the judge as a trial rule in candidates, and a comparison against live
475
+ * admin tuning people | invite --email e [--note "…"] | revoke <id> who an admin invited into the console as an editor
451
476
  * 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
452
477
  * admin tuning candidates --kernels Hazel,Aspen|all --patch '{…}' [--base N] [--note "…"] the same changes as a candidate of each kernel
453
478
  * admin tuning promote <experiment-id> --versions <service-kernel-id>:<version>,… promote from a "ship" decision
@@ -633,7 +658,7 @@ async function cmdAdminTuning(ctx) {
633
658
  const list = flagList(flags.candidates);
634
659
  if (!title || !list?.length)
635
660
  throw new CliError(
636
- 'Usage: admin tuning compare --title "…" --candidates Hazel:21,Aspen:20 [--experiment <id>]'
661
+ 'Usage: admin tuning compare --title "…" --candidates Hazel:21,Aspen:20 [--experiment <id>] [--pairs 20]'
637
662
  );
638
663
  // kernel:version, or kernel:version@control to read it against a test version instead of live.
639
664
  const candidates = list.map((item) => {
@@ -651,7 +676,12 @@ async function cmdAdminTuning(ctx) {
651
676
  ctx,
652
677
  "POST",
653
678
  `${T}/comparisons`,
654
- compact({ title, candidates, experiment_id: flagStr(flags.experiment) })
679
+ compact({
680
+ title,
681
+ candidates,
682
+ experiment_id: flagStr(flags.experiment),
683
+ pairs: flags.pairs !== undefined ? Number(flags.pairs) : undefined,
684
+ })
655
685
  );
656
686
  return json ? printResult(io, flags, r) : io.log(`comparison ${r.comparison?.id ?? r.id ?? ""} started`);
657
687
  }
@@ -670,24 +700,255 @@ async function cmdAdminTuning(ctx) {
670
700
  case "comparison":
671
701
  if (!id) throw new CliError("Usage: admin tuning comparison <id>");
672
702
  return printResult(io, { ...flags, json: true }, await staffRequest(ctx, "GET", `${T}/comparisons/${id}`));
673
- case "picks":
674
- if (!id) throw new CliError("Usage: admin tuning picks <comparison-id>");
703
+ case "picks": {
704
+ if (!id) throw new CliError("Usage: admin tuning picks <comparison-id> [--criterion opening]");
705
+ const criterion = flagStr(flags.criterion);
706
+ const qs = criterion ? `?${new URLSearchParams({ criterion })}` : "";
675
707
  return printResult(
676
708
  io,
677
709
  { ...flags, json: true },
678
- await staffRequest(ctx, "GET", `${T}/comparisons/${id}/picks`)
710
+ await staffRequest(ctx, "GET", `${T}/comparisons/${id}/picks${qs}`)
679
711
  );
712
+ }
680
713
  case "pick": {
681
714
  const cell = flagStr(flags.cell);
682
715
  const pick = flagStr(flags.pick);
716
+ const note = flagStr(flags.note);
717
+ const criterion = flagStr(flags.criterion);
683
718
  if (!id || !cell || !["left", "right", "tie"].includes(pick ?? ""))
684
- throw new CliError("Usage: admin tuning pick <comparison-id> --cell <key> --pick left|right|tie");
719
+ throw new CliError(
720
+ 'Usage: admin tuning pick <comparison-id> --cell <key> --pick left|right|tie [--criterion opening] [--note "why"]'
721
+ );
685
722
  return printResult(
686
723
  io,
687
724
  flags,
688
- await staffRequest(ctx, "POST", `${T}/comparisons/${id}/picks`, { cell_key: cell, outcome: pick })
725
+ await staffRequest(ctx, "POST", `${T}/comparisons/${id}/picks`, {
726
+ cell_key: cell,
727
+ outcome: pick,
728
+ ...(criterion ? { criterion } : {}),
729
+ ...(note ? { note } : {}),
730
+ })
689
731
  );
690
732
  }
733
+ case "evals": {
734
+ const r = await staffRequest(ctx, "GET", `${T}/evals`);
735
+ if (json) return printResult(io, flags, r);
736
+ const rows = r.categories ?? [];
737
+ if (!rows.length) return io.log("(no categories)");
738
+ const cell = (v, w) => String(v ?? "—").padEnd(w);
739
+ io.log(
740
+ `${cell("id", 24)} ${cell("status", 10)} ${cell("layer", 10)} ${cell("lever", 30)} ${"n".padStart(4)} ${"agree".padStart(6)} ${cell("suggest", 10)} ${"ex".padStart(3)}`
741
+ );
742
+ for (const c of rows) {
743
+ const v = c.vetting ?? {};
744
+ io.log(
745
+ `${cell(c.id, 24)} ${cell(c.status, 10)} ${cell(c.layer, 10)} ${cell(c.lever || "—", 30)} ${String(v.n ?? 0).padStart(4)} ${String(v.agreement ?? "—").padStart(6)} ${cell(v.suggestion, 10)} ${String((c.examples ?? []).length).padStart(3)}`
746
+ );
747
+ }
748
+ return;
749
+ }
750
+ case "eval-add": {
751
+ const body = {
752
+ id: flagStr(flags.id),
753
+ label: flagStr(flags.label),
754
+ how: flagStr(flags.how),
755
+ layer: flagStr(flags.layer),
756
+ lever: flagStr(flags.lever) ?? "",
757
+ locality: flagStr(flags.locality),
758
+ axis: flagStr(flags.axis),
759
+ };
760
+ const example = flagStr(flags.example);
761
+ if (
762
+ !body.id ||
763
+ !body.label ||
764
+ !body.how ||
765
+ !body.layer ||
766
+ !body.axis ||
767
+ !["local", "structural"].includes(body.locality ?? "")
768
+ )
769
+ throw new CliError(
770
+ 'Usage: admin tuning eval-add --id … --label … --how … --layer … --lever … --locality local|structural --axis … [--example "span"]'
771
+ );
772
+ if (example) body.examples = [{ span: example, ...(flags.fix ? { fix: flagStr(flags.fix) } : {}) }];
773
+ const r = await staffRequest(ctx, "POST", `${T}/evals`, body);
774
+ return json ? printResult(io, flags, r) : io.log(`added ${r.category.id} (${r.category.status})`);
775
+ }
776
+ case "eval-set": {
777
+ const status = flagStr(flags.status);
778
+ const example = flagStr(flags.example);
779
+ if (!id || (!status && !example) || (status && !["confirmed", "rejected", "unvetted"].includes(status)))
780
+ throw new CliError(
781
+ 'Usage: admin tuning eval-set <id> [--status confirmed|rejected|unvetted] [--example "span" [--fix "…"]]'
782
+ );
783
+ const patch = {};
784
+ if (status) patch.status = status;
785
+ if (example) {
786
+ // The PATCH replaces the whole list: read what is stored and append.
787
+ const current = (await staffRequest(ctx, "GET", `${T}/evals`)).categories?.find((c) => c.id === id);
788
+ if (!current) throw new CliError(`No category ${id} in the eval list`);
789
+ const fix = flagStr(flags.fix);
790
+ patch.examples = [...(current.examples ?? []), { span: example, ...(fix ? { fix } : {}) }];
791
+ }
792
+ const r = await staffRequest(ctx, "PATCH", `${T}/evals/${encodeURIComponent(id)}`, patch);
793
+ return json
794
+ ? printResult(io, flags, r)
795
+ : io.log(`${r.category.id}: ${r.category.status}, ${(r.category.examples ?? []).length} examples`);
796
+ }
797
+ case "proposals": {
798
+ const status = flagStr(flags.status);
799
+ if (status && !["proposed", "captured", "rejected"].includes(status))
800
+ throw new CliError("Usage: admin tuning proposals [--status proposed|captured|rejected]");
801
+ const qs = status ? `?${new URLSearchParams({ status })}` : "";
802
+ const r = await staffRequest(ctx, "GET", `${T}/proposals${qs}`);
803
+ if (json) return printResult(io, flags, r);
804
+ const rows = r.proposals ?? [];
805
+ if (!rows.length) return io.log("(no proposals)");
806
+ for (const p of rows) {
807
+ const where = [p.cell_key, p.side].filter(Boolean).join(" / ") || "—";
808
+ const edit =
809
+ p.fix != null
810
+ ? `\n → "${p.fix}" [${p.edit_class ?? "?"} → ${p.lever ?? "no lever: schema gap"}]`
811
+ : "";
812
+ io.log(
813
+ `${p.id} ${p.status.padEnd(9)} ${p.kind.padEnd(6)} ${where}${p.category_id ? ` → ${p.category_id}` : ""}\n "${p.span}"${edit}${p.comment ? `\n ${p.comment}` : ""}`
814
+ );
815
+ }
816
+ return;
817
+ }
818
+ case "propose-eval": {
819
+ const span = flagStr(flags.span);
820
+ const side = flagStr(flags.side);
821
+ const kind = flagStr(flags.kind);
822
+ if (
823
+ !span ||
824
+ (side && !["control", "candidate"].includes(side)) ||
825
+ (kind && !["narrow", "wide"].includes(kind))
826
+ )
827
+ throw new CliError(
828
+ 'Usage: admin tuning propose-eval --span "…" --comment "…" [--fix "…"] [--comparison <id> --cell <key> --side control|candidate] [--kind narrow|wide]'
829
+ );
830
+ const r = await staffRequest(
831
+ ctx,
832
+ "POST",
833
+ `${T}/proposals`,
834
+ compact({
835
+ span,
836
+ comment: flagStr(flags.comment),
837
+ fix: flagStr(flags.fix),
838
+ comparison_id: flagStr(flags.comparison),
839
+ cell_key: flagStr(flags.cell),
840
+ side,
841
+ kind,
842
+ })
843
+ );
844
+ if (json) return printResult(io, flags, r);
845
+ const p = r.proposal;
846
+ const edit = p.fix != null ? ` — ${p.edit_class}: ${p.lever ?? "no lever maps: a schema gap"}` : "";
847
+ return io.log(`proposed ${p.id} (${p.kind})${edit}`);
848
+ }
849
+ case "capture-eval": {
850
+ const as = flagStr(flags.as);
851
+ const usage =
852
+ 'Usage: admin tuning capture-eval <proposal-id> --as category --id … --label … --how … --layer … --lever … --locality local|structural --axis … | --as rule | --as lever [--note "…"]';
853
+ if (!id || !["category", "rule", "lever"].includes(as ?? "")) throw new CliError(usage);
854
+ let body = { as };
855
+ if (as === "lever") body = compact({ as, note: flagStr(flags.note) });
856
+ if (as === "category") {
857
+ const category = {
858
+ id: flagStr(flags.id),
859
+ label: flagStr(flags.label),
860
+ how: flagStr(flags.how),
861
+ layer: flagStr(flags.layer),
862
+ lever: flagStr(flags.lever) ?? "",
863
+ locality: flagStr(flags.locality),
864
+ axis: flagStr(flags.axis),
865
+ };
866
+ if (
867
+ !category.id ||
868
+ !category.label ||
869
+ !category.how ||
870
+ !category.layer ||
871
+ !category.axis ||
872
+ !["local", "structural"].includes(category.locality ?? "")
873
+ )
874
+ throw new CliError(usage);
875
+ body = { as, category };
876
+ }
877
+ const r = await staffRequest(ctx, "POST", `${T}/proposals/${id}/capture`, body);
878
+ return json
879
+ ? printResult(io, flags, r)
880
+ : io.log(
881
+ `captured ${r.proposal.id} as ${r.proposal.kind}${r.category ? ` → ${r.category.id} (${(r.category.examples ?? []).length} examples)` : r.proposal.kind === "lever" ? "; a schema gap, filed for the kernels repo" : "; write the rule in the kernels rule lab"}`
882
+ );
883
+ }
884
+ case "reject-eval": {
885
+ if (!id) throw new CliError("Usage: admin tuning reject-eval <proposal-id>");
886
+ const r = await staffRequest(ctx, "POST", `${T}/proposals/${id}/reject`, {});
887
+ return json ? printResult(io, flags, r) : io.log(`rejected ${r.proposal.id}`);
888
+ }
889
+ case "criteria": {
890
+ const r = await staffRequest(ctx, "GET", `${T}/comparisons/criteria`);
891
+ if (json) return printResult(io, flags, r);
892
+ for (const c of r.criteria ?? []) io.log(`${c.id.padEnd(12)} ${c.label.padEnd(12)} ${c.question}`);
893
+ return;
894
+ }
895
+ case "loop": {
896
+ const r = await staffRequest(ctx, "GET", `${T}/loop`);
897
+ if (json) return printResult(io, flags, r);
898
+ io.log(`propose ${r.propose.running} running`);
899
+ io.log(`read ${r.read.settled_unread} settled, unread`);
900
+ io.log(`eye test ${r.eye_test.finalists} finalists, ${r.eye_test.pairs_to_read} pairs to read`);
901
+ io.log(
902
+ `evals ${r.evals.proposals_waiting} proposals waiting, ${r.evals.unvetted} unvetted, ${r.evals.suggested} with a suggestion${r.evals.reachable ? "" : " (the eval list could not be read)"}`
903
+ );
904
+ io.log(`promote ${r.promote.promotable} won their picks`);
905
+ io.log(`\nnext: ${r.next}`);
906
+ return;
907
+ }
908
+ case "ledger": {
909
+ if (!id) throw new CliError("Usage: admin tuning ledger <comparison-id>");
910
+ const read = await staffRequest(ctx, "GET", `${T}/comparisons/${id}`);
911
+ const ledger = read.ledger;
912
+ if (!ledger)
913
+ throw new CliError(
914
+ "This comparison has no ledger (not a Write comparison, or read before the diagnosis read existed)."
915
+ );
916
+ if (json) return printResult(io, flags, ledger);
917
+ const arm = (a) =>
918
+ `${a.arm}: ${a.read} read · floor ${a.floor ?? "—"} · ceiling ${a.ceiling ?? "—"} · local/pair ${a.local_per_pair ?? "—"} · structural/pair ${a.structural_per_pair ?? "—"}`;
919
+ io.log(arm(ledger.control));
920
+ io.log(arm(ledger.candidate));
921
+ io.log(`Δ floor ${ledger.delta.floor ?? "—"} · Δ ceiling ${ledger.delta.ceiling ?? "—"}`);
922
+ for (const a of [ledger.control, ledger.candidate]) {
923
+ io.log(`\n${a.arm} — by category (cost, pairs, lever):`);
924
+ for (const c of a.categories.slice(0, 12))
925
+ io.log(
926
+ ` ${c.category}${c.locality === "structural" ? " (structural)" : ""} cost ${c.cost} on ${c.pairs} pairs → ${c.lever ?? "—"}${c.example ? ` e.g. “${String(c.example).slice(0, 90)}”` : ""}`
927
+ );
928
+ }
929
+ io.log("\nnext arm:");
930
+ if (!ledger.next_arm.length) io.log(" nothing local recurs on two pairs or more");
931
+ for (const n of ledger.next_arm)
932
+ io.log(` ${n.instruction}${n.patch ? ` patch ${JSON.stringify(n.patch)}` : ""}`);
933
+ const edits = read.edits;
934
+ if (edits) {
935
+ io.log("\nreader edits:");
936
+ for (const side of ["control", "candidate"]) {
937
+ const a = edits[side];
938
+ io.log(` ${side}: ${a.n} edit${a.n === 1 ? "" : "s"}`);
939
+ for (const l of a.by_lever) {
940
+ const ex = l.examples[0];
941
+ io.log(
942
+ ` ${l.lever} ${l.edit_class} n ${l.n}${ex ? ` e.g. “${String(ex.span).slice(0, 60)}” → “${String(ex.fix ?? "").slice(0, 60)}”` : ""}`
943
+ );
944
+ }
945
+ }
946
+ io.log(
947
+ ` unmapped (schema gaps): ${edits.unmapped}${edits.unplaced ? ` · unplaced: ${edits.unplaced}` : ""}`
948
+ );
949
+ }
950
+ return;
951
+ }
691
952
  case "reading-list": {
692
953
  if (!id) throw new CliError("Usage: admin tuning reading-list <comparison-id> [--n 10]");
693
954
  const n = flagStr(flags.n);
@@ -698,6 +959,41 @@ async function cmdAdminTuning(ctx) {
698
959
  );
699
960
  return json ? printResult(io, flags, r) : io.log(r.text);
700
961
  }
962
+ case "finalists": {
963
+ const limit = flagNum(flags.limit);
964
+ const qs = limit ? `?${new URLSearchParams({ limit: String(limit) })}` : "";
965
+ const r = await staffRequest(ctx, "GET", `${T}/finalists${qs}`);
966
+ if (json) return printResult(io, flags, r);
967
+ const rows = r.finalists ?? [];
968
+ if (!rows.length)
969
+ return io.log(
970
+ "(no finalists: nothing done with the gates clean and no craft loss that the picks have not decided)"
971
+ );
972
+ const cell = (v, w) => String(v ?? "—").padEnd(w);
973
+ const delta = (x) => (x == null ? "—" : x > 0 ? `+${x}` : String(x));
974
+ io.log(
975
+ `${"#".padStart(2)} ${cell("id", 8)} ${cell("title", 40)} ${cell("quality", 12)} ${"Δceil".padStart(6)} ${cell("in band", 12)} ${cell("latency", 12)} ${"picks".padStart(6)} why`
976
+ );
977
+ rows.forEach((f, i) =>
978
+ io.log(
979
+ `${String(i + 1).padStart(2)} ${cell(f.id.slice(0, 8), 8)} ${cell(String(f.title).slice(0, 40), 40)} ${cell(f.quality, 12)} ${delta(f.ceiling_delta).padStart(6)} ${cell(f.in_band, 12)} ${cell(f.latency, 12)} ${`${f.picks.read}/${f.picks.target}`.padStart(6)} ${f.why}`
980
+ )
981
+ );
982
+ return;
983
+ }
984
+ case "finalists-queue": {
985
+ const n = flagNum(flags.n);
986
+ const qs = n ? `?${new URLSearchParams({ limit: String(n) })}` : "";
987
+ const r = await staffRequest(ctx, "GET", `${T}/finalists/queue${qs}`);
988
+ if (json) return printResult(io, flags, r);
989
+ const pairs = r.pairs ?? [];
990
+ if (!pairs.length) return io.log("(nothing to read blind across the finalists)");
991
+ pairs.forEach((p, i) => {
992
+ const link = `${ctx.client.url}/superadmin/tuning?${new URLSearchParams({ tab: "compare", comparison: p.comparison_id, view: "read" })}`;
993
+ io.log(`${i + 1}. ${p.title}\n ${p.cell_key}\n ${link}`);
994
+ });
995
+ return;
996
+ }
701
997
  case "pair": {
702
998
  const cell = flagStr(flags.cell);
703
999
  if (!id || !cell) throw new CliError("Usage: admin tuning pair <comparison-id> --cell <key>");
@@ -707,6 +1003,156 @@ async function cmdAdminTuning(ctx) {
707
1003
  await staffRequest(ctx, "GET", `${T}/comparisons/${id}/pair?${new URLSearchParams({ cell })}`)
708
1004
  );
709
1005
  }
1006
+ case "bank": {
1007
+ const r = await staffRequest(ctx, "GET", `${T}/bank`);
1008
+ if (json) return printResult(io, flags, r);
1009
+ for (const e of r.evals)
1010
+ io.log(
1011
+ `${e.id} [${e.status}${e.kind ? `, ${e.kind}` : ""}] ${e.name} — ${e.items} instances, ${e.labelled} labelled`
1012
+ );
1013
+ return;
1014
+ }
1015
+ case "bank-eval": {
1016
+ if (!id) throw new CliError("Usage: admin tuning bank-eval <eval-id>");
1017
+ return printResult(
1018
+ io,
1019
+ { ...flags, json: true },
1020
+ await staffRequest(ctx, "GET", `${T}/bank/${encodeURIComponent(id)}`)
1021
+ );
1022
+ }
1023
+ case "bank-draft": {
1024
+ const name = flagStr(flags.name);
1025
+ const shape = flagStr(flags.shape);
1026
+ if (!name || !shape)
1027
+ throw new CliError("Usage: admin tuning bank-draft --name \"…\" --shape '<regex>' [--flags i]");
1028
+ return printResult(
1029
+ io,
1030
+ flags,
1031
+ await staffRequest(ctx, "POST", `${T}/bank`, compact({ name, shape, flags: flagStr(flags.flags) }))
1032
+ );
1033
+ }
1034
+ case "bank-shape": {
1035
+ const shape = flagBool(flags.rule) ? null : flagStr(flags.shape);
1036
+ if (!id || shape === undefined)
1037
+ throw new CliError("Usage: admin tuning bank-shape <eval-id> --shape '<regex>'|--rule [--flags i]");
1038
+ return printResult(
1039
+ io,
1040
+ flags,
1041
+ await staffRequest(ctx, "PATCH", `${T}/bank/${encodeURIComponent(id)}`, {
1042
+ shape,
1043
+ ...compact({ flags: flagStr(flags.flags) }),
1044
+ })
1045
+ );
1046
+ }
1047
+ case "bank-pool": {
1048
+ if (!id) throw new CliError("Usage: admin tuning bank-pool <eval-id> [--per-source 150] [--days 14]");
1049
+ const r = await staffRequest(
1050
+ ctx,
1051
+ "POST",
1052
+ `${T}/bank/${encodeURIComponent(id)}/pool`,
1053
+ compact({ per_source: flagNum(flags["per-source"]), days: flagNum(flags.days) })
1054
+ );
1055
+ if (json) return printResult(io, flags, r);
1056
+ io.log(`${r.added} new instances.`);
1057
+ for (const [src, s] of Object.entries(r.stats))
1058
+ io.log(
1059
+ `${src}: ${s.matches} matches in ${s.textsWithMatch} of ${s.texts} texts (${s.perText} a text, ${Math.round(100 * s.share)}% of texts)`
1060
+ );
1061
+ return;
1062
+ }
1063
+ case "bank-judge": {
1064
+ if (!id)
1065
+ throw new CliError(
1066
+ "Usage: admin tuning bank-judge <eval-id> --live | --prompt-file f.md [--model m] [--mode score|same] [--threshold 4] [--context paragraph|sentence] [--examples id,id]"
1067
+ );
1068
+ const file = flagStr(flags["prompt-file"]);
1069
+ const body = flagBool(flags.live)
1070
+ ? { from_live: true }
1071
+ : compact({
1072
+ prompt: file ? readFileSync(file, "utf8") : flagStr(flags.prompt),
1073
+ model: flagStr(flags.model),
1074
+ mode: flagStr(flags.mode),
1075
+ threshold: flagNum(flags.threshold),
1076
+ context: flagStr(flags.context),
1077
+ example_item_ids: flagStr(flags.examples)
1078
+ ?.split(",")
1079
+ .map((x) => x.trim())
1080
+ .filter(Boolean),
1081
+ note: flagStr(flags.note),
1082
+ });
1083
+ return printResult(
1084
+ io,
1085
+ flags,
1086
+ await staffRequest(ctx, "POST", `${T}/bank/${encodeURIComponent(id)}/judges`, body)
1087
+ );
1088
+ }
1089
+ case "bank-score": {
1090
+ const version = flagNum(flags.version);
1091
+ if (!id || !version) throw new CliError("Usage: admin tuning bank-score <eval-id> --version N");
1092
+ const r = await staffRequest(
1093
+ ctx,
1094
+ "POST",
1095
+ `${T}/bank/${encodeURIComponent(id)}/judges/${version}/score`,
1096
+ {}
1097
+ );
1098
+ if (json) return printResult(io, flags, r);
1099
+ const read = r.report.judges.find((j) => j.version === version);
1100
+ io.log(`scored ${r.scored} (${r.failed} failed).`);
1101
+ if (read)
1102
+ io.log(
1103
+ `v${version} on ${read.held_out.n} held-out labels: agreement ${read.held_out.agreement ?? "—"}, precision ${read.held_out.precision ?? "—"}, recall ${read.held_out.recall ?? "—"}; cuts ours ${read.cut_rate.ours ?? "—"}, human ${read.cut_rate.human ?? "—"}${read.calibrates ? " — calibrates a trial" : ""}`
1104
+ );
1105
+ return;
1106
+ }
1107
+ case "bank-try": {
1108
+ const version = flagNum(flags.version);
1109
+ if (!id || !version)
1110
+ throw new CliError("Usage: admin tuning bank-try <eval-id> --version N [--kernels Hazel,Aspen]");
1111
+ const kernels = flagStr(flags.kernels)
1112
+ ?.split(",")
1113
+ .map((x) => x.trim())
1114
+ .filter(Boolean);
1115
+ const r = await staffRequest(
1116
+ ctx,
1117
+ "POST",
1118
+ `${T}/bank/${encodeURIComponent(id)}/judges/${version}/trial`,
1119
+ compact({ kernels, title: flagStr(flags.title) })
1120
+ );
1121
+ if (json) return printResult(io, flags, r);
1122
+ io.log(`comparison ${r.comparison_id}: ${r.candidates.map((c) => `${c.kernel} v${c.version}`).join(", ")}`);
1123
+ if (r.refused.length)
1124
+ io.log(`refused: ${r.refused.map((x) => `${x.kernel ?? "?"}: ${x.error}`).join("; ")}`);
1125
+ return io.log(
1126
+ `${ctx.client.url}/superadmin/tuning?${new URLSearchParams({ tab: "compare", comparison: r.comparison_id })}`
1127
+ );
1128
+ }
1129
+ case "people": {
1130
+ const r = await staffRequest(ctx, "GET", `${T}/people`);
1131
+ if (json) return printResult(io, flags, r);
1132
+ for (const p of r.people)
1133
+ io.log(
1134
+ `${p.id} ${p.email} ${p.role} — ${p.revoked_at ? "revoked" : p.signed_in ? "signed in" : "invited"}${p.invite_link ? ` ${p.invite_link}` : ""}`
1135
+ );
1136
+ return;
1137
+ }
1138
+ case "invite": {
1139
+ const email = flagStr(flags.email);
1140
+ if (!email) throw new CliError('Usage: admin tuning invite --email e [--note "…"]');
1141
+ const r = await staffRequest(
1142
+ ctx,
1143
+ "POST",
1144
+ `${T}/people`,
1145
+ compact({ email, role: "editor", note: flagStr(flags.note) })
1146
+ );
1147
+ if (json) return printResult(io, flags, r);
1148
+ return io.log(
1149
+ `invited ${r.person.email} as an editor${r.invite_link ? `: ${r.invite_link}` : ""}${r.email_sent ? " (emailed)" : ""}`
1150
+ );
1151
+ }
1152
+ case "revoke": {
1153
+ if (!id) throw new CliError("Usage: admin tuning revoke <person-id>");
1154
+ return printResult(io, flags, await staffRequest(ctx, "DELETE", `${T}/people/${id}`));
1155
+ }
710
1156
  case "side-by-side": {
711
1157
  const ids = (id ?? "")
712
1158
  .split(",")
@@ -1456,7 +1902,7 @@ async function cmdAdminTuning(ctx) {
1456
1902
  }
1457
1903
  default:
1458
1904
  throw new CliError(
1459
- "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|arena-dials|comparison-extend|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)"
1905
+ "Usage: admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|loop|ledger|evals|eval-add|eval-set|proposals|propose-eval|capture-eval|reject-eval|reading-list|finalists|finalists-queue|pair|bank|bank-eval|bank-draft|bank-shape|bank-pool|bank-judge|bank-score|bank-try|people|invite|revoke|side-by-side|candidates|base-kernels|base-versions|sessions|session|session-new|propose|trial-add|trial-run|trial-save|promote|arenas|arena|arena-candidate|arena-dials|comparison-extend|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)"
1460
1906
  );
1461
1907
  }
1462
1908
  }
@@ -5,7 +5,7 @@
5
5
  import { CliError } from "../client.mjs";
6
6
  import { printResult, ok, argsFromFlags } from "./shared.mjs";
7
7
 
8
- const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
8
+ const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful", "github"];
9
9
  const PUBLISH_CONNECTORS = ["webflow", "framer", "google_docs", "contentful", "github"];
10
10
 
11
11
  // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
@@ -121,7 +121,7 @@ async function connectorsPull(ctx) {
121
121
  // or both (--provider wires an unbound --collection first). At least one required.
122
122
  const args = argsFromFlags(ARGS.import_from_cms, flags);
123
123
  if (!args.collection_id && !args.provider) {
124
- throw new CliError("Pass --collection <id> and/or --provider <webflow|framer|sanity|contentful>");
124
+ throw new CliError("Pass --collection <id> and/or --provider <webflow|framer|sanity|contentful|github>");
125
125
  }
126
126
  const result = await client.callTool("import_from_cms", args);
127
127
  ok(
@@ -66,7 +66,7 @@ import {
66
66
  const COVER_STYLES = ["editorial_image", "full_ai", "line_art"];
67
67
  const DOMAIN_MODES = ["subdomain", "buy_now", "connect_later"];
68
68
  const SEER_MODES = ["suggest", "skip"];
69
- const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
69
+ const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful", "github"];
70
70
  const APPROVAL_MODES = ["autopilot", "review_window", "manual"];
71
71
 
72
72
  // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.70.2",
3
+ "version": "0.71.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",