octwin-cli 0.7.2 → 0.8.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/dist/index.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * octwin whoami [--url <url>] [--tenant <slug>]
15
15
  * octwin projects [--archived] # the --project slugs this token can name
16
16
  * octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
17
+ * [--request-listing | --withdraw-listing] # marketplace listing — opt-in, see `deploy` help
17
18
  * octwin pull <packId> [--dir <out>] [--version v] [--force] # write a DEPLOYED pack's source back to disk
18
19
  * octwin status [<packId>] # did my deploy land? which version is live?
19
20
  * octwin records [entity] [id] # inspect the pack's XRM data (records:read token)
@@ -27,6 +28,11 @@
27
28
  * octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion, any pipelined entity
28
29
  * octwin catalog [--readiness] # commerce products + stock + the WhatsApp catalog binding (catalog:read)
29
30
  * octwin scheduling [--slots <resourceRecordId>] # engine state / computed slots (scheduling:read)
31
+ * octwin automation [campaigns] # the jobs your declarations produced + health + last result (automation:read)
32
+ * octwin integrations [deliveries|events|preflight|test …] # declared vs configured, and the delivery log (integrations:read)
33
+ * octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition] # declared journeys, measured (journeys:read)
34
+ * octwin performance [--detail] # the project's business indicators (records:read — there is no performance scope)
35
+ * octwin usage # model calls, tokens and cost (any valid token; NOT Meta message billing)
30
36
  * octwin platform-kb [pull] [--if-stale|--check] [--dir .] # the platform capability reference (no token needed)
31
37
  * octwin test [--dir .] # = validate --remote (the full platform check)
32
38
  *
@@ -52,7 +58,7 @@
52
58
  * operator's GitHub repo import applies. The platform re-validates it
53
59
  * (pure-YAML enforcement + manifest/flow Zod) and installs it onto the project.
54
60
  */
55
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, rmSync } from 'node:fs';
61
+ import { readFileSync, writeFileSync, writeSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, rmSync } from 'node:fs';
56
62
  import { join, resolve, dirname, basename } from 'node:path';
57
63
  import { homedir } from 'node:os';
58
64
  import { fileURLToPath } from 'node:url';
@@ -155,7 +161,7 @@ function parseFlags(argv) {
155
161
  }
156
162
  function die(msg) {
157
163
  console.error(`✗ ${msg}`);
158
- process.exit(1);
164
+ exitNow(1);
159
165
  }
160
166
  // ── network helpers ─────────────────────────────────────────────────────────
