@letterstory/cli 0.20.0 → 0.25.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
@@ -287,6 +287,18 @@ single fallback plan, the client's existing publications with an overlap warning
287
287
  custom-domain headroom. `mastheads` returns names that never carry the client's own name. Without
288
288
  `--plan-file`, `phantom-onboard <domain>` still plans from the homepage alone.
289
289
 
290
+ To review a plan and then apply **that** plan, hand it back with `--accept-plan`. A bare
291
+ onboarding computes the plan twice — once for the dry run you read, once inside the job — and
292
+ the second masthead proposal need not match the first, so the name committed into the URL
293
+ would not be the one you approved. `--accept-plan` pins it, implies `--accept`, and skips the
294
+ second homepage read.
295
+
296
+ ```bash
297
+ letterstory phantom-onboard acme.com --json > plan.json # dry run: read it, edit nothing
298
+ letterstory phantom-onboard acme.com --accept-plan plan.json
299
+ jq '.plan' plan.json | letterstory phantom-onboard acme.com --accept-plan -
300
+ ```
301
+
290
302
  ## Content series
291
303
 
292
304
  A series is a coverage obligation, not a keyword bet: "content that looks like this",
@@ -326,6 +338,34 @@ letterstory kernel run --article <uuid> --kernel <uuid> [--brief "…" | --brief
326
338
  letterstory kernel status <kernel-job-id>
327
339
  ```
328
340
 
341
+ ### The standalone kernel endpoint
342
+
343
+ `kernel write` and `kernel polish` hit the standalone door: prose comes back, nothing is
344
+ saved into an article, and it is metered in kernel-run credits rather than the AI-usage gate.
345
+
346
+ ```bash
347
+ letterstory kernel endpoint create --name "Writer"
348
+ letterstory kernel endpoint delete <endpoint-id> --yes
349
+
350
+ letterstory kernel write <endpoint-id> --file outline.md \
351
+ --brief-mode outline --person first --author "Ada" --shredder <endpoint-id>
352
+ # Follows the run and prints the ARTICLE when it lands. --no-wait returns the run id instead.
353
+ ```
354
+
355
+ ### Shredding
356
+
357
+ ```bash
358
+ letterstory shredder run <endpoint-id> --file post.md --format markdown
359
+ # Queues, follows the job, prints the finished DOCUMENT. Exits non-zero if the job lands
360
+ # as error or degraded, so a script can branch on it without parsing the payload.
361
+
362
+ letterstory shredder run <endpoint-id> --file post.md --no-wait # just queue it
363
+ letterstory shredder job <job-id> # poll it yourself
364
+ ```
365
+
366
+ Both follow loops poll every 15–20 s. `LETTERSTORY_POLL_INTERVAL_MS` overrides that — set
367
+ it to `0` in CI so a test does not wait on a real interval.
368
+
329
369
  `phantom-job create --topic "…" --collection <uuid>` runs topic → draft → publish → rebuild
330
370
  end to end, with `phantom-job status <job-id>` to follow it.
331
371
 
package/lib/cli.mjs CHANGED
@@ -44,7 +44,7 @@ import {
44
44
  } from "./commands.mjs";
45
45
 
46
46
  // Keep in sync with cli/package.json.
47
- export const VERSION = "0.20.0";
47
+ export const VERSION = "0.25.0";
48
48
 
49
49
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
50
50
  // can't accidentally swallow the id as --json's value.
@@ -82,6 +82,9 @@ const BOOLEAN_FLAGS = new Set([
82
82
  "no-visuals",
83
83
  "no-enhance",
84
84
  "no-key",
85
+ // `collections activate <id> --no-topic-map` / `--no-headlines`
86
+ "no-topic-map",
87
+ "no-headlines",
85
88
  "onboard",
86
89
  "test",
87
90
  "real",
@@ -205,6 +208,8 @@ Collections:
205
208
  [--cover-canvas-type-id <uuid>|none] [--seo-market <cc>]
206
209
  collections delete <id> --yes
207
210
  collections assign <article-id> <collection-id>
211
+ collections activate <id> [--cadence <n>] [--approval manual|review_window|autopilot] [--no-topic-map] [--no-headlines]
212
+ The review is done: topic map from the areas, first headlines, Planner on, queue filled
208
213
  collections autopen <id> [--template <key>|none] [--kernel-family <name>|org]
209
214
  [--mention off|subtle|woven] [--outline-depth barebones|fleshed_out]
210
215
  [--direction-gate true|false] [--auto-start true|false]
@@ -306,17 +311,25 @@ Writing kernels (kernels write the draft; they do not research — run research
306
311
 
307
312
  Standalone — prose back, nothing saved into an article (metered in kernel-run credits):
308
313
  kernel endpoints Your kernel endpoints
314
+ kernel endpoint create --name <n> [--kernel <uuid>] [--shredder <endpoint-id>]
315
+ New kernel endpoint, optionally bound
316
+ to a kernel and an in-loop shredder
317
+ kernel endpoint delete <endpoint-id> --yes Remove one
309
318
  kernel write <endpoint-id> (--body <text>|--file <path>|--file -)
310
- [--kernel <uuid>] [--words <n>] [--no-wait]
311
- [--shredder <uuid>] shred every round in the loop, so the
312
- kernel judges the text that ships
313
- [--read-as brief|blog|outline|research|notes]
314
319
  WRITE a new article from a headline,
315
320
  outline, notes or a finished post.
316
- Follows the run unless --no-wait.
317
- kernel polish <endpoint-id> (--body <html>|--file <path>) [--format auto|html]
318
- POLISH the document you have: prose
319
- reworded, your own markup kept
321
+ Follows the run unless --no-wait, and
322
+ prints the article when it lands.
323
+ kernel polish <endpoint-id> (--body <html>|--file <path>|--file -) [--no-wait]
324
+ Run the SAME pipeline against a
325
+ document you already have instead of
326
+ writing a new one: prose reworded,
327
+ your own markup kept, the same rules
328
+ and jury as any other run. Follows the
329
+ run unless --no-wait, and costs a run
330
+ exactly like 'kernel write' does —
331
+ there is no separate free, instant
332
+ polish any more.
320
333
  kernel run-status <run-id> Poll a write started with 'kernel write'
321
334
  kernel runs [--endpoint <id>] [--limit <n>] Recent endpoint writes (telemetry)
322
335
  kernel balance Kernel runs left on this org
@@ -344,6 +357,13 @@ Phantom onboarding recipe (domain -> live phantom blog with first posts):
344
357
  lane below: its direction AND pillars are
345
358
  applied verbatim, so the site covers what
346
359
  was approved.
360
+ [--accept-plan <path|->] Apply a plan you already reviewed,
361
+ verbatim — the "plan" from a dry run, or
362
+ a whole saved --json response. Implies
363
+ --accept and skips re-planning, so the
364
+ masthead you approved is the one committed
365
+ (without it the apply re-reads the homepage
366
+ and may propose a different name).
347
367
 
348
368
  Propose lane (the Fleet wizard's Plan and Name screens; persists nothing, spends nothing):
349
369
  ${bin === "phantom" ? "onboard" : "phantom-onboard"} propose [<client-domain>] [--about "the beat"] [--name X] [--json]
@@ -361,6 +381,15 @@ Propose lane (the Fleet wizard's Plan and Name screens; persists nothing, spends
361
381
  --plan-id picks the plan) or a single plan
362
382
  (--plan '<json>' inline, or - for stdin).
363
383
 
384
+ What does this org still need? (the router — reads only, decides nothing):
385
+ ${bin === "phantom" ? "onboard" : "phantom-onboard"} plan Every onboarding step (context, main blog,
386
+ Planner, CMS, phantom) with its status:
387
+ done / running / ready / blocked, and what
388
+ a blocked step is waiting on.
389
+ ${bin === "phantom" ? "onboard" : "phantom-onboard"} next The first ready step Letterstory recommends,
390
+ as the exact command to run next. Optional
391
+ steps are listed, never chosen.
392
+
364
393
  Collection onboarding (your OWN blog, on your org's domain — no provisioning):
365
394
  ${bin === "phantom" ? "onboard" : "phantom-onboard"} collection [--collection <uuid>] [--name "<collection name>"]
366
395
  [--about "the direction"] [--sitemap <url>] [--cms <provider>]
@@ -463,6 +492,9 @@ Shredder (diversify content produced outside Letterstory; structure preserved):
463
492
  shredder create --name <n> New endpoint (returns a callable public id)
464
493
  shredder delete <endpoint-id> --yes
465
494
  shredder run <endpoint-id> (--body <text>|--file <path>|--file -) [--coverage <0..1>] [--attempts <n>]
495
+ [--format auto|html|markdown]
496
+ what the text IS; 'auto' reads a document
497
+ containing block-level HTML as HTML
466
498
  [--aggression standard|high|max]
467
499
  standard (default) keeps the piece's own terminology;
468
500
  high rewrites more and may reword recurring terms;
@@ -476,8 +508,11 @@ Shredder (diversify content produced outside Letterstory; structure preserved):
476
508
  [--sync] wait inside one request (only for a few
477
509
  sentences — anything longer is cut off)
478
510
  Shred prose through an endpoint (logs telemetry).
479
- Queues the work and follows it; a shred takes minutes
480
- and keeps running on the server if you stop waiting.
511
+ Queues the work and follows it, printing the finished
512
+ document; a shred takes minutes and keeps running on
513
+ the server if you stop waiting. Exits non-zero if the
514
+ job lands as error or degraded. Pace the polling with
515
+ LETTERSTORY_POLL_INTERVAL_MS.
481
516
  shredder job <job-id> Poll a queued shred and print it when it lands
482
517
  shredder runs [--endpoint <id>] [--limit <n>] Recent shred calls (telemetry), newest first
483
518
 
@@ -43,6 +43,17 @@ export const ARGS = {
43
43
  collection_id: { positional: 1, label: "collection-id", required: true },
44
44
  },
45
45
  // `collections autopen <id>` — a PATCH: only the flags given are sent.
46
+ // `collections activate <id>` — the review is done, turn the Planner on. Omitted
47
+ // knobs keep the collection's saved values, so nothing here carries a default:
48
+ // an absent flag must stay absent rather than becoming an opinion (see #1773).
49
+ activate_collection: {
50
+ collection_id: { positional: 0, label: "collection-id", required: true },
51
+ cadence_per_week: { flag: "cadence", type: "num" },
52
+ approval_mode: { flag: "approval", enum: ["manual", "review_window", "autopilot"] },
53
+ // `--no-topic-map` / `--no-headlines` mean "skip that step"; absent means "do it".
54
+ regenerate_topic_map: { value: (flags) => (flags["no-topic-map"] === undefined ? undefined : false) },
55
+ stock_headlines: { value: (flags) => (flags["no-headlines"] === undefined ? undefined : false) },
56
+ },
46
57
  update_auto_pen_config: {
47
58
  collection_id: { positional: 0, label: "collection-id", required: true },
48
59
  // `--template none` clears the binding; the server takes null for that.
@@ -87,13 +98,38 @@ export async function cmdCollections(ctx) {
87
98
  return collectionsAssign(rest);
88
99
  case "autopen":
89
100
  return collectionsAutoPen(rest);
101
+ case "activate":
102
+ return collectionsActivate(rest);
90
103
  default:
91
104
  throw new CliError(
92
- `Unknown collections subcommand: ${sub ?? "(none)"}. Try: list, new, update, delete, assign, autopen`
105
+ `Unknown collections subcommand: ${sub ?? "(none)"}. Try: list, new, update, delete, assign, autopen, activate`
93
106
  );
94
107
  }
95
108
  }
96
109
 
110
+ /**
111
+ * `collections activate <id>` — the review is done: build the topic map from the
112
+ * collection's content areas, seed headlines, turn the Planner on with the queue
113
+ * filled. The same activation the app's Guided review step runs on Continue.
114
+ * Omitted knobs keep the collection's saved values; the response says, per knob,
115
+ * whether it came from you, from the saved config, or from the default.
116
+ */
117
+ async function collectionsActivate(ctx) {
118
+ const { client, positionals, flags, io } = ctx;
119
+ const args = argsFromFlags(ARGS.activate_collection, flags, positionals);
120
+ const result = await client.callTool("activate_collection", args);
121
+ if (result && result.activated === false) {
122
+ // The K1 floor, as data: say why, and name the tools that clear it.
123
+ io.error(`Not activated: ${result.reason}`);
124
+ for (const r of result.remedies ?? []) io.error(` ${r.tool} — ${r.why}`);
125
+ printResult(io, flags, result);
126
+ return 1;
127
+ }
128
+ ok(ctx, result?.note ?? `Activated collection ${args.collection_id}.`);
129
+ printResult(io, flags, result);
130
+ return 0;
131
+ }
132
+
97
133
  async function collectionsList(ctx) {
98
134
  const { client, flags, io } = ctx;
99
135
  const result = await client.callTool("list_collections", {});
@@ -1,12 +1,12 @@
1
1
  // `connectors` — export finished articles to external CMSs/doc tools (Webflow,
2
- // Framer, Google Docs, Contentful) and check publish status. Ported from
3
- // phantomstory-cli's connectors.ts.
2
+ // Framer, Google Docs, Contentful, GitHub) and check publish status. Ported
3
+ // from phantomstory-cli's connectors.ts.
4
4
 
5
5
  import { CliError } from "../client.mjs";
6
6
  import { printResult, ok, argsFromFlags } from "./shared.mjs";
7
7
 
8
8
  const CMS_PROVIDERS = ["webflow", "framer", "sanity", "contentful"];
9
- const PUBLISH_CONNECTORS = ["webflow", "framer", "google_docs", "contentful"];
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.
12
12
  // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
@@ -16,7 +16,42 @@
16
16
  // unless you tell it not to.
17
17
 
18
18
  import { CliError } from "../client.mjs";
19
- import { flagStr, requireFlag, requirePositional, printResult, compact, ok, readBodyInput } from "./shared.mjs";
19
+ import {
20
+ flagStr,
21
+ flagBool,
22
+ requireFlag,
23
+ requirePositional,
24
+ printResult,
25
+ compact,
26
+ ok,
27
+ readBodyInput,
28
+ toolField,
29
+ followJob,
30
+ argsFromFlags,
31
+ } from "./shared.mjs";
32
+
33
+ /**
34
+ * How the commands ADDED to this file source their arguments — see `argsFromFlags`
35
+ * in shared.mjs, and `scripts/parity-check.ts`, which compares these keys against
36
+ * each tool's published input schema.
37
+ *
38
+ * The rest of this module predates the field-level ratchet and is covered by the
39
+ * frozen UNMAPPED_TOOLS list. New commands declare; that list only shrinks.
40
+ */
41
+ export const ARGS = {
42
+ create_kernel_endpoint: {
43
+ name: { flag: "name", required: true },
44
+ // Both optional bindings the tool has always accepted at create: which kernel
45
+ // writes, and which shredder (if any) runs inside the loop. Exposed rather than
46
+ // left as a known gap — an endpoint you cannot bind at create is one you have to
47
+ // go and fix in the app.
48
+ kernel_id: "kernel",
49
+ shredder_endpoint_id: "shredder",
50
+ },
51
+ delete_kernel_endpoint: {
52
+ endpoint_id: { positional: 1, label: "endpoint-id", required: true },
53
+ },
54
+ };
20
55
 
21
56
  export async function cmdKernel(ctx) {
22
57
  const sub = ctx.positionals[0];
@@ -31,6 +66,8 @@ export async function cmdKernel(ctx) {
31
66
  return kernelStatus(rest);
32
67
  case "endpoints":
33
68
  return kernelEndpoints(rest);
69
+ case "endpoint":
70
+ return kernelEndpoint(rest);
34
71
  case "write":
35
72
  return kernelWrite(rest);
36
73
  case "polish":
@@ -43,7 +80,7 @@ export async function cmdKernel(ctx) {
43
80
  return kernelBalance(rest);
44
81
  default:
45
82
  throw new CliError(
46
- `Unknown kernel subcommand: ${sub ?? "(none)"}. Try: list, endpoints, write, polish, run-status, runs, balance, run, status`
83
+ `Unknown kernel subcommand: ${sub ?? "(none)"}. Try: list, endpoints, endpoint, write, polish, run-status, runs, balance, run, status`
47
84
  );
48
85
  }
49
86
  }
@@ -84,19 +121,6 @@ const POLL_INTERVAL_MS = 20_000;
84
121
  /** Stop following after this; the run keeps going and `run-status` picks it up. */
85
122
  const POLL_CEILING_MS = 45 * 60 * 1000;
86
123
 
87
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
88
-
89
- /** The tool result is a text payload holding JSON; pull one field out, leniently. */
90
- function readToolField(result, field) {
91
- const raw = result?.content?.[0]?.text;
92
- if (typeof raw !== "string") return undefined;
93
- try {
94
- return JSON.parse(raw)?.[field];
95
- } catch {
96
- return undefined;
97
- }
98
- }
99
-
100
124
  async function kernelEndpoints(ctx) {
101
125
  const { client, flags, io } = ctx;
102
126
  const result = await client.callTool("list_kernel_endpoints", {});
@@ -121,10 +145,20 @@ async function kernelWrite(ctx) {
121
145
  // it, so the verdict lands on the text that actually ships. Costs a shred as well as a
122
146
  // run. Reachable from every other surface and, until now, not from here.
123
147
  shredder_endpoint_id: flagStr(flags.shredder),
148
+ // How hard that in-loop shred rewrites — 'standard' (default, recommended), 'high',
149
+ // or 'max'. Ignored when no shred runs.
150
+ shred_aggression: flagStr(flags["shred-aggression"]),
151
+ // The flag is --read-as, which is what shipped; an earlier draft of this branch
152
+ // called it --brief-mode and never reached anyone.
124
153
  brief_mode: flagStr(flags["read-as"]),
154
+ // The other three `run_kernel` has always taken and the CLI still did not expose,
155
+ // so the only way to reach them was hand-written JSON through `mcp call`.
156
+ person: flagStr(flags.person),
157
+ author_name: flagStr(flags.author),
158
+ allow_experience_claims: flagBool(flags["experience-claims"]),
125
159
  });
126
160
  const result = await client.callTool("run_kernel", args);
127
- const runId = readToolField(result, "run_id");
161
+ const runId = toolField(result, "run_id");
128
162
  if (!runId) {
129
163
  printResult(io, flags, result);
130
164
  return 0;
@@ -136,25 +170,76 @@ async function kernelWrite(ctx) {
136
170
  }
137
171
 
138
172
  ok(ctx, `Write started as ${runId}. Waiting — this keeps running on the server if you stop.`);
139
- const deadline = Date.now() + POLL_CEILING_MS;
140
- for (;;) {
141
- await sleep(POLL_INTERVAL_MS);
142
- const polled = await client.callTool("get_kernel_endpoint_run", { run_id: runId });
143
- const status = readToolField(polled, "status");
144
- if (status && status !== "running") {
145
- ok(ctx, `Write ${status}.`);
146
- printResult(io, flags, polled);
147
- return status === "done" ? 0 : 1;
148
- }
149
- if (Date.now() > deadline) {
150
- ok(ctx, `Still running after 45 minutes. Follow it with: letterstory kernel run-status ${runId}`);
151
- printResult(io, flags, polled);
152
- return 0;
173
+ let lastStatus;
174
+ const { settled, value: polled } = await followJob(
175
+ io,
176
+ async () => {
177
+ const r = await client.callTool("get_kernel_endpoint_run", { run_id: runId });
178
+ lastStatus = toolField(r, "status");
179
+ return { done: !!lastStatus && lastStatus !== "running" && lastStatus !== "queued", value: r };
180
+ },
181
+ { intervalMs: POLL_INTERVAL_MS, ceilingMs: POLL_CEILING_MS }
182
+ );
183
+
184
+ if (!settled) {
185
+ ok(ctx, `Still running after 45 minutes. Follow it with: letterstory kernel run-status ${runId}`);
186
+ printResult(io, flags, polled);
187
+ return 0;
188
+ }
189
+ ok(ctx, `Write ${lastStatus}.`);
190
+ // The operator waited for the ARTICLE; print it, not the envelope. --json still
191
+ // gets the whole payload.
192
+ printResult(io, flags, polled, (v) => (typeof v?.article === "string" ? v.article : JSON.stringify(v, null, 2)));
193
+ return lastStatus === "done" ? 0 : 1;
194
+ }
195
+
196
+ /**
197
+ * `kernel endpoint create|delete` — the standalone endpoint's own CRUD.
198
+ *
199
+ * `create_kernel_endpoint` and `delete_kernel_endpoint` existed as tools with no
200
+ * command at all, so provisioning the thing `kernel write` runs against could
201
+ * only be done through the app or hand-written JSON.
202
+ */
203
+ async function kernelEndpoint(ctx) {
204
+ const { client, positionals, flags, io } = ctx;
205
+ const action = positionals[0];
206
+ if (action === "create" || action === "new") {
207
+ const args = argsFromFlags(ARGS.create_kernel_endpoint, flags, positionals);
208
+ const name = args.name;
209
+ const result = await client.callTool("create_kernel_endpoint", args);
210
+ ok(ctx, `Created kernel endpoint "${name}".`);
211
+ printResult(io, flags, result);
212
+ return 0;
213
+ }
214
+ if (action === "delete" || action === "rm") {
215
+ const id = requirePositional(positionals, 1, "endpoint-id");
216
+ if (!flags.yes) {
217
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} kernel endpoint delete ${id} --yes`);
218
+ return 1;
153
219
  }
