@letterstory/cli 0.47.0 → 0.48.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/lib/cli.mjs CHANGED
@@ -48,12 +48,13 @@ import {
48
48
  cmdMailers,
49
49
  cmdAdmin,
50
50
  cmdFeedback,
51
+ cmdEvals,
51
52
  cmdOfferings,
52
53
  cmdTargets,
53
54
  } from "./commands.mjs";
54
55
 
55
56
  // Keep in sync with cli/package.json.
56
- export const VERSION = "0.47.0";
57
+ export const VERSION = "0.48.0";
57
58
 
58
59
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
59
60
  // can't accidentally swallow the id as --json's value.
@@ -94,6 +95,8 @@ const BOOLEAN_FLAGS = new Set([
94
95
  "no-visuals",
95
96
  "no-enhance",
96
97
  "no-key",
98
+ // `admin evals triage --overdue`
99
+ "overdue",
97
100
  // `autopen start --article <id> --from-scratch`
98
101
  "from-scratch",
99
102
  // `collections activate <id> --no-topic-map` / `--no-headlines`
@@ -594,6 +597,16 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
594
597
  org:create. onboard-plan reads the calls
595
598
  (sites, who to invite, paid or not);
596
599
  payment-link needs billing:write.
600
+ admin evals scoreboard [--scope fleet|org|collection --id <uuid>] [--days N]
601
+ | cases [--org <uuid>] [--status s] [--stage s] | runs [--limit N] [--mode record|full]
602
+ | run [--mode record|full] [--keys a,b] [--org <uuid>] [--baseline latest|<id>]
603
+ | triage [--owner research|kernel|links|mentions|accuracy|pipeline] [--overdue]
604
+ | guard <case-key> [--sha <commit>] [--fix-pr #N] | retire <case-key> --reason "…"
605
+ The fleet-wide eval surface: scoreboard,
606
+ every org's cases and suite runs, the
607
+ triage queue, and the two lifecycle calls
608
+ an org key cannot make. Reads need
609
+ fleet:read; writes need tuning:experiment.
597
610
  admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
598
611
  the pipeline as deployed, levers, measures,
599
612
  goals, related history.
@@ -833,6 +846,18 @@ Feedback (tell us what's broken, missing, unclear, or wanted):
833
846
  [--message <text>|--body <text>|--file <path>|--file -] [--rating 1-5]
834
847
  [--context '<json>']
835
848
 
849
+ Evals (how the pipeline is doing on your posts, and the regression cases captured from them):
850
+ evals scoreboard [--collection <id>] [--days N] Every measure by stage: posts, failing, rate
851
+ evals cases [--status open|guarding|proposed|retired|rejected] [--stage research|brief|links|mentions|accuracy|pipeline]
852
+ evals capture (--article <id> | --research-run <id> | --kernel-job <id>) --title "…"
853
+ (--description "…" | --body … | --file <path>) (--measure <key> | --assertion "…")
854
+ [--stage <s>] [--replay <stage>] [--auto-pen <id>] [--published-version N]
855
+ [--incident <ref>] [--link <url>] [--status open|proposed]
856
+ Freeze the post's record as a case; exits 1 when
857
+ it does NOT detect the issue on current code
858
+ evals run [--keys a,b] [--baseline latest|<run-id>] Record-only suite; exits 1 on a regression
859
+ evals run-status <run-id>
860
+
836
861
  Anything else:
837
862
  tools list List every tool this server exposes, by name
838
863
  tools show <name> Show one tool's capability, argument schema, and return shape
@@ -907,6 +932,7 @@ const CLIENT_COMMANDS = {
907
932
  mail: cmdMail,
908
933
  mailers: cmdMailers,
909
934
  feedback: cmdFeedback,
935
+ evals: cmdEvals,
910
936
  // Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
911
937
  admin: cmdAdmin,
912
938
  };
@@ -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 evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
10
11
  // admin crm import|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)
11
12
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
12
13
  // [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
@@ -879,6 +880,125 @@ async function cmdAdminCrm(ctx) {
879
880
  }
880
881
  }
881
882
 
883
+ /* ------------------------------------------------------------ content evals */
884
+
885
+ const EVALS = "/api/superadmin/evals";
886
+
887
+ /**
888
+ * `admin evals …` — the staff half of the eval surface, against /api/superadmin/evals: the
889
+ * fleet-wide scoreboard, every org's cases and runs, the triage queue, and the two lifecycle
890
+ * decisions an org key cannot make (guard, retire). Reads need fleet:read; writes need
891
+ * tuning:experiment. The org-scoped half is `letterstory evals …` on an org key.
892
+ */
893
+ async function cmdAdminEvals(ctx) {
894
+ const [sub, id] = ctx.positionals;
895
+ const { flags, io } = ctx;
896
+ const json = flagBool(flags.json);
897
+ const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
898
+ const query = (obj) => {
899
+ const params = new URLSearchParams(compact(obj));
900
+ const q = params.toString();
901
+ return q ? `?${q}` : "";
902
+ };
903
+ switch (sub) {
904
+ case "scoreboard": {
905
+ const q = query({ scope: flagStr(flags.scope), id: flagStr(flags.id), days: flagStr(flags.days) });
906
+ return out(
907
+ await staffRequest(ctx, "GET", `${EVALS}/scoreboard${q}`),
908
+ (r) =>
909
+ (r.stages ?? [])
910
+ .flatMap((st) => st.measures ?? [])
911
+ .map(
912
+ (m) => `${m.stage.padEnd(9)} ${m.measure.padEnd(44)} ${m.failing}/${m.posts} ${m.severity}`
913
+ )
914
+ .join("\n") || "(no measurements in the window)"
915
+ );
916
+ }
917
+ case "cases": {
918
+ const q = query({ org_id: flagStr(flags.org), status: flagStr(flags.status), stage: flagStr(flags.stage) });
919
+ return out(
920
+ await staffRequest(ctx, "GET", `${EVALS}/cases${q}`),
921
+ (r) =>
922
+ (r.cases ?? [])
923
+ .map(
924
+ (c) =>
925
+ `${c.key.padEnd(48)} ${c.status.padEnd(9)} ${c.stage.padEnd(9)} ${c.latestResult ? c.latestResult.verdict : "never run"}`
926
+ )
927
+ .join("\n") || "(no cases)"
928
+ );
929
+ }
930
+ case "runs": {
931
+ const q = query({ limit: flagStr(flags.limit), mode: flagStr(flags.mode) });
932
+ return out(
933
+ await staffRequest(ctx, "GET", `${EVALS}/suite/runs${q}`),
934
+ (r) =>
935
+ (r.runs ?? [])
936
+ .map(
937
+ (run) =>
938
+ `${run.id} ${run.startedAt} ${run.mode.padEnd(6)} ${run.verdict.padEnd(4)} ${run.isBaseline ? "baseline " : ""}${run.totals?.regressed ?? 0} regressed`
939
+ )
940
+ .join("\n") || "(no runs)"
941
+ );
942
+ }
943
+ case "run": {
944
+ const keys = flagStr(flags.keys);
945
+ const body = compact({
946
+ mode: flagStr(flags.mode),
947
+ case_keys: keys
948
+ ? keys
949
+ .split(",")
950
+ .map((k) => k.trim())
951
+ .filter(Boolean)
952
+ : undefined,
953
+ org_id: flagStr(flags.org),
954
+ baseline: flagStr(flags.baseline),
955
+ });
956
+ const r = await staffRequest(ctx, "POST", `${EVALS}/suite/runs`, body);
957
+ out(
958
+ r,
959
+ (x) =>
960
+ `Run ${x.run?.id ?? "(queued)"} — ${String(x.run?.verdict ?? "").toUpperCase()} ${x.run?.totals?.regressed ?? 0} regressed`
961
+ );
962
+ return r.run?.verdict === "fail" ? 1 : 0;
963
+ }
964
+ case "triage": {
965
+ const q = query({ owner: flagStr(flags.owner), overdue: flagBool(flags.overdue) ? "true" : undefined });
966
+ return out(await staffRequest(ctx, "GET", `${EVALS}/triage${q}`), (r) =>
967
+ [
968
+ ...(r.findings ?? []).map(
969
+ (f) =>
970
+ `${f.id} ${(f.owner ?? "unassigned").padEnd(10)} ${f.evalKey.padEnd(36)} ${f.overdue ? "OVERDUE " : ""}${f.title ?? f.summary ?? ""}`
971
+ ),
972
+ `${r.total ?? 0} open, ${r.overdue ?? 0} overdue`,
973
+ ].join("\n")
974
+ );
975
+ }
976
+ case "guard": {
977
+ if (!id) throw new CliError("Usage: admin evals guard <case-key> [--sha <commit>] [--fix-pr #123]");
978
+ const body = compact({ action: "guard", sha: flagStr(flags.sha), fix_pr: flagStr(flags["fix-pr"]) });
979
+ return out(await staffRequest(ctx, "PATCH", `${EVALS}/cases/${encodeURIComponent(id)}`, body), (r) =>
980
+ r.ok ? `${r.case.key} is now guarding (first passed ${r.case.firstPassedSha})` : `refused: ${r.reason}`
981
+ );
982
+ }
983
+ case "retire": {
984
+ if (!id) throw new CliError('Usage: admin evals retire <case-key> --reason "…"');
985
+ const reason = flagStr(flags.reason);
986
+ if (!reason) throw new CliError("Retiring a case needs --reason.");
987
+ return out(
988
+ await staffRequest(ctx, "PATCH", `${EVALS}/cases/${encodeURIComponent(id)}`, {
989
+ action: "retire",
990
+ reason,
991
+ }),
992
+ (r) => (r.ok ? `${r.case.key} retired` : `refused: ${r.reason}`)
993
+ );
994
+ }
995
+ default:
996
+ throw new CliError(
997
+ `Unknown admin evals subcommand: ${sub ?? "(none)"}. Try: scoreboard, cases, runs, run, triage, guard, retire`
998
+ );
999
+ }
1000
+ }
1001
+
882
1002
  export async function cmdAdmin(ctx) {
883
1003
  const sub = ctx.positionals[0];
884
1004
  if (sub === "crm") return cmdAdminCrm({ ...ctx, positionals: ctx.positionals.slice(1) });
@@ -887,7 +1007,8 @@ export async function cmdAdmin(ctx) {
887
1007
  if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
888
1008
  if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
889
1009
  if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
1010
+ if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
890
1011
  throw new CliError(
891
- 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin tuning-context (see --help)'
1012
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin evals … | admin tuning-context (see --help)'
892
1013
  );
893
1014
  }
@@ -0,0 +1,198 @@
1
+ // `evals` — the content-eval surface on an org key: how the pipeline is doing on YOUR
2
+ // posts (the scoreboard), the regression cases captured from them, capturing a new one from
3
+ // a bad post, and running the record-only suite.
4
+ //
5
+ // evals scoreboard [--collection <id>] [--days N]
6
+ // evals cases [--status open|guarding|…] [--stage research|brief|…]
7
+ // evals capture (--article <id> | --research-run <id> | --kernel-job <id>) --title "…"
8
+ // --description "…" (--measure <key> | --assertion "…") [--stage s] [--replay <stage>]
9
+ // [--auto-pen <id>] [--published-version N] [--incident …] [--link …]
10
+ // evals run [--keys a,b] [--baseline latest|<run-id>] exits 1 when a guarding case regressed
11
+ // evals run-status <run-id>
12
+ //
13
+ // Five commands, five tools; every input key is declared in ARGS so parity-check compares
14
+ // them against the tools' published schemas (see shared.mjs `argsFromFlags`).
15
+
16
+ import { CliError } from "../client.mjs";
17
+ import { flagStr, readBodyInput, printResult, ok, argsFromFlags } from "./shared.mjs";
18
+
19
+ const STAGES = ["research", "brief", "links", "mentions", "accuracy", "pipeline"];
20
+ const STATUSES = ["proposed", "open", "guarding", "retired", "rejected"];
21
+
22
+ /** `--keys a,b,c` (or repeated `--keys`) → ["a", "b", "c"]; undefined when unset. */
23
+ function keysFrom(flags) {
24
+ const raw = flags.keys;
25
+ if (raw === undefined) return undefined;
26
+ const items = (Array.isArray(raw) ? raw : [raw])
27
+ .flatMap((v) => String(v).split(","))
28
+ .map((k) => k.trim())
29
+ .filter(Boolean);
30
+ return items.length ? items : undefined;
31
+ }
32
+
33
+ export const ARGS = {
34
+ get_eval_scoreboard: {
35
+ // One collection narrows the scope; otherwise the whole org (the tool's default).
36
+ scope: { value: (flags) => (flagStr(flags.collection) ? "collection" : undefined) },
37
+ collection_id: { flag: "collection" },
38
+ days: { flag: "days", type: "num" },
39
+ },
40
+ list_eval_cases: {
41
+ status: { flag: "status", enum: STATUSES },
42
+ stage: { flag: "stage", enum: STAGES },
43
+ },
44
+ capture_eval_case: {
45
+ article_id: { flag: "article" },
46
+ research_run_id: { flag: "research-run" },
47
+ kernel_job_id: { flag: "kernel-job" },
48
+ title: { flag: "title", required: true },
49
+ // --description, or the larger-blob trio --body/--file/stdin.
50
+ description: { value: (flags) => flagStr(flags.description) ?? readBodyInput(flags) },
51
+ expected: { object: { measure: { flag: "measure" }, assertion: { flag: "assertion" } } },
52
+ stage: { flag: "stage", enum: STAGES },
53
+ // Pin the incident's auto-pen run and live version when the post has been re-run since.
54
+ auto_pen_id: { flag: "auto-pen" },
55
+ published_version: { flag: "published-version", type: "num" },
56
+ // A record-only replay stage to re-run over the fixture before grading (evals catalog).
57
+ replay: "replay",
58
+ link: "link",
59
+ incident: "incident",
60
+ status: { flag: "status", enum: ["open", "proposed"] },
61
+ },
62
+ run_eval_suite: {
63
+ case_keys: { value: (flags) => keysFrom(flags) },
64
+ baseline: { flag: "baseline" },
65
+ },
66
+ get_eval_suite_run: {
67
+ run_id: { positional: 0, label: "run-id", required: true },
68
+ },
69
+ };
70
+
71
+ export async function cmdEvals(ctx) {
72
+ const sub = ctx.positionals[0];
73
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
74
+ switch (sub) {
75
+ case "scoreboard":
76
+ return evalsScoreboard(rest);
77
+ case "cases":
78
+ return evalsCases(rest);
79
+ case "capture":
80
+ return evalsCapture(rest);
81
+ case "run":
82
+ return evalsRun(rest);
83
+ case "run-status":
84
+ return evalsRunStatus(rest);
85
+ default:
86
+ throw new CliError(
87
+ `Unknown evals subcommand: ${sub ?? "(none)"}. Try: scoreboard, cases, capture, run, run-status`
88
+ );
89
+ }
90
+ }
91
+
92
+ function pct(rate) {
93
+ return `${Math.round((rate ?? 0) * 100)}%`;
94
+ }
95
+
96
+ function formatScoreboard(r) {
97
+ const lines = [`Scoreboard — ${r.scope}${r.id ? ` ${r.id}` : ""}, last ${r.days} days`];
98
+ for (const stage of r.stages ?? []) {
99
+ if (!stage.measures?.length) continue;
100
+ lines.push(`\n${stage.stage}`);
101
+ for (const m of stage.measures) {
102
+ lines.push(
103
+ ` ${m.measure.padEnd(44)} ${String(m.failing).padStart(4)}/${String(m.posts).padEnd(4)} ${pct(m.rate).padStart(4)} ${m.severity}${m.kind === "judge" ? " (judge)" : ""}`
104
+ );
105
+ }
106
+ }
107
+ if (r.judgeReliance) {
108
+ lines.push(`\njudge reliance: ${r.judgeReliance.measures} measure(s), ${r.judgeReliance.cases} case(s)`);
109
+ }
110
+ return lines.join("\n");
111
+ }
112
+
113
+ async function evalsScoreboard(ctx) {
114
+ const { client, flags, io } = ctx;
115
+ const args = argsFromFlags(ARGS.get_eval_scoreboard, flags, ctx.positionals);
116
+ const result = await client.callTool("get_eval_scoreboard", args);
117
+ printResult(io, flags, result, formatScoreboard);
118
+ return 0;
119
+ }
120
+
121
+ function caseLine(c) {
122
+ const latest = c.latestResult ? `${c.latestResult.verdict} (${c.latestResult.gate})` : "never run";
123
+ const expected = c.expected?.measure ? c.expected.measure : `assertion: ${c.expected?.assertion ?? ""}`;
124
+ return `${c.key.padEnd(48)} ${c.status.padEnd(9)} ${c.stage.padEnd(9)} ${latest.padEnd(26)} ${expected}`;
125
+ }
126
+
127
+ async function evalsCases(ctx) {
128
+ const { client, flags, io } = ctx;
129
+ const args = argsFromFlags(ARGS.list_eval_cases, flags, ctx.positionals);
130
+ const result = await client.callTool("list_eval_cases", args);
131
+ printResult(io, flags, result, (r) =>
132
+ r.cases?.length ? r.cases.map(caseLine).join("\n") : "(no cases captured from this org's posts)"
133
+ );
134
+ return 0;
135
+ }
136
+
137
+ async function evalsCapture(ctx) {
138
+ const { client, flags, io } = ctx;
139
+ const args = argsFromFlags(ARGS.capture_eval_case, flags, ctx.positionals);
140
+ if (!args.article_id && !args.research_run_id && !args.kernel_job_id) {
141
+ throw new CliError("Name the post: --article <id>, --research-run <id> or --kernel-job <id>.");
142
+ }
143
+ if (!args.description) {
144
+ throw new CliError(
145
+ "Missing --description (or --body / --file <path> / stdin): what went wrong, in a sentence or two."
146
+ );
147
+ }
148
+ const expected = args.expected ?? {};
149
+ if ((expected.measure ? 1 : 0) + (expected.assertion ? 1 : 0) !== 1) {
150
+ throw new CliError('Say what must hold once fixed: exactly one of --measure <key> or --assertion "…".');
151
+ }
152
+ const result = await client.callTool("capture_eval_case", args);
153
+ const detects = result.detectsIssue;
154
+ ok(
155
+ ctx,
156
+ detects
157
+ ? `Captured ${result.case?.key}: it FAILS on current code, so it detects the issue.`
158
+ : `Captured ${result.case?.key}, but it PASSES on current code — it does not detect what it claims. Tighten the assertion or pick the right measure.`
159
+ );
160
+ printResult(
161
+ io,
162
+ flags,
163
+ result,
164
+ (r) => `${r.case?.key}\n ${r.firstResult?.verdict}: ${r.firstResult?.summary ?? ""}`
165
+ );
166
+ return detects === false ? 1 : 0;
167
+ }
168
+
169
+ function resultLine(r) {
170
+ const delta = r.delta && r.delta !== "no_baseline" ? ` ${r.delta}` : "";
171
+ return ` ${r.caseKey.padEnd(48)} ${r.verdict.padEnd(6)} ${r.gate.padEnd(16)}${delta} ${r.summary ?? ""}`;
172
+ }
173
+
174
+ function formatRun(run) {
175
+ const t = run.totals ?? {};
176
+ const head = `Run ${run.id ?? "(not persisted)"} — ${run.verdict.toUpperCase()} (${t.cases ?? 0} cases: ${t.pass ?? 0} pass, ${t.fail ?? 0} fail, ${t.skip ?? 0} skip, ${t.error ?? 0} error; ${t.regressed ?? 0} regressed, ${t.readyToGuard ?? 0} ready to guard)${run.gitSha ? ` @ ${run.gitSha.slice(0, 10)}` : ""}`;
177
+ const lines = [head, ...(run.results ?? []).map(resultLine)];
178
+ if (run.unresolved?.length) {
179
+ lines.push("", ...run.unresolved.map((u) => ` ! ${u.key}: ${u.reason}`));
180
+ }
181
+ return lines.join("\n");
182
+ }
183
+
184
+ async function evalsRun(ctx) {
185
+ const { client, flags, io } = ctx;
186
+ const args = argsFromFlags(ARGS.run_eval_suite, flags, ctx.positionals);
187
+ const result = await client.callTool("run_eval_suite", args);
188
+ printResult(io, flags, result, (r) => formatRun(r.run));
189
+ return result.run?.verdict === "fail" ? 1 : 0;
190
+ }
191
+
192
+ async function evalsRunStatus(ctx) {
193
+ const { client, flags, io } = ctx;
194
+ const args = argsFromFlags(ARGS.get_eval_suite_run, flags, ctx.positionals);
195
+ const result = await client.callTool("get_eval_suite_run", args);
196
+ printResult(io, flags, result, (r) => formatRun(r.run));
197
+ return 0;
198
+ }
package/lib/commands.mjs CHANGED
@@ -38,3 +38,4 @@ export * from "./commands/offerings.mjs";
38
38
  export * from "./commands/targets.mjs";
39
39
  export * from "./commands/admin.mjs";
40
40
  export * from "./commands/feedback.mjs";
41
+ export * from "./commands/evals.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",