moshcode 0.58.0 → 0.60.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/src/commands.mjs CHANGED
@@ -16,11 +16,19 @@
16
16
  import { spawn, spawnSync } from "node:child_process";
17
17
 
18
18
  import { createRegistry } from "./registry.mjs";
19
- import { cliVerb, aiVerb } from "./cli.mjs";
19
+ import { cliVerb, aiVerb, runMoshcode } from "./cli.mjs";
20
20
  import { ingestApproval, pollApproval } from "./notify.mjs";
21
- import { capture, killSession, sendPrompt } from "./herd.mjs";
22
- import { herdStart, roster, waitFor } from "./herd-cli.mjs";
21
+ import { capture, killSession, remoteStatus, sendPrompt } from "./herd.mjs";
22
+ import { herdStart, isRemoteMember, roster, waitForMany, waitMember } from "./herd-cli.mjs";
23
+ import { endTask, findTask, readTasks, startTask } from "./herd-tasks.mjs";
23
24
  import { shellInvocation } from "./shell.mjs";
25
+ import { identity, loginAuto, logout as forgetCreds } from "./auth.mjs";
26
+ import { expandAlias, getAlias, loadAliases, removeAlias, setAlias } from "./aliases.mjs";
27
+ import { CORE_CLI_COMMAND_NAMES, PIT_COMMANDS } from "./cli-schema.mjs";
28
+ import { fetchAdvisor, stocksArgs } from "./advisor.mjs";
29
+ import { cryptoArgs, fetchCrypto } from "./crypto.mjs";
30
+ import { collectNews, loadListFeeds, readingList } from "./news.mjs";
31
+ import { resolveList } from "./news-sources.mjs";
24
32
 
25
33
  // The moshcoding pit-anthem playlist. mosh() blasts this URL and, on a desktop
26
34
  // with a GUI, tries to open it in the default browser.
@@ -64,6 +72,61 @@ function expectNoArgs(name, args) {
64
72
  }
65
73
  }
66
74
 
75
+ /**
76
+ * Whether a name already belongs to moshcode, for alias().
77
+ *
78
+ * The pit asks the dispatcher this by resolving; a script has no dispatcher, so
79
+ * it asks the schema instead — the CLI commands and the pit's own verbs, which
80
+ * is what an alias could collide with. Kept as a predicate (the shape
81
+ * setAlias() takes) rather than an exported list, so this stays the caller's
82
+ * answer and not a second roster to drift from the first.
83
+ */
84
+ function isReserved(name) {
85
+ const key = String(name).toLowerCase();
86
+ return CORE_CLI_COMMAND_NAMES.includes(key)
87
+ || PIT_COMMANDS.some((c) => (typeof c === "string" ? c : c.name) === key);
88
+ }
89
+
90
+ // The shell verb, named so runAlias() can execute an expanded alias line
91
+ // through exactly the same path a script's own shell() call takes — one
92
+ // invocation, one dry-run story, one { ok, code } contract. It is registered in
93
+ // COMMANDS below like every other verb.
94
+ const SHELL = {
95
+ name: "shell",
96
+ summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL elsewhere)",
97
+ usage: "shell(cmd)",
98
+ detail: "runs cmd in $SHELL, loading your rc file where it can; returns { ok, code, signal }",
99
+ // The moshscript system verb for arbitrary shell commands. Blocking
100
+ // (spawnSync + inherited stdio) so it runs inline in the no-`await` style,
101
+ // and the child owns the terminal for interactive commands. Returns
102
+ // { ok, code } so scripts can branch on the exit status:
103
+ // const r = shell("npm test"); if (!r.ok) say("tests failed");
104
+ run(ctx, ...args) {
105
+ const cmd = args.join(" ");
106
+ if (!cmd) throw new Error("moshscript: shell() requires a command string");
107
+ if (ctx.dryRun) {
108
+ ctx.out(` ▶ shell(${JSON.stringify(cmd)}) → would run: $SHELL ${shellInvocation(cmd).flags} ${JSON.stringify(cmd)}`);
109
+ // Same R8 contract as the comment above: `code` is always present, so a
110
+ // script branching on the exit status behaves the same under --dry-run.
111
+ return { ok: true, code: 0, dryRun: true };
112
+ }
113
+ // Same invocation the pit's own `!cmd` uses, so a command that works when
114
+ // typed works when scripted: interactive where a terminal is attached, so
115
+ // the user's rc file — and the aliases in it — are loaded. src/shell.mjs
116
+ // has the reasoning, including why a headless run stays non-interactive.
117
+ const { shell: sh, args: shArgs } = shellInvocation(cmd);
118
+ ctx.out(` ▶ shell: ${cmd}`);
119
+ const res = spawnSync(sh, shArgs, { stdio: "inherit" });
120
+ if (res.error) throw res.error;
121
+ const code = res.status ?? 1;
122
+ if (code !== 0) {
123
+ ctx.out(` ✗ shell() exited ${res.signal || code}`);
124
+ return { ok: false, code, signal: res.signal || null };
125
+ }
126
+ return { ok: true, code: 0 };
127
+ },
128
+ };
129
+
67
130
  // The vocabulary, in registration order. mosh() is the worked example of the