220
+ const result = await client.callTool(
221
+ "delete_kernel_endpoint",
222
+ argsFromFlags(ARGS.delete_kernel_endpoint, flags, positionals)
223
+ );
224
+ ok(ctx, "Kernel endpoint deleted.");
225
+ printResult(io, flags, result);
226
+ return 0;
154
227
  }
228
+ throw new CliError(`Unknown kernel endpoint action: ${action ?? "(none)"}. Try: create, delete`);
155
229
  }
156
230
 
157
- /** The OTHER door: reword the document you already have, keeping its own markup. */
231
+ /**
232
+ * The OTHER door: run the same pipeline against a document you already have, instead of
233
+ * writing a new one. Skips generation only — the rules, the in-loop shred and the jury all
234
+ * still run, which is what keeps your own markup (images, embeds, links, tables, anchors,
235
+ * shortcodes) intact through a real pass rather than an unguarded shortcut.
236
+ *
237
+ * ASYNCHRONOUS, same as `write` — this used to be a separate, synchronous, unmetered "polish"
238
+ * that answered in one request. That path was deleted (2026-09-15, letterstory#1783): it
239
+ * worked by skipping the rules and the jury, and that was the actual cost of being instant
240
+ * and free. `document` now costs a run and follows the identical submit-and-poll shape
241
+ * `write` does, so this command does too — `--no-wait` behaves the same way here.
242
+ */
158
243
  async function kernelPolish(ctx) {
159
244
  const { client, positionals, flags, io } = ctx;
160
245
  const endpointId = requirePositional(positionals, 0, "endpoint-id");
@@ -167,10 +252,38 @@ async function kernelPolish(ctx) {
167
252
  document,
168
253
  format: flagStr(flags.format),
169
254
  });