161
167
  /** `fetch` that dies with the TARGET URL on a network failure — a bare
@@ -215,6 +221,21 @@ const VERB_REQUIREMENTS = {
215
221
  // which does NOT confer this — that 403 is otherwise baffling.
216
222
  'projects create': { scope: 'projects:write' },
217
223
  'projects rm': { scope: 'projects:write' },
224
+ // Jobs are declaration-derived, so there is no `create` — only acting on one.
225
+ // `campaigns` is absent on purpose: it is a READ sharing the verb slot, and an
226
+ // entry here would print "needs automation:write" on a read failure.
227
+ 'automation run': { scope: 'automation:write' },
228
+ 'automation pause': { scope: 'automation:write' },
229
+ 'automation resume': { scope: 'automation:write' },
230
+ 'automation send': { scope: 'automation:write' },
231
+ // `preflight` is the odd one: it is a diagnosis, so the route gates it on
232
+ // `view`, not `act`. Naming the READ scope here is what stops a 403 on it
233
+ // sending the author to mint a write token they do not need.
234
+ 'integrations preflight': { scope: 'integrations:read' },
235
+ 'integrations test': { scope: 'integrations:write' },
236
+ 'integrations retry': { scope: 'integrations:write' },
237
+ 'integrations cancel': { scope: 'integrations:write' },
238
+ 'integrations send-now': { scope: 'integrations:write' },
218
239
  };
219
240
  const COMMAND_REQUIREMENTS = {
220
241
  deploy: { scope: 'pack:deploy' },
@@ -249,6 +270,17 @@ const COMMAND_REQUIREMENTS = {
249
270
  // hint names the scope a NON-deploy token would be missing — a `pack:deploy`
250
271
  // holder never sees this line, because they never get the 403.
251
272
  projects: { scope: 'projects:read' },
273
+ automation: { scope: 'automation:read' },
274
+ integrations: { scope: 'integrations:read' },
275
+ journeys: { scope: 'journeys:read' },
276
+ // `records:read`, not a `performance:*` scope — there is none. The indicators are
277
+ // derived from record + journey data, and the route is gated accordingly, so the
278
+ // Read-only token preset already reaches this.
279
+ performance: { scope: 'records:read' },
280
+ // `usage` is deliberately absent: its route is `requireTenantAccess` only, so any
281
+ // valid token reaches it. Declaring a requirement would print "needs the X scope"
282
+ // on a failure whose real cause is an unreachable instance — the same reasoning as
283
+ // `platform-kb` above.
252
284
  };
253
285
  /** The command currently running — set once in `main()` so any failure printer can
254
286
  * name the scope that command needs without threading it through every call.
@@ -460,8 +492,10 @@ async function latestPublishedVersion() {
460
492
  const timer = setTimeout(() => ctrl.abort(), 1500);
461
493
  const res = await fetch('https://registry.npmjs.org/octwin-cli/latest', { signal: ctrl.signal });
462
494
  clearTimeout(timer);
463
- if (!res.ok)
495
+ if (!res.ok) {
496
+ await res.arrayBuffer().catch(() => undefined);
464
497
  return null;
498
+ } // drain — see `fetchKbMeta`
465
499
  const latest = (await res.json()).version;
466
500
  if (typeof latest !== 'string')
467
501
  return null;
@@ -488,19 +522,30 @@ function isNpx() {
488
522
  return false;
489
523
  }
490
524
  }
491
- /** Print a one-line upgrade notice (to stderr) when a newer octwin-cli is published.
492
- * Skipped when piped/CI (not a TTY) or run via npx (nothing to upgrade). Never throws. */
493
- async function notifyIfOutdated() {
494
- if (!process.stdout.isTTY || isNpx())
495
- return;
525
+ /** A one-line upgrade notice when a newer octwin-cli is published, or [] when current.
526
+ *
527
+ * Skipped only under npx, where there is genuinely nothing to upgrade. It USED to bail on
528
+ * `!process.stdout.isTTY` as well, which silently made this the one notice a piped reader never
529
+ * saw — the opposite of the rule its two siblings already carry in their docblocks, and measured
530
+ * 2026-08-26: with a newer version cached, `octwin whoami | tail` printed the KB nudge and not
531
+ * this one. An agent driving the CLI cannot notice an outdated CLI on its own, so it is exactly
532
+ * the reader that needs telling. One line on stderr, so piped stdout stays clean either way.
533
+ *
534
+ * Never throws — a version check must never break a command. */
535
+ async function outdatedNotice() {
536
+ if (isNpx())
537
+ return [];
496
538
  try {
497
539
  const latest = await latestPublishedVersion();
498
540
  if (latest && isNewer(latest, VERSION)) {
499
- console.error(`\n⬆ octwin-cli ${latest} is available (you have ${VERSION}).`);
500
- console.error(' Upgrade: npm i -g octwin-cli@latest (or just use npx octwin-cli@latest)');
541
+ return [
542
+ `\n⬆ octwin-cli ${latest} is available (you have ${VERSION}).`,
543
+ ' Upgrade: npm i -g octwin-cli@latest (or just use npx octwin-cli@latest)',
544
+ ];
501
545
  }
502
546
  }
503
547
  catch { /* a version check must never break the CLI */ }
548
+ return [];
504
549
  }
505
550
  // ── platform-KB drift check (observe the pulled reference, fail-silent) ──
506
551
  //
@@ -550,20 +595,20 @@ function diffKbIndex(prev, next) {
550
595
  */
551
596
  async function fetchKbMeta(t, timeoutMs = 2_000) {
552
597
  const ep = kbEndpoint(t);
553
- const ctrl = new AbortController();
554
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
555
598
  try {
556
- const res = await fetch(`${ep.url}?meta=1`, { headers: ep.headers, signal: ctrl.signal });
557
- if (!res.ok)
599
+ const res = await fetch(`${ep.url}?meta=1`, { headers: ep.headers, signal: AbortSignal.timeout(timeoutMs) });
600
+ if (!res.ok) {
601
+ // Drain the body even though we do not want it. An unread `Response` body keeps its undici
602
+ // socket checked out of the pool, and this poll runs on the way to a possible `exitNow` —
603
+ // a held socket at exit is a pending libuv handle.
604
+ await res.arrayBuffer().catch(() => undefined);
558
605
  return { ok: false, notAuthorized: res.status === 401 || res.status === 403 };
606
+ }
559
607
  return { ok: true, meta: await res.json() };
560
608
  }
561
609
  catch {
562
610
  return { ok: false, notAuthorized: false };
563
611
  }
564
- finally {
565
- clearTimeout(timer);
566
- }
567
612
  }
568
613
  /** Nudge (to stderr) when the platform's capability KB has changed since the last
569
614
  * `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
@@ -577,15 +622,15 @@ async function fetchKbMeta(t, timeoutMs = 2_000) {
577
622
  * authoring agent whose output is piped, and gating on `isTTY` meant the one
578
623
  * reader that CANNOT notice a stale reference on its own was the only one never
579
624
  * told. It is a single line on stderr, so piped stdout stays clean either way. */
580
- async function notifyIfKbStale(flags) {
625
+ async function kbStaleNotice(flags) {
581
626
  try {
582
627
  const packDir = resolve(flags.dir ?? '.');
583
628
  const local = readLocalKb(packDir);
584
629
  if (!local?.content_hash)
585
- return; // never pulled → the skill already says to pull
630
+ return []; // never pulled → the skill already says to pull
586
631
  const t = resolveTargetOrNull(flags);
587
632
  if (!t)
588
- return;
633
+ return [];
589
634
  const polled = await fetchKbMeta(t);
590
635
  if (!polled.ok) {
591
636
  // The tenant-scoped meta poll needs `pack:deploy`, but this nudge rides on every
@@ -594,10 +639,12 @@ async function notifyIfKbStale(flags) {
594
639
  // makes an author invent a primitive from memory. Say so once; stay silent for every
595
640
  // other failure (offline, timeout, a platform without the route).
596
641
  if (polled.notAuthorized) {
597
- console.error('\nⓘ can\'t check whether the platform capability reference drifted — that token lacks `pack:deploy`.');
598
- console.error(' Check it without a token: octwin platform-kb --check (or refresh: octwin platform-kb --token oct_…)');
642
+ return [
643
+ '\nⓘ can\'t check whether the platform capability reference drifted that token lacks `pack:deploy`.',
644
+ ' Check it without a token: octwin platform-kb --check (or refresh: octwin platform-kb --token oct_…)',
645
+ ];
599
646
  }
600
- return;
647
+ return [];
601
648
  }
602
649
  const meta = polled.meta;
603
650
  if (meta.content_hash && meta.content_hash !== local.content_hash) {
@@ -614,11 +661,14 @@ async function notifyIfKbStale(flags) {
614
661
  if (parts.length)
615
662
  summary = ` (${parts.join(' · ')})`;
616
663
  }
617
- console.error(`\n⬆ the platform capability reference changed since you last pulled it${summary}.`);
618
- console.error(' Refresh it: octwin platform-kb --if-stale');
664
+ return [
665
+ `\n⬆ the platform capability reference changed since you last pulled it${summary}.`,
666
+ ' Refresh it: octwin platform-kb --if-stale',
667
+ ];
619
668
  }
620
669
  }
621
670
  catch { /* a KB check must never break the CLI */ }
671
+ return [];
622
672
  }
623
673
  /**
624
674
  * Nudge (to stderr) when the platform has memos this workspace has not read — a reply to
@@ -642,33 +692,107 @@ async function notifyIfKbStale(flags) {
642
692
  * platform, so an agent on a fresh machine or in a fresh container still learns about an
643
693
  * unread memo. That is the whole reason it is not a local marker file.
644
694
  */
645
- async function notifyIfMemosWaiting(flags) {
695
+ async function memosWaitingNotice(flags) {
646
696
  try {
647
697
  const t = resolveTargetOrNull(flags);
648
698
  if (!t)
649
- return;
650
- const { status, json } = await apiGet(`${t.url}/api/self/p/memos?meta=1`, t);
699
+ return [];
700
+ // A PLAIN fetch, not `apiGet` which goes through `fetchOrDie`, whose whole job is to `die`
701
+ // on a network failure. An observer that can terminate the CLI is not an observer: with an
702
+ // unreachable platform this nudge would have failed the command it was only meant to annotate,
703
+ // and now that the polls run BEFORE the command it would have failed it before it even ran.
704
+ const res = await fetch(`${t.url}/api/self/p/memos?meta=1`, {
705
+ headers: authHeaders(t), signal: AbortSignal.timeout(2_000),
706
+ });
707
+ const status = res.status;
708
+ const json = await res.json().catch(() => null);
651
709
  // Silent on ANY failure — an older platform has no such route, and a nudge is never
652
710
  // worth a diagnostic of its own. Unlike the KB poll there is no scope to explain: the
653
711
  // route is gated on tenant access precisely so every token can answer it.
654
712
  if (status !== 200 || !json || typeof json !== 'object')
655
- return;
713
+ return [];
656
714
  const unread = Number(json.unread ?? 0);
657
715
  if (!Number.isFinite(unread) || unread <= 0)
658
- return;
716
+ return [];
659
717
  const actionable = Number(json.unread_actionable ?? 0);
660
718
  const what = unread === 1 ? '1 memo' : `${unread} memos`;
661
719
  const tail = actionable > 0 ? ` (${actionable} needing action)` : '';
662
- console.error(`\n✉ ${what} from the platform${tail} — read them: octwin memos`);
720
+ const lines = [`\n✉ ${what} from the platform${tail} — read them: octwin memos`];
663
721
  if (actionable > 0) {
664
722
  // Said separately, because the whole point of the severity axis is that an agent
665
723
  // mid-build should stop and read rather than finish first.
666
- console.error(' One or more may change what you are building — read before continuing.');
724
+ lines.push(' One or more may change what you are building — read before continuing.');
667
725
  }
726
+ return lines;
668
727
  }
669
728
  catch { /* a memo check must never break the CLI */ }
729
+ return [];
730
+ }
731
+ // ── the notice channel: computed BEFORE the command, printed however it ends ──
732
+ //
733
+ // These three nudges used to be awaited at the very END of `main()`, and that placement quietly
734
+ // meant "only when the command SUCCEEDS": `die()` is `process.exit(1)`, so a failed deploy, an
735
+ // auth error or a bad manifest skipped all three. Measured 2026-08-26 — a failing command printed
736
+ // nothing while the same command succeeding printed the KB nudge. That is backwards. A stale
737
+ // capability reference is a LEADING CAUSE of the failure an author is staring at, so the one run
738
+ // that most needs the nudge was the only one that never got it.
739
+ //
740
+ // So: compute the lines up front (async, needs the network), then print them through a SYNCHRONOUS
741
+ // flush that also runs from `process.on('exit')`. The strings already exist by then, so the exit
742
+ // hook has nothing to await — which is what makes `die()`'s hard exit safe. `die` itself is left
743
+ // alone deliberately: making it throw instead would have to survive 30 broad `catch` blocks in
744
+ // this file, any one of which would turn a hard failure into a silent continue.
745
+ let PENDING_NOTICES = [];
746
+ let NOTICES_FLUSHED = false;
747
+ /** Print the computed notices exactly once. */
748
+ function flushNotices() {
749
+ if (NOTICES_FLUSHED)
750
+ return;
751
+ NOTICES_FLUSHED = true;
752
+ if (PENDING_NOTICES.length === 0)
753
+ return;
754
+ try {
755
+ writeSync(2, `${PENDING_NOTICES.join('\n')}\n`);
756
+ }
757
+ catch { /* a nudge is never worth a crash */ }
758
+ }
759
+ /** Thrown by `exitNow` to unwind to `main` instead of calling `process.exit`. */
760
+ class CliExit extends Error {
761
+ code;
762
+ constructor(code) {
763
+ super('cli-exit');
764
+ this.code = code;
765
+ }
670
766
  }
671
- /** Which commands already made a platform call, so the trailing KB-drift poll
767
+ /**
768
+ * The ONE way this CLI ends non-zero: flush the notices, then unwind.
769
+ *
770
+ * ## Why this throws instead of calling `process.exit`
771
+ *
772
+ * Because `process.exit()` is not safe here, measured 2026-08-26 on Windows. Once the notice polls
773
+ * moved to BEFORE the command (so a failing run still gets its nudge), a hard exit began aborting
774
+ * with `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\\win\\async.c` — and, far worse
775
+ * than the noise, it replaced the real exit code with **127**, so a failing `octwin deploy` in CI
776
+ * reported neither 0 nor 1. Ruled out one at a time: it is not the notice I/O (a no-fetch failure
777
+ * exits 1 cleanly), not an `exit` handler (removing it changed nothing), not an unread response
778
+ * body, not the `AbortController`, and not concurrency (serialising them changed nothing). It
779
+ * reproduces on **two** pre-command fetches and not on one, which points at undici's pooled
780
+ * sockets — not something this CLI can reach in to close.
781
+ *
782
+ * A NATURAL exit has none of these problems: the same two fetches on the success path exit 0
783
+ * cleanly, because Node tears the pool down itself when the event loop drains. So `main` catches
784
+ * `CliExit`, sets `process.exitCode`, and returns.
785
+ *
786
+ * Safe to throw rather than exit because every `catch` in this file was audited for it: all but a
787
+ * handful guard a single `JSON.parse` / `readFileSync`, and in the three with real bodies (plus
788
+ * `fetchOrDie` and `--fields-json`) the `die()` sits in the **catch handler**, never inside the
789
+ * `try` — so nothing swallows this.
790
+ */
791
+ function exitNow(code) {
792
+ flushNotices();
793
+ throw new CliExit(code);
794
+ }
795
+ /** Which commands already made a platform call, so the KB-drift poll
672
796
  * rides on existing network work (never on offline `validate` / `init`;
673
797
  * `platform-kb` refreshes the reference itself, so it needs no nudge). */
674
798
  function commandTouchesPlatform(command, flags) {
@@ -683,6 +807,11 @@ function commandTouchesPlatform(command, flags) {
683
807
  // `memos` is networked but needs NO memo nudge — it just read them. It still gets the
684
808
  // KB drift check, which is a different question.
685
809
  case 'memos': return true;
810
+ // `login` verifies the token over the network and is usually the FIRST command of a session —
811
+ // the best possible moment to say the reference drifted. It reads the target from the FLAGS
812
+ // (`--url`/`--token`), which is what the notices resolve too, so it does not depend on the
813
+ // credentials this command is about to save.
814
+ case 'login':
686
815
  case 'records':
687
816
  case 'work':
688
817
  case 'logs':
@@ -693,6 +822,11 @@ function commandTouchesPlatform(command, flags) {
693
822
  case 'analytics':
694
823
  case 'catalog':
695
824
  case 'scheduling':
825
+ case 'automation':
826
+ case 'integrations':
827
+ case 'journeys':
828
+ case 'performance':
829
+ case 'usage':
696
830
  case 'projects':
697
831
  case 'seed': return true;
698
832
  default: return false;
@@ -991,7 +1125,7 @@ async function cmdValidate(flags) {
991
1125
  console.error(`✗ remote validate failed (HTTP ${res.status})`);
992
1126
  printAuthHint(res.status, url);
993
1127
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
994
- process.exit(1);
1128
+ exitNow(1);
995
1129
  }
996
1130
  const warnings = (json?.warnings ?? []);
997
1131
  if (warnings.length) {
@@ -1016,7 +1150,7 @@ async function cmdValidate(flags) {
1016
1150
  for (const m of msgs)
1017
1151
  console.error(` • ${m}`);
1018
1152
  }
1019
- process.exit(1);
1153
+ exitNow(1);
1020
1154
  }
1021
1155
  async function cmdLogin(flags) {
1022
1156
  const rawUrl = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
@@ -1157,7 +1291,7 @@ async function cmdPull(flags) {
1157
1291
  console.error(' → a pack is pullable by the tenant that OWNS it (deployed it), or by an operator.');
1158
1292
  }
1159
1293
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
1160
- process.exit(1);
1294
+ exitNow(1);
1161
1295
  }
1162
1296
  const files = json.files ?? {};
1163
1297
  const blobs = json.blobs ?? {};
@@ -1230,23 +1364,68 @@ async function readDeployProgress(body) {
1230
1364
  }
1231
1365
  return { terminal, stepErrors };
1232
1366
  }
1367
+ /** What a `--request-listing` / `--withdraw-listing` deploy says it is doing, for the header line. */
1368
+ const LISTING_LABEL = {
1369
+ submit: 'requesting a public marketplace listing',
1370
+ withdraw: 'withdrawing the public marketplace listing',
1371
+ };
1372
+ /**
1373
+ * Which marketplace act this deploy is, from the two mutually-exclusive flags.
1374
+ *
1375
+ * Returns undefined for the common case — an ordinary deploy asks for nothing. Both flags at once
1376
+ * is a `die` rather than a precedence rule: the two are opposites, and guessing which one the
1377
+ * author meant is exactly the silent-wrong-thing this opt-in default exists to prevent.
1378
+ */
1379
+ function resolveListingFlags(flags) {
1380
+ const submit = flags['request-listing'] === true;
1381
+ const withdraw = flags['withdraw-listing'] === true;
1382
+ if (submit && withdraw) {
1383
+ die('--request-listing and --withdraw-listing are opposites — pass one, not both');
1384
+ }
1385
+ return submit ? 'submit' : withdraw ? 'withdraw' : undefined;
1386
+ }
1387
+ /**
1388
+ * Nudge the author whose manifest asks to be public but whose deploy did not submit it.
1389
+ *
1390
+ * The listing request is OPT-IN (`--request-listing`), because the CLI is overwhelmingly an
1391
+ * edit-deploy-chat test loop and reading the manifest alone filed a review request on every
1392
+ * single deploy. But a silent default has its own failure: an author who wrote
1393
+ * `listing.public: true` months ago and waits for a review that was never requested. One line,
1394
+ * only when the manifest actually declares it, so a pack that never asked stays silent.
1395
+ */
1396
+ function printListingNotAsked(packDir) {
1397
+ let declared = false;
1398
+ try {
1399
+ const raw = readFileSync(join(packDir, 'manifest.yaml'), 'utf8');
1400
+ declared = parseYaml(raw)?.listing?.public === true;
1401
+ }
1402
+ catch {
1403
+ return;
1404
+ } // unreadable/unparseable is the deploy's to report
1405
+ if (!declared)
1406
+ return;
1407
+ console.log(' ⓘ your manifest declares listing.public — this deploy did NOT submit it for review.');
1408
+ console.log(' Ask for a public marketplace listing with: octwin deploy --request-listing');
1409
+ }
1233
1410
  /**
1234
1411
  * The anonymous-marketplace verdict, for `deploy` and `status` alike.
1235
1412
  *
1236
- * `listing.public: true` in the manifest puts a pack into an operator review queue, and the
1237
- * state lived only in the console so an author working from the CLI got no acknowledgement
1238
- * that the request had registered, and no sight of a rejection note (which the platform
1239
- * REQUIRES precisely because it is their only feedback). Silent for `none`/absent, so a pack
1240
- * that never asked prints nothing.
1413
+ * The state lived only in the console so an author working from the CLI got no acknowledgement
1414
+ * that a request had registered, and no sight of a rejection note (which the platform REQUIRES
1415
+ * precisely because it is their only feedback). Silent for `none`/absent, so a pack that never
1416
+ * asked prints nothing.
1241
1417
  *
1242
- * `pending` after a redeploy is normal rather than a regression: an approval pins the sha it
1243
- * reviewed, so any content edit returns the pack to the queue on its own.
1418
+ * `justAsked` separates the two ways a pack lands in the queue, which read as the same state and
1419
+ * mean opposite things to the author: they submitted it, or a content edit invalidated an approval
1420
+ * they already had (an approval pins the sha it reviewed, so it cannot survive a content change).
1244
1421
  */
1245
- function printPublicListing(state, note, live) {
1422
+ function printPublicListing(state, note, live, justAsked = false) {
1246
1423
  const n = typeof note === 'string' && note ? ` — operator note: ${note}` : '';
1247
1424
  switch (state) {
1248
1425
  case 'pending':
1249
- console.log(' ⓘ marketplace listing: PENDING operator review (any content edit re-queues it — the approval pins a content sha).');
1426
+ console.log(justAsked
1427
+ ? ' ⓘ marketplace listing: SUBMITTED — now pending operator review.'
1428
+ : ' ⓘ marketplace listing: back to PENDING review — this deploy changed the content, and an approval only covers the content it was made against.');
1250
1429
  break;
1251
1430
  case 'approved':
1252
1431
  console.log(live === false
@@ -1260,7 +1439,7 @@ function printPublicListing(state, note, live) {
1260
1439
  break;
1261
1440
  }
1262
1441
  }
1263
- function printDeploySuccess(id, version, t, r) {
1442
+ function printDeploySuccess(id, version, t, r, listing) {
1264
1443
  console.log(`✓ Deployed ${id}@${version} and installed onto ${targetLabel(t)}`);
1265
1444
  if (r?.warning)
1266
1445
  console.log(` ⚠ ${r.warning}`);
@@ -1285,7 +1464,12 @@ function printDeploySuccess(id, version, t, r) {
1285
1464
  if (dropped > 0) {
1286
1465
  console.log(` ⓘ ${dropped} suspended run(s) invalidated — a tap on any card rendered before this deploy will report a stale run. Start those conversations again.`);
1287
1466
  }
1288
- printPublicListing(r?.public_listing, r?.public_review_note);
1467
+ printPublicListing(r?.public_listing, r?.public_review_note, undefined, listing?.intent === 'submit');
1468
+ // Only when the deploy asked for nothing AND the row says nothing: a pack sitting at `none`
1469
+ // with `listing.public` in its manifest is an author waiting for a review nobody requested.
1470
+ if (listing && !listing.intent && (r?.public_listing == null || r.public_listing === 'none')) {
1471
+ printListingNotAsked(listing.packDir);
1472
+ }
1289
1473
  console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
1290
1474
  }
1291
1475
  /**
@@ -1316,17 +1500,17 @@ async function cmdSeed(flags) {
1316
1500
  const { terminal: final, stepErrors } = await readDeployProgress(res.body);
1317
1501
  if (!final || final.stage === 'error')
1318
1502
  die(`seed failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
1319
- console.log(`
1503
+ console.log(`
1320
1504
  ✓ ${final.message ?? 'seed complete'}`);
1321
1505
  printSeedCounts(final.result?.seeded);
1322
1506
  if (stepErrors.length) {
1323
1507
  // A kind failed but the rest ran — the reconcile softens each step. Say which,
1324
1508
  // and exit non-zero so a scripted `seed && chat` doesn't read as clean.
1325
- console.error(`
1509
+ console.error(`
1326
1510
  ⚠ ${stepErrors.length} step${stepErrors.length === 1 ? '' : 's'} failed — data may be incomplete:`);
1327
1511
  for (const e of stepErrors)
1328
1512
  console.error(` • ${e}`);
1329
- process.exit(1);
1513
+ exitNow(1);
1330
1514
  }
1331
1515
  return;
1332
1516
  }
@@ -1341,7 +1525,7 @@ async function cmdSeed(flags) {
1341
1525
  if (!res.ok) {
1342
1526
  console.error(`✗ seed failed (HTTP ${res.status})${errDetail(json)}`);
1343
1527
  printAuthHint(res.status, url);
1344
- process.exit(1);
1528
+ exitNow(1);
1345
1529
  }
1346
1530
  console.log('✓ seed complete');
1347
1531
  printSeedCounts(json?.seeded);
@@ -1360,13 +1544,17 @@ async function cmdDeploy(flags) {
1360
1544
  const { id, version, files, blobs } = localValidate(packDir);
1361
1545
  const endpoint = `${url}/api/self/p/packs/deploy`;
1362
1546
  const seed = flags.seed === true;
1363
- console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${seed ? ' — with demo seed' : ''} …`);
1547
+ const listing = resolveListingFlags(flags);
1548
+ const extras = [seed ? 'with demo seed' : null, listing ? LISTING_LABEL[listing] : null].filter(Boolean);
1549
+ console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${extras.length ? ` — ${extras.join(', ')}` : ''} …`);
1364
1550
  const res = await fetchOrDie(endpoint, {
1365
1551
  method: 'POST',
1366
1552
  // Ask for a progress stream; the platform falls back to plain JSON if it
1367
1553
  // (or an error before any progress) can't stream — handled below.
1368
1554
  headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
1369
- body: JSON.stringify({ files, blobs, seed }),
1555
+ // `listing` is omitted entirely unless asked for: an ordinary deploy says NOTHING about the
1556
+ // marketplace, which is what keeps a test loop out of the operator review queue.
1557
+ body: JSON.stringify({ files, blobs, seed, ...(listing ? { listing } : {}) }),
1370
1558
  }, 'deploy');
1371
1559
  // Streaming path — live install + seed progress (image generation can take a
1372
1560
  // while, so `--seed` prints per-record / per-image lines as they happen).
@@ -1374,7 +1562,7 @@ async function cmdDeploy(flags) {
1374
1562
  const { terminal: final, stepErrors } = await readDeployProgress(res.body);
1375
1563
  if (!final || final.stage === 'error')
1376
1564
  die(`deploy failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
1377
- printDeploySuccess(id, version, t, final);
1565
+ printDeploySuccess(id, version, t, final, { intent: listing, packDir });
1378
1566
  if (stepErrors.length) {
1379
1567
  // The pack IS installed, but a step (e.g. the demo seed) failed — say so
1380
1568
  // plainly and exit non-zero so CI / a `deploy && chat` chain doesn't treat
@@ -1382,7 +1570,7 @@ async function cmdDeploy(flags) {
1382
1570
  console.error(`\n⚠ Deployed with ${stepErrors.length} warning${stepErrors.length === 1 ? '' : 's'} — data may be incomplete:`);
1383
1571
  for (const e of stepErrors)
1384
1572
  console.error(` • ${e}`);
1385
- process.exit(1);
1573
+ exitNow(1);
1386
1574
  }
1387
1575
  return;
1388
1576
  }
@@ -1400,9 +1588,9 @@ async function cmdDeploy(flags) {
1400
1588
  console.error(`✗ deploy failed (HTTP ${res.status})`);
1401
1589
  printAuthHint(res.status, url);
1402
1590
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
1403
- process.exit(1);
1591
+ exitNow(1);
1404
1592
  }
1405
- printDeploySuccess(id, version, t, json);
1593
+ printDeploySuccess(id, version, t, json, { intent: listing, packDir });
1406
1594
  }
1407
1595
  /**
1408
1596
  * The QUALIFIED pack id (`<owner>.<name>`) for a manifest's bare name.
@@ -1472,7 +1660,7 @@ async function cmdStatus(flags) {
1472
1660
  console.error(`✗ status check failed (HTTP ${res.status})`);
1473
1661
  printAuthHint(res.status, url);
1474
1662
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
1475
- process.exit(1);
1663
+ exitNow(1);
1476
1664
  }
1477
1665
  console.log(`${id} on ${targetLabel(t)} @ ${url}`);
1478
1666
  console.log(` installed version : ${json.installed_version}`);
@@ -1571,16 +1759,16 @@ async function cmdPlatformKb(flags) {
1571
1759
  console.error(polled.notAuthorized
1572
1760
  ? '✗ cannot check — the platform refused the token, and this instance serves no anonymous reference.'
1573
1761
  : `✗ cannot check — ${url} did not answer.`);
1574
- process.exit(1);
1762
+ exitNow(1);
1575
1763
  }
1576
1764
  const remote = polled.meta.content_hash;
1577
1765
  if (!local?.content_hash) {
1578
1766
  console.log(`⬆ no reference pulled yet (platform is at ${remote ?? '?'}) — run \`octwin platform-kb pull\`.`);
1579
- process.exit(2);
1767
+ exitNow(2);
1580
1768
  }
1581
1769
  if (remote && remote !== local.content_hash) {
1582
1770
  console.log(`⬆ stale: local ${local.content_hash} → platform ${remote}. Run \`octwin platform-kb pull\`.`);
1583
- process.exit(2);
1771
+ exitNow(2);
1584
1772
  }
1585
1773
  console.log(`✓ current (${local.content_hash}).`);
1586
1774
  return;
@@ -1612,7 +1800,7 @@ async function cmdPlatformKb(flags) {
1612
1800
  }
1613
1801
  console.error(`✗ platform-kb pull failed (HTTP ${res.status})`);
1614
1802
  console.error(typeof j === 'string' ? j : JSON.stringify(j, null, 2));
1615
- process.exit(1);
1803
+ exitNow(1);
1616
1804
  }
1617
1805
  const bundle = JSON.parse(text);
1618
1806
  // Snapshot the prior pull's index BEFORE overwriting it, so we can show the
@@ -1767,11 +1955,21 @@ async function apiGet(endpoint, t) {
1767
1955
  * `content-type` + `authHeaders` block was inlined at each of the four original
1768
1956
  * write sites, and fifteen more copies is how one of them ends up subtly different.
1769
1957
  * A `204` (media delete) has no body to parse, hence the empty-text guard.
1958
+ *
1959
+ * `body: undefined` sends NO `content-type` either. The header used to be
1960
+ * unconditional, so a genuinely body-less write announced `application/json` and
1961
+ * then sent nothing — Fastify tried to parse the empty body and answered a bare
1962
+ * `400 Bad Request` with no hint of the cause. Latent until 2026-08-27 because every
1963
+ * caller until then passed an object; the first body-less POST (`automation run`)
1964
+ * hit it immediately, and a caller having to know "pass `{}` or you get a 400" is
1965
+ * exactly the per-site divergence this helper exists to prevent.
1770
1966
  */
1771
1967
  async function apiSend(method, endpoint, body, t) {
1772
1968
  const res = await fetchOrDie(endpoint, {
1773
1969
  method,
1774
- headers: { 'content-type': 'application/json', ...authHeaders(t) },
1970
+ headers: body === undefined
1971
+ ? authHeaders(t)
1972
+ : { 'content-type': 'application/json', ...authHeaders(t) },
1775
1973
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
1776
1974
  }, 'request');
1777
1975
  const text = await res.text();
@@ -1998,7 +2196,7 @@ async function cmdRecordsWrite(flags) {
1998
2196
  // useful thing to show, so don't bury it in the generic error line.
1999
2197
  console.error(`✗ '${to}' is not a legal move from this record's stage.`);
2000
2198
  console.error(` → allowed: ${json.allowed.join(', ') || '(none — terminal stage)'}`);
2001
- process.exit(1);
2199
+ exitNow(1);
2002
2200
  }
2003
2201
  if (status !== 200)
2004
2202
  writeFail(`move record ${id} to '${to}'`, status, json, url, true);
@@ -2597,13 +2795,13 @@ async function cmdChat(flags) {
2597
2795
  // isn't answering — the remaining turns would land out of context.
2598
2796
  await cancel();
2599
2797
  console.error(` turn ${i + 1} produced no render after ${Math.round(REPLY_TIMEOUT_MS / 1000)}s — stopping the script here.`);
2600
- process.exit(1);
2798
+ exitNow(1);
2601
2799
  }
2602
2800
  }
2603
2801
  await cancel();
2604
2802
  if (totalRenders === 0) {
2605
2803
  console.error(` no reply after ${Math.round(REPLY_TIMEOUT_MS / 1000)}s — the pack may not be warm yet, or the turn produced no render.`);
2606
- process.exit(1);
2804
+ exitNow(1);
2607
2805
  }
2608
2806
  console.log(`\n(same --as '${from}' continues this conversation — timeline: octwin logs --as ${from})`);
2609
2807
  }
@@ -2645,7 +2843,7 @@ async function cmdMedia(flags) {
2645
2843
  }
2646
2844
  console.error(`✗ media generate failed (HTTP ${res.status})${errDetail(j)}`);
2647
2845
  printAuthHint(res.status, url);
2648
- process.exit(1);
2846
+ exitNow(1);
2649
2847
  }
2650
2848
  const r = JSON.parse(text);
2651
2849
  const absUrl = /^https?:/i.test(r.url) ? r.url : `${url}${r.url}`;
@@ -3212,7 +3410,9 @@ async function cmdAgents(flags) {
3212
3410
  return;
3213
3411
  }
3214
3412
  if (!ref) {
3215
- const { status, json } = await apiGet(base, t);
3413
+ // Template literal, not bare `base` see the note in `cmdScheduling`: the route
3414
+ // guard's extractor cannot read a bare identifier, so this URL was exempt.
3415
+ const { status, json } = await apiGet(`${base}`, t);
3216
3416
  if (status !== 200)
3217
3417
  die(`could not read agents (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
3218
3418
  if (asJson) {
@@ -3316,7 +3516,7 @@ async function cmdOrdersWrite(flags) {
3316
3516
  console.error(` → allowed: ${allowed.join(', ')}`);
3317
3517
  else
3318
3518
  console.error(` → see the allowed set: octwin orders ${ref}`);
3319
- process.exit(1);
3519
+ exitNow(1);
3320
3520
  }
3321
3521
  if (status !== 200)
3322
3522
  writeFail(`move order ${ref} to '${to}'`, status, json, url);
@@ -3351,7 +3551,7 @@ async function cmdOrdersWrite(flags) {
3351
3551
  console.log(` gateway : ${gw.status ?? (gw.ok === false ? 'failed' : 'ok')}${gw.error || gw.message ? ` — ${gw.error ?? gw.message}` : ''}`);
3352
3552
  if (refused) {
3353
3553
  console.error('\n✗ the PAYMENT GATEWAY refused the refund — the order was updated but no money moved.');
3354
- process.exit(1);
3554
+ exitNow(1);
3355
3555
  }
3356
3556
  console.log('✓ Refund accepted.');
3357
3557
  }
@@ -3846,7 +4046,9 @@ async function cmdScheduling(flags) {
3846
4046
  }
3847
4047
  if (!asJson)
3848
4048
  console.log(`→ Reading the scheduling engine state from ${targetLabel(t)} …`);
3849
- const { status, json } = await apiGet(base, t);
4049
+ // A template literal, not the bare `base` — `cli-routes.test.ts` cannot read a
4050
+ // bare identifier, so this URL was silently exempt from the route guard.
4051
+ const { status, json } = await apiGet(`${base}`, t);
3850
4052
  if (status !== 200)
3851
4053
  die(`could not read scheduling (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
3852
4054
  if (asJson) {
@@ -3864,276 +4066,1127 @@ async function cmdScheduling(flags) {
3864
4066
  console.log(` upcoming slots: ${json?.upcoming_slots ?? 0} booked seats: ${json?.booked_seats ?? 0}`);
3865
4067
  console.log('\nSlots for one resource: octwin scheduling --slots <resourceRecordId> (ids: octwin records <entity>)');
3866
4068
  }
4069
+ // ── automation: declaration-derived jobs + campaigns ─────────────────────────
4070
+ /**
4071
+ * Verbs that mean "act on a job", not "an id".
4072
+ *
4073
+ * `pause` / `resume` rather than a literal `status active|paused`: the route body
4074
+ * takes the status, but the RBAC layer it calls checks the ACTION
4075
+ * (`assertCan(…, action: 'pause' | 'resume')`), and `VERB_REQUIREMENTS` is keyed
4076
+ * per verb — so one verb per intent makes both the permission hint and the 403
4077
+ * say the right thing.
4078
+ */
4079
+ const AUTOMATION_VERBS = new Set(['run', 'pause', 'resume', 'campaigns', 'send']);
4080
+ /**
4081
+ * Turn whatever the author typed into the UUID the route demands.
4082
+ *
4083
+ * (`UUID_RE` is the one already declared for `--media`, deliberately reused rather
4084
+ * than a second copy of the same pattern.)
4085
+ *
4086
+ * The write routes take a UUID path param and reject anything else with a bare
4087
+ * *"Malformed identifier in the URL — expected a UUID"* 400. But the thing an author
4088
+ * has in front of them is the declaration KEY (`cart_recovery_nudge`) — that is what
4089
+ * the list prints, and it is the name in their own YAML. Measured: passing the key
4090
+ * 400s on all three write verbs.
4091
+ *
4092
+ * So the key is resolved here, against the list route, rather than documented as a
4093
+ * gotcha. A UUID passes straight through, and an unknown key fails naming the keys
4094
+ * that DO exist — which is the answer to the question the author is actually asking.
4095
+ */
4096
+ async function resolveAutomationId(kind, typed, base, t) {
4097
+ if (UUID_RE.test(typed))
4098
+ return typed;
4099
+ const { status, json } = await apiGet(`${base}/${kind}`, t);
4100
+ if (status !== 200) {
4101
+ die(`could not resolve '${typed}' — reading ${kind} failed (HTTP ${status})${errDetail(json)}`);
4102
+ }
4103
+ const rows = readPage(json).rows;
4104
+ const hit = rows.find(r => r.key === typed || r.id === typed);
4105
+ if (hit?.id)
4106
+ return hit.id;
4107
+ const keys = rows.map(r => r.key ?? r.id).filter(Boolean);
4108
+ die(`no ${kind === 'jobs' ? 'job' : 'campaign'} '${typed}' in this project.`
4109
+ + (keys.length ? ` Available: ${keys.join(', ')}` : ` This project declares none.`));
4110
+ }
4111
+ /** `pause`/`resume`/`run`/`send` — the writes behind `octwin automation`. */
4112
+ async function cmdAutomationWrite(flags) {
4113
+ const t = resolveTarget(flags);
4114
+ const { url } = t;
4115
+ const base = `${url}/api/self/p/automation`;
4116
+ const verb = flags._[0];
4117
+ const typed = flags._[1];
4118
+ const asJson = flags.json === true;
4119
+ if (!typed)
4120
+ die(`usage: octwin automation ${verb} <${verb === 'send' ? 'campaignId' : 'jobId'}> (ids: octwin automation${verb === 'send' ? ' campaigns' : ''})`);
4121
+ const id = await resolveAutomationId(verb === 'send' ? 'campaigns' : 'jobs', typed, base, t);
4122
+ if (verb === 'pause' || verb === 'resume') {
4123
+ const { status, json } = await apiSend('PATCH', `${base}/jobs/${encodeURIComponent(id)}/status`, { status: verb === 'pause' ? 'paused' : 'active' }, t);
4124
+ if (status === 404)
4125
+ die(`job '${typed}' not found in ${targetLabel(t)} (ids: octwin automation)`);
4126
+ // RBAC re-checks the ACTION on the job, so a 403 here can be a grant gap
4127
+ // rather than a missing scope — same caveat as a record write.
4128
+ if (status !== 200)
4129
+ writeFail(`${verb} job '${typed}'`, status, json, url, true);
4130
+ if (asJson) {
4131
+ console.log(JSON.stringify(json, null, 2));
4132
+ return;
4133
+ }
4134
+ const j = json?.job ?? {};
4135
+ console.log(`✓ job '${j.key ?? typed}' is now ${j.status}`);
4136
+ if (j.next_run_at)
4137
+ console.log(` next run: ${j.next_run_at}`);
4138
+ return;
4139
+ }
4140
+ if (verb === 'run') {
4141
+ if (!asJson)
4142
+ console.log(`→ Running job '${typed}' in ${targetLabel(t)} …`);
4143
+ const { status, json } = await apiSend('POST', `${base}/jobs/${encodeURIComponent(id)}/run`, undefined, t);
4144
+ if (status === 404)
4145
+ die(`job '${typed}' not found in ${targetLabel(t)} (ids: octwin automation)`);
4146
+ if (status !== 200)
4147
+ writeFail(`run job '${typed}'`, status, json, url, true);
4148
+ if (asJson) {
4149
+ console.log(JSON.stringify(json, null, 2));
4150
+ return;
4151
+ }
4152
+ const r = json?.result ?? {};
4153
+ console.log(`✓ ran '${typed}': matched ${r.matched ?? 0}, acted ${r.acted ?? 0}, errors ${r.errors ?? 0}`);
4154
+ // `acted < matched` is the route's own definition of a PARTIAL run, so say so
4155
+ // rather than leaving three numbers for the author to compare.
4156
+ if ((r.errors ?? 0) > 0)
4157
+ console.log(' ⚠ some rows errored — see the job\'s last_result in `octwin automation`');
4158
+ else if ((r.acted ?? 0) < (r.matched ?? 0))
4159
+ console.log(' partial: matched rows were skipped (cooldown, or already acted on)');
4160
+ return;
4161
+ }
4162
+ // send — one campaign
4163
+ if (!asJson)
4164
+ console.log(`→ Sending campaign '${typed}' in ${targetLabel(t)} …`);
4165
+ const { status, json } = await apiSend('POST', `${base}/campaigns/${encodeURIComponent(id)}/send`, undefined, t);
4166
+ if (status === 404)
4167
+ die(`campaign '${typed}' not found in ${targetLabel(t)} (ids: octwin automation campaigns)`);
4168
+ if (status !== 200)
4169
+ writeFail(`send campaign '${typed}'`, status, json, url, true);
4170
+ if (asJson) {
4171
+ console.log(JSON.stringify(json, null, 2));
4172
+ return;
4173
+ }
4174
+ const r = json?.result ?? {};
4175
+ console.log(`✓ campaign '${typed}': matched ${r.matched ?? 0}, enqueued ${r.enqueued ?? 0}`);
4176
+ if ((r.enqueued ?? 0) < (r.matched ?? 0))
4177
+ console.log(' partial: some matched contacts were not enqueued (cooldown, or no reachable channel)');
4178
+ console.log(' enqueued ≠ delivered — watch the sends land with `octwin logs`');
4179
+ }
4180
+ /**
4181
+ * `octwin automation [--campaigns] [--json]` — the jobs a pack's declarations
4182
+ * produced, with their last result, plus the health counts.
4183
+ *
4184
+ * Needs `automation:read`. The job list is CAPPED server-side and the counts are
4185
+ * computed in SQL, so the header numbers come from `/health` rather than from
4186
+ * filtering the page — past the cap a client-side count would depend on the cap
4187
+ * instead of the data.
4188
+ */
4189
+ async function cmdAutomation(flags) {
4190
+ if (typeof flags._[0] === 'string' && AUTOMATION_VERBS.has(flags._[0])) {
4191
+ // `campaigns` is a READ that shares the verb slot with the writes.
4192
+ if (flags._[0] !== 'campaigns')
4193
+ return cmdAutomationWrite(flags);
4194
+ }
4195
+ const t = resolveTarget(flags);
4196
+ const { url } = t;
4197
+ const base = `${url}/api/self/p/automation`;
4198
+ const asJson = flags.json === true;
4199
+ const campaigns = flags._[0] === 'campaigns' || flags.campaigns === true;
4200
+ if (campaigns) {
4201
+ if (!asJson)
4202
+ console.log(`→ Reading campaigns from ${targetLabel(t)} …`);
4203
+ const { status, json } = await apiGet(`${base}/campaigns?${pagingQs(flags)}`, t);
4204
+ if (status !== 200)
4205
+ die(`could not read campaigns (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
4206
+ if (asJson) {
4207
+ console.log(JSON.stringify(json, null, 2));
4208
+ return;
4209
+ }
4210
+ const page = readPage(json);
4211
+ console.log(`Campaigns in ${targetLabel(t)}: ${page.total ?? page.rows.length}`);
4212
+ if (page.rows.length === 0)
4213
+ console.log(' (none — a campaign comes from a `campaigns:` block in the pack\'s automation declaration)');
4214
+ for (const c of page.rows) {
4215
+ const label = pickLabel(c.label) ?? c.key ?? c.id;
4216
+ console.log(` ${String(label).padEnd(28)} ${String(c.audience_size ?? c.matched ?? '—').padStart(6)} contact(s) ${c.key ?? c.id}`);
4217
+ }
4218
+ const more = morePageHint(page, 'octwin automation campaigns');
4219
+ if (more)
4220
+ console.log(more);
4221
+ console.log('\nSend one: octwin automation send <campaignId> (needs automation:write)');
4222
+ return;
4223
+ }
4224
+ if (!asJson)
4225
+ console.log(`→ Reading automation jobs from ${targetLabel(t)} …`);
4226
+ const [jobs, health] = await Promise.all([
4227
+ apiGet(`${base}/jobs`, t),
4228
+ apiGet(`${base}/health`, t),
4229
+ ]);
4230
+ if (jobs.status !== 200)
4231
+ die(`could not read automation jobs (HTTP ${jobs.status})${errDetail(jobs.json)}${authFailureDetail(jobs.status, url)}`);
4232
+ if (asJson) {
4233
+ console.log(JSON.stringify({ jobs: jobs.json, health: health.json }, null, 2));
4234
+ return;
4235
+ }
4236
+ const page = readPage(jobs.json);
4237
+ const h = health.status === 200 ? (health.json ?? {}) : {};
4238
+ console.log(`Automation in ${targetLabel(t)}: ${h.total ?? page.rows.length} job(s)`
4239
+ + ` — ${h.active ?? '?'} active, ${h.paused ?? '?'} paused, ${h.failing ?? '?'} failing, ${h.never_ran ?? '?'} never ran`);
4240
+ if (page.rows.length === 0) {
4241
+ console.log(' (none — jobs are DERIVED from the pack\'s automation declaration, not created here.');
4242
+ console.log(' No `automation.yaml` block → no jobs. `octwin deploy` installs them.)');
4243
+ return;
4244
+ }
4245
+ for (const j of page.rows) {
4246
+ const r = j.last_result ?? {};
4247
+ const ran = j.last_run_at ? `last ${j.last_run_at}` : 'never ran';
4248
+ const result = j.last_result
4249
+ ? ` matched ${r.matched ?? 0}/acted ${r.acted ?? 0}${(r.errors ?? 0) > 0 ? `/ERRORS ${r.errors}` : ''}`
4250
+ : '';
4251
+ console.log(` ${String(j.key ?? j.id).padEnd(26)} ${String(j.status).padEnd(7)} ${j.kind}/${j.entity ?? '—'}`
4252
+ + ` every ${j.interval_seconds}s ${ran}${result}`);
4253
+ }
4254
+ console.log('\nRun one now: octwin automation run <jobId> (jobId = the `key` above, or its uuid)');
4255
+ console.log('Pause/resume: octwin automation pause|resume <jobId>');
4256
+ console.log('Campaigns: octwin automation campaigns');
4257
+ }
4258
+ // ── integrations: declared connections, their credentials, and the delivery log ──
4259
+ /** Verbs that act on a connection or a delivery, rather than naming one. */
4260
+ const INTEGRATION_VERBS = new Set(['test', 'preflight', 'deliveries', 'retry', 'cancel', 'send-now', 'events']);
4261
+ /** The three delivery actions — each its own verb so the scope hint can differ. */
4262
+ const DELIVERY_ACTIONS = {
4263
+ 'retry': { path: 'retry', what: 'retry' },
4264
+ 'cancel': { path: 'cancel', what: 'cancel' },
4265
+ 'send-now': { path: 'send-now', what: 'send' },
4266
+ };
4267
+ /** `octwin integrations <verb> …` — the connection + delivery verbs. */
4268
+ async function cmdIntegrationsVerb(flags) {
4269
+ const t = resolveTarget(flags);
4270
+ const { url } = t;
4271
+ const base = `${url}/api/self/p/integrations`;
4272
+ const verb = flags._[0];
4273
+ const arg = flags._[1];
4274
+ const asJson = flags.json === true;
4275
+ // ── deliveries: the outbound log ──────────────────────────────────────────
4276
+ if (verb === 'deliveries') {
4277
+ if (arg) {
4278
+ const { status, json } = await apiGet(`${base}/deliveries/${encodeURIComponent(arg)}`, t);
4279
+ if (status === 404)
4280
+ die(`delivery '${arg}' not found`);
4281
+ if (status !== 200)
4282
+ die(`could not read delivery (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
4283
+ if (asJson) {
4284
+ console.log(JSON.stringify(json, null, 2));
4285
+ return;
4286
+ }
4287
+ const d = json?.delivery ?? {};
4288
+ console.log(`Delivery ${d.id} ${d.status} ${d.connection_key}/${d.operation_id}`);
4289
+ console.log(` attempts ${d.attempts}${d.http_status ? ` HTTP ${d.http_status}` : ''}${d.port ? ` port: ${d.port}` : ''}`);
4290
+ if (d.last_error)
4291
+ console.log(` last error: ${d.last_error}`);
4292
+ if (d.next_attempt_at)
4293
+ console.log(` next attempt: ${d.next_attempt_at}`);
4294
+ console.log(` from ${d.source_kind ?? '—'}${d.source_hook ? ` (${d.source_hook})` : ''}${d.source_record_id ? ` record ${d.source_record_id}` : ''}`);
4295
+ // The snapshots are redacted at WRITE time, which is why the detail view may print them.
4296
+ if (d.request)
4297
+ console.log(` request: ${JSON.stringify(d.request)}`);
4298
+ if (d.response)
4299
+ console.log(` response: ${JSON.stringify(d.response)}`);
4300
+ return;
4301
+ }
4302
+ const q = new URLSearchParams(pagingQs(flags));
4303
+ if (typeof flags.status === 'string')
4304
+ q.set('status', flags.status);
4305
+ if (typeof flags.operation === 'string')
4306
+ q.set('operation', flags.operation);
4307
+ if (!asJson)
4308
+ console.log(`→ Reading the delivery log from ${targetLabel(t)} …`);
4309
+ const { status, json } = await apiGet(`${base}/deliveries?${q.toString()}`, t);
4310
+ if (status !== 200)
4311
+ die(`could not read deliveries (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
4312
+ if (asJson) {
4313
+ console.log(JSON.stringify(json, null, 2));
4314
+ return;
4315
+ }
4316
+ const page = readPage(json);
4317
+ const c = json?.counts;
4318
+ console.log(`Deliveries in ${targetLabel(t)}: ${page.total ?? page.rows.length}`
4319
+ + (c ? ` — queued ${c.queued ?? 0}, sent ${c.sent ?? 0}, failed ${c.failed ?? 0}, cancelled ${c.cancelled ?? 0}` : ''));
4320
+ if (page.rows.length === 0)
4321
+ console.log(' (none — a delivery is produced by an `integrations:` operation firing on a record hook)');
4322
+ for (const d of page.rows) {
4323
+ const err = d.last_error ? ` ${String(d.last_error).slice(0, 60)}` : '';
4324
+ console.log(` ${String(d.status).padEnd(9)} ${String(d.connection_key ?? '—').padEnd(16)} ${String(d.operation_id ?? '—').padEnd(20)}`
4325
+ + ` try ${d.attempts}${d.http_status ? ` HTTP ${d.http_status}` : ''} ${d.id}${err}`);
4326
+ }
4327
+ const more = morePageHint(page, 'octwin integrations deliveries');
4328
+ if (more)
4329
+ console.log(more);
4330
+ console.log('\nOne delivery + its request/response: octwin integrations deliveries <id>');
4331
+ console.log('Act on one: octwin integrations retry|cancel|send-now <id>');
4332
+ return;
4333
+ }
4334
+ // ── inbound events ────────────────────────────────────────────────────────
4335
+ if (verb === 'events') {
4336
+ if (!asJson)
4337
+ console.log(`→ Reading inbound integration events from ${targetLabel(t)} …`);
4338
+ const { status, json } = await apiGet(`${base}/inbound-events?${pagingQs(flags)}`, t);
4339
+ if (status !== 200)
4340
+ die(`could not read inbound events (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
4341
+ if (asJson) {
4342
+ console.log(JSON.stringify(json, null, 2));
4343
+ return;
4344
+ }
4345
+ const events = (json?.events ?? readPage(json).rows);
4346
+ console.log(`Inbound events in ${targetLabel(t)}: ${events.length}`);
4347
+ if (events.length === 0)
4348
+ console.log(' (none — an inbound event arrives at POST /api/integrations/<tenant>/<project>/<inboundKey>)');
4349
+ for (const e of events) {
4350
+ console.log(` ${e.received_at ?? e.created_at ?? '—'} ${e.inbound_key ?? '—'} ${e.status ?? e.outcome ?? '—'}${e.detail ? ` ${e.detail}` : ''}`);
4351
+ }
4352
+ return;
4353
+ }
4354
+ // ── a delivery action ─────────────────────────────────────────────────────
4355
+ const action = DELIVERY_ACTIONS[verb];
4356
+ if (action) {
4357
+ if (!arg)
4358
+ die(`usage: octwin integrations ${verb} <deliveryId> (ids: octwin integrations deliveries)`);
4359
+ const { status, json } = await apiSend('POST', `${base}/deliveries/${encodeURIComponent(arg)}/${action.path}`, undefined, t);
4360
+ // 409 is the route's own "wrong state" answer, and it carries the rule — print
4361
+ // it rather than a generic failure, because the fix is choosing another delivery.
4362
+ if (status === 409)
4363
+ die(`cannot ${action.what} delivery '${arg}'${errDetail(json)}`);
4364
+ if (status === 404)
4365
+ die(`delivery '${arg}' not found`);
4366
+ if (status !== 200)
4367
+ writeFail(`${action.what} delivery '${arg}'`, status, json, url);
4368
+ if (asJson) {
4369
+ console.log(JSON.stringify(json, null, 2));
4370
+ return;
4371
+ }
4372
+ const d = json?.delivery ?? {};
4373
+ console.log(`✓ delivery ${d.id ?? arg} is now ${d.status}${d.next_attempt_at ? ` (next attempt ${d.next_attempt_at})` : ''}`);
4374
+ return;
4375
+ }
4376
+ // ── preflight / test on one connection ────────────────────────────────────
4377
+ if (!arg)
4378
+ die(`usage: octwin integrations ${verb} <connectionKey> (keys: octwin integrations)`);
4379
+ if (verb === 'preflight') {
4380
+ if (!asJson)
4381
+ console.log(`→ Preflighting connection '${arg}' in ${targetLabel(t)} …`);
4382
+ const { status, json } = await apiSend('POST', `${base}/connections/${encodeURIComponent(arg)}/preflight`, undefined, t);
4383
+ if (status !== 200)
4384
+ die(`could not preflight '${arg}' (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
4385
+ if (asJson) {
4386
+ console.log(JSON.stringify(json, null, 2));
4387
+ return;
4388
+ }
4389
+ const marks = { pass: '✓', fail: '✗', warn: '⚠', skipped: '–' };
4390
+ console.log(`Preflight '${json?.connection_key ?? arg}': ${json?.ok ? 'READY' : 'NOT READY'}`);
4391
+ for (const c of (json?.checks ?? [])) {
4392
+ console.log(` ${marks[c.status] ?? '?'} ${String(c.label).padEnd(30)} ${c.detail}`);
4393
+ if (c.fix)
4394
+ console.log(` fix: ${c.fix}`);
4395
+ }
4396
+ // Preflight is a DIAGNOSIS and needs only `integrations:read`; `test` makes a
4397
+ // live call and needs write. Worth saying, because the two read alike.
4398
+ if (!json?.ok)
4399
+ console.log('\nPreflight makes no live call. Once it is READY: octwin integrations test <key>');
4400
+ return;
4401
+ }
4402
+ // test — a live call against the connection's declared `health:` operation
4403
+ if (!asJson)
4404
+ console.log(`→ Testing connection '${arg}' against its health operation …`);
4405
+ const { status, json } = await apiSend('POST', `${base}/connections/${encodeURIComponent(arg)}/test`, undefined, t);
4406
+ if (status === 409)
4407
+ die(`no pack is installed on ${targetLabel(t)}${errDetail(json)}`);
4408
+ if (status === 404)
4409
+ die(`${errDetail(json).replace(/^ — /, '') || `connection '${arg}' is not declared by the installed pack`}`);
4410
+ // A 400 here is a real answer, not a usage error: the route returns
4411
+ // `{ ok:false, detail }` when the live call fails, and that detail IS the result.
4412
+ if (status === 400 && json && typeof json === 'object' && 'ok' in json) {
4413
+ if (asJson) {
4414
+ console.log(JSON.stringify(json, null, 2));
4415
+ process.exitCode = 1;
4416
+ return;
4417
+ }
4418
+ console.log(`✗ '${arg}' failed: ${json.detail ?? '(no detail)'}`);
4419
+ console.log(' Diagnose without calling out: octwin integrations preflight ' + arg);
4420
+ process.exitCode = 1;
4421
+ return;
4422
+ }
4423
+ if (status !== 200)
4424
+ writeFail(`test connection '${arg}'`, status, json, url);
4425
+ if (asJson) {
4426
+ console.log(JSON.stringify(json, null, 2));
4427
+ return;
4428
+ }
4429
+ console.log(`${json?.ok ? '✓' : '✗'} '${arg}': ${json?.detail ?? '(no detail)'}`);
4430
+ if (json?.http_status)
4431
+ console.log(` HTTP ${json.http_status} port: ${json.port ?? '—'}`);
4432
+ if (json?.data !== undefined && json?.data !== null)
4433
+ console.log(` data: ${JSON.stringify(json.data).slice(0, 300)}`);
4434
+ if (!json?.ok)
4435
+ process.exitCode = 1;
4436
+ }
4437
+ /**
4438
+ * `octwin integrations [--json]` — what the pack DECLARES beside what is actually
4439
+ * configured, in one view.
4440
+ *
4441
+ * Needs `integrations:read`. The two halves are deliberately joined: a declared
4442
+ * connection with no configured row is the single most common reason an
4443
+ * integration silently never fires, and reading either list alone cannot show it.
4444
+ */
4445
+ async function cmdIntegrations(flags) {
4446
+ if (typeof flags._[0] === 'string' && INTEGRATION_VERBS.has(flags._[0]))
4447
+ return cmdIntegrationsVerb(flags);
4448
+ const t = resolveTarget(flags);
4449
+ const { url } = t;
4450
+ const base = `${url}/api/self/p/integrations`;
4451
+ const asJson = flags.json === true;
4452
+ if (!asJson)
4453
+ console.log(`→ Reading integrations from ${targetLabel(t)} …`);
4454
+ const [declared, configured] = await Promise.all([
4455
+ apiGet(`${base}/declared`, t),
4456
+ apiGet(`${base}/connections`, t),
4457
+ ]);
4458
+ if (declared.status !== 200)
4459
+ die(`could not read declared integrations (HTTP ${declared.status})${errDetail(declared.json)}${authFailureDetail(declared.status, url)}`);
4460
+ if (asJson) {
4461
+ console.log(JSON.stringify({ declared: declared.json, configured: configured.json }, null, 2));
4462
+ return;
4463
+ }
4464
+ const d = declared.json ?? {};
4465
+ const rows = (configured.status === 200 ? (configured.json?.connections ?? []) : []);
4466
+ const byKey = new Map(rows.map(r => [r.connection_key, r]));
4467
+ const conns = (d.connections ?? []);
4468
+ if (!d.pack_id) {
4469
+ console.log('No pack is installed on this project — nothing declares an integration.');
4470
+ return;
4471
+ }
4472
+ if (conns.length === 0 && (d.operations ?? []).length === 0 && (d.inbound ?? []).length === 0) {
4473
+ console.log(`Pack '${d.pack_id}' declares no integrations — no \`integrations.yaml\`.`);
4474
+ return;
4475
+ }
4476
+ console.log(`Integrations declared by '${d.pack_id}':`);
4477
+ for (const c of conns) {
4478
+ const row = byKey.get(c.key);
4479
+ const state = !row
4480
+ ? 'NOT CONFIGURED'
4481
+ : row.status !== 'active'
4482
+ ? row.status
4483
+ : row.has_credential ? `ready (…${row.credential_hint ?? '••••'})` : 'no credential';
4484
+ const test = row?.last_test_at
4485
+ ? ` last test ${row.last_test_ok ? 'ok' : 'FAILED'} ${row.last_test_at}`
4486
+ : '';
4487
+ console.log(` ${String(c.key).padEnd(20)} ${state.padEnd(22)} ${c.auth_kind} in ${c.auth_in}${test}`);
4488
+ if (!row && c.setup_hint)
4489
+ console.log(` setup: ${c.setup_hint}`);
4490
+ if (row?.last_test_detail && row.last_test_ok === false)
4491
+ console.log(` ${row.last_test_detail}`);
4492
+ }
4493
+ const ops = (d.operations ?? []);
4494
+ if (ops.length) {
4495
+ console.log(`\n operations: ${ops.map(o => o.id ?? o.key).join(', ')}`);
4496
+ }
4497
+ const inbound = (d.inbound ?? []);
4498
+ if (inbound.length) {
4499
+ console.log(` inbound keys: ${inbound.map(i => i.key ?? i.id).join(', ')}`);
4500
+ }
4501
+ // A declared-but-unconfigured connection is the failure this view exists to make
4502
+ // visible, so it gets the next step rather than being left as a status word.
4503
+ const missing = conns.filter(c => !byKey.has(c.key)).map(c => c.key);
4504
+ if (missing.length) {
4505
+ console.log(`\n⚠ ${missing.length} connection(s) declared but never configured: ${missing.join(', ')}`);
4506
+ console.log(' Nothing using them will fire. Configure them in the console → Integrations,');
4507
+ console.log(` then: octwin integrations preflight ${missing[0]}`);
4508
+ }
4509
+ console.log('\nDiagnose one: octwin integrations preflight <key> Live call: octwin integrations test <key>');
4510
+ console.log('Outbound log: octwin integrations deliveries Inbound: octwin integrations events');
4511
+ }
4512
+ // ── journeys: the pack's declared customer journeys, measured ────────────────
4513
+ /**
4514
+ * The five journey analytics modes, plus `definition`.
4515
+ *
4516
+ * Deliberately the SAME flag grammar as `octwin analytics`
4517
+ * (`--funnel|--overview|--trends|--cost`) rather than a second shape for the same
4518
+ * idea — a journey funnel and an entity funnel are the same question asked of a
4519
+ * different subject. `goals` is the journey-only member (an entity has
4520
+ * milestones); `definition` prints what the pack declared, unmeasured.
4521
+ */
4522
+ const JOURNEY_MODES = ['funnel', 'overview', 'goals', 'trends', 'cost', 'definition'];
4523
+ /** Both "no such journey" and "no `view` grant" answer 200 + `has_data:false` — a
4524
+ * deliberate empty state, never a 403 — so a bare "no data" would hide the cause. */
4525
+ function printNoJourneyData(journeyId) {
4526
+ console.log(`No data for journey '${journeyId}'. Either:`);
4527
+ console.log(` • the pack declares no journey with that id (list them: octwin journeys), or`);
4528
+ console.log(` • your token's role has no \`view\` grant on it, or`);
4529
+ console.log(` • nothing has entered the journey in the window yet — drive one with \`octwin chat\`.`);
4530
+ }
4531
+ /**
4532
+ * `octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition]
4533
+ * [--stage <stageId>] [--json]` — the journeys a pack declares, and how they perform.
4534
+ *
4535
+ * Needs `journeys:read`. Journeys carry RBAC ON TOP of the scope, so a token can
4536
+ * hold the scope and still see an empty journey — `printNoJourneyData` names that
4537
+ * rather than reporting it as absence of data.
4538
+ */
4539
+ async function cmdJourneys(flags) {
4540
+ const t = resolveTarget(flags);
4541
+ const { url } = t;
4542
+ const base = `${url}/api/self/p/journeys`;
4543
+ const journeyId = flags._[0];
4544
+ const asJson = flags.json === true;
4545
+ const stage = typeof flags.stage === 'string' ? flags.stage : undefined;
4546
+ const mode = JOURNEY_MODES.find(m => flags[m] === true) ?? 'funnel';
4547
+ if (stage && !journeyId)
4548
+ die('usage: octwin journeys <journeyId> --stage <stageId> (a stage belongs to a journey)');
4549
+ // ── the list ──────────────────────────────────────────────────────────────
4550
+ if (!journeyId) {
4551
+ if (!asJson)
4552
+ console.log(`→ Reading declared journeys from ${targetLabel(t)} …`);
4553
+ /**
4554
+ * A template literal, not the bare `base`, so `cli-routes.test.ts` can SEE this
4555
+ * URL — its extractor only reads a template literal in the first argument
4556
+ * position, and a bare identifier slips past unchecked. That guard exists
4557
+ * because six deleted routes shipped as silent 404s; a call it cannot read is a
4558
+ * call it cannot protect.
4559
+ *
4560
+ * The first draft of this very comment QUOTED the call shape it was describing,
4561
+ * which made the comment itself match the extractor's pattern — the scan
4562
+ * consumed the prose and skipped the real call one line below. So the note that
4563
+ * explains the guard silently disabled it. Do not spell the scanned pattern
4564
+ * inside a comment in a file that is itself scanned.
4565
+ */
4566
+ const { status, json } = await apiGet(`${base}`, t);
4567
+ if (status !== 200)
4568
+ die(`could not read journeys (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
4569
+ if (asJson) {
4570
+ console.log(JSON.stringify(json, null, 2));
4571
+ return;
4572
+ }
4573
+ const js = (json?.journeys ?? []);
4574
+ if (js.length === 0) {
4575
+ console.log('No journeys declared — a journey comes from the pack\'s `journeys.yaml`.');
4576
+ console.log('(Per-ENTITY stage funnels are a different surface: octwin analytics)');
4577
+ return;
4578
+ }
4579
+ console.log(`Journeys in ${targetLabel(t)}:`);
4580
+ for (const j of js)
4581
+ console.log(` ${String(j.id).padEnd(24)} ${pickLabel(j.label) ?? ''}`);
4582
+ console.log('\nOne journey: octwin journeys <journeyId> (add --overview / --goals / --trends / --cost / --definition)');
4583
+ console.log('Who is at a stage: octwin journeys <journeyId> --stage <stageId>');
4584
+ return;
4585
+ }
4586
+ // ── stage drill-down: the runs currently at a stage ────────────────────────
4587
+ if (stage) {
4588
+ if (!asJson)
4589
+ console.log(`→ Reading '${journeyId}' runs at stage '${stage}' …`);
4590
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(journeyId)}/stages/${encodeURIComponent(stage)}/runs?${pagingQs(flags)}`, t);
4591
+ if (status === 404)
4592
+ die(`unknown stage '${stage}' for journey '${journeyId}'${errDetail(json)}`);
4593
+ if (status !== 200)
4594
+ die(`could not read stage runs (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
4595
+ if (asJson) {
4596
+ console.log(JSON.stringify(json, null, 2));
4597
+ return;
4598
+ }
4599
+ if (json?.has_data === false) {
4600
+ printNoJourneyData(journeyId);
4601
+ return;
4602
+ }
4603
+ const page = readPage(json);
4604
+ console.log(`'${journeyId}' at '${stage}' (live snapshot): ${page.total ?? page.rows.length} run(s)`);
4605
+ for (const r of page.rows) {
4606
+ const who = r.channel_contact_handle ?? r.display_name ?? r.contact_id ?? '—';
4607
+ console.log(` ${who} entered ${r.entered_at ?? r.created_at ?? '—'}${r.completed_at ? ` completed ${r.completed_at}` : ''}`);
4608
+ }
4609
+ const more = morePageHint(page, `octwin journeys ${journeyId} --stage ${stage}`);
4610
+ if (more)
4611
+ console.log(more);
4612
+ return;
4613
+ }
4614
+ if (!asJson)
4615
+ console.log(`→ Reading '${journeyId}' ${mode} from ${targetLabel(t)} …`);
4616
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(journeyId)}/${mode}`, t);
4617
+ if (status === 404)
4618
+ die(`journey '${journeyId}' not found (list them: octwin journeys)`);
4619
+ if (status !== 200)
4620
+ die(`could not read ${mode} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
4621
+ if (asJson) {
4622
+ console.log(JSON.stringify(json, null, 2));
4623
+ return;
4624
+ }
4625
+ if (json?.has_data === false) {
4626
+ printNoJourneyData(journeyId);
4627
+ return;
4628
+ }
4629
+ const range = json?.range ? ` (${String(json.range.from).slice(0, 10)} → ${String(json.range.to).slice(0, 10)})` : '';
4630
+ console.log(`Journey '${journeyId}' — ${mode}${range}:`);
4631
+ switch (mode) {
4632
+ case 'definition': {
4633
+ /**
4634
+ * Unmeasured: what the pack DECLARED. The one mode that answers "is this journey
4635
+ * even wired the way I think" with no traffic at all.
4636
+ *
4637
+ * The payload nests under `definition` and its own field names differ from the
4638
+ * measured modes: a stage carries `order` (not the funnel's `rank`), and a goal
4639
+ * names the `stage` it fires on. Read off the live route rather than assumed —
4640
+ * the first draft printed `?.` for every rank because it reused `rank`.
4641
+ */
4642
+ const def = json?.definition ?? {};
4643
+ const stages = (def.stages ?? []);
4644
+ const goals = (def.goals ?? []);
4645
+ const events = (def.events ?? []);
4646
+ for (const s of stages) {
4647
+ console.log(` ${String(s.order ?? '?').padStart(2)}. ${String(s.id).padEnd(24)} ${pickLabel(s.label) ?? ''}`);
4648
+ }
4649
+ if (goals.length) {
4650
+ console.log(' goals:');
4651
+ for (const g of goals) {
4652
+ console.log(` ${String(g.id).padEnd(24)} ${String(pickLabel(g.label) ?? '').padEnd(22)}`
4653
+ + `${g.stage ? ` on stage '${g.stage}'` : ''}${g.value != null ? ` value ${g.value}` : ''}`);
4654
+ }
4655
+ }
4656
+ // An event is keyed by `name` and carries what it MOVES — `advances_to` a stage
4657
+ // and optionally `completes` a goal. That wiring is the whole reason to read a
4658
+ // definition, so it gets a row each rather than a comma list of names.
4659
+ if (events.length) {
4660
+ console.log(' events (what moves the journey):');
4661
+ for (const e of events) {
4662
+ console.log(` ${String(e.name).padEnd(24)} ${String(pickLabel(e.label) ?? '').padEnd(22)}`
4663
+ + `${e.advances_to ? ` → stage '${e.advances_to}'` : ''}${e.completes ? `, completes '${e.completes}'` : ''}`);
4664
+ }
4665
+ }
4666
+ break;
4667
+ }
4668
+ case 'funnel':
4669
+ for (const s of (json?.funnel ?? [])) {
4670
+ const conv = s.conversion_from_prev_pct == null ? '' : ` ${s.conversion_from_prev_pct}% of prev`;
4671
+ const lost = s.drop_off_from_prev ? ` (−${s.drop_off_from_prev})` : '';
4672
+ console.log(` ${String(s.rank).padStart(2)}. ${String(pickLabel(s.label) ?? s.stage_id).padEnd(24)} ${String(s.reached).padStart(6)}${conv}${lost}`);
4673
+ }
4674
+ break;
4675
+ case 'overview': {
4676
+ const s = json?.summary ?? {};
4677
+ console.log(` entered ${s.entered} → converted ${s.converted}${s.conversion_pct == null ? '' : ` (${s.conversion_pct}%)`}`
4678
+ + `${s.converted_basis ? ` [basis: ${s.converted_basis}]` : ''}`);
4679
+ if (s.biggest_dropoff)
4680
+ console.log(` biggest drop-off: ${s.biggest_dropoff.from} → ${s.biggest_dropoff.to} (lost ${s.biggest_dropoff.lost})`);
4681
+ if (s.top_goal)
4682
+ console.log(` top goal: ${pickLabel(s.top_goal.label) ?? s.top_goal.goal_id} (${s.top_goal.completions})`);
4683
+ break;
4684
+ }
4685
+ case 'goals':
4686
+ for (const g of (json?.goals ?? [])) {
4687
+ const p50 = g.p50_seconds == null ? '' : ` p50 ${Math.round(g.p50_seconds / 60)}m`;
4688
+ console.log(` ${String(pickLabel(g.label) ?? g.goal_id).padEnd(28)} ${String(g.completions).padStart(6)} completion(s),`
4689
+ + ` ${g.unique_contacts} contact(s)${g.total_value ? `, value ${g.total_value}` : ''}${p50}`);
4690
+ }
4691
+ break;
4692
+ case 'trends':
4693
+ for (const b of (json?.buckets ?? [])) {
4694
+ console.log(` ${String(b.bucket).slice(0, 10)} active ${b.active_contacts} goals ${b.goal_completions}`);
4695
+ }
4696
+ break;
4697
+ case 'cost':
4698
+ for (const g of (json?.by_goal ?? [])) {
4699
+ const unknown = g.cost_unknown_rows ? ` (${g.cost_unknown_rows} row(s) unpriced)` : '';
4700
+ console.log(` ${String(pickLabel(g.label) ?? g.id).padEnd(24)} ${String(g.conversations).padStart(5)} conv,`
4701
+ + ` ${String(g.total_tokens).padStart(8)} tokens, $${(g.cost_usd ?? 0).toFixed(4)}${unknown}`);
4702
+ }
4703
+ break;
4704
+ }
4705
+ }
4706
+ // ── performance: the project's business indicators ──────────────────────────
4707
+ /**
4708
+ * `octwin performance [--detail] [--json]` — the indicators the project's own
4709
+ * declarations produce: value, conversion, duration, per journey.
4710
+ *
4711
+ * Needs `records:read` — **not** a `performance:*` scope, which does not exist.
4712
+ * That means the Read-only token preset already reaches this.
4713
+ */
4714
+ async function cmdPerformance(flags) {
4715
+ const t = resolveTarget(flags);
4716
+ const { url } = t;
4717
+ const base = `${url}/api/self/p/performance`;
4718
+ const asJson = flags.json === true;
4719
+ const detail = flags.detail === true;
4720
+ if (!asJson)
4721
+ console.log(`→ Reading business performance from ${targetLabel(t)} …`);
4722
+ // Two explicit calls rather than `apiGet(detail ? … : base)`: the route guard's
4723
+ // extractor only reads a template literal in the FIRST argument position, so a
4724
+ // ternary hides both URLs from it.
4725
+ const { status, json } = detail
4726
+ ? await apiGet(`${base}/detail`, t)
4727
+ : await apiGet(`${base}`, t);
4728
+ if (status !== 200)
4729
+ die(`could not read performance (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
4730
+ if (asJson) {
4731
+ console.log(JSON.stringify(json, null, 2));
4732
+ return;
4733
+ }
4734
+ if (json?.has_data === false) {
4735
+ console.log('No performance indicators — they are DERIVED from declarations (a journey with a');
4736
+ console.log('goal value, a pipelined entity), so a pack that declares none produces none.');
4737
+ return;
4738
+ }
4739
+ const r = json?.range ?? {};
4740
+ console.log(`Performance in ${targetLabel(t)}`
4741
+ + `${r.from ? ` (${String(r.from).slice(0, 10)} → ${String(r.to).slice(0, 10)}, ${r.days ?? '?'}d, by ${json?.bucket ?? 'day'})` : ''}:`);
4742
+ const inds = (json?.indicators ?? []);
4743
+ if (inds.length === 0)
4744
+ console.log(' (none)');
4745
+ for (const i of inds) {
4746
+ const unit = i.unit === 'pct' ? '%' : '';
4747
+ // `delta_pct` is signed and against the PREVIOUS window — sign it explicitly so
4748
+ // a fall is never read as a rise.
4749
+ const delta = i.delta_pct == null ? '' : ` ${i.delta_pct >= 0 ? '+' : ''}${i.delta_pct}% vs prev`;
4750
+ const frac = i.numerator != null && i.denominator != null
4751
+ ? ` (${i.numerator}/${i.denominator}${i.denominator_of ? ` ${i.denominator_of}` : ''})`
4752
+ : '';
4753
+ console.log(` ${String(pickLabel(i.heading) ?? i.kind).padEnd(16)} ${String(pickLabel(i.label) ?? '').padEnd(20)}`
4754
+ + ` ${String(i.value ?? '—').padStart(9)}${unit}${delta}${frac}`);
4755
+ if (i.why)
4756
+ console.log(` why: ${i.why}`);
4757
+ if (i.biggest_dropoff)
4758
+ console.log(` biggest drop-off: ${i.biggest_dropoff.from} → ${i.biggest_dropoff.to} (lost ${i.biggest_dropoff.lost})`);
4759
+ }
4760
+ if (!detail)
4761
+ console.log('\nPer-indicator breakdown: octwin performance --detail');
4762
+ }
4763
+ // ── usage: model calls, tokens and cost ─────────────────────────────────────
4764
+ /** One `{ key, calls, total_tokens, cost_usd }` breakdown row. */
4765
+ function printUsageRows(title, rows) {
4766
+ if (!rows?.length)
4767
+ return;
4768
+ console.log(` ${title}:`);
4769
+ for (const r of rows) {
4770
+ console.log(` ${String(r.key ?? r.day).padEnd(40)} ${String(r.calls ?? '—').padStart(6)} call(s)`
4771
+ + ` ${String(r.total_tokens ?? 0).padStart(10)} tokens $${(r.cost_usd ?? 0).toFixed(4)}`
4772
+ + `${r.cost_partial ? ' (partial — some rows unpriced)' : ''}`);
4773
+ }
4774
+ }
4775
+ /**
4776
+ * `octwin usage [--json]` — model calls, tokens and cost for the resolved scope.
4777
+ *
4778
+ * Needs NO scope beyond a valid token (the route is `requireTenantAccess`), which
4779
+ * is why it has no `COMMAND_REQUIREMENTS` entry: declaring one would print
4780
+ * "needs the X scope" on a failure whose cause is something else.
4781
+ *
4782
+ * Project-scoped when a project is resolved, tenant-wide otherwise — both routes
4783
+ * exist and the narrower one is the more useful default while testing a pack.
4784
+ * This is spend on MODEL calls; WhatsApp/Meta billing is operator-only and no
4785
+ * token can reach it.
4786
+ */
4787
+ async function cmdUsage(flags) {
4788
+ const t = resolveTarget(flags);
4789
+ const { url } = t;
4790
+ const asJson = flags.json === true;
4791
+ const scoped = Boolean(t.project);
4792
+ if (!asJson)
4793
+ console.log(`→ Reading model usage for ${scoped ? targetLabel(t) : 'the whole workspace'} …`);
4794
+ // Both URLs written out in place, for the same reason as `performance` above: an
4795
+ // `endpoint` variable would leave BOTH invisible to `cli-routes.test.ts`.
4796
+ const { status, json } = scoped
4797
+ ? await apiGet(`${url}/api/self/p/usage`, t)
4798
+ : await apiGet(`${url}/api/self/t/usage`, t);
4799
+ if (status !== 200)
4800
+ die(`could not read usage (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
4801
+ if (asJson) {
4802
+ console.log(JSON.stringify(json, null, 2));
4803
+ return;
4804
+ }
4805
+ const u = json?.usage ?? {};
4806
+ const tot = u.totals ?? {};
4807
+ const r = json?.range ?? {};
4808
+ console.log(`Model usage — ${scoped ? `project '${json?.project?.slug ?? t.project}'` : `workspace '${json?.tenant?.slug ?? ''}'`}`
4809
+ + `${r.from ? ` (${String(r.from).slice(0, 10)} → ${String(r.to).slice(0, 10)})` : ''}`);
4810
+ console.log(` ${tot.calls ?? 0} call(s) ${tot.total_tokens ?? 0} tokens`
4811
+ + ` (${tot.prompt_tokens ?? 0} in / ${tot.completion_tokens ?? 0} out) $${(tot.cost_usd ?? 0).toFixed(4)}`
4812
+ + `${tot.cost_partial ? ' ⚠ partial: some calls had no price' : ''}`);
4813
+ if ((tot.calls ?? 0) === 0) {
4814
+ console.log(' (nothing in the window — drive a turn with `octwin chat`)');
4815
+ return;
4816
+ }
4817
+ printUsageRows('by model', u.by_model);
4818
+ printUsageRows('by kind', u.by_kind);
4819
+ printUsageRows('by agent', u.by_agent);
4820
+ printUsageRows('by channel', u.by_channel);
4821
+ // Not WhatsApp/Meta spend: that is operator-only and deliberately outside the
4822
+ // token scope registry, so this command cannot show it at all.
4823
+ console.log('\nThis is MODEL spend. WhatsApp/Meta message billing is operator-only — not reachable by an API token.');
4824
+ }
3867
4825
  function help() {
3868
- console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
3869
-
3870
- octwin --version # print the CLI version (+ any upgrade notice)
3871
- octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
3872
- octwin validate [--dir .] [--remote] [--require-kb] # --remote runs the platform's FULL schema check + lint (all errors at once)
3873
- octwin login --url <platformUrl> --token oct_… # a deploy token from the console
3874
- octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
3875
- octwin projects [--archived] [--json] # the --project slugs this token can name
3876
- octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
3877
- octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
3878
- octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
3879
- octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
3880
- octwin work [recordId] [--queues] [--json] # inspect the work inbox (worked records) + timelines
3881
- octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
3882
- octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
3883
- octwin media generate "<prompt>" [--out <file.png>] [--json] # AI-generate an image MEDIA- handle (needs media:generate scope)
3884
- octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
3885
- octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
3886
- octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
3887
- octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
3888
- octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
3889
- octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
3890
- octwin test [--dir .] # = validate --remote (the full platform check)
3891
- octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
3892
- octwin memos [--all] [--json] # read the platform's replies + notices (a reply to your feedback lands here)
3893
-
3894
- Writes exercise the state your pack creates (each needs the matching :write scope):
3895
- octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
3896
- octwin records tasks | task complete <taskId> [--outcome done|cancelled]
3897
- octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
3898
- octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
3899
- octwin orders transition <ref> --to <status> | refund <ref> --force
3900
- octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
3901
- octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
3902
- octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
3903
-
3904
- Multi-turn: the platform keeps ONE open conversation per --as handle consecutive
3905
- \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
3906
- button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
3907
- Get a deploy token: console your workspace Settings API tokens → Generate (tick records:read to inspect data).
3908
- octwin platform-kb pull writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
3909
- Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
4826
+ console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
4827
+
4828
+ octwin --version # print the CLI version (+ any upgrade notice)
4829
+ octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
4830
+ octwin validate [--dir .] [--remote] [--require-kb] # --remote runs the platform's FULL schema check + lint (all errors at once)
4831
+ octwin login --url <platformUrl> --token oct_… # a deploy token from the console
4832
+ octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
4833
+ octwin projects [--archived] [--json] # the --project slugs this token can name
4834
+ octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
4835
+ [--request-listing | --withdraw-listing] # public marketplace opt-in, see: octwin help deploy
4836
+ octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
4837
+ octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
4838
+ octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
4839
+ octwin work [recordId] [--queues] [--json] # inspect the work inbox (worked records) + timelines
4840
+ octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
4841
+ octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
4842
+ octwin media generate "<prompt>" [--out <file.png>] [--json] # AI-generate an image MEDIA- handle (needs media:generate scope)
4843
+ octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
4844
+ octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
4845
+ octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
4846
+ octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
4847
+ octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
4848
+ octwin automation [campaigns] [--json] # the jobs your declarations produced + health, last result each
4849
+ octwin integrations [--json] # declared connections BESIDE what is configured (the silent-never-fires check)
4850
+ octwin integrations deliveries [<id>] | events # the outbound delivery log / inbound events
4851
+ octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition] [--stage <id>]
4852
+ octwin performance [--detail] [--json] # the project's business indicators (value/conversion/duration)
4853
+ octwin usage [--json] # model calls, tokens and cost (project if pinned, else workspace)
4854
+ octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
4855
+ octwin test [--dir .] # = validate --remote (the full platform check)
4856
+ octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
4857
+ octwin memos [--all] [--json] # read the platform's replies + notices (a reply to your feedback lands here)
4858
+
4859
+ Writes exercise the state your pack creates (each needs the matching :write scope):
4860
+ octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
4861
+ octwin records tasks | task complete <taskId> [--outcome done|cancelled]
4862
+ octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
4863
+ octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
4864
+ octwin orders transition <ref> --to <status> | refund <ref> --force
4865
+ octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
4866
+ octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
4867
+ octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
4868
+ octwin automation run <jobId> | pause <jobId> | resume <jobId> | send <campaignId>
4869
+ octwin integrations test <key> # a LIVE call to the connection's health: operation
4870
+ octwin integrations retry|cancel|send-now <deliveryId>
4871
+ (octwin integrations preflight <key> needs only integrations:read — it makes no call)
4872
+
4873
+ Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
4874
+ \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
4875
+ button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
4876
+ Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
4877
+ octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
4878
+ Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
3910
4879
  Per-command usage: octwin <command> --help`);
3911
4880
  }
3912
4881
  /** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
3913
4882
  * network/auth work (a --help that 401s is worse than no help at all). */
3914
4883
  const COMMAND_HELP = {
3915
- init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
4884
+ init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
3916
4885
  Scaffold a pure-YAML starter pack into <dir>.`,
3917
- validate: `octwin validate [--dir .] [--remote] [--require-kb] [--strict-primitives]
3918
- Offline structural check, plus two checks driven by the pulled capability
3919
- reference (render-intent fields, primitive arguments). Those two SKIP when the
3920
- reference is missing — the run says so, and --require-kb turns the skip into a
3921
- failure for CI. --remote additionally runs the platform's FULL manifest +
3922
- flow-DSL validation and its flow lint (all errors at once) — same check as deploy.
3923
- --strict-primitives (with --remote) additionally type-checks LITERAL args:
3924
- values against each primitive's declared input schema; expression strings
4886
+ validate: `octwin validate [--dir .] [--remote] [--require-kb] [--strict-primitives]
4887
+ Offline structural check, plus two checks driven by the pulled capability
4888
+ reference (render-intent fields, primitive arguments). Those two SKIP when the
4889
+ reference is missing — the run says so, and --require-kb turns the skip into a
4890
+ failure for CI. --remote additionally runs the platform's FULL manifest +
4891
+ flow-DSL validation and its flow lint (all errors at once) — same check as deploy.
4892
+ --strict-primitives (with --remote) additionally type-checks LITERAL args:
4893
+ values against each primitive's declared input schema; expression strings
3925
4894
  ('$found.id', '{$t(…)}') are always exempt.`,
3926
- login: `octwin login --url <platformUrl> --token oct_…
3927
- Save a deploy token (console → Settings → API tokens) for that platform url,
3928
- make that url the DEFAULT deploy target for every later command, and echo the
4895
+ login: `octwin login --url <platformUrl> --token oct_…
4896
+ Save a deploy token (console → Settings → API tokens) for that platform url,
4897
+ make that url the DEFAULT deploy target for every later command, and echo the
3929
4898
  workspace + project pin + scopes the token reaches.`,
3930
- whoami: `octwin whoami [--url <url>] [--tenant <slug>]
4899
+ whoami: `octwin whoami [--url <url>] [--tenant <slug>]
3931
4900
  Verify the resolved token authenticates against the tenant.`,
3932
- projects: `octwin projects [--archived] [--json]
3933
- List the workspace's projects — the slugs every --project flag takes, with the
3934
- plan's project cap. --archived includes archived ones. A pack:deploy token
3935
- reaches this (it names a project in every other command).
3936
-
3937
- octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
3938
- Create a project. The URL slug is derived from the name unless --slug pins one.
3939
- --pack installs an ALREADY-published pack; the usual next step is instead
3940
- \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
3941
-
3942
- octwin projects rm <slug> [--yes]
3943
- HARD delete — the project and everything cascading from it (conversations,
3944
- contacts, records, installs). No undo, and not the same as archiving.
3945
- WITHOUT --yes it only previews what would be destroyed, so the dry run is the
3946
- default. Together these make a disposable end-to-end environment:
3947
- octwin projects create "Scratch" && octwin deploy --project scratch --seed
3948
- octwin chat "hi" --project scratch
3949
- octwin projects rm scratch --yes
4901
+ projects: `octwin projects [--archived] [--json]
4902
+ List the workspace's projects — the slugs every --project flag takes, with the
4903
+ plan's project cap. --archived includes archived ones. A pack:deploy token
4904
+ reaches this (it names a project in every other command).
4905
+
4906
+ octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
4907
+ Create a project. The URL slug is derived from the name unless --slug pins one.
4908
+ --pack installs an ALREADY-published pack; the usual next step is instead
4909
+ \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
4910
+
4911
+ octwin projects rm <slug> [--yes]
4912
+ HARD delete — the project and everything cascading from it (conversations,
4913
+ contacts, records, installs). No undo, and not the same as archiving.
4914
+ WITHOUT --yes it only previews what would be destroyed, so the dry run is the
4915
+ default. Together these make a disposable end-to-end environment:
4916
+ octwin projects create "Scratch" && octwin deploy --project scratch --seed
4917
+ octwin chat "hi" --project scratch
4918
+ octwin projects rm scratch --yes
3950
4919
  Both verbs need the \`projects:write\` scope — a pack:deploy token does NOT confer it.`,
3951
- deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
3952
- Upload the pack bundle, validate server-side, install onto the project.
3953
- --seed additionally applies the pack's demo seed (streams progress).`,
3954
- seed: `octwin seed [--pack <packId>]
3955
- Apply the pack's demo/reference data to the project it is installed on, without
3956
- redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
3957
- and the demo operator topology. Reports what each kind produced.
3958
- Idempotent and safe to re-run — records upsert, and existing media is REUSED rather
3959
- than regenerated, so a second pass costs nothing. --pack is only needed when a
4920
+ deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
4921
+ [--request-listing | --withdraw-listing]
4922
+ Upload the pack bundle, validate server-side, install onto the project.
4923
+ --seed additionally applies the pack's demo seed (streams progress).
4924
+
4925
+ A plain deploy says NOTHING about the public marketplace — it is a test loop, so it
4926
+ neither asks for a listing nor gives one up. The marketplace flags are opt-in:
4927
+
4928
+ --request-listing ask an operator to review this pack for the public marketplace
4929
+ (the pre-signup storefront at /packs). Requires 'public: true'
4930
+ under 'listing:' in manifest.yaml — the manifest states that the
4931
+ pack is a product, the flag is you choosing to ask.
4932
+ --withdraw-listing retract the request, including an approved listing.
4933
+
4934
+ An approval covers the CONTENT it was made against, so a later deploy that changes the
4935
+ pack returns it to the review queue on its own — no flag needed, and the CLI says so.`,
4936
+ seed: `octwin seed [--pack <packId>]
4937
+ Apply the pack's demo/reference data to the project it is installed on, without
4938
+ redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
4939
+ and the demo operator topology. Reports what each kind produced.
4940
+ Idempotent and safe to re-run — records upsert, and existing media is REUSED rather
4941
+ than regenerated, so a second pass costs nothing. --pack is only needed when a
3960
4942
  project somehow runs more than one.`,
3961
- status: `octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
3962
- Show installed vs live version + the flow list for this pack.
3963
- The pack id is read from manifest.yaml and QUALIFIED with your workspace slug
3964
- (a manifest declares a bare name; the owner is attached when you publish). Pass
3965
- <packId> explicitly to skip that lookup — \`octwin agents\` and \`octwin projects\`
4943
+ status: `octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
4944
+ Show installed vs live version + the flow list for this pack.
4945
+ The pack id is read from manifest.yaml and QUALIFIED with your workspace slug
4946
+ (a manifest declares a bare name; the owner is attached when you publish). Pass
4947
+ <packId> explicitly to skip that lookup — \`octwin agents\` and \`octwin projects\`
3966
4948
  both print the qualified form.`,
3967
- records: `octwin records [entity] [id] [--limit 50] [--offset n]
3968
- Inspect the pack's XRM data. No args = list entities. Worked records (cases,
3969
- tickets, anything routed to a queue) read best through \`octwin work\`.
3970
-
3971
- WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
3972
- octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
3973
- octwin records patch <recordId> --entity <entity> --set field=value
3974
- octwin records stage <recordId> --to <stage> [--note "..."]
3975
- octwin records note <recordId> "the note text"
3976
- octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
3977
- octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
3978
-
3979
- --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
3980
- sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
3981
- \`patch\` needs --entity even though it has an id: the route resolves the field
3982
- validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
4949
+ records: `octwin records [entity] [id] [--limit 50] [--offset n]
4950
+ Inspect the pack's XRM data. No args = list entities. Worked records (cases,
4951
+ tickets, anything routed to a queue) read best through \`octwin work\`.
4952
+
4953
+ WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
4954
+ octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
4955
+ octwin records patch <recordId> --entity <entity> --set field=value
4956
+ octwin records stage <recordId> --to <stage> [--note "..."]
4957
+ octwin records note <recordId> "the note text"
4958
+ octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
4959
+ octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
4960
+
4961
+ --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
4962
+ sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
4963
+ \`patch\` needs --entity even though it has an id: the route resolves the field
4964
+ validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
3983
4965
  VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
3984
- work: `octwin work [recordId] [--queues] [--limit 50] [--offset n] [--json]
3985
- Inspect the work inbox — every entity the pack declares worked (cases, orders
3986
- needing review, applications, …): the inbox, one item + its timeline
3987
- (+ applicable actions), or --queues for queue keys + open counts.
3988
-
3989
- WRITES (need \`work:write\`; \`stage\` needs \`records:write\`):
3990
- octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none
3991
- octwin work note <recordId> "the note text"
3992
- octwin work stage <recordId> --to <stage> [--note "..."]
3993
- octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]
3994
-
3995
- \`decide\` applies one of the entity's declared operator actions — \`octwin work <id>\`
3996
- lists them with their params. --dry-run previews the customer-facing copy and the
3997
- resulting stage WITHOUT committing (that route needs only \`work:read\`).
4966
+ work: `octwin work [recordId] [--queues] [--limit 50] [--offset n] [--json]
4967
+ Inspect the work inbox — every entity the pack declares worked (cases, orders
4968
+ needing review, applications, …): the inbox, one item + its timeline
4969
+ (+ applicable actions), or --queues for queue keys + open counts.
4970
+
4971
+ WRITES (need \`work:write\`; \`stage\` needs \`records:write\`):
4972
+ octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none
4973
+ octwin work note <recordId> "the note text"
4974
+ octwin work stage <recordId> --to <stage> [--note "..."]
4975
+ octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]
4976
+
4977
+ \`decide\` applies one of the entity's declared operator actions — \`octwin work <id>\`
4978
+ lists them with their params. --dry-run previews the customer-facing copy and the
4979
+ resulting stage WITHOUT committing (that route needs only \`work:read\`).
3998
4980
  \`stage\` is the XRM records verb (one transition spelling platform-wide).`,
3999
- logs: `octwin logs [conversationId] [--as <handle>] [--json]
4000
- No id = recent conversations (handle, status, last activity; --as filters).
4001
- With id = the full event timeline including what each turn rendered.
4981
+ logs: `octwin logs [conversationId] [--as <handle>] [--json]
4982
+ No id = recent conversations (handle, status, last activity; --as filters).
4983
+ With id = the full event timeline including what each turn rendered.
4002
4984
  --json = raw events (verbatim payloads).`,
4003
- pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
4004
- Write a DEPLOYED pack's source back to disk — the inverse of deploy.
4005
- A pack pushed with 'octwin deploy' lives on the platform as an artifact the
4006
- runtime serves but nothing hands back, so its only source copy is the machine
4007
- that pushed it. Pull it, fix it, redeploy it.
4008
- Defaults to the version installed on the target project; --version overrides.
4009
- --dir defaults to ./<packId>; a non-empty dir needs --force.
4010
- The pulled dir redeploys where it came from — the target is your saved login.
4985
+ pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
4986
+ Write a DEPLOYED pack's source back to disk — the inverse of deploy.
4987
+ A pack pushed with 'octwin deploy' lives on the platform as an artifact the
4988
+ runtime serves but nothing hands back, so its only source copy is the machine
4989
+ that pushed it. Pull it, fix it, redeploy it.
4990
+ Defaults to the version installed on the target project; --version overrides.
4991
+ --dir defaults to ./<packId>; a non-empty dir needs --force.
4992
+ The pulled dir redeploys where it came from — the target is your saved login.
4011
4993
  You may pull a pack your tenant OWNS (deployed); an operator token pulls any.`,
4012
- chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
4013
- octwin chat --script <file> [--as <handle>] [--json]
4014
- Drive ONE turn through the dev web channel and print every render with its
4015
- tap ids. Same --as handle = same conversation (multi-turn works).
4016
- --tap presses a rendered button/list row instead of sending text.
4017
- --media uploads a local file (or a media id from 'media generate --json') as
4018
- an image/document/audio inbound — any "message" rides as its caption; feeds a
4019
- running media-collect flow (e.g. activate-app).
4020
- --json dumps the raw SSE envelopes for the turn.
4021
-
4022
- --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
4023
- process over one connection — waiting for each turn to settle before sending
4024
- the next. Use this for any multi-step flow: chaining shell invocations races
4025
- the agent loop, because a turn ends on a quiet gap that can arrive while the
4026
- server is still working (the symptom is placeholder-filled fields or a second
4027
- workflow run). Blank lines and # comments are skipped:
4028
-
4029
- # book an appointment end to end
4030
- احجز موعد
4031
- tap:t:invoke:book-appointment:doctor_id=D1
4032
- media:./licence.jpg | here is my licence
4994
+ chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
4995
+ octwin chat --script <file> [--as <handle>] [--json]
4996
+ Drive ONE turn through the dev web channel and print every render with its
4997
+ tap ids. Same --as handle = same conversation (multi-turn works).
4998
+ --tap presses a rendered button/list row instead of sending text.
4999
+ --media uploads a local file (or a media id from 'media generate --json') as
5000
+ an image/document/audio inbound — any "message" rides as its caption; feeds a
5001
+ running media-collect flow (e.g. activate-app).
5002
+ --json dumps the raw SSE envelopes for the turn.
5003
+
5004
+ --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
5005
+ process over one connection — waiting for each turn to settle before sending
5006
+ the next. Use this for any multi-step flow: chaining shell invocations races
5007
+ the agent loop, because a turn ends on a quiet gap that can arrive while the
5008
+ server is still working (the symptom is placeholder-filled fields or a second
5009
+ workflow run). Blank lines and # comments are skipped:
5010
+
5011
+ # book an appointment end to end
5012
+ احجز موعد
5013
+ tap:t:invoke:book-appointment:doctor_id=D1
5014
+ media:./licence.jpg | here is my licence
4033
5015
  tap:t:resume:book-appointment:run_id=R1;_ctl_approved=true`,
4034
- media: `octwin media generate "<prompt>" [--out <file.png>] [--json]
4035
- AI-generate an image (needs a media:generate-scoped token), store it as a
4036
- public asset, and print its MEDIA- handle + serve URL. --out downloads the
4037
- bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
5016
+ media: `octwin media generate "<prompt>" [--out <file.png>] [--json]
5017
+ AI-generate an image (needs a media:generate-scoped token), store it as a
5018
+ public asset, and print its MEDIA- handle + serve URL. --out downloads the
5019
+ bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
4038
5020
  bytes }. Pair with 'octwin chat --media' to drive media flows.`,
4039
- agents: `octwin agents [packId::agentId] [--prompt] [--json]
4040
- No args = the roster with each agent's EFFECTIVE model and which layer set it.
4041
- With an agent = every governed setting (model / memory.last_messages /
4042
- working_memory) plus the layer that won — an operator PLATFORM default can
4043
- override what your manifest declares, and this is where you see that.
4044
- --prompt = the exact system prompt the LLM sees for this project (pack
4045
- instructions + platform protocol + any project overlay). Needs agents:read.
4046
- The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
4047
-
4048
- WRITES (need \`agents:write\`):
4049
- octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
4050
- [--enable-tool <toolId>] [--disable-tool <toolId>]
4051
-
4052
- Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
4053
- so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
5021
+ agents: `octwin agents [packId::agentId] [--prompt] [--json]
5022
+ No args = the roster with each agent's EFFECTIVE model and which layer set it.
5023
+ With an agent = every governed setting (model / memory.last_messages /
5024
+ working_memory) plus the layer that won — an operator PLATFORM default can
5025
+ override what your manifest declares, and this is where you see that.
5026
+ --prompt = the exact system prompt the LLM sees for this project (pack
5027
+ instructions + platform protocol + any project overlay). Needs agents:read.
5028
+ The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
5029
+
5030
+ WRITES (need \`agents:write\`):
5031
+ octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
5032
+ [--enable-tool <toolId>] [--disable-tool <toolId>]
5033
+
5034
+ Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
5035
+ so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
4054
5036
  ids refuses --model with a 403 — the platform default governs there.`,
4055
- orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
4056
- No args = the order list (#number, status/payment, total, contact). With a
4057
- reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
4058
- payment_ref, and the allowed status transitions. Needs orders:read + the
4059
- \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
4060
- so \`pending\` on a gateway-less workspace is expected, not a bug.
4061
-
4062
- WRITES (need \`orders:write\`):
4063
- octwin orders transition <reference_id> --to <status>
4064
- octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
4065
-
4066
- Refund is irreversible and moves money, hence --force. The route answers 200 even
4067
- when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
4068
- on a refusal rather than reporting a refund that never happened. Only a payment in
5037
+ orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
5038
+ No args = the order list (#number, status/payment, total, contact). With a
5039
+ reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
5040
+ payment_ref, and the allowed status transitions. Needs orders:read + the
5041
+ \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
5042
+ so \`pending\` on a gateway-less workspace is expected, not a bug.
5043
+
5044
+ WRITES (need \`orders:write\`):
5045
+ octwin orders transition <reference_id> --to <status>
5046
+ octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
5047
+
5048
+ Refund is irreversible and moves money, hence --force. The route answers 200 even
5049
+ when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
5050
+ on a refusal rather than reporting a refund that never happened. Only a payment in
4069
5051
  \`captured\` state can be refunded; \`payment_status\` is never settable directly.`,
4070
- analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
4071
- No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
4072
- With an entity = stage-by-stage conversion (default --funnel) over the last 30
4073
- days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
5052
+ analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
5053
+ No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
5054
+ With an entity = stage-by-stage conversion (default --funnel) over the last 30
5055
+ days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
4074
5056
  range-filtered). Needs records:read + a \`view\` grant on \`record.<entity>\`.`,
4075
- catalog: `octwin catalog [--readiness] [--json]
4076
- The commerce \`product\` records + price, availability, stock (null = not
4077
- inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
4078
- Graph checklist (LIVE Graph calls; needs a bound access token). Needs
4079
- catalog:read + the \`catalog\` plan feature.
4080
-
4081
- WRITES (need \`catalog:write\`):
4082
- octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
4083
- octwin catalog stock <retailerId> [--set-on-hand <n>]
4084
-
4085
- \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
4086
- inventory-tracked (always sellable), which is different from 0. Lowering on_hand
4087
- below the units already reserved for open carts is refused. Creating/deleting
5057
+ catalog: `octwin catalog [--readiness] [--json]
5058
+ The commerce \`product\` records + price, availability, stock (null = not
5059
+ inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
5060
+ Graph checklist (LIVE Graph calls; needs a bound access token). Needs
5061
+ catalog:read + the \`catalog\` plan feature.
5062
+
5063
+ WRITES (need \`catalog:write\`):
5064
+ octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
5065
+ octwin catalog stock <retailerId> [--set-on-hand <n>]
5066
+
5067
+ \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
5068
+ inventory-tracked (always sellable), which is different from 0. Lowering on_hand
5069
+ below the units already reserved for open carts is refused. Creating/deleting
4088
5070
  products and the Meta catalog binding/sync stay in the console.`,
4089
- scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
4090
- No args = the engine state (bookable resource types, upcoming slots, booked
4091
- seats). --slots <recordId> computes the slots for one bookable resource
4092
- (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
4093
- the availability rules a \`deploy --seed\` created. Needs scheduling:read.
4094
-
4095
- RULES (list needs scheduling:read; add/rm need scheduling:write):
4096
- octwin scheduling rules --resource <resourceRecordId>
4097
- octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
4098
- [--slot-minutes 30] [--capacity 1]
4099
- octwin scheduling rule rm <ruleId>
4100
- octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
4101
- [--start 09:00 --end 13:00]
4102
- octwin scheduling exception rm <exceptionId>
4103
-
4104
- --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
5071
+ scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
5072
+ No args = the engine state (bookable resource types, upcoming slots, booked
5073
+ seats). --slots <recordId> computes the slots for one bookable resource
5074
+ (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
5075
+ the availability rules a \`deploy --seed\` created. Needs scheduling:read.
5076
+
5077
+ RULES (list needs scheduling:read; add/rm need scheduling:write):
5078
+ octwin scheduling rules --resource <resourceRecordId>
5079
+ octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
5080
+ [--slot-minutes 30] [--capacity 1]
5081
+ octwin scheduling rule rm <ruleId>
5082
+ octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
5083
+ [--start 09:00 --end 13:00]
5084
+ octwin scheduling exception rm <exceptionId>
5085
+
5086
+ --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
4105
5087
  \`--slots\` is how you check what a rule actually produces.`,
4106
- 'platform-kb': `octwin platform-kb [pull] [--if-stale] [--check] [--dir .] [--url <url>] [--token <t>]
4107
- Pull the platform capability reference (markdown + JSON catalogs) into
4108
- .octwin/platform-kb/ for the octwin-pack authoring skill, plus three maps:
4109
- INDEX.md (the corpus) · SYMBOLS.md (every name -> its file; grep this) ·
4110
- OUTLINE.md (every heading with its line number).
4111
-
4112
- NO TOKEN NEEDED the reference is platform stdlib and is served anonymously.
4113
- A token is used when you have one (it also works against older platforms).
4114
-
4115
- --if-stale poll the platform's content_hash first and skip the download when
4116
- nothing changed. Cheap enough to run at the start of every session.
4117
- --check report only, write nothing. Exit 0 = current, 2 = stale or never
4118
- pulled, 1 = could not tell (offline / refused). For scripts and
5088
+ automation: `octwin automation [campaigns] [--limit n] [--offset n] [--json]
5089
+ No args = every job the pack's automation declaration produced, with its status,
5090
+ interval and LAST RESULT (matched / acted / errors), under a health line whose
5091
+ counts come from SQL rather than from filtering the page the job list is capped
5092
+ server-side, so a client-side count would depend on the cap. Needs automation:read.
5093
+
5094
+ Jobs are DERIVED from declarations. There is no \`create\`: no automation block in
5095
+ the pack means no jobs, and \`octwin deploy\` is what installs them.
5096
+
5097
+ WRITES (automation:write):
5098
+ octwin automation run <jobId> # run once, now prints matched/acted/errors
5099
+ octwin automation pause|resume <jobId>
5100
+ octwin automation send <campaignId> # enqueue a campaign; enqueued != delivered
5101
+
5102
+ <jobId> is the \`key\` the list shows (its uuid works too). The routes themselves
5103
+ accept only a uuid — the CLI resolves the key for you, and names the keys that do
5104
+ exist when it cannot. A 403 on a write can be an RBAC grant gap rather than a
5105
+ missing scope: the action is re-checked against the job.`,
5106
+ integrations: `octwin integrations [--json]
5107
+ What the pack DECLARES beside what is actually CONFIGURED, in one view — because a
5108
+ connection that is declared and never configured is the commonest reason an
5109
+ integration silently never fires, and neither list alone can show it. Flags the
5110
+ gap explicitly. Needs integrations:read.
5111
+
5112
+ DIAGNOSE ONE CONNECTION:
5113
+ octwin integrations preflight <key> # every check, with a fix hint. Makes NO
5114
+ # outbound call — needs only integrations:read
5115
+ octwin integrations test <key> # a LIVE call to its health: operation
5116
+ # (integrations:write). Exits 1 when it fails.
5117
+
5118
+ THE DELIVERY LOG:
5119
+ octwin integrations deliveries [--status s] [--operation id] [--limit n]
5120
+ octwin integrations deliveries <id> # + the redacted request/response snapshots
5121
+ octwin integrations retry|cancel|send-now <id> # integrations:write
5122
+ octwin integrations events # INBOUND events (what arrived at your webhook)
5123
+
5124
+ retry/cancel answer 409 when the delivery is in the wrong state; the message
5125
+ carries the rule.`,
5126
+ journeys: `octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition]
5127
+ [--stage <stageId>] [--limit n] [--json]
5128
+ No args = the journeys the pack declares. With an id, one of six views —
5129
+ --funnel (default) stage-by-stage reach and drop-off · --overview entered vs
5130
+ converted plus the biggest drop-off · --goals completions, contacts, value and
5131
+ p50 time · --trends per-bucket activity · --cost tokens and dollars per goal ·
5132
+ --definition what was DECLARED, unmeasured (the one view that works with no
5133
+ traffic). Needs journeys:read.
5134
+
5135
+ --stage <stageId> lists the runs sitting at a stage right now (a live snapshot,
5136
+ not the funnel's cumulative reached counts).
5137
+
5138
+ Same flag grammar as \`octwin analytics\` on purpose: a journey funnel and an
5139
+ entity funnel are the same question about different subjects. Journeys carry RBAC
5140
+ on top of the scope, so an empty answer can be a missing \`view\` grant rather
5141
+ than missing data — the output says which causes are possible.`,
5142
+ performance: `octwin performance [--detail] [--json]
5143
+ The project's business indicators — value produced, conversion, duration — each
5144
+ with its delta against the previous window and a \`why\` naming the declaration it
5145
+ came from. --detail adds the per-indicator breakdown.
5146
+
5147
+ Needs records:read, NOT a performance scope (there is none), so a read-only token
5148
+ already reaches it. Indicators are DERIVED: a pack that declares no journey goal
5149
+ value and no pipelined entity produces none, which is a different thing from zero.`,
5150
+ usage: `octwin usage [--json]
5151
+ Model calls, tokens and cost for the resolved scope — project when one is pinned
5152
+ or passed with --project, otherwise the whole workspace. Broken down by model,
5153
+ kind, agent and channel.
5154
+
5155
+ Needs no particular scope: any valid token reaches it.
5156
+
5157
+ This is MODEL spend only. WhatsApp/Meta message billing is operator-only and
5158
+ deliberately outside the token scope registry — no API token can read it.`,
5159
+ 'platform-kb': `octwin platform-kb [pull] [--if-stale] [--check] [--dir .] [--url <url>] [--token <t>]
5160
+ Pull the platform capability reference (markdown + JSON catalogs) into
5161
+ .octwin/platform-kb/ for the octwin-pack authoring skill, plus three maps:
5162
+ INDEX.md (the corpus) · SYMBOLS.md (every name -> its file; grep this) ·
5163
+ OUTLINE.md (every heading with its line number).
5164
+
5165
+ NO TOKEN NEEDED — the reference is platform stdlib and is served anonymously.
5166
+ A token is used when you have one (it also works against older platforms).
5167
+
5168
+ --if-stale poll the platform's content_hash first and skip the download when
5169
+ nothing changed. Cheap enough to run at the start of every session.
5170
+ --check report only, write nothing. Exit 0 = current, 2 = stale or never
5171
+ pulled, 1 = could not tell (offline / refused). For scripts and
4119
5172
  agent loops that want to branch without parsing prose.`,
4120
- test: `octwin test [--dir .]
5173
+ test: `octwin test [--dir .]
4121
5174
  Alias for \`octwin validate --remote\` — the full platform check.`,
4122
- memos: `octwin memos [--all] [--json]
4123
- Read what the platform has told you: a REPLY to a report you sent with
4124
- \`octwin feedback\`, or a NOTICE published to every author (a new capability,
4125
- a deprecation, a breaking change). Bodies are printed in full.
4126
- Reading marks them read, so the reminder stops. --all re-reads history and
4127
- acks nothing. --json to branch on \`severity\`
5175
+ memos: `octwin memos [--all] [--json]
5176
+ Read what the platform has told you: a REPLY to a report you sent with
5177
+ \`octwin feedback\`, or a NOTICE published to every author (a new capability,
5178
+ a deprecation, a breaking change). Bodies are printed in full.
5179
+ Reading marks them read, so the reminder stops. --all re-reads history and
5180
+ acks nothing. --json to branch on \`severity\`
4128
5181
  (info | action_required | breaking).`,
4129
- feedback: `octwin feedback [--dir .]
4130
- Submit this pack's FEEDBACK.md to the platform team.
4131
- The octwin-pack skill writes that file in its last step — findings grouped by
4132
- owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
4133
- you to paste it into a chat.
4134
- Attaches the pack id + version from manifest.yaml, this CLI's version, and the
4135
- content_hash of the capability reference in .octwin/platform-kb/ — triage needs
4136
- the last two to tell "the platform is wrong" from "that was already fixed" or
5182
+ feedback: `octwin feedback [--dir .]
5183
+ Submit this pack's FEEDBACK.md to the platform team.
5184
+ The octwin-pack skill writes that file in its last step — findings grouped by
5185
+ owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
5186
+ you to paste it into a chat.
5187
+ Attaches the pack id + version from manifest.yaml, this CLI's version, and the
5188
+ content_hash of the capability reference in .octwin/platform-kb/ — triage needs
5189
+ the last two to tell "the platform is wrong" from "that was already fixed" or
4137
5190
  "you were reading a stale reference". Needs the \`pack:deploy\` scope.`,
4138
5191
  };
4139
5192
  async function main() {
@@ -4152,6 +5205,23 @@ async function main() {
4152
5205
  console.log(COMMAND_HELP[command]);
4153
5206
  return;
4154
5207
  }
5208
+ // Compute the trailing nudges BEFORE the command runs, so they survive a `die()` — see the
5209
+ // notice-channel block above. They are PRINTED after the command, by `flushNotices` here on the
5210
+ // success path and by `exitNow` on every failure path. Fail-silent by construction (each helper
5211
+ // swallows and returns []), and the KB + memo polls still ride only on commands that were
5212
+ // already going to hit the network.
5213
+ // SEQUENTIAL, not `Promise.all`. Two concurrent polls to the same host leave a socket checked
5214
+ // out of undici's pool, and `exitNow`'s `process.exit` then aborts on Windows with
5215
+ // `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c` — replacing the real
5216
+ // exit code with 127, so a failing `octwin deploy` in CI reported the wrong thing. Measured
5217
+ // 2026-08-26: one pre-command poll exits 1 cleanly, two concurrent ones abort. Awaiting them in
5218
+ // turn costs one extra round-trip on an already-networked command and keeps the exit code true.
5219
+ const networked = commandTouchesPlatform(command, flags);
5220
+ if (networked) {
5221
+ PENDING_NOTICES.push(...await kbStaleNotice(flags));
5222
+ PENDING_NOTICES.push(...await memosWaitingNotice(flags));
5223
+ }
5224
+ PENDING_NOTICES.push(...await outdatedNotice());
4155
5225
  switch (command) {
4156
5226
  case 'init':
4157
5227
  cmdInit(flags);
@@ -4210,6 +5280,21 @@ async function main() {
4210
5280
  case 'scheduling':
4211
5281
  await cmdScheduling(flags);
4212
5282
  break;
5283
+ case 'automation':
5284
+ await cmdAutomation(flags);
5285
+ break;
5286
+ case 'integrations':
5287
+ await cmdIntegrations(flags);
5288
+ break;
5289
+ case 'journeys':
5290
+ await cmdJourneys(flags);
5291
+ break;
5292
+ case 'performance':
5293
+ await cmdPerformance(flags);
5294
+ break;
5295
+ case 'usage':
5296
+ await cmdUsage(flags);
5297
+ break;
4213
5298
  case 'platform-kb':
4214
5299
  await cmdPlatformKb(flags);
4215
5300
  break;
@@ -4235,13 +5320,18 @@ async function main() {
4235
5320
  break;
4236
5321
  default: die(`unknown command '${command}' — run \`octwin help\``);
4237
5322
  }
4238
- // Trailing, fail-silent, TTY-only notices. KB-drift only after commands that
4239
- // already hit the platform (so it's one extra tiny GET, never a new call on
4240
- // offline paths); CLI-upgrade always.
4241
- if (commandTouchesPlatform(command, flags))
4242
- await notifyIfKbStale(flags);
4243
- if (commandTouchesPlatform(command, flags))
4244
- await notifyIfMemosWaiting(flags);
4245
- await notifyIfOutdated();
5323
+ flushNotices();
4246
5324
  }
4247
- main().catch((err) => die(err?.message ?? String(err)));
5325
+ main().catch((err) => {
5326
+ // `CliExit` is our own controlled stop — the message is already printed and the notices already
5327
+ // flushed. Setting `exitCode` rather than calling `process.exit` is the whole point: the process
5328
+ // ends when the event loop drains, which is the only teardown that does not abort on Windows
5329
+ // after the notice polls have opened sockets. See `exitNow`.
5330
+ if (err instanceof CliExit) {
5331
+ process.exitCode = err.code;
5332
+ return;
5333
+ }
5334
+ console.error(`✗ ${err?.message ?? String(err)}`);
5335
+ flushNotices();
5336
+ process.exitCode = 1;
5337
+ });