@letterstory/cli 0.31.0 → 0.33.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
@@ -276,6 +276,25 @@ LETTERSTORY_STAFF_API_KEY=<your staff key with org:create> \
276
276
  Everything the parent job does is also its own tool for existing orgs:
277
277
  `ensure_collection_enhance_flow`, `provision_org_visuals`, `propose_seers` (`--args '{"apply":true}'`).
278
278
 
279
+ ### Staff: ask the fleet a question
280
+
281
+ ```bash
282
+ # Needs a STAFF key with the fleet:query capability. The answer is derived from
283
+ # read-only SQL across every org -- nothing here can write, and credential columns
284
+ # are unreadable, so this is safe to point at production because it cannot be
285
+ # anything else.
286
+ LETTERSTORY_STAFF_API_KEY=<your staff key with fleet:query> \
287
+ letterstory admin ask "which writing kernels are hooked up to the most clients?"
288
+
289
+ # --sql prints every query behind the answer, which is what makes a scripted answer
290
+ # checkable; --json emits the whole result (answer + steps) for a caller that wants
291
+ # the queries as data.
292
+ letterstory admin ask "which clients published nothing in the last 30 days?" --sql
293
+ ```
294
+
295
+ This is the headless half of `/superadmin/ask` — same function, same schema — so a
296
+ long-running agent can watch the fleet on a schedule without a browser.
297
+
279
298
  ### Propose, then onboard a phantom
280
299
 
281
300
  The Fleet wizard's Plan and Name screens as two commands: pick the beat, then the name,
package/lib/cli.mjs CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  cmdFlows,
25
25
  cmdConnectors,
26
26
  cmdStrategy,
27
+ cmdDirectives,
27
28
  cmdProbes,
28
29
  cmdSuggestions,
29
30
  cmdOnboarding,
@@ -44,7 +45,7 @@ import {
44
45
  } from "./commands.mjs";
45
46
 
46
47
  // Keep in sync with cli/package.json.
47
- export const VERSION = "0.31.0";
48
+ export const VERSION = "0.33.0";
48
49
 
49
50
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
50
51
  // can't accidentally swallow the id as --json's value.
@@ -286,6 +287,10 @@ Strategy & onboarding:
286
287
  strategy topics set --collection <uuid> (--topic <topic-id> | --suggestion <suggestion-id>)
287
288
  suggestions list <collection_id> List pending strategy suggestions + their auto-apply countdown
288
289
  suggestions approve | dismiss | snooze <verdict|proposal> <id> [--days <n>]
290
+ directives list <collection_id> What is steering this collection right now
291
+ directives propose <collection_id> <what you want> Read one instruction back as concrete levers (applies nothing)
292
+ directives apply <directive_id> --effect <id>... Commit the levers you kept (or --reason to decline them all)
293
+ directives off <directive_id> --reason <why> Stop a live instruction; the reason is read back by the next proposal
289
294
  probes list [--collection <uuid>] [--active-only] [--kind mention|citation|page]
290
295
  probes keep | pause | resume <probe_id> Keep = protect an AI probe from rotation (no pause)
291
296
  probes edit <probe_id> <new question text> Fix/sharpen a generated probe
@@ -463,6 +468,11 @@ Domain onboarding (ONE SHOT: a bare domain in, a fully-configured organization o
463
468
  ${bin === "phantom" ? "onboard" : "phantom-onboard"} domain --status Latest domain onboarding on this org.
464
469
 
465
470
  Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org exists):
471
+ admin ask "<question>" [--sql] [--json] Ask the whole fleet a question in English.
472
+ Answered from the live database with
473
+ read-only SQL across every org; --sql prints
474
+ the queries behind the answer. Needs the
475
+ fleet:query capability on the staff key.
466
476
  admin create-org --domain <bare-domain> [--name "Acme"] [--test] [--no-key]
467
477
  [--key-name N] [--capabilities a,b] [--onboard] [--watch] [+ the onboard domain flags]
468
478
  The key exchange: create the client org from