170
- // Synchronous by nature: a polish is one pass over the sentences, not a multi-round write.
171
255
  const result = await client.callTool("run_kernel", args);
172
- printResult(io, flags, result);
173
- return 0;
256
+ const runId = toolField(result, "run_id");
257
+ if (!runId) {
258
+ printResult(io, flags, result);
259
+ return 0;
260
+ }
261
+ if (flags["no-wait"] === true) {
262
+ ok(ctx, `Polish started as ${runId}. Follow it with: letterstory kernel run-status ${runId}`);
263
+ printResult(io, flags, result);
264
+ return 0;
265
+ }
266
+
267
+ ok(ctx, `Polish started as ${runId}. Waiting — this keeps running on the server if you stop.`);
268
+ let lastStatus;
269
+ const { settled, value: polled } = await followJob(
270
+ io,
271
+ async () => {
272
+ const r = await client.callTool("get_kernel_endpoint_run", { run_id: runId });
273
+ lastStatus = toolField(r, "status");
274
+ return { done: !!lastStatus && lastStatus !== "running" && lastStatus !== "queued", value: r };
275
+ },
276
+ { intervalMs: POLL_INTERVAL_MS, ceilingMs: POLL_CEILING_MS }
277
+ );
278
+
279
+ if (!settled) {
280
+ ok(ctx, `Still running after 45 minutes. Follow it with: letterstory kernel run-status ${runId}`);
281
+ printResult(io, flags, polled);
282
+ return 0;
283
+ }
284
+ ok(ctx, `Polish ${lastStatus}.`);
285
+ printResult(io, flags, polled, (v) => (typeof v?.article === "string" ? v.article : JSON.stringify(v, null, 2)));
286
+ return lastStatus === "done" ? 0 : 1;
174
287
  }
