@letterstory/cli 0.36.1 → 0.37.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 +23 -0
- package/lib/cli.mjs +19 -1
- package/lib/commands/admin.mjs +196 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -324,6 +324,29 @@ letterstory admin ask "which clients published nothing in the last 30 days?" --s
|
|
|
324
324
|
This is the headless half of `/superadmin/ask` — same function, same schema — so a
|
|
325
325
|
long-running agent can watch the fleet on a schedule without a browser.
|
|
326
326
|
|
|
327
|
+
### Staff: kernel experiments
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
# Needs a STAFF key with the tuning:experiment capability. A key always acts as an
|
|
331
|
+
# agent: it can propose, plan, note, spend and conclude, never approve a plan.
|
|
332
|
+
export LETTERSTORY_STAFF_API_KEY=<your staff key with tuning:experiment>
|
|
333
|
+
|
|
334
|
+
letterstory admin tuning-context --q "colons" # what the agent knows (JSON)
|
|
335
|
+
letterstory admin experiments list --q colons # what we've tried
|
|
336
|
+
letterstory admin experiments new "We use too many colons" --as "claude-code"
|
|
337
|
+
letterstory admin experiments update <id> --plan-file plan.json --budget 120
|
|
338
|
+
# ...a person approves the plan in Kernel Tuning -> Experiments...
|
|
339
|
+
letterstory admin experiments spend <id> 54
|
|
340
|
+
letterstory admin tuning-import lab/results/colons/publish/<brief>.json --experiment <id>
|
|
341
|
+
letterstory admin experiments conclude <id> --decision drop \
|
|
342
|
+
--conclusion "Colons fell 60%; P(machine) did not move; quality held." \
|
|
343
|
+
--evidence lab:"kernels lab/results/colons/report.md"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The ledger behind Kernel Tuning's Experiments tab
|
|
347
|
+
(docs/architecture/kernel-experiment-agent.md): every question asked of the kernels,
|
|
348
|
+
how it was tested, and what we learned and why.
|
|
349
|
+
|
|
327
350
|
### Propose, then onboard a phantom
|
|
328
351
|
|
|
329
352
|
The Fleet wizard's Plan and Name screens as two commands: pick the beat, then the name,
|
package/lib/cli.mjs
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
} from "./commands.mjs";
|
|
48
48
|
|
|
49
49
|
// Keep in sync with cli/package.json.
|
|
50
|
-
export const VERSION = "0.
|
|
50
|
+
export const VERSION = "0.37.0";
|
|
51
51
|
|
|
52
52
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
53
53
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -506,6 +506,24 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
506
506
|
read-only SQL across every org; --sql prints
|
|
507
507
|
the queries behind the answer. Needs the
|
|
508
508
|
fleet:query capability on the staff key.
|
|
509
|
+
admin experiments list [--q colons] [--status proposed] | show <id>
|
|
510
|
+
| new "<question>" [--hypothesis …] [--plan-file p.json] [--tags a,b] [--as "<agent>"]
|
|
511
|
+
| update <id> [--plan-file …] [--result-file …] [--evidence kind:ref,…] [--budget N]
|
|
512
|
+
| note|started <id> "<text>" | spend <id> <runs>
|
|
513
|
+
| conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "…" [--evidence …]
|
|
514
|
+
| abandon <id> --reason "…"
|
|
515
|
+
The kernel experiment ledger: what we tried,
|
|
516
|
+
how, what it found and why. Approving a plan
|
|
517
|
+
stays with a signed-in person. Needs the
|
|
518
|
+
tuning:experiment capability.
|
|
519
|
+
admin tuning-import <brief-results.json> [--experiment <id>]
|
|
520
|
+
One brief's kernels lab results as a console
|
|
521
|
+
session (a trial per arm), linked to its
|
|
522
|
+
ledger entry. Written by \`npm run lab --
|
|
523
|
+
publish\` in the kernels repo.
|
|
524
|
+
admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
|
|
525
|
+
the pipeline as deployed, levers, measures,
|
|
526
|
+
goals, related history.
|
|
509
527
|
admin create-org --domain <bare-domain> [--name "Acme"] [--test] [--no-key]
|
|
510
528
|
[--key-name N] [--capabilities a,b] [--onboard] [--watch] [+ the onboard domain flags]
|
|
511
529
|
The key exchange: create the client org from
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// routes, because they exist precisely to act BEFORE an org exists.
|
|
5
5
|
//
|
|
6
6
|
// admin ask "which kernels are hooked up to the most clients?" [--sql] [--json]
|
|
7
|
+
// admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
|
|
8
|
+
// admin tuning-context [--q "<question>"] (tuning:experiment)
|
|
9
|
+
// admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
|
|
7
10
|
// admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
|
|
8
11
|
// [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
|
|
9
12
|
// [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
|
|
@@ -26,6 +29,7 @@
|
|
|
26
29
|
// The staff key comes from --staff-key or LETTERSTORY_STAFF_API_KEY; the base URL from the
|
|
27
30
|
// usual --url / LETTERSTORY_API_URL / config resolution.
|
|
28
31
|
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
29
33
|
import { CliError, LetterstoryClient } from "../client.mjs";
|
|
30
34
|
import { flagStr, flagNum, flagBool, flagList, printResult, compact, ok } from "./shared.mjs";
|
|
31
35
|
import { watchDomainJob } from "./onboard.mjs";
|
|
@@ -46,12 +50,17 @@ function staffKeyFrom(flags) {
|
|
|
46
50
|
|
|
47
51
|
/** POST a superadmin route with the staff key; surfaces the server's error text on non-2xx. */
|
|
48
52
|
async function staffPost(ctx, path, body) {
|
|
53
|
+
return staffRequest(ctx, "POST", path, body);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Any method against a superadmin route with the staff key. */
|
|
57
|
+
async function staffRequest(ctx, method, path, body) {
|
|
49
58
|
const { client, flags } = ctx;
|
|
50
59
|
const url = `${client.url}${path}`;
|
|
51
60
|
const res = await client.fetch(url, {
|
|
52
|
-
method
|
|
61
|
+
method,
|
|
53
62
|
headers: { "content-type": "application/json", "x-staff-api-key": staffKeyFrom(flags) },
|
|
54
|
-
body: JSON.stringify(body),
|
|
63
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
55
64
|
});
|
|
56
65
|
let payload = null;
|
|
57
66
|
try {
|
|
@@ -220,11 +229,195 @@ async function cmdAdminAsk(ctx) {
|
|
|
220
229
|
}
|
|
221
230
|
}
|
|
222
231
|
|
|
232
|
+
/* ------------------------------------------------------------ kernel experiments */
|
|
233
|
+
|
|
234
|
+
const EXPERIMENTS = "/api/superadmin/tuning/experiments";
|
|
235
|
+
|
|
236
|
+
/** `--as "<agent name>"` records the actor as that agent; a staff key always acts as an agent. */
|
|
237
|
+
function actorFrom(flags) {
|
|
238
|
+
const as = flagStr(flags.as);
|
|
239
|
+
return as ? { kind: "agent", label: as } : undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A JSON object from `--<name>-file <path>` (or `-` for stdin is not supported: pass a file). */
|
|
243
|
+
function jsonFile(flags, name) {
|
|
244
|
+
const path = flagStr(flags[`${name}-file`]);
|
|
245
|
+
if (!path) return undefined;
|
|
246
|
+
try {
|
|
247
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
248
|
+
} catch (err) {
|
|
249
|
+
throw new CliError(`--${name}-file ${path}: ${err.message}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** `--evidence kind:ref[,kind:ref…]`, e.g. `lab:kernels lab/results/colons/report.md,session:<uuid>`. */
|
|
254
|
+
function evidenceFrom(flags) {
|
|
255
|
+
const items = flagList(flags.evidence);
|
|
256
|
+
if (!items?.length) return undefined;
|
|
257
|
+
return items.map((item) => {
|
|
258
|
+
const at = item.indexOf(":");
|
|
259
|
+
if (at < 1) throw new CliError(`--evidence items are kind:ref, got "${item}"`);
|
|
260
|
+
return { kind: item.slice(0, at), ref: item.slice(at + 1) };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function experimentLine(e) {
|
|
265
|
+
const decision = e.decision ? ` → ${e.decision}` : "";
|
|
266
|
+
return `${e.id} [${e.status}${decision}] ${e.question}${e.gist ? `\n ${e.gist}` : ""}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* `admin experiments …` — the experiment ledger (docs/architecture/kernel-experiment-agent.md),
|
|
271
|
+
* the headless half of the tuning console's Experiments tab. Needs `tuning:experiment` on the key.
|
|
272
|
+
*/
|
|
273
|
+
async function cmdAdminExperiments(ctx) {
|
|
274
|
+
const [sub, id, ...rest] = ctx.positionals;
|
|
275
|
+
const { flags, io } = ctx;
|
|
276
|
+
const json = flagBool(flags.json);
|
|
277
|
+
const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
|
|
278
|
+
const one = (r) => {
|
|
279
|
+
const e = r.experiment;
|
|
280
|
+
const lines = [experimentLine(e)];
|
|
281
|
+
if (e.restated) lines.push(` hypothesis: ${e.restated}`);
|
|
282
|
+
if (e.plan) lines.push(` plan: ${JSON.stringify(e.plan)}`);
|
|
283
|
+
lines.push(` runs: ${e.runsSpent} of ${e.budgetRuns}`);
|
|
284
|
+
for (const ev of e.events ?? [])
|
|
285
|
+
lines.push(` ${ev.at} ${ev.kind} ${ev.actorLabel ?? ev.actorKind}${ev.text ? `: ${ev.text}` : ""}`);
|
|
286
|
+
return lines.join("\n");
|
|
287
|
+
};
|
|
288
|
+
switch (sub) {
|
|
289
|
+
case "list": {
|
|
290
|
+
const params = new URLSearchParams(
|
|
291
|
+
compact({ q: flagStr(flags.q), status: flagStr(flags.status), limit: flagStr(flags.limit) })
|
|
292
|
+
);
|
|
293
|
+
const result = await staffRequest(ctx, "GET", `${EXPERIMENTS}?${params}`);
|
|
294
|
+
return out(result, (r) =>
|
|
295
|
+
r.experiments.length ? r.experiments.map(experimentLine).join("\n") : "(no experiments)"
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
case "show":
|
|
299
|
+
if (!id) throw new CliError("Usage: admin experiments show <id>");
|
|
300
|
+
return out(await staffRequest(ctx, "GET", `${EXPERIMENTS}/${id}`), one);
|
|
301
|
+
case "new": {
|
|
302
|
+
const question = [id, ...rest].filter(Boolean).join(" ").trim();
|
|
303
|
+
if (question.length < 3)
|
|
304
|
+
throw new CliError('Usage: admin experiments new "<question>" [--plan-file plan.json] [--tags a,b]');
|
|
305
|
+
const body = compact({
|
|
306
|
+
question,
|
|
307
|
+
restated: flagStr(flags.hypothesis),
|
|
308
|
+
plan: jsonFile(flags, "plan"),
|
|
309
|
+
method: flagStr(flags.method),
|
|
310
|
+
tags: flagList(flags.tags),
|
|
311
|
+
builds_on: flagList(flags["builds-on"]),
|
|
312
|
+
evidence: evidenceFrom(flags),
|
|
313
|
+
actor: actorFrom(flags),
|
|
314
|
+
});
|
|
315
|
+
return out(await staffRequest(ctx, "POST", EXPERIMENTS, body), one);
|
|
316
|
+
}
|
|
317
|
+
case "update": {
|
|
318
|
+
if (!id)
|
|
319
|
+
throw new CliError(
|
|
320
|
+
"Usage: admin experiments update <id> [--hypothesis …] [--plan-file …] [--result-file …] [--evidence kind:ref,…]"
|
|
321
|
+
);
|
|
322
|
+
const body = compact({
|
|
323
|
+
restated: flagStr(flags.hypothesis),
|
|
324
|
+
plan: jsonFile(flags, "plan"),
|
|
325
|
+
measures: jsonFile(flags, "measures"),
|
|
326
|
+
result: jsonFile(flags, "result"),
|
|
327
|
+
method: flagStr(flags.method),
|
|
328
|
+
tags: flagList(flags.tags),
|
|
329
|
+
budget_runs: flagNum(flags.budget),
|
|
330
|
+
engine_build: flagStr(flags.build),
|
|
331
|
+
add_evidence: evidenceFrom(flags),
|
|
332
|
+
actor: actorFrom(flags),
|
|
333
|
+
});
|
|
334
|
+
return out(await staffRequest(ctx, "PATCH", `${EXPERIMENTS}/${id}`, body), one);
|
|
335
|
+
}
|
|
336
|
+
case "note":
|
|
337
|
+
case "started": {
|
|
338
|
+
const text = rest.join(" ").trim();
|
|
339
|
+
if (!id || (sub === "note" && !text)) throw new CliError(`Usage: admin experiments ${sub} <id> "<text>"`);
|
|
340
|
+
const body = compact({ kind: sub, text: text || undefined, actor: actorFrom(flags) });
|
|
341
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/events`, body), one);
|
|
342
|
+
}
|
|
343
|
+
case "spend": {
|
|
344
|
+
const runs = Number(rest[0]);
|
|
345
|
+
if (!id || !Number.isInteger(runs) || runs < 1)
|
|
346
|
+
throw new CliError("Usage: admin experiments spend <id> <runs> [--text …]");
|
|
347
|
+
const body = compact({ kind: "spend", runs, text: flagStr(flags.text), actor: actorFrom(flags) });
|
|
348
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/events`, body), one);
|
|
349
|
+
}
|
|
350
|
+
case "conclude": {
|
|
351
|
+
const decision = flagStr(flags.decision);
|
|
352
|
+
const conclusion = flagStr(flags.conclusion);
|
|
353
|
+
if (!id || !decision || !conclusion)
|
|
354
|
+
throw new CliError(
|
|
355
|
+
'Usage: admin experiments conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "<what we learned and why>" [--evidence kind:ref,…] [--result-file r.json]'
|
|
356
|
+
);
|
|
357
|
+
const body = compact({
|
|
358
|
+
action: "conclude",
|
|
359
|
+
decision,
|
|
360
|
+
conclusion,
|
|
361
|
+
result: jsonFile(flags, "result"),
|
|
362
|
+
add_evidence: evidenceFrom(flags),
|
|
363
|
+
actor: actorFrom(flags),
|
|
364
|
+
});
|
|
365
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/decide`, body), one);
|
|
366
|
+
}
|
|
367
|
+
case "abandon": {
|
|
368
|
+
const reason = flagStr(flags.reason);
|
|
369
|
+
if (!id || !reason) throw new CliError('Usage: admin experiments abandon <id> --reason "<why>"');
|
|
370
|
+
const body = compact({ action: "abandon", reason, actor: actorFrom(flags) });
|
|
371
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/decide`, body), one);
|
|
372
|
+
}
|
|
373
|
+
default:
|
|
374
|
+
throw new CliError(
|
|
375
|
+
"Usage: admin experiments list|show|new|update|note|started|spend|conclude|abandon (see --help)"
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* `admin tuning-import <file.json>` — one brief's kernels lab results into a console session
|
|
382
|
+
* (the lab's `publish` writes these files; the body is POST /api/superadmin/tuning/lab-import's).
|
|
383
|
+
*/
|
|
384
|
+
async function cmdAdminTuningImport(ctx) {
|
|
385
|
+
const path = ctx.positionals[0];
|
|
386
|
+
if (!path) throw new CliError("Usage: admin tuning-import <brief-results.json> [--experiment <id>]");
|
|
387
|
+
let body;
|
|
388
|
+
try {
|
|
389
|
+
body = JSON.parse(readFileSync(path, "utf8"));
|
|
390
|
+
} catch (err) {
|
|
391
|
+
throw new CliError(`${path}: ${err.message}`);
|
|
392
|
+
}
|
|
393
|
+
const experiment = flagStr(ctx.flags.experiment);
|
|
394
|
+
if (experiment) body.experiment_id = experiment;
|
|
395
|
+
const result = await staffRequest(ctx, "POST", "/api/superadmin/tuning/lab-import", body);
|
|
396
|
+
if (flagBool(ctx.flags.json)) return printResult(ctx.io, ctx.flags, result);
|
|
397
|
+
ctx.io.log(
|
|
398
|
+
`${result.trials} trials, ${result.runs} runs → ${ctx.client.url}/superadmin/tuning/${result.sessionId}`
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** `admin tuning-context [--q "<question>"]` — the experiment agent's context pack, as JSON. */
|
|
403
|
+
async function cmdAdminTuningContext(ctx) {
|
|
404
|
+
const q = flagStr(ctx.flags.q) || ctx.positionals.join(" ").trim() || undefined;
|
|
405
|
+
const params = q ? `?${new URLSearchParams({ q })}` : "";
|
|
406
|
+
printResult(
|
|
407
|
+
ctx.io,
|
|
408
|
+
{ ...ctx.flags, json: true },
|
|
409
|
+
await staffRequest(ctx, "GET", `/api/superadmin/tuning/context${params}`)
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
223
413
|
export async function cmdAdmin(ctx) {
|
|
224
414
|
const sub = ctx.positionals[0];
|
|
225
415
|
if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
226
416
|
if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
417
|
+
if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
418
|
+
if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
419
|
+
if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
227
420
|
throw new CliError(
|
|
228
|
-
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" (see --help)'
|
|
421
|
+
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin tuning-context (see --help)'
|
|
229
422
|
);
|
|
230
423
|
}
|