@@ -640,6 +650,7 @@ const CLIENT_COMMANDS = {
640
650
  flows: cmdFlows,
641
651
  connectors: cmdConnectors,
642
652
  strategy: cmdStrategy,
653
+ directives: cmdDirectives,
643
654
  probes: cmdProbes,
644
655
  suggestions: cmdSuggestions,
645
656
  onboarding: cmdOnboarding,
@@ -3,6 +3,7 @@
3
3
  // (`x-staff-api-key`, minted at Settings → Staff keys) against the superadmin REST
4
4
  // routes, because they exist precisely to act BEFORE an org exists.
5
5
  //
6
+ // admin ask "which kernels are hooked up to the most clients?" [--sql] [--json]
6
7
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
7
8
  // [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
8
9
  // [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
@@ -17,6 +18,11 @@
17
18
  // `phantom-onboard domain --watch` uses. Without --watch, watch it afterwards with
18
19
  // LETTERSTORY_API_KEY=<printed key> letterstory phantom-onboard domain --watch --attach <job-id>
19
20
  //
21
+ // `admin ask` is the headless half of /superadmin/ask: the same question, the same
22
+ // function, the same read-only SQL across every org — no browser. That projection is the
23
+ // point rather than a convenience, because a background agent that watches the fleet
24
+ // cannot open a console (AGENTS.md principle 5). Needs a staff key with `fleet:query`.
25
+ //
20
26
  // The staff key comes from --staff-key or LETTERSTORY_STAFF_API_KEY; the base URL from the
21
27
  // usual --url / LETTERSTORY_API_URL / config resolution.
22
28
 
@@ -178,8 +184,47 @@ async function cmdAdminCreateOrg(ctx) {
178
184
  return watchDomainJob({ ...ctx, client: orgClient }, result.job_id);
179
185
  }
180
186
 
187
+ /**
188
+ * `admin ask "<question>"` — one fleet question, one answer.
189
+ *
190
+ * Prints the prose by default. `--sql` also prints every query behind it, which is
191
+ * what makes a scripted answer checkable; `--json` emits the whole result for a
192
+ * caller that wants the steps as data.
193
+ */
194
+ async function cmdAdminAsk(ctx) {
195
+ const question = ctx.positionals.join(" ").trim();
196
+ if (question.length < 3) {
197
+ throw new CliError('Usage: admin ask "which clients published nothing in the last 30 days?"');
198
+ }
199
+
200
+ const result = await staffPost(ctx, "/api/superadmin/ask", { question });
201
+
202
+ if (flagBool(ctx.flags.json)) {
203
+ printResult(ctx.io, ctx.flags, result);
204
+ return;
205
+ }
206
+
207
+ // The partial-answer warning goes to stderr, so `admin ask ... > answer.md` still
208
+ // captures only the answer while a human at a terminal still sees the caveat.
209
+ if (result.stopped_by && result.stopped_by !== "end_turn") {
210
+ ctx.io.error(`! Run ended on ${result.stopped_by} — the answer below is partial.`);
211
+ }
212
+ ctx.io.log(result.answer || "(no answer)");
213
+
214
+ if (flagBool(ctx.flags.sql)) {
215
+ for (const step of result.steps ?? []) {
216
+ if (!step.sql) continue;
217
+ ctx.io.log(`\n-- ${step.tool}${step.ok ? "" : " (failed)"}${step.truncated ? " (truncated)" : ""}`);
218
+ ctx.io.log(step.sql);
219
+ }
220
+ }
221
+ }
222
+
181
223
  export async function cmdAdmin(ctx) {
182
224
  const sub = ctx.positionals[0];
183
225
  if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
184
- throw new CliError("Usage: admin create-org --domain <bare-domain> [--onboard] … (see --help)");
226
+ if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
227
+ throw new CliError(
228
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" (see --help)'
229
+ );
185
230
  }
@@ -0,0 +1,147 @@
1
+ // `directives` — tell one collection what to do, and see what you've already told it.
2
+ //
3
+ // Thin wrappers over the four MCP tools (propose / apply / list / revoke), which
4
+ // call the same lib functions the app's routes do — so a script or an agent gets
5
+ // exactly the sentence-to-levers interpretation a person gets, and reads back the
6
+ // same "what is steering this collection" list.
7
+ //
8
+ // `propose` PERSISTS NOTHING you'd notice: it writes one proposal row nothing
9
+ // consumes and returns the levers, so `--json | jq` is a legitimate way to ask
10
+ // "what would you do?" without doing it. Applying is a second, explicit call.
11
+
12
+ import { CliError } from "../client.mjs";
13
+ import { flagStr, flagList, requirePositional, printResult, argsFromFlags, ok } from "./shared.mjs";
14
+
15
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
16
+ // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
17
+ export const ARGS = {
18
+ propose_collection_directive: {
19
+ collection_id: { positional: 0, label: "collection-id", required: true },
20
+ // The instruction is the REST of the line, joined — quoting a sentence on a
21
+ // shell is exactly the friction that makes people type one word instead.
22
+ text: { value: (_flags, positionals) => positionals.slice(1).join(" ").trim() || undefined },
23
+ },
24
+ apply_collection_directive: {
25
+ directive_id: { positional: 0, label: "directive-id", required: true },
26
+ accepted_effect_ids: { flag: "effect", type: "list" },
27
+ rejection_reason: "reason",
28
+ },
29
+ list_collection_directives: {
30
+ collection_id: { positional: 0, label: "collection-id", required: true },
31
+ },
32
+ revoke_collection_directive: {
33
+ directive_id: { positional: 0, label: "directive-id", required: true },
34
+ reason: { flag: "reason", required: true },
35
+ },
36
+ };
37
+
38
+ export async function cmdDirectives(ctx) {
39
+ const sub = ctx.positionals[0];
40
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
41
+ switch (sub) {
42
+ case "list":
43
+ case "ls":
44
+ return directivesList(rest);
45
+ case "propose":
46
+ return directivesPropose(rest);
47
+ case "apply":
48
+ return directivesApply(rest);
49
+ case "off":
50
+ case "revoke":
51
+ return directivesRevoke(rest);
52
+ default:
53
+ throw new CliError("Usage: directives <list|propose|apply|off>");
54
+ }
55
+ }
56
+
57
+ function formatEffect(e) {
58
+ const where = e.clusterName ? ` (${e.clusterName})` : "";
59
+ const life = e.ongoing ? "keeps applying" : "once";
60
+ return ` ${e.id} ${e.label}${where} [${life}]\n why: ${e.rationale}`;
61
+ }
62
+
63
+ async function directivesList(ctx) {
64
+ const { client, flags, positionals, io } = ctx;
65
+ const result = await client.callTool(
66
+ "list_collection_directives",
67
+ argsFromFlags(ARGS.list_collection_directives, flags, positionals)
68
+ );
69
+ printResult(io, flags, result, (r) => {
70
+ const list = r.directives ?? [];
71
+ if (!list.length) return "Nothing is steering this collection.";
72
+ return list
73
+ .map((d) => {
74
+ const until = d.expiresAt ? ` stops ${d.expiresAt.slice(0, 10)}` : "";
75
+ const who = d.decidedBy ? "set by a person" : "set without a person in the loop";
76
+ const head = `${d.id} ${d.body}${until}\n (${who}, ${d.createdAt.slice(0, 10)})`;
77
+ return [head, ...(d.effects ?? []).map(formatEffect)].join("\n");
78
+ })
79
+ .join("\n\n");
80
+ });
81
+ return 0;
82
+ }
83
+
84
+ async function directivesPropose(ctx) {
85
+ const { client, flags, positionals, io } = ctx;
86
+ if (!positionals.slice(1).join(" ").trim()) {
87
+ throw new CliError(
88
+ "Usage: directives propose <collection-id> <what you want>\n e.g. directives propose abc-123 focus on the pricing area"
89
+ );
90
+ }
91
+ const result = await client.callTool(
92
+ "propose_collection_directive",
93
+ argsFromFlags(ARGS.propose_collection_directive, flags, positionals)
94
+ );
95
+ printResult(io, flags, result, (r) => {
96
+ const p = r.proposal ?? {};
97
+ const lines = [p.interpretation || p.body, "", `directive: ${p.directiveId}`];
98
+ for (const e of p.effects ?? []) lines.push(formatEffect(e));
99
+ if (!(p.effects ?? []).length) lines.push(" (no levers — nothing here we can currently do)");
100
+ if (p.unsupported) lines.push("", `Can't express: ${p.unsupported}`);
101
+ for (const s of p.supersedes ?? []) lines.push("", `Accepting turns off: "${s.body}"`);
102
+ lines.push(
103
+ "",
104
+ `Then: directives apply ${p.directiveId} --effect <id> [--effect <id>]`,
105
+ ` or: directives apply ${p.directiveId} --reason "why none of these"`
106
+ );
107
+ return lines.join("\n");
108
+ });
109
+ return 0;
110
+ }
111
+
112
+ async function directivesApply(ctx) {
113
+ const { client, flags, positionals } = ctx;
114
+ const args = argsFromFlags(ARGS.apply_collection_directive, flags, positionals);
115
+ const effects = flagList(flags.effect);
116
+ // Declining everything is a legitimate answer, but it has to say why — the next
117
+ // proposal reads the reason back, and one that doesn't is a dismissal.
118
+ if (!effects.length && !flagStr(flags.reason)) {
119
+ throw new CliError('Pass --effect <id> to apply one, or --reason "why" to decline the lot.');
120
+ }
121
+ args.accepted_effect_ids = effects;
122
+ const result = await client.callTool("apply_collection_directive", args);
123
+ const applied = result.applied ?? {};
124
+ const failed = (applied.results ?? []).filter((r) => !r.ok);
125
+ if (!effects.length) {
126
+ ok(ctx, "Noted — we won't suggest that again.");
127
+ } else if (failed.length) {
128
+ ok(
129
+ ctx,
130
+ `Applied ${effects.length - failed.length}/${effects.length}. ${failed.map((f) => f.detail).join(" ")}`
131
+ );
132
+ } else {
133
+ ok(ctx, `Applied ${effects.length} change${effects.length === 1 ? "" : "s"}.`);
134
+ }
135
+ return 0;
136
+ }
137
+
138
+ async function directivesRevoke(ctx) {
139
+ const { client, flags, positionals } = ctx;
140
+ requirePositional(positionals, 0, "directive-id");
141
+ await client.callTool(
142
+ "revoke_collection_directive",
143
+ argsFromFlags(ARGS.revoke_collection_directive, flags, positionals)
144
+ );
145
+ ok(ctx, "Turned off.");
146
+ return 0;
147
+ }
@@ -75,6 +75,22 @@ export const ARGS = {
75
75
  days: { flag: "days", type: "num" },
76
76
  limit: { flag: "limit", type: "num" },
77
77
  },
78
+ // What actually happened to a lede after you sent it. The engine can measure
79
+ // everything up to handing the line over and nothing after, so this is the only
80
+ // path by which a send, a reply or a bad observation ever reaches it.
81
+ report_lede_outcome: {
82
+ run_id: { positional: 0, label: "run-id", required: true },
83
+ shipped_template_id: { flag: "shipped-template" },
84
+ sent: { value: (flags) => (flags.sent === true ? true : undefined) },
85
+ replied: { value: (flags) => (flags.replied === true ? true : undefined) },
86
+ reply_sentiment: { flag: "sentiment", enum: ["positive", "neutral", "negative"] },
87
+ flagged_wrong: { value: (flags) => (flags["flag-wrong"] === true ? true : undefined) },
88
+ flagged_reason: { flag: "reason" },
89
+ },
90
+ // Per line: picked, sent, replied, positive, flagged wrong.
91
+ lede_line_funnel: {
92
+ window_days: { flag: "days", type: "num" },
93
+ },
78
94
  list_lede_endpoints: {},
79
95
  get_lede_balance: {},
80
96
  create_lede_endpoint: {
@@ -122,13 +138,34 @@ export async function cmdLede(ctx) {
122
138
  return ledeEvals(rest);
123
139
  case "balance":
124
140
  return ledeBalance(rest);
141
+ case "outcome":
142
+ case "report":
143
+ return ledeOutcome(rest);
144
+ case "funnel":
145
+ return ledeFunnel(rest);
125
146
  default:
126
147
  throw new CliError(
127
- `Unknown lede subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, run, runs, history, evals, balance`
148
+ `Unknown lede subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, run, runs, history, evals, balance, outcome, funnel`
128
149
  );
129
150
  }
130
151
  }
131
152
 
153
+ async function ledeOutcome(ctx) {
154
+ const { client, flags, positionals, io } = ctx;
155
+ const args = argsFromFlags(ARGS.report_lede_outcome, flags, positionals);
156
+ const result = await client.callTool("report_lede_outcome", args);
157
+ ok(ctx, `Recorded the outcome for run ${args.run_id}.`);
158
+ printResult(io, flags, result);
159
+ return 0;
160
+ }
161
+
162
+ async function ledeFunnel(ctx) {
163
+ const { client, flags, io } = ctx;
164
+ const result = await client.callTool("lede_line_funnel", argsFromFlags(ARGS.lede_line_funnel, flags));
165
+ printResult(io, flags, result);
166
+ return 0;
167
+ }
168
+
132
169
  async function ledeList(ctx) {
133
170
  const { client, flags, io } = ctx;
134
171
  const result = await client.callTool("list_lede_endpoints", argsFromFlags(ARGS.list_lede_endpoints, flags));
package/lib/commands.mjs CHANGED
@@ -16,6 +16,7 @@ export * from "./commands/authors.mjs";
16
16
  export * from "./commands/flows.mjs";
17
17
  export * from "./commands/connectors.mjs";
18
18
  export * from "./commands/strategy.mjs";
19
+ export * from "./commands/directives.mjs";
19
20
  export * from "./commands/probes.mjs";
20
21
  export * from "./commands/series.mjs";
21
22
  export * from "./commands/insights.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",