@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 +40 -0
- package/lib/cli.mjs +46 -11
- package/lib/commands/collections.mjs +37 -1
- package/lib/commands/connectors.mjs +3 -3
- package/lib/commands/kernel.mjs +147 -34
- package/lib/commands/onboard.mjs +188 -25
- package/lib/commands/shared.mjs +52 -0
- package/lib/commands/shredder.mjs +31 -29
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
480
|
-
|
|
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
|
|
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.
|
package/lib/commands/kernel.mjs
CHANGED
|
@@ -16,7 +16,42 @@
|
|
|
16
16
|
// unless you tell it not to.
|
|
17
17
|
|
|
18
18
|
import { CliError } from "../client.mjs";
|
|
19
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
173
|
-
|
|
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) {
|
package/lib/commands/onboard.mjs
CHANGED
|
@@ -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
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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({
|
|
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.");
|
package/lib/commands/shared.mjs
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
164
|
-
const
|
|
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;
|