68
131
  // command shape; the rest follow the same pattern.
69
132
  const COMMANDS = [
@@ -189,39 +252,227 @@ const COMMANDS = [
189
252
  },
190
253
  },
191
254
 
255
+ SHELL,
256
+
257
+ // The account. Local rather than cliVerbs for the same reason the herd is:
258
+ // `moshcode whoami` prints, and a script needs to *branch* on the answer —
259
+ // "am I logged in?", "whose account is this?", "are there credits left?".
260
+ // Shelling out returns { ok, code }, so the only way to read the account
261
+ // would be to re-parse stdout. src/auth.mjs owns the flows either way; these
262
+ // are a second caller of the same identity(), not a second implementation.
192
263
  {
193
- name: "shell",
194
- summary: "run a shell command (blocking, cmd.exe on Windows or $SHELL elsewhere)",
195
- usage: "shell(cmd)",
196
- detail: "runs cmd in $SHELL, loading your rc file where it can; returns { ok, code, signal }",
197
- // The moshscript system verb for arbitrary shell commands. Blocking
198
- // (spawnSync + inherited stdio) so it runs inline in the no-`await` style,
199
- // and the child owns the terminal for interactive commands. Returns
200
- // { ok, code } so scripts can branch on the exit status:
201
- // const r = shell("npm test"); if (!r.ok) say("tests failed");
264
+ name: "whoami",
265
+ summary: "the logged-in account, as a value (verified against app.moshcode.sh)",
266
+ usage: "whoami()",
267
+ detail: "returns { status, verified, api, user: { id, email, name, credits } }. needs await",
268
+ // Never throws an unreachable app is a `status`, not an exception,
269
+ // because the caller is usually deciding whether to start work.
270
+ async run(ctx) {
271
+ if (ctx.dryRun) {
272
+ ctx.out(" 👤 whoami() would check app.moshcode.sh");
273
+ return { status: "dry_run", verified: false, api: null, user: null, dryRun: true };
274
+ }
275
+ const me = await identity();
276
+ const who = me.user?.email || me.user?.name;
277
+ ctx.out(me.verified
278
+ ? ` 👤 whoami() → ${who || "moshcoder"} (${me.user.credits ?? "?"} credits)`
279
+ : ` 👤 whoami() → ${me.status}${who ? ` (${who}, unverified)` : ""}`);
280
+ return me;
281
+ },
282
+ },
283
+ {
284
+ name: "login",
285
+ summary: "authenticate this machine against app.moshcode.sh",
286
+ usage: "login({ device, browser, force })",
287
+ detail: "no-op when already authenticated unless force; returns { ok, email, already }. needs await",
288
+ // Idempotent by default. A script that opens with login() should be safe to
289
+ // re-run all day without throwing a browser tab at an operator who is
290
+ // already signed in — so the verified case returns early. `force` re-runs
291
+ // the flow anyway (switching accounts), and device/browser pin the flow
292
+ // rather than letting loginAuto sniff for SSH.
293
+ async run(ctx, opts = {}) {
294
+ if (ctx.dryRun) {
295
+ ctx.out(" 🔑 login() → would authenticate against app.moshcode.sh");
296
+ return { ok: true, email: null, already: false, dryRun: true };
297
+ }
298
+ if (!opts.force) {
299
+ const me = await identity();
300
+ if (me.verified) {
301
+ ctx.out(` 🔑 login() → already signed in as ${me.user.email || me.user.name || "moshcoder"}`);
302
+ return { ok: true, email: me.user.email ?? null, already: true };
303
+ }
304
+ }
305
+ try {
306
+ const r = await loginAuto({ device: Boolean(opts.device), browser: Boolean(opts.browser) });
307
+ ctx.out(` 🔑 login() → signed in as ${r.email || "moshcoder"} 🤘`);
308
+ return { ok: true, email: r.email ?? null, already: false };
309
+ } catch (e) {
310
+ // R8: hand back the failure instead of throwing, so a script can fall
311
+ // back (skip the notify, run read-only) rather than die at line 1.
312
+ ctx.out(` ✗ login() failed — ${e.message}`);
313
+ return { ok: false, email: null, already: false, error: e.message };
314
+ }
315
+ },
316
+ },
317
+ {
318
+ name: "requireLogin",
319
+ summary: "BLOCK until this machine is authenticated — the gate for scripts that need an account",
320
+ usage: "requireLogin({ device, browser })",
321
+ detail: "returns the verified { id, email, name, credits }; THROWS if it can't authenticate. needs await",
322
+ // The one verb here that throws, and on purpose: "require" means the script
323
+ // must not continue unauthenticated. Everything downstream (notify, ask,
324
+ // credits) would fail one call at a time and much less legibly, so a script
325
+ // that needs an account says so once, at the top.
326
+ async run(ctx, opts = {}) {
327
+ if (ctx.dryRun) {
328
+ ctx.out(" 🔒 requireLogin() → would require an authenticated account");
329
+ return { id: null, email: null, name: null, credits: null, dryRun: true };
330
+ }
331
+ let me = await identity();
332
+ if (!me.verified) {
333
+ ctx.out(` 🔒 requireLogin() → ${me.status} — starting the login flow…`);
334
+ try { await loginAuto({ device: Boolean(opts.device), browser: Boolean(opts.browser) }); }
335
+ catch (e) { throw new Error(`moshscript: requireLogin() could not authenticate — ${e.message}`); }
336
+ me = await identity();
337
+ }
338
+ if (!me.verified) {
339
+ throw new Error(`moshscript: requireLogin() could not authenticate (${me.status}) — run \`moshcode login\``);
340
+ }
341
+ ctx.out(` 🔒 requireLogin() → ${me.user.email || me.user.name || "moshcoder"} 🤘`);
342
+ return me.user;
343
+ },
344
+ },
345
+ {
346
+ name: "logout",
347
+ summary: "forget this machine's credentials",
348
+ usage: "logout()",
349
+ detail: "returns { ok }",
202
350
  run(ctx, ...args) {
203
- const cmd = args.join(" ");
204
- if (!cmd) throw new Error("moshscript: shell() requires a command string");
351
+ expectNoArgs("logout", args);
352
+ if (ctx.dryRun) { ctx.out(" 🚪 logout() would forget the local credentials"); return { ok: true, dryRun: true }; }
353
+ forgetCreds();
354
+ return { ok: true };
355
+ },
356
+ },
357
+
358
+ // Aliases. The pit already keeps named shortcuts (src/aliases.mjs) and they
359
+ // are the operator's own vocabulary — the things *they* retype. A script that
360
+ // cannot reach them has to re-spell every one of those lines, so the same
361
+ // store is readable and writable here, and runAlias() executes one.
362
+ {
363
+ name: "alias",
364
+ summary: "read, list, or define pit aliases",
365
+ usage: 'alias() | alias(name) | alias(name, line)',
366
+ detail: "no args → the whole map; one arg → that line or null; two → defines it, returns { ok, name, value, previous }",
367
+ run(ctx, name, value) {
368
+ if (name === undefined) return loadAliases();
369
+ if (value === undefined) return getAlias(String(name));
205
370
  if (ctx.dryRun) {
206
- ctx.out(` shell(${JSON.stringify(cmd)}) → would run: $SHELL ${shellInvocation(cmd).flags} ${JSON.stringify(cmd)}`);
207
- // Same R8 contract as the comment above: `code` is always present, so a
208
- // script branching on the exit status behaves the same under --dry-run.
209
- return { ok: true, code: 0, dryRun: true };
371
+ ctx.out(` 🔖 alias(${name}) → would define: ${value}`);
372
+ return { ok: true, name: String(name), value: String(value), previous: null, dryRun: true };
210
373
  }
211
- // Same invocation the pit's own `!cmd` uses, so a command that works when
212
- // typed works when scripted: interactive where a terminal is attached, so
213
- // the user's rc file and the aliases in it — are loaded. src/shell.mjs
214
- // has the reasoning, including why a headless run stays non-interactive.
215
- const { shell: sh, args: shArgs } = shellInvocation(cmd);
216
- ctx.out(` ▶ shell: ${cmd}`);
217
- const res = spawnSync(sh, shArgs, { stdio: "inherit" });
218
- if (res.error) throw res.error;
219
- const code = res.status ?? 1;
220
- if (code !== 0) {
221
- ctx.out(` ✗ shell() exited ${res.signal || code}`);
222
- return { ok: false, code, signal: res.signal || null };
374
+ // Same reservation rule the pit enforces: a shortcut that collides with a
375
+ // built-in would be silently dead, so it is refused rather than shadowed.
376
+ const r = setAlias(name, value, { isReserved });
377
+ ctx.out(r.ok ? ` 🔖 alias(${r.name}) ${r.value}` : ` ✗ alias() ${r.error}`);
378
+ return r;
379
+ },
380
+ },
381
+ {
382
+ name: "unalias",
383
+ summary: "forget a pit alias",
384
+ usage: "unalias(name)",
385
+ detail: "returns { ok, name, value } — { ok: false } when there was no such alias",
386
+ run(ctx, name) {
387
+ if (!name) throw new Error("moshscript: unalias(name) requires an alias name");
388
+ if (ctx.dryRun) { ctx.out(` 🔖 unalias(${name}) → would forget it`); return { ok: true, name: String(name), dryRun: true }; }
389
+ const r = removeAlias(name);
390
+ ctx.out(r.ok ? ` 🔖 unalias(${r.name})` : ` ✗ unalias() — ${r.error}`);
391
+ return r;
392
+ },
393
+ },
394
+ {
395
+ name: "runAlias",
396
+ summary: "run a pit alias by name, with extra arguments appended",
397
+ usage: "runAlias(name, ...args)",
398
+ detail: "returns { ok, code } like shell()/CLI verbs; { ok: false, code: 127 } when undefined",
399
+ // The expansion rule is the pit's (src/aliases.mjs): a leading `/` is a pit
400
+ // command, anything else is a shell line, and typed arguments are appended
401
+ // rather than substituted. A pit command routes to its CLI twin here —
402
+ // moshscript is not the pit, but `/agents claude` and `moshcode agents
403
+ // claude` are the same capability, which is the whole cliVerb premise.
404
+ run(ctx, name, ...args) {
405
+ if (!name) throw new Error("moshscript: runAlias(name) requires an alias name");
406
+ const value = getAlias(String(name));
407
+ if (value == null) {
408
+ ctx.out(` ✗ runAlias(${name}) — no alias named "${name}"`);
409
+ return { ok: false, code: 127 };
410
+ }
411
+ const line = expandAlias(value, args.map(String).join(" "));
412
+ if (line.startsWith("/")) {
413
+ const [verb, ...rest] = line.slice(1).split(/\s+/).filter(Boolean);
414
+ return runMoshcode(verb, rest, ctx);
415
+ }
416
+ return SHELL.run(ctx, line.replace(/^!/, ""));
417
+ },
418
+ },
419
+
420
+ // Reading the tools, not just running them. `stocks report NVDA` prints a
421
+ // table; a script wants the score. These call the same advis0r/feed layer the
422
+ // CLI renders from, so a verb here can never drift from its printed twin.
423
+ {
424
+ name: "stocksRead",
425
+ summary: "run a stocks query and RETURN its JSON (advis0r)",
426
+ usage: 'stocksRead("report", "NVDA")',
427
+ detail: "same arguments as stocks(); returns the parsed data, or null on error. needs await",
428
+ async run(ctx, ...args) {
429
+ const request = stocksArgs(args.map(String));
430
+ if (request.error) throw new Error(`moshscript: stocksRead() — ${request.error}`);
431
+ if (ctx.dryRun) { ctx.out(` 📈 stocksRead(${args.join(" ")}) → would query advis0r`); return null; }
432
+ ctx.out(` 📈 stocksRead(${args.join(" ")})`);
433
+ const res = await fetchAdvisor(request);
434
+ if (!res.ok) { ctx.out(` ! ${res.error || `advis0r returned ${res.status}`}`); return null; }
435
+ return res.data;
436
+ },
437
+ },
438
+ {
439
+ name: "cryptoRead",
440
+ summary: "run a crypto query and RETURN its JSON (advis0r)",
441
+ usage: 'cryptoRead("report", "BTC/USD")',
442
+ detail: "same arguments as crypto(); returns the parsed data, or null on error. needs await",
443
+ async run(ctx, ...args) {
444
+ const request = cryptoArgs(args.map(String));
445
+ if (request.error) throw new Error(`moshscript: cryptoRead() — ${request.error}`);
446
+ if (ctx.dryRun) { ctx.out(` 🪙 cryptoRead(${args.join(" ")}) → would query advis0r`); return null; }
447
+ ctx.out(` 🪙 cryptoRead(${args.join(" ")})`);
448
+ const res = await fetchCrypto(request);
449
+ if (!res.ok) { ctx.out(` ! ${res.error || `advis0r returned ${res.status}`}`); return null; }
450
+ return res.data;
451
+ },
452
+ },
453
+ {
454
+ name: "newsRead",
455
+ summary: "fetch the news feeds and RETURN the headlines",
456
+ usage: 'newsRead({ list, limit })',
457
+ detail: "returns [{ title, link, source, date }, …] — your subscriptions, or a named list. needs await",
458
+ // `list` names one of the built-in feed lists; omit it for the operator's
459
+ // own subscriptions (the same reading list `/news` shows).
460
+ async run(ctx, opts = {}) {
461
+ const limit = Number(opts.limit) || 20;
462
+ if (ctx.dryRun) { ctx.out(` 📰 newsRead() → would fetch ${opts.list || "your"} feeds`); return []; }
463
+ let feeds;
464
+ if (opts.list) {
465
+ const list = resolveList(String(opts.list));
466
+ if (!list) throw new Error(`moshscript: newsRead() — no feed list named "${opts.list}"`);
467
+ const loaded = await loadListFeeds(list);
468
+ if (!loaded.ok) { ctx.out(` ! couldn't load ${opts.list}`); return []; }
469
+ feeds = loaded.feeds;
470
+ } else {
471
+ feeds = readingList().feeds;
223
472
  }
224
- return { ok: true, code: 0 };
473
+ ctx.out(` 📰 newsRead() reading ${feeds.length} feed(s)…`);
474
+ const { items } = await collectNews(feeds);
475
+ return items.slice(0, limit).map(({ title, link, source, date }) => ({ title, link, source, date }));
225
476
  },
226
477
  },
227
478
 
@@ -261,29 +512,62 @@ const COMMANDS = [
261
512
  },
262
513
  {
263
514
  name: "herdPrompt",
264
- summary: "type a prompt into a herd session",
515
+ summary: "type a prompt into a herd session (local or remote)",
265
516
  usage: "herdPrompt(name, text)",
266
- detail: "returns { ok }; does not wait — use herdWait() to join",
517
+ detail: "returns { ok, task }; does not wait — use herdWait() to join",
267
518
  run(ctx, name, ...words) {
268
519
  const text = words.join(" ");
269
520
  if (!name || !text) throw new Error("moshscript: herdPrompt(name, text) requires both");
270
521
  if (ctx.dryRun) { ctx.out(` 💬 herdPrompt(${name}) → would send: ${text}`); return { ok: true, dryRun: true }; }
271
522
  ctx.out(` 💬 herdPrompt(${name}) → ${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`);
272
- const sent = sendPrompt(String(name), text);
273
- return { ok: Boolean(sent.ok) };
523
+ const session = String(name);
524
+
525
+ // A remote member takes the same call with the same arguments — the point
526
+ // of PRD 0011 R12 is that a script fanning across a local pty and a
527
+ // deployed agent contains no `if (remote)`. This one stays synchronous
528
+ // like every other local verb, so the no-`await` style keeps working: the
529
+ // request is in flight when it returns, and herdWait() is how a script
530
+ // joins on it, exactly as for a local session.
531
+ if (isRemoteMember(session)) {
532
+ const task = startTask(session, text, { screen: "" });
533
+ import("./herd-remote.mjs")
534
+ .then((remote) => remote.promptRemote(session, text)
535
+ .then((sent) => endTask(session, task, { state: sent.state || "done", artifact: sent.artifact || String(sent.error?.message || "") })))
536
+ .catch(() => endTask(session, task, { state: "done", artifact: "the request never left this machine" }));
537
+ return { ok: true, task, remote: true };
538
+ }
539
+
540
+ const task = startTask(session, text, { screen: capture(session, { lines: 60 }) });
541
+ const sent = sendPrompt(session, text);
542
+ if (!sent.ok) endTask(session, task, { state: "done", artifact: String(sent.error?.message || sent.error) });
543
+ return { ok: Boolean(sent.ok), task };
274
544
  },
275
545
  },
276
546
  {
277
547
  name: "herdWait",
278
548
  summary: "BLOCK until a herd session is blocked, done, or idle",
279
- usage: "herdWait(name, { states, timeout })",
280
- detail: "returns the state it reached. needs await",
549
+ usage: "herdWait(name | [names], { states, timeout, any })",
550
+ detail: "one name returns the state it reached; a list returns the winner's name (any) or every result (all). needs await",
281
551
  async run(ctx, name, opts = {}) {
282
552
  if (!name) throw new Error("moshscript: herdWait(name) requires a session name");
283
553
  const states = opts.states || ["blocked", "done", "idle"];
554
+ const timeout = opts.timeout ? { timeoutMs: Number(opts.timeout) } : {};
555
+
556
+ // A list of names is a join (PRD 0011 R8) — the thing every fan-out
557
+ // script so far has spelled out by hand as a polling loop.
558
+ if (Array.isArray(name)) {
559
+ const names = name.map(String);
560
+ const mode = opts.any ? "any" : "all";
561
+ if (ctx.dryRun) { ctx.out(` ⏳ herdWait([${names.join(", ")}]) → would wait for ${mode} of them to reach ${states.join("/")}`); return mode === "any" ? names[0] : names.map((n) => ({ name: n, state: "idle" })); }
562
+ ctx.out(` ⏳ herdWait([${names.join(", ")}]) → waiting for ${mode}…`);
563
+ const result = await waitForMany(names, states, { mode, ...timeout });
564
+ ctx.out(` ${result.outcome === "matched" ? "✅" : "⌛"} ${mode === "any" ? `${result.winner} first` : `${result.outcome}`}`);
565
+ return mode === "any" ? result.winner : result.results;
566
+ }
567
+
284
568
  if (ctx.dryRun) { ctx.out(` ⏳ herdWait(${name}) → would wait for ${states.join("/")}`); return "idle"; }
285
569
  ctx.out(` ⏳ herdWait(${name}) → waiting for ${states.join("/")}…`);
286
- const result = await waitFor(String(name), states, opts.timeout ? { timeoutMs: Number(opts.timeout) } : {});
570
+ const result = await waitMember(String(name), states, timeout);
287
571
  ctx.out(` ${result.outcome === "matched" ? "✅" : "⌛"} ${name} is ${result.state}`);
288
572
  return result.state;
289
573
  },
@@ -296,7 +580,11 @@ const COMMANDS = [
296
580
  run(ctx, name, opts = {}) {
297
581
  if (!name) throw new Error("moshscript: herdRead(name) requires a session name");
298
582
  if (ctx.dryRun) { ctx.out(` 📖 herdRead(${name}) → would read its screen`); return ""; }
299
- return capture(String(name), { lines: Number(opts.lines) || 60 });
583
+ const session = String(name);
584
+ // A remote has no screen; what it has is the last thing it said, and
585
+ // that is what `read` means for it (PRD 0011 R12).
586
+ if (isRemoteMember(session)) return String(remoteStatus(session)?.artifact || "");
587
+ return capture(session, { lines: Number(opts.lines) || 60 });
300
588
  },
301
589
  },
302
590
  {
@@ -322,6 +610,41 @@ const COMMANDS = [
322
610
  },
323
611
  },
324
612
 
613
+ // The ledger (PRD 0011 R6). Same contract as herdRead/herdList and for the
614
+ // same reason: a script fans work out and then has to read what came back.
615
+ // `[]`/`null` on anything missing, never a throw — a script joining on four
616
+ // agents must not die because one of them has no history yet.
617
+ //
618
+ // herdPrompt("api", "port the auth routes");
619
+ // await herdWait("api");
620
+ // const [last] = herdTasks("api").slice(-1);
621
+ // say(herdTask(last.id).artifact);
622
+ {
623
+ name: "herdTasks",
624
+ summary: "every prompt submitted to a session, and what came of it",
625
+ usage: "herdTasks(name)",
626
+ detail: "returns [{ id, text, state, status, submitted, durationMs }, …], oldest first",
627
+ run(ctx, name) {
628
+ if (!name) throw new Error("moshscript: herdTasks(name) requires a session name");
629
+ if (ctx.dryRun) { ctx.out(` 📒 herdTasks(${name}) → would read the ledger`); return []; }
630
+ try {
631
+ return readTasks(String(name)).map(({ id, text, state, status, submitted, endedAt, durationMs }) =>
632
+ ({ id, text, state, status, submitted, endedAt, durationMs }));
633
+ } catch { return []; }
634
+ },
635
+ },
636
+ {
637
+ name: "herdTask",
638
+ summary: "one task by id — its transitions and its output",
639
+ usage: "herdTask(id)",
640
+ detail: "returns { id, session, text, transitions, artifact, state } or null",
641
+ run(ctx, id) {
642
+ if (!id) throw new Error("moshscript: herdTask(id) requires a task id");
643
+ if (ctx.dryRun) { ctx.out(` 📒 herdTask(${id}) → would read the ledger`); return null; }
644
+ try { return findTask(String(id)); } catch { return null; }
645
+ },
646
+ },
647
+
325
648
  // CLI verbs — each is `moshcode <name> ...args`. This is the whole point:
326
649
  // scripting the CLI. Add a capability by adding a line here.
327
650
  //
@@ -334,6 +657,7 @@ const COMMANDS = [
334
657
  cliVerb("agents", "launch an autonomous agent session (moshcode agents <engine>)"),
335
658
  cliVerb("herd", "drive the herd (moshcode herd <verb>) — see herdStart/herdWait for values"),
336
659
  cliVerb("ps", "print the herd roster"),
660
+ cliVerb("cost", "print what the herd is spending (moshcode cost [name] [--all])"),
337
661
  cliVerb("start", "raw-launch an engine (moshcode start <engine>)"),
338
662
  cliVerb("install", "install an engine or workflow tool"),
339
663
  cliVerb("upgrade", "upgrade moshcode, engines, and tools"),
@@ -350,6 +674,7 @@ const COMMANDS = [
350
674
  cliVerb("supabase", "drive the Supabase CLI (local stack, migrations, functions)"),
351
675
  cliVerb("doppler", "drive the Doppler CLI (secrets, env injection)"),
352
676
  cliVerb("doctl", "drive the DigitalOcean CLI (droplets, apps, databases)"),
677
+ cliVerb("gradient", "drive the DigitalOcean Gradient ADK (init, run, deploy, logs, evaluate)"),
353
678
  cliVerb("turso", "drive the Turso CLI (auth, databases, replicas)"),
354
679
  cliVerb("tailscale", "drive the Tailscale CLI (mesh VPN: up, status, ssh, serve)"),
355
680
  cliVerb("coral", "drive the Coral CLI (SQL over APIs, databases, and internal systems)"),
@@ -357,6 +682,31 @@ const COMMANDS = [
357
682
  cliVerb("mcpjam", "drive the MCPJam CLI (test, debug, and validate MCP servers)"),
358
683
  cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
359
684
  cliVerb("pwd", "print the current repo/location"),
685
+
686
+ // Research and feeds. The *Read() verbs above return the data; these are the
687
+ // rendered CLI, for when a script wants the table on the operator's screen.
688
+ cliVerb("stocks", "research tickers via advis0r (report, discover, signals, research)"),
689
+ cliVerb("crypto", "research crypto pairs via advis0r (quote, report, bars, book)"),
690
+ cliVerb("advisor", "query advis0r directly"),
691
+ cliVerb("news", "read, search, and subscribe to news feeds"),
692
+ cliVerb("rss", "manage RSS subscriptions and reading lists"),
693
+
694
+ // Extending moshcode from a script — the same fan-out `mcp`/`skill` do.
695
+ cliVerb("plugin", "install/manage moshcode plugins from the marketplace"),
696
+ cliVerb("engines", "list coding engines and whether they're installed"),
697
+ cliVerb("tools", "list the adjacent workflow CLIs and whether they're installed"),
698
+
699
+ // Hosting: the Moshpit side of the CLI, so a deploy script can claim a name,
700
+ // serve a site, and bring the resolver up without dropping to $SHELL.
701
+ cliVerb("dns", "drive the Moshpit DNS bridge (enable, status, resolve)"),
702
+ cliVerb("doh", "run/inspect the DNS-over-HTTPS endpoint"),
703
+ cliVerb("site", "scaffold and publish a site"),
704
+ cliVerb("serve", "serve a directory over HTTP"),
705
+ cliVerb("template", "scaffold from a moshcode template"),
706
+
707
+ // Settings sync (PRD 0010) — needs an account, so pair with requireLogin().
708
+ cliVerb("save", "push local settings to your moshcode account"),
709
+ cliVerb("load", "pull settings from your moshcode account"),
360
710
  ];
361
711
 
362
712
  /** A fresh registry preloaded with the built-in vocabulary. */