175
288
 
176
289
  async function kernelRunStatus(ctx) {
@@ -17,6 +17,12 @@
17
17
  // so to use a name other than the inferred one, pass --name BEFORE applying. The first
18
18
  // posts are written by background jobs (20-40min each); they're reported as job ids and
19
19
  // this command does not block on them — poll with `phantom-job status <job-id>`.
20
+ //
21
+ // `--accept-plan <path|->` closes the review loop: the apply PINS the plan instead of
22
+ // recomputing it, so the masthead that was reviewed is the masthead that is committed. In one
23
+ // command the dry run's plan is carried into the apply automatically; the flag is for the
24
+ // two-command shape (`onboard acme.com --json > plan.json`, read it, then `onboard acme.com
25
+ // --accept-plan plan.json`), which is what an agent or a script actually does.
20
26
 
21
27
  // `onboard domain` is the ONE-SHOT parent: a bare company domain in (the org's registered
22
28
  // domain, from the API key), a fully-configured organization out — the main blog onboarded
@@ -66,6 +72,11 @@ const APPROVAL_MODES = ["autopilot", "review_window", "manual"];
66
72
  // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
67
73
  // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
68
74
  export const ARGS = {
75
+ // The router read. It takes no arguments at all — the org comes from the API
76
+ // key — but it still declares an entry, because the parity ratchet's rule is
77
+ // that a curated command's tool is DECLARED, not that it has flags. An empty
78
+ // map says "no inputs" explicitly, where a missing one says "nobody checked".
79
+ get_onboarding_plan: {},
69
80
  onboard_collection: {
70
81
  collection_id: "collection",
71
82
  collection_name: "name",
@@ -114,6 +125,11 @@ export const ARGS = {
114
125
  // --accept gates the two-call flow below (dry run, then apply) rather than being
115
126
  // forwarded: the first call never sends it, the second always sends true.
116
127
  accept: { internal: "false on the dry run, true on apply — decided by the command" },
128
+ // The reviewed plan, pinned so the apply does not recompute the masthead the user
129
+ // just approved (#1773 / C2). Like `accept`, the command decides it rather than a
130
+ // flag mapping straight through: it comes from `--accept-plan <path|->` /
131
+ // `--accept-plan-json`, or from the dry run this same command just printed.
132
+ plan: { internal: "the reviewed plan — from --accept-plan, or the dry run just printed" },
117
133
  overrides: {
118
134
  object: {
119
135
  name: "name",
@@ -217,6 +233,38 @@ export function readTopicPlan(flags) {
217
233
  return doc;
218
234
  }
219
235
 
236
+ // The plan a caller already reviewed, handed back to be applied VERBATIM: `--accept-plan
237
+ // <path|->` (or `--accept-plan-json '<json>'`). Accepts either the plan object itself or a
238
+ // saved dry-run response, whose `plan` key holds it — so `onboard <domain> --json > plan.json`
239
+ // then `onboard <domain> --accept-plan plan.json` is the whole review-then-accept loop.
240
+ //
241
+ // Why it exists: without a pinned plan the apply RE-PLANS — a second homepage read and a
242
+ // second masthead proposal, which can answer differently than the one just approved, and
243
+ // that name is committed into the URL when the site is created.
244
+ export function readAcceptedPlan(flags) {
245
+ const raw = readTextInput(flags, "accept-plan-json", "accept-plan");
246
+ if (raw === undefined) return undefined;
247
+ let doc;
248
+ try {
249
+ doc = JSON.parse(raw);
250
+ } catch (err) {
251
+ throw new CliError(`--accept-plan is not valid JSON: ${err.message}`);
252
+ }
253
+ // A saved dry-run response wraps the plan; the plan itself does not.
254
+ const plan = doc && typeof doc === "object" && doc.plan && typeof doc.plan === "object" ? doc.plan : doc;
255
+ if (!plan || typeof plan !== "object" || typeof plan.description !== "string" || !plan.defaults) {
256
+ throw new CliError(
257
+ "--accept-plan must be the `plan` object from an onboard dry run (or the whole saved --json response)."
258
+ );
259
+ }
260
+ if (!plan.name) {
261
+ throw new CliError(
262
+ "That plan has no masthead name, and the name is committed at create. Re-run the dry run with --name <MastheadName> and accept the plan it returns."
263
+ );
264
+ }
265
+ return plan;
266
+ }
267
+
220
268
  // The recipe's spelling under this binary, for next-step hints.
221
269
  function recipeName(ctx) {
222
270
  return ctx.bin === "phantom" ? "onboard" : "phantom-onboard";
@@ -588,8 +636,108 @@ async function cmdOnboardDomain(ctx) {
588
636
  return watchDomainJob(ctx, started.job_id);
589
637
  }
590
638
 
639
+ // `onboard plan` — what does this organization still need? One read of get_onboarding_plan
640
+ // (the router), printed as a table. `onboard next` prints the first ready step's exact CLI
641
+ // invocation with what the facts already determine (the main collection's id) filled in.
642
+ // Neither decides anything: an optional step is listed, never chosen.
643
+ const STATUS_ORDER = ["ready", "running", "blocked", "done"];
644
+
645
+ function formatPlanTable(plan) {
646
+ const rows = plan.steps.map((s) => [
647
+ s.id,
648
+ s.status,
649
+ s.disposition,
650
+ s.blocked_by.length
651
+ ? s.blocked_by.join(", ")
652
+ : s.missing.length && s.status === "done"
653
+ ? `(${s.missing.join(", ")})`
654
+ : "",
655
+ s.cli,
656
+ ]);
657
+ const head = ["step", "status", "opinion", "blocked by", "run"];
658
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i]).length)));
659
+ const line = (cells) =>
660
+ cells
661
+ .map((c, i) => String(c).padEnd(widths[i]))
662
+ .join(" ")
663
+ .trimEnd();
664
+ const lines = [line(head), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)];
665
+ lines.push("");
666
+ lines.push(
667
+ plan.next
668
+ ? `Next: ${plan.next} — ${plan.steps.find((s) => s.id === plan.next)?.why ?? ""}`
669
+ : plan.steps.every((s) => s.status === "done")
670
+ ? "Nothing left: every step is done."
671
+ : "Nothing is ready to run — see the blocked-by column."
672
+ );
673
+ if (plan.optional_ready?.length) lines.push(`Optional and ready (needs a yes): ${plan.optional_ready.join(", ")}`);
674
+ return lines.join("\n");
675
+ }
676
+
677
+ function cliInvocation(ctx, step) {
678
+ const hint = Object.entries(step.args_hint ?? {});
679
+ // The generic door: `tool <name> --arg k=v` takes the schema's own keys.
680
+ if (/^tool /.test(step.cli)) {
681
+ const args = hint.map(([k, v]) => ` --arg ${k}=${v}`).join("");
682
+ return `${ctx.bin} ${step.cli}${args}`;
683
+ }
684
+ // A curated command: the main collection's id is `--collection` on every one that takes it.
685
+ const collection = step.args_hint?.collection_id ? ` --collection ${step.args_hint.collection_id}` : "";
686
+ return `${ctx.bin} ${step.cli}${collection}`;
687
+ }
688
+
689
+ async function cmdOnboardPlan(ctx) {
690
+ const plan = await ctx.client.callTool("get_onboarding_plan", {});
691
+ if (!Array.isArray(plan.steps)) throw new CliError("get_onboarding_plan did not return steps.");
692
+ printResult(ctx.io, ctx.flags, plan, formatPlanTable);
693
+ return 0;
694
+ }
695
+
696
+ async function cmdOnboardNext(ctx) {
697
+ const plan = await ctx.client.callTool("get_onboarding_plan", {});
698
+ if (!Array.isArray(plan.steps)) throw new CliError("get_onboarding_plan did not return steps.");
699
+ const next = plan.next ? plan.steps.find((s) => s.id === plan.next) : null;
700
+ if (!next) {
701
+ const running = plan.steps.find((s) => s.status === "running");
702
+ if (running) {
703
+ printResult(ctx.io, ctx.flags, running, (s) => `Waiting on "${s.id}": ${s.why}`);
704
+ return 0;
705
+ }
706
+ const optional = (plan.optional_ready ?? []).map((id) => plan.steps.find((s) => s.id === id)).filter(Boolean);
707
+ printResult(ctx.io, ctx.flags, plan, () =>
708
+ [
709
+ plan.steps.every((s) => s.status === "done")
710
+ ? "Nothing left: every step is done."
711
+ : "Nothing is ready to run.",
712
+ ...optional.map((s) => `Optional (needs a yes): ${s.id} — ${cliInvocation(ctx, s)}`),
713
+ ...plan.steps
714
+ .filter((s) => s.status === "blocked")
715
+ .sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status))
716
+ .map((s) => `Blocked: ${s.id} — ${s.why}`),
717
+ ].join("\n")
718
+ );
719
+ return 0;
720
+ }
721
+ printResult(ctx.io, ctx.flags, next, (s) =>
722
+ [
723
+ `Next: ${s.title} (${s.disposition})`,
724
+ ` ${s.purpose}`,
725
+ s.prepare.length ? ` Read first: ${s.prepare.join(", ")}` : null,
726
+ ` Run: ${cliInvocation(ctx, s)}`,
727
+ s.poll ? ` Poll: ${s.poll}` : null,
728
+ ` Docs: https://app.letterstory.com${s.docs}`,
729
+ ]
730
+ .filter(Boolean)
731
+ .join("\n")
732
+ );
733
+ return 0;
734
+ }
735
+
591
736
  export async function cmdOnboard(ctx) {
592
737
  const { client, flags, io } = ctx;
738
+ // `onboard plan` / `onboard next` — the router: what does this org still need?
739
+ if (ctx.positionals[0] === "plan") return cmdOnboardPlan(ctx);
740
+ if (ctx.positionals[0] === "next") return cmdOnboardNext(ctx);
593
741
  // `onboard collection …` — the client-blog variant (no <domain>; org from the key).
594
742
  if (ctx.positionals[0] === "collection") {
595
743
  return cmdOnboardCollection({ ...ctx, positionals: ctx.positionals.slice(1) });
@@ -612,39 +760,54 @@ export async function cmdOnboard(ctx) {
612
760
  const domain = requirePositional(ctx.positionals, 0, "domain");
613
761
  const overrides = overridesFromFlags(flags);
614
762
 
615
- // Step 1: dry-run plan (accept defaults to false server-side). Persists nothing.
616
- const plan = await client.callTool(
617
- "onboard_phantom",
618
- compact({ domain, overrides: Object.keys(overrides).length ? overrides : undefined })
619
- );
620
- ok(ctx, "Onboarding plan:");
621
- printResult(io, flags, plan, formatPlan);
622
-
623
- if (flagBool(flags["dry-run"])) return 0;
763
+ // A plan handed in with --accept-plan was reviewed already, and re-running the dry run
764
+ // would re-read the homepage and re-propose a masthead — exactly what pinning prevents.
765
+ // So it also stands in for --accept: you cannot hand back a plan you have not read.
766
+ let reviewedPlan = readAcceptedPlan(flags);
624
767
 
625
- // Step 2: the review gate. A bare, unaccepted run stops here so nothing is provisioned
626
- // by accident — re-run with --accept (or an override flag) once happy.
627
- const overrideGiven = Object.keys(overrides).length > 0;
628
- if (!flagBool(flags.accept) && !overrideGiven) {
629
- ok(
630
- ctx,
631
- "Re-run with --accept to apply, or pass --about/--name/--cadence/--cover/--posts/--theme/--domain to adjust " +
632
- `(or pick the beat and name first: ${recipeName(ctx)} propose / mastheads). ` +
633
- "Note: --name is committed at create (it seeds the URL)."
634
- );
635
- return 0;
636
- }
637
- if (plan.proposed_name === null && flagStr(flags.name) === undefined) {
638
- throw new CliError(
639
- "No blog name available: the plan proposed none (strategy soft-failed). Pass --name <MastheadName> to apply."
768
+ if (!reviewedPlan) {
769
+ // Step 1: dry-run plan (accept defaults to false server-side). Persists nothing.
770
+ const dryRun = await client.callTool(
771
+ "onboard_phantom",
772
+ compact({ domain, overrides: Object.keys(overrides).length ? overrides : undefined })
640
773
  );
774
+ ok(ctx, "Onboarding plan:");
775
+ printResult(io, flags, dryRun, formatPlan);
776
+
777
+ if (flagBool(flags["dry-run"])) return 0;
778
+
779
+ // Step 2: the review gate. A bare, unaccepted run stops here so nothing is provisioned
780
+ // by accident — re-run with --accept (or an override flag) once happy.
781
+ const overrideGiven = Object.keys(overrides).length > 0;
782
+ if (!flagBool(flags.accept) && !overrideGiven) {
783
+ ok(
784
+ ctx,
785
+ "Re-run with --accept to apply, or pass --about/--name/--cadence/--cover/--posts/--theme/--domain to adjust " +
786
+ `(or pick the beat and name first: ${recipeName(ctx)} propose / mastheads). ` +
787
+ "Note: --name is committed at create (it seeds the URL)."
788
+ );
789
+ return 0;
790
+ }
791
+ if (dryRun.proposed_name === null && flagStr(flags.name) === undefined) {
792
+ throw new CliError(
793
+ "No blog name available: the plan proposed none (strategy soft-failed). Pass --name <MastheadName> to apply."
794
+ );
795
+ }
796
+ // Apply the plan that was just printed, not a fresh one: an old server that does not
797
+ // return `plan` leaves this undefined and the job plans for itself, as before.
798
+ reviewedPlan = dryRun.plan;
641
799
  }
642
800
 
643
801
  // Step 3: apply — start the durable onboarding job.
644
802
  ok(ctx, "Applying — starting the onboarding job…");
645
803
  const started = await client.callTool(
646
804
  "onboard_phantom",
647
- compact({ domain, accept: true, overrides: Object.keys(overrides).length ? overrides : undefined })
805
+ compact({
806
+ domain,
807
+ accept: true,
808
+ overrides: Object.keys(overrides).length ? overrides : undefined,
809
+ plan: reviewedPlan,
810
+ })
648
811
  );
649
812
  const jobId = started.job_id;
650
813
  if (!jobId) throw new CliError("onboard_phantom did not return a job id.");
@@ -221,6 +221,58 @@ export async function pollUntil(io, poll, opts = {}) {
221
221
  }
222
222
  }
223
223
 
224
+ // Pull one field out of a tool result.
225
+ //
226
+ // `client.callTool` ALREADY unwraps the MCP envelope and parses the JSON, so a
227
+ // tool result here is the payload itself. Two commands used to reach for
228
+ // `result.content[0].text` — the envelope shape — which is always undefined
229
+ // after unwrapping, so the job id was never found, the follow loop never ran,
230
+ // and `--no-wait` never printed its hint. The envelope fallback below is kept
231
+ // only so a raw result passed in from somewhere else still reads correctly.
232
+ export function toolField(result, field) {
233
+ if (result && typeof result === "object" && field in result) return result[field];
234
+ const raw = result?.content?.[0]?.text;
235
+ if (typeof raw === "string") {
236
+ try {
237
+ return JSON.parse(raw)?.[field];
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ // Follow a queued job to a terminal state, SLEEPING FIRST.
246
+ //
247
+ // Unlike `pollUntil`, which checks immediately: a shred or a kernel run takes
248
+ // minutes and starts on the next tick, so an instant first poll only ever says
249
+ // "queued". Sleeping first is also what makes the interval the operator's —
250
+ // both loops used a file-local `setTimeout`, so `io.sleep` (and with it
251
+ // LETTERSTORY_POLL_INTERVAL_MS) was ignored and nobody could pace them.
252
+ //
253
+ // Returns `{ settled, value }`. `settled:false` means the ceiling passed — the
254
+ // caller decides what to say, because the job is still running on the server
255
+ // and that is not an error.
256
+ export async function followJob(io, poll, opts = {}) {
257
+ // The default is the command's own (a shred is slow; polling fast only burns
258
+ // requests). An operator who EXPLICITLY sets LETTERSTORY_POLL_INTERVAL_MS
259
+ // means it, and that is what makes the interval theirs — a CI run or a test
260
+ // harness sets it to 0 and the same loop runs without the wait.
261
+ const explicit = process.env.LETTERSTORY_POLL_INTERVAL_MS;
262
+ const intervalMs =
263
+ explicit !== undefined && explicit !== "" && Number.isFinite(Number(explicit))
264
+ ? Number(explicit)
265
+ : (opts.intervalMs ?? 15000);
266
+ const ceilingMs = opts.ceilingMs ?? 30 * 60 * 1000;
267
+ const deadline = Date.now() + ceilingMs;
268
+ for (;;) {
269
+ await io.sleep(intervalMs);
270
+ const { done, value } = await poll();
271
+ if (done) return { settled: true, value };
272
+ if (Date.now() > deadline) return { settled: false, value };
273
+ }
274
+ }
275
+
224
276
  // Read a text value from an inline flag, or from a file flag (a path, or "-" for
225
277
  // stdin). Returns undefined if neither flag was given, so callers can decide whether
226
278
  // that's an error. `readBodyInput` is the --body/--file pairing; the propose lane reads
@@ -13,6 +13,8 @@ import {
13
13
  printResult,
14
14
  compact,
15
15
  ok,
16
+ toolField,
17
+ followJob,
16
18
  } from "./shared.mjs";
17
19
 
18
20
  export async function cmdShredder(ctx) {
@@ -83,8 +85,6 @@ async function shredderDelete(ctx) {
83
85
  const POLL_INTERVAL_MS = 15_000;
84
86
  const POLL_CEILING_MS = 30 * 60 * 1000;
85
87
 
86
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
87
-
88
88
  async function shredderRun(ctx) {
89
89
  const { client, positionals, flags, io } = ctx;
90
90
  const id = requirePositional(positionals, 0, "endpoint-id");
@@ -105,6 +105,9 @@ async function shredderRun(ctx) {
105
105
  text,
106
106
  coverage: flagNum(flags.coverage),
107
107
  attempts_per_unit: flagNum(flags.attempts),
108
+ // What the text IS. Without this an HTML file could only go through the
109
+ // `auto` heuristic, which misreads a markdown post carrying an embed.
110
+ ...(flags.format ? { format: String(flags.format) } : {}),
108
111
  // How hard to rewrite. Validated service-side; sent only when the caller asked.
109
112
  ...(flags.aggression ? { aggression: String(flags.aggression) } : {}),
110
113
  // Hold every rewrite to the live writing rules. A trade, not a free win: refused rewrites
@@ -132,36 +135,35 @@ async function shredderRun(ctx) {
132
135
  }
133
136
 
134
137
  ok(ctx, `Shred queued as ${jobId}. Waiting — this keeps running on the server if you stop.`);
135
- const deadline = Date.now() + POLL_CEILING_MS;
136
- for (;;) {
137
- await sleep(POLL_INTERVAL_MS);
138
- const polled = await client.callTool("get_shred_job", { job_id: jobId });
139
- const status = readStatus(polled);
140
- if (status && status !== "queued" && status !== "running") {
141
- ok(ctx, `Shred ${status}.`);
142
- printResult(io, flags, polled);
143
- return status === "done" ? 0 : 1;
144
- }
145
- if (Date.now() > deadline) {
146
- ok(ctx, `Still ${status ?? "running"} after 30 minutes. Poll it with: letterstory shredder job ${jobId}`);
147
- printResult(io, flags, polled);
148
- return 0;
149
- }
150
- }
151
- }
138
+ let lastStatus;
139
+ const { settled, value: polled } = await followJob(
140
+ io,
141
+ async () => {
142
+ const result = await client.callTool("get_shred_job", { job_id: jobId });
143
+ lastStatus = readStatus(result);
144
+ const terminal = !!lastStatus && lastStatus !== "queued" && lastStatus !== "running";
145
+ return { done: terminal, value: result };
146
+ },
147
+ { intervalMs: POLL_INTERVAL_MS, ceilingMs: POLL_CEILING_MS }
148
+ );
152
149
 
153
- /** The tool result is a text payload holding JSON; pull one field out of it, leniently. */
154
- function readToolField(result, field) {
155
- const raw = result?.content?.[0]?.text;
156
- if (typeof raw !== "string") return undefined;
157
- try {
158
- return JSON.parse(raw)?.[field];
159
- } catch {
160
- return undefined;
150
+ if (!settled) {
151
+ ok(ctx, `Still ${lastStatus ?? "running"} after 30 minutes. Poll it with: letterstory shredder job ${jobId}`);
152
+ printResult(io, flags, polled);
153
+ return 0;
161
154
  }
155
+ ok(ctx, `Shred ${lastStatus}.`);
156
+ // The operator followed this job to get their DOCUMENT, so print the document
157
+ // and not the envelope around it. `--json` still gets the whole payload, which
158
+ // is what a script piping this wants.
159
+ printResult(io, flags, polled, (v) => (typeof v?.text === "string" ? v.text : JSON.stringify(v, null, 2)));
160
+ // A job that landed as error or degraded is not a success, and a script that
161
+ // pipes this needs to know without parsing the payload.
162
+ return lastStatus === "done" ? 0 : 1;
162
163
  }
163
- const readJobId = (result) => readToolField(result, "job_id");
164
- const readStatus = (result) => readToolField(result, "status");
164
+
165
+ const readJobId = (result) => toolField(result, "job_id");
166
+ const readStatus = (result) => toolField(result, "status");
165
167
 
166
168
  async function shredderJob(ctx) {
167
169
  const { client, positionals, flags, io } = ctx;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.20.0",
3
+ "version": "0.25.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",