octwin-cli 0.7.1 → 0.7.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,69 @@ Format: [Keep a Changelog](https://keepachangelog.com/) — newest first, bucket
5
5
  **Added · Changed · Deprecated · Removed · Fixed · Security**. The platform-wide view lives in the
6
6
  repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut that ships with the package.
7
7
 
8
+ ## [0.7.3] - 2026-08-26
9
+
10
+ ### Changed
11
+ - **A marketplace listing request is now OPT-IN: `octwin deploy --request-listing`.** A plain
12
+ `octwin deploy` says nothing about the public marketplace — it neither asks for a listing nor
13
+ gives one up. `--withdraw-listing` retracts, including an approved listing.
14
+ **Why:** the request was read from the manifest's `listing.public` on every deploy, and deploying
15
+ is how you *test*. 17 of the 23 published packs carry that key, so an edit-deploy-chat loop filed
16
+ a review request every time round. Worse, the *absence* of the key meant **retract** — so an
17
+ ordinary deploy of a pack whose manifest you had not yet annotated could wipe a listing you
18
+ already had. `--request-listing` still requires `listing.public: true` in the manifest (the
19
+ manifest states that the pack is a product; the flag is you choosing to ask), and the flag
20
+ without the key is a clean error rather than a silent no-op.
21
+ - **The listing verdict distinguishes "you submitted this" from "your approval expired".** They are
22
+ both `pending` and they mean opposite things to an author. A deploy that changes the content of an
23
+ approved pack now says so in those words — an approval covers only the content it was made
24
+ against, so it cannot survive an edit.
25
+
26
+ ### Added
27
+ - **A one-line nudge when your manifest asks and your deploy did not.** A pack carrying
28
+ `listing.public: true` that has never been submitted now prints the flag to use, so an opt-in
29
+ default cannot turn into waiting for a review nobody requested.
30
+
31
+ ### Fixed
32
+ - **The "newer CLI" and "KB drifted" notices now print when a command FAILS.** All three nudges
33
+ (CLI upgrade · KB drift · unread memos) were awaited at the very end of `main()`, which silently
34
+ meant *only on success* — `die()` is a hard exit, so a failed deploy, an auth error or a bad
35
+ manifest printed none of them. That is backwards: a stale capability reference is a leading cause
36
+ of the failure an author is staring at, so the run that most needed the nudge was the only one
37
+ that never got it. They are now computed before the command and printed however it ends.
38
+ - **The CLI-upgrade notice is no longer hidden from piped output.** It began with
39
+ `if (!process.stdout.isTTY) return`, so every agent-driven and CI run — the readers that cannot
40
+ notice an outdated CLI on their own — was the one audience never told. Its two siblings had
41
+ already dropped that gate for exactly this reason; now all three agree. Still skipped under
42
+ `npx`, where there is nothing to upgrade.
43
+ - **`octwin login` now reports KB drift too.** It is networked and usually the first command of a
44
+ session, i.e. the best possible moment to say the reference moved, and it was excluded.
45
+ - **A memo poll can no longer fail the command it was annotating.** It went through `apiGet` →
46
+ `fetchOrDie`, whose job is to `die` on a network failure — so an unreachable platform turned an
47
+ observer into a command failure. It now uses a plain, timed, fail-silent fetch.
48
+ - **A failing command reports its real exit code again on Windows.** Moving the polls before the
49
+ command made `process.exit()` abort with `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`
50
+ and return **127** instead of 1 — so CI saw neither success nor the documented failure code. The
51
+ CLI now unwinds through a `CliExit` and lets the event loop drain, which is the only teardown
52
+ that is clean once the polls have opened sockets. `--check`'s exit 2 and the streaming commands
53
+ were re-verified.
54
+
55
+ ## [0.7.2] - 2026-08-20
56
+
57
+ ### Fixed
58
+ - **`octwin feedback` now tells you what is already waiting, on stdout.** It discarded the submit
59
+ response entirely — destructuring `{ status, json }`, checking the status and never reading the
60
+ body — then printed *"a reply arrives as a memo, this CLI will tell you when one is waiting"*. The
61
+ platform started returning the unread counts on that response (2026-08-20), so the answer was
62
+ there to print and was being thrown away.
63
+ **Why it matters more than it sounds:** `notifyIfMemosWaiting` already covers this command and was
64
+ firing correctly — but it writes to **stderr**, and the authors driving this CLI are agents reading
65
+ stdout. The one signal that mattered was addressed to a channel nobody was listening on. On
66
+ 2026-08-19 an author submitted two reports two hours after three replies were published and
67
+ re-reported five findings those replies had already closed. Now the count prints in the same block
68
+ as the acknowledgement, naming the command, and says outright when a reply may already answer what
69
+ you just sent. Against an older platform the field is absent and the previous line stands.
70
+
8
71
  ## [0.7.1] - 2026-08-19
9
72
 
10
73
  ### Fixed
package/README.md CHANGED
@@ -67,7 +67,7 @@ octwin status # "✓ live and current" once it's warm
67
67
  | `octwin validate` | Check the pack locally (structure + pure-YAML rules). `--remote` runs **the same validation the platform runs to load your pack** — every declaration file (`xrm.yaml`, `scheduling.yaml`, `worklist.yaml`, `roles.yaml`, `automation.yaml`, `integrations.yaml`, `surveys.yaml`, `taps.yaml`, `commands.yaml`, `journeys/`, `messages.<lang>.yaml`), the cross-file checks between them, and the **flow lint** — conventions the schema can't express, like an `assign:` whose value is a quoted literal or a `$t()` key with no namespace. These need template expansion and the platform's own schemas, which only the server has, so a local ✓ does not cover them. A `--remote` ✓ and a successful deploy are now the same answer by construction, not by convention. |
68
68
  | `octwin login` | Save a deploy token for a platform URL **and make that URL the default target** (both stored in `~/.octwin/credentials.json`). `--url`, `--token`. |
69
69
  | `octwin whoami` | Verify the saved/passed token is valid for a tenant. `--url`, `--tenant`. |
70
- | `octwin deploy` | Upload + install the pack onto your tenant's project. `--seed` also runs the pack's demo seed. Reports the **marketplace-listing verdict** when the manifest carries `listing.public: true`. |
70
+ | `octwin deploy` | Upload + install the pack onto your tenant's project. `--seed` also runs the pack's demo seed. Says **nothing** about the public marketplace unless asked: `--request-listing` submits the pack for operator review (needs `listing.public: true` in the manifest too), `--withdraw-listing` retracts. Reports the **listing verdict** whenever one exists. |
71
71
  | `octwin status` | Report what the platform has live for this pack — installed version, the **content sha** the instance loaded vs. the one the catalog holds (a redeploy of the *same* version changes it), its flows, and whether it is live on the public marketplace. |
72
72
  | `octwin pull <packId>` | Write a **deployed** pack's source back to disk — the inverse of `deploy`, and how a pack pushed from one machine is recovered. Defaults to the version installed on the target project; `--version` overrides, `--dir` defaults to `./<packId>`, a non-empty dir needs `--force`. You may pull a pack your tenant **owns**. |
73
73
  | `octwin chat "msg"` | Drive a turn through the dev web channel and print **every render with its tap ids**. `--as <handle>` picks the test user; `--tap "<tap-id>"` presses a rendered button/list row; `--json` dumps the raw envelopes. |
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)
@@ -52,7 +53,7 @@
52
53
  * operator's GitHub repo import applies. The platform re-validates it
53
54
  * (pure-YAML enforcement + manifest/flow Zod) and installs it onto the project.
54
55
  */
55
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, rmSync } from 'node:fs';
56
+ import { readFileSync, writeFileSync, writeSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, rmSync } from 'node:fs';
56
57
  import { join, resolve, dirname, basename } from 'node:path';
57
58
  import { homedir } from 'node:os';
58
59
  import { fileURLToPath } from 'node:url';
@@ -155,7 +156,7 @@ function parseFlags(argv) {
155
156
  }
156
157
  function die(msg) {
157
158
  console.error(`✗ ${msg}`);
158
- process.exit(1);
159
+ exitNow(1);
159
160
  }
160
161
  // ── network helpers ─────────────────────────────────────────────────────────
161
162
  /** `fetch` that dies with the TARGET URL on a network failure — a bare
@@ -460,8 +461,10 @@ async function latestPublishedVersion() {
460
461
  const timer = setTimeout(() => ctrl.abort(), 1500);
461
462
  const res = await fetch('https://registry.npmjs.org/octwin-cli/latest', { signal: ctrl.signal });
462
463
  clearTimeout(timer);
463
- if (!res.ok)
464
+ if (!res.ok) {
465
+ await res.arrayBuffer().catch(() => undefined);
464
466
  return null;
467
+ } // drain — see `fetchKbMeta`
465
468
  const latest = (await res.json()).version;
466
469
  if (typeof latest !== 'string')
467
470
  return null;
@@ -488,19 +491,30 @@ function isNpx() {
488
491
  return false;
489
492
  }
490
493
  }
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;
494
+ /** A one-line upgrade notice when a newer octwin-cli is published, or [] when current.
495
+ *
496
+ * Skipped only under npx, where there is genuinely nothing to upgrade. It USED to bail on
497
+ * `!process.stdout.isTTY` as well, which silently made this the one notice a piped reader never
498
+ * saw — the opposite of the rule its two siblings already carry in their docblocks, and measured
499
+ * 2026-08-26: with a newer version cached, `octwin whoami | tail` printed the KB nudge and not
500
+ * this one. An agent driving the CLI cannot notice an outdated CLI on its own, so it is exactly
501
+ * the reader that needs telling. One line on stderr, so piped stdout stays clean either way.
502
+ *
503
+ * Never throws — a version check must never break a command. */
504
+ async function outdatedNotice() {
505
+ if (isNpx())
506
+ return [];
496
507
  try {
497
508
  const latest = await latestPublishedVersion();
498
509
  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)');
510
+ return [
511
+ `\n⬆ octwin-cli ${latest} is available (you have ${VERSION}).`,
512
+ ' Upgrade: npm i -g octwin-cli@latest (or just use npx octwin-cli@latest)',
513
+ ];
501
514
  }
502
515
  }
503
516
  catch { /* a version check must never break the CLI */ }
517
+ return [];
504
518
  }
505
519
  // ── platform-KB drift check (observe the pulled reference, fail-silent) ──
506
520
  //
@@ -550,20 +564,20 @@ function diffKbIndex(prev, next) {
550
564
  */
551
565
  async function fetchKbMeta(t, timeoutMs = 2_000) {
552
566
  const ep = kbEndpoint(t);
553
- const ctrl = new AbortController();
554
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
555
567
  try {
556
- const res = await fetch(`${ep.url}?meta=1`, { headers: ep.headers, signal: ctrl.signal });
557
- if (!res.ok)
568
+ const res = await fetch(`${ep.url}?meta=1`, { headers: ep.headers, signal: AbortSignal.timeout(timeoutMs) });
569
+ if (!res.ok) {
570
+ // Drain the body even though we do not want it. An unread `Response` body keeps its undici
571
+ // socket checked out of the pool, and this poll runs on the way to a possible `exitNow` —
572
+ // a held socket at exit is a pending libuv handle.
573
+ await res.arrayBuffer().catch(() => undefined);
558
574
  return { ok: false, notAuthorized: res.status === 401 || res.status === 403 };
575
+ }
559
576
  return { ok: true, meta: await res.json() };
560
577
  }
561
578
  catch {
562
579
  return { ok: false, notAuthorized: false };
563
580
  }
564
- finally {
565
- clearTimeout(timer);
566
- }
567
581
  }
568
582
  /** Nudge (to stderr) when the platform's capability KB has changed since the last
569
583
  * `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
@@ -577,15 +591,15 @@ async function fetchKbMeta(t, timeoutMs = 2_000) {
577
591
  * authoring agent whose output is piped, and gating on `isTTY` meant the one
578
592
  * reader that CANNOT notice a stale reference on its own was the only one never
579
593
  * told. It is a single line on stderr, so piped stdout stays clean either way. */
580
- async function notifyIfKbStale(flags) {
594
+ async function kbStaleNotice(flags) {
581
595
  try {
582
596
  const packDir = resolve(flags.dir ?? '.');
583
597
  const local = readLocalKb(packDir);
584
598
  if (!local?.content_hash)
585
- return; // never pulled → the skill already says to pull
599
+ return []; // never pulled → the skill already says to pull
586
600
  const t = resolveTargetOrNull(flags);
587
601
  if (!t)
588
- return;
602
+ return [];
589
603
  const polled = await fetchKbMeta(t);
590
604
  if (!polled.ok) {
591
605
  // The tenant-scoped meta poll needs `pack:deploy`, but this nudge rides on every
@@ -594,10 +608,12 @@ async function notifyIfKbStale(flags) {
594
608
  // makes an author invent a primitive from memory. Say so once; stay silent for every
595
609
  // other failure (offline, timeout, a platform without the route).
596
610
  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_…)');
611
+ return [
612
+ '\nⓘ can\'t check whether the platform capability reference drifted that token lacks `pack:deploy`.',
613
+ ' Check it without a token: octwin platform-kb --check (or refresh: octwin platform-kb --token oct_…)',
614
+ ];
599
615
  }
600
- return;
616
+ return [];
601
617
  }
602
618
  const meta = polled.meta;
603
619
  if (meta.content_hash && meta.content_hash !== local.content_hash) {
@@ -614,11 +630,14 @@ async function notifyIfKbStale(flags) {
614
630
  if (parts.length)
615
631
  summary = ` (${parts.join(' · ')})`;
616
632
  }
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');
633
+ return [
634
+ `\n⬆ the platform capability reference changed since you last pulled it${summary}.`,
635
+ ' Refresh it: octwin platform-kb --if-stale',
636
+ ];
619
637
  }
620
638
  }
621
639
  catch { /* a KB check must never break the CLI */ }
640
+ return [];
622
641
  }
623
642
  /**
624
643
  * Nudge (to stderr) when the platform has memos this workspace has not read — a reply to
@@ -642,33 +661,107 @@ async function notifyIfKbStale(flags) {
642
661
  * platform, so an agent on a fresh machine or in a fresh container still learns about an
643
662
  * unread memo. That is the whole reason it is not a local marker file.
644
663
  */
645
- async function notifyIfMemosWaiting(flags) {
664
+ async function memosWaitingNotice(flags) {
646
665
  try {
647
666
  const t = resolveTargetOrNull(flags);
648
667
  if (!t)
649
- return;
650
- const { status, json } = await apiGet(`${t.url}/api/self/p/memos?meta=1`, t);
668
+ return [];
669
+ // A PLAIN fetch, not `apiGet` which goes through `fetchOrDie`, whose whole job is to `die`
670
+ // on a network failure. An observer that can terminate the CLI is not an observer: with an
671
+ // unreachable platform this nudge would have failed the command it was only meant to annotate,
672
+ // and now that the polls run BEFORE the command it would have failed it before it even ran.
673
+ const res = await fetch(`${t.url}/api/self/p/memos?meta=1`, {
674
+ headers: authHeaders(t), signal: AbortSignal.timeout(2_000),
675
+ });
676
+ const status = res.status;
677
+ const json = await res.json().catch(() => null);
651
678
  // Silent on ANY failure — an older platform has no such route, and a nudge is never
652
679
  // worth a diagnostic of its own. Unlike the KB poll there is no scope to explain: the
653
680
  // route is gated on tenant access precisely so every token can answer it.
654
681
  if (status !== 200 || !json || typeof json !== 'object')
655
- return;
682
+ return [];
656
683
  const unread = Number(json.unread ?? 0);
657
684
  if (!Number.isFinite(unread) || unread <= 0)
658
- return;
685
+ return [];
659
686
  const actionable = Number(json.unread_actionable ?? 0);
660
687
  const what = unread === 1 ? '1 memo' : `${unread} memos`;
661
688
  const tail = actionable > 0 ? ` (${actionable} needing action)` : '';
662
- console.error(`\n✉ ${what} from the platform${tail} — read them: octwin memos`);
689
+ const lines = [`\n✉ ${what} from the platform${tail} — read them: octwin memos`];
663
690
  if (actionable > 0) {
664
691
  // Said separately, because the whole point of the severity axis is that an agent
665
692
  // 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.');
693
+ lines.push(' One or more may change what you are building — read before continuing.');
667
694
  }
695
+ return lines;
668
696
  }
669
697
  catch { /* a memo check must never break the CLI */ }
698
+ return [];
670
699
  }
671
- /** Which commands already made a platform call, so the trailing KB-drift poll
700
+ // ── the notice channel: computed BEFORE the command, printed however it ends ──
701
+ //
702
+ // These three nudges used to be awaited at the very END of `main()`, and that placement quietly
703
+ // meant "only when the command SUCCEEDS": `die()` is `process.exit(1)`, so a failed deploy, an
704
+ // auth error or a bad manifest skipped all three. Measured 2026-08-26 — a failing command printed
705
+ // nothing while the same command succeeding printed the KB nudge. That is backwards. A stale
706
+ // capability reference is a LEADING CAUSE of the failure an author is staring at, so the one run
707
+ // that most needs the nudge was the only one that never got it.
708
+ //
709
+ // So: compute the lines up front (async, needs the network), then print them through a SYNCHRONOUS
710
+ // flush that also runs from `process.on('exit')`. The strings already exist by then, so the exit
711
+ // hook has nothing to await — which is what makes `die()`'s hard exit safe. `die` itself is left
712
+ // alone deliberately: making it throw instead would have to survive 30 broad `catch` blocks in
713
+ // this file, any one of which would turn a hard failure into a silent continue.
714
+ let PENDING_NOTICES = [];
715
+ let NOTICES_FLUSHED = false;
716
+ /** Print the computed notices exactly once. */
717
+ function flushNotices() {
718
+ if (NOTICES_FLUSHED)
719
+ return;
720
+ NOTICES_FLUSHED = true;
721
+ if (PENDING_NOTICES.length === 0)
722
+ return;
723
+ try {
724
+ writeSync(2, `${PENDING_NOTICES.join('\n')}\n`);
725
+ }
726
+ catch { /* a nudge is never worth a crash */ }
727
+ }
728
+ /** Thrown by `exitNow` to unwind to `main` instead of calling `process.exit`. */
729
+ class CliExit extends Error {
730
+ code;
731
+ constructor(code) {
732
+ super('cli-exit');
733
+ this.code = code;
734
+ }
735
+ }
736
+ /**
737
+ * The ONE way this CLI ends non-zero: flush the notices, then unwind.
738
+ *
739
+ * ## Why this throws instead of calling `process.exit`
740
+ *
741
+ * Because `process.exit()` is not safe here, measured 2026-08-26 on Windows. Once the notice polls
742
+ * moved to BEFORE the command (so a failing run still gets its nudge), a hard exit began aborting
743
+ * with `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\\win\\async.c` — and, far worse
744
+ * than the noise, it replaced the real exit code with **127**, so a failing `octwin deploy` in CI
745
+ * reported neither 0 nor 1. Ruled out one at a time: it is not the notice I/O (a no-fetch failure
746
+ * exits 1 cleanly), not an `exit` handler (removing it changed nothing), not an unread response
747
+ * body, not the `AbortController`, and not concurrency (serialising them changed nothing). It
748
+ * reproduces on **two** pre-command fetches and not on one, which points at undici's pooled
749
+ * sockets — not something this CLI can reach in to close.
750
+ *
751
+ * A NATURAL exit has none of these problems: the same two fetches on the success path exit 0
752
+ * cleanly, because Node tears the pool down itself when the event loop drains. So `main` catches
753
+ * `CliExit`, sets `process.exitCode`, and returns.
754
+ *
755
+ * Safe to throw rather than exit because every `catch` in this file was audited for it: all but a
756
+ * handful guard a single `JSON.parse` / `readFileSync`, and in the three with real bodies (plus
757
+ * `fetchOrDie` and `--fields-json`) the `die()` sits in the **catch handler**, never inside the
758
+ * `try` — so nothing swallows this.
759
+ */
760
+ function exitNow(code) {
761
+ flushNotices();
762
+ throw new CliExit(code);
763
+ }
764
+ /** Which commands already made a platform call, so the KB-drift poll
672
765
  * rides on existing network work (never on offline `validate` / `init`;
673
766
  * `platform-kb` refreshes the reference itself, so it needs no nudge). */
674
767
  function commandTouchesPlatform(command, flags) {
@@ -683,6 +776,11 @@ function commandTouchesPlatform(command, flags) {
683
776
  // `memos` is networked but needs NO memo nudge — it just read them. It still gets the
684
777
  // KB drift check, which is a different question.
685
778
  case 'memos': return true;
779
+ // `login` verifies the token over the network and is usually the FIRST command of a session —
780
+ // the best possible moment to say the reference drifted. It reads the target from the FLAGS
781
+ // (`--url`/`--token`), which is what the notices resolve too, so it does not depend on the
782
+ // credentials this command is about to save.
783
+ case 'login':
686
784
  case 'records':
687
785
  case 'work':
688
786
  case 'logs':
@@ -991,7 +1089,7 @@ async function cmdValidate(flags) {
991
1089
  console.error(`✗ remote validate failed (HTTP ${res.status})`);
992
1090
  printAuthHint(res.status, url);
993
1091
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
994
- process.exit(1);
1092
+ exitNow(1);
995
1093
  }
996
1094
  const warnings = (json?.warnings ?? []);
997
1095
  if (warnings.length) {
@@ -1016,7 +1114,7 @@ async function cmdValidate(flags) {
1016
1114
  for (const m of msgs)
1017
1115
  console.error(` • ${m}`);
1018
1116
  }
1019
- process.exit(1);
1117
+ exitNow(1);
1020
1118
  }
1021
1119
  async function cmdLogin(flags) {
1022
1120
  const rawUrl = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
@@ -1157,7 +1255,7 @@ async function cmdPull(flags) {
1157
1255
  console.error(' → a pack is pullable by the tenant that OWNS it (deployed it), or by an operator.');
1158
1256
  }
1159
1257
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
1160
- process.exit(1);
1258
+ exitNow(1);
1161
1259
  }
1162
1260
  const files = json.files ?? {};
1163
1261
  const blobs = json.blobs ?? {};
@@ -1230,23 +1328,68 @@ async function readDeployProgress(body) {
1230
1328
  }
1231
1329
  return { terminal, stepErrors };
1232
1330
  }
1331
+ /** What a `--request-listing` / `--withdraw-listing` deploy says it is doing, for the header line. */
1332
+ const LISTING_LABEL = {
1333
+ submit: 'requesting a public marketplace listing',
1334
+ withdraw: 'withdrawing the public marketplace listing',
1335
+ };
1336
+ /**
1337
+ * Which marketplace act this deploy is, from the two mutually-exclusive flags.
1338
+ *
1339
+ * Returns undefined for the common case — an ordinary deploy asks for nothing. Both flags at once
1340
+ * is a `die` rather than a precedence rule: the two are opposites, and guessing which one the
1341
+ * author meant is exactly the silent-wrong-thing this opt-in default exists to prevent.
1342
+ */
1343
+ function resolveListingFlags(flags) {
1344
+ const submit = flags['request-listing'] === true;
1345
+ const withdraw = flags['withdraw-listing'] === true;
1346
+ if (submit && withdraw) {
1347
+ die('--request-listing and --withdraw-listing are opposites — pass one, not both');
1348
+ }
1349
+ return submit ? 'submit' : withdraw ? 'withdraw' : undefined;
1350
+ }
1351
+ /**
1352
+ * Nudge the author whose manifest asks to be public but whose deploy did not submit it.
1353
+ *
1354
+ * The listing request is OPT-IN (`--request-listing`), because the CLI is overwhelmingly an
1355
+ * edit-deploy-chat test loop and reading the manifest alone filed a review request on every
1356
+ * single deploy. But a silent default has its own failure: an author who wrote
1357
+ * `listing.public: true` months ago and waits for a review that was never requested. One line,
1358
+ * only when the manifest actually declares it, so a pack that never asked stays silent.
1359
+ */
1360
+ function printListingNotAsked(packDir) {
1361
+ let declared = false;
1362
+ try {
1363
+ const raw = readFileSync(join(packDir, 'manifest.yaml'), 'utf8');
1364
+ declared = parseYaml(raw)?.listing?.public === true;
1365
+ }
1366
+ catch {
1367
+ return;
1368
+ } // unreadable/unparseable is the deploy's to report
1369
+ if (!declared)
1370
+ return;
1371
+ console.log(' ⓘ your manifest declares listing.public — this deploy did NOT submit it for review.');
1372
+ console.log(' Ask for a public marketplace listing with: octwin deploy --request-listing');
1373
+ }
1233
1374
  /**
1234
1375
  * The anonymous-marketplace verdict, for `deploy` and `status` alike.
1235
1376
  *
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.
1377
+ * The state lived only in the console so an author working from the CLI got no acknowledgement
1378
+ * that a request had registered, and no sight of a rejection note (which the platform REQUIRES
1379
+ * precisely because it is their only feedback). Silent for `none`/absent, so a pack that never
1380
+ * asked prints nothing.
1241
1381
  *
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.
1382
+ * `justAsked` separates the two ways a pack lands in the queue, which read as the same state and
1383
+ * mean opposite things to the author: they submitted it, or a content edit invalidated an approval
1384
+ * they already had (an approval pins the sha it reviewed, so it cannot survive a content change).
1244
1385
  */
1245
- function printPublicListing(state, note, live) {
1386
+ function printPublicListing(state, note, live, justAsked = false) {
1246
1387
  const n = typeof note === 'string' && note ? ` — operator note: ${note}` : '';
1247
1388
  switch (state) {
1248
1389
  case 'pending':
1249
- console.log(' ⓘ marketplace listing: PENDING operator review (any content edit re-queues it — the approval pins a content sha).');
1390
+ console.log(justAsked
1391
+ ? ' ⓘ marketplace listing: SUBMITTED — now pending operator review.'
1392
+ : ' ⓘ marketplace listing: back to PENDING review — this deploy changed the content, and an approval only covers the content it was made against.');
1250
1393
  break;
1251
1394
  case 'approved':
1252
1395
  console.log(live === false
@@ -1260,7 +1403,7 @@ function printPublicListing(state, note, live) {
1260
1403
  break;
1261
1404
  }
1262
1405
  }
1263
- function printDeploySuccess(id, version, t, r) {
1406
+ function printDeploySuccess(id, version, t, r, listing) {
1264
1407
  console.log(`✓ Deployed ${id}@${version} and installed onto ${targetLabel(t)}`);
1265
1408
  if (r?.warning)
1266
1409
  console.log(` ⚠ ${r.warning}`);
@@ -1285,7 +1428,12 @@ function printDeploySuccess(id, version, t, r) {
1285
1428
  if (dropped > 0) {
1286
1429
  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
1430
  }
1288
- printPublicListing(r?.public_listing, r?.public_review_note);
1431
+ printPublicListing(r?.public_listing, r?.public_review_note, undefined, listing?.intent === 'submit');
1432
+ // Only when the deploy asked for nothing AND the row says nothing: a pack sitting at `none`
1433
+ // with `listing.public` in its manifest is an author waiting for a review nobody requested.
1434
+ if (listing && !listing.intent && (r?.public_listing == null || r.public_listing === 'none')) {
1435
+ printListingNotAsked(listing.packDir);
1436
+ }
1289
1437
  console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
1290
1438
  }
1291
1439
  /**
@@ -1326,7 +1474,7 @@ async function cmdSeed(flags) {
1326
1474
  ⚠ ${stepErrors.length} step${stepErrors.length === 1 ? '' : 's'} failed — data may be incomplete:`);
1327
1475
  for (const e of stepErrors)
1328
1476
  console.error(` • ${e}`);
1329
- process.exit(1);
1477
+ exitNow(1);
1330
1478
  }
1331
1479
  return;
1332
1480
  }
@@ -1341,7 +1489,7 @@ async function cmdSeed(flags) {
1341
1489
  if (!res.ok) {
1342
1490
  console.error(`✗ seed failed (HTTP ${res.status})${errDetail(json)}`);
1343
1491
  printAuthHint(res.status, url);
1344
- process.exit(1);
1492
+ exitNow(1);
1345
1493
  }
1346
1494
  console.log('✓ seed complete');
1347
1495
  printSeedCounts(json?.seeded);
@@ -1360,13 +1508,17 @@ async function cmdDeploy(flags) {
1360
1508
  const { id, version, files, blobs } = localValidate(packDir);
1361
1509
  const endpoint = `${url}/api/self/p/packs/deploy`;
1362
1510
  const seed = flags.seed === true;
1363
- console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${seed ? ' — with demo seed' : ''} …`);
1511
+ const listing = resolveListingFlags(flags);
1512
+ const extras = [seed ? 'with demo seed' : null, listing ? LISTING_LABEL[listing] : null].filter(Boolean);
1513
+ console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${extras.length ? ` — ${extras.join(', ')}` : ''} …`);
1364
1514
  const res = await fetchOrDie(endpoint, {
1365
1515
  method: 'POST',
1366
1516
  // Ask for a progress stream; the platform falls back to plain JSON if it
1367
1517
  // (or an error before any progress) can't stream — handled below.
1368
1518
  headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
1369
- body: JSON.stringify({ files, blobs, seed }),
1519
+ // `listing` is omitted entirely unless asked for: an ordinary deploy says NOTHING about the
1520
+ // marketplace, which is what keeps a test loop out of the operator review queue.
1521
+ body: JSON.stringify({ files, blobs, seed, ...(listing ? { listing } : {}) }),
1370
1522
  }, 'deploy');
1371
1523
  // Streaming path — live install + seed progress (image generation can take a
1372
1524
  // while, so `--seed` prints per-record / per-image lines as they happen).
@@ -1374,7 +1526,7 @@ async function cmdDeploy(flags) {
1374
1526
  const { terminal: final, stepErrors } = await readDeployProgress(res.body);
1375
1527
  if (!final || final.stage === 'error')
1376
1528
  die(`deploy failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
1377
- printDeploySuccess(id, version, t, final);
1529
+ printDeploySuccess(id, version, t, final, { intent: listing, packDir });
1378
1530
  if (stepErrors.length) {
1379
1531
  // The pack IS installed, but a step (e.g. the demo seed) failed — say so
1380
1532
  // plainly and exit non-zero so CI / a `deploy && chat` chain doesn't treat
@@ -1382,7 +1534,7 @@ async function cmdDeploy(flags) {
1382
1534
  console.error(`\n⚠ Deployed with ${stepErrors.length} warning${stepErrors.length === 1 ? '' : 's'} — data may be incomplete:`);
1383
1535
  for (const e of stepErrors)
1384
1536
  console.error(` • ${e}`);
1385
- process.exit(1);
1537
+ exitNow(1);
1386
1538
  }
1387
1539
  return;
1388
1540
  }
@@ -1400,9 +1552,9 @@ async function cmdDeploy(flags) {
1400
1552
  console.error(`✗ deploy failed (HTTP ${res.status})`);
1401
1553
  printAuthHint(res.status, url);
1402
1554
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
1403
- process.exit(1);
1555
+ exitNow(1);
1404
1556
  }
1405
- printDeploySuccess(id, version, t, json);
1557
+ printDeploySuccess(id, version, t, json, { intent: listing, packDir });
1406
1558
  }
1407
1559
  /**
1408
1560
  * The QUALIFIED pack id (`<owner>.<name>`) for a manifest's bare name.
@@ -1472,7 +1624,7 @@ async function cmdStatus(flags) {
1472
1624
  console.error(`✗ status check failed (HTTP ${res.status})`);
1473
1625
  printAuthHint(res.status, url);
1474
1626
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
1475
- process.exit(1);
1627
+ exitNow(1);
1476
1628
  }
1477
1629
  console.log(`${id} on ${targetLabel(t)} @ ${url}`);
1478
1630
  console.log(` installed version : ${json.installed_version}`);
@@ -1571,16 +1723,16 @@ async function cmdPlatformKb(flags) {
1571
1723
  console.error(polled.notAuthorized
1572
1724
  ? '✗ cannot check — the platform refused the token, and this instance serves no anonymous reference.'
1573
1725
  : `✗ cannot check — ${url} did not answer.`);
1574
- process.exit(1);
1726
+ exitNow(1);
1575
1727
  }
1576
1728
  const remote = polled.meta.content_hash;
1577
1729
  if (!local?.content_hash) {
1578
1730
  console.log(`⬆ no reference pulled yet (platform is at ${remote ?? '?'}) — run \`octwin platform-kb pull\`.`);
1579
- process.exit(2);
1731
+ exitNow(2);
1580
1732
  }
1581
1733
  if (remote && remote !== local.content_hash) {
1582
1734
  console.log(`⬆ stale: local ${local.content_hash} → platform ${remote}. Run \`octwin platform-kb pull\`.`);
1583
- process.exit(2);
1735
+ exitNow(2);
1584
1736
  }
1585
1737
  console.log(`✓ current (${local.content_hash}).`);
1586
1738
  return;
@@ -1612,7 +1764,7 @@ async function cmdPlatformKb(flags) {
1612
1764
  }
1613
1765
  console.error(`✗ platform-kb pull failed (HTTP ${res.status})`);
1614
1766
  console.error(typeof j === 'string' ? j : JSON.stringify(j, null, 2));
1615
- process.exit(1);
1767
+ exitNow(1);
1616
1768
  }
1617
1769
  const bundle = JSON.parse(text);
1618
1770
  // Snapshot the prior pull's index BEFORE overwriting it, so we can show the
@@ -1998,7 +2150,7 @@ async function cmdRecordsWrite(flags) {
1998
2150
  // useful thing to show, so don't bury it in the generic error line.
1999
2151
  console.error(`✗ '${to}' is not a legal move from this record's stage.`);
2000
2152
  console.error(` → allowed: ${json.allowed.join(', ') || '(none — terminal stage)'}`);
2001
- process.exit(1);
2153
+ exitNow(1);
2002
2154
  }
2003
2155
  if (status !== 200)
2004
2156
  writeFail(`move record ${id} to '${to}'`, status, json, url, true);
@@ -2109,7 +2261,33 @@ async function cmdFeedback(flags) {
2109
2261
  console.log(' ⓘ no local capability reference found, so the report carries no KB version.');
2110
2262
  console.log(' Pull it before your next session: octwin platform-kb');
2111
2263
  }
2112
- console.log(' A reply arrives as a memo this CLI will tell you when one is waiting.');
2264
+ // WHAT IS ALREADY WAITING, said here rather than promised for later.
2265
+ //
2266
+ // The submit response carries the same counts `notifyIfMemosWaiting` polls. That nudge
2267
+ // fires on this command too — but it writes to STDERR, and the authors driving this CLI
2268
+ // are agents reading stdout, so the one signal that mattered was addressed to a channel
2269
+ // nobody was listening on. On 2026-08-19 an author sent two reports two hours after
2270
+ // three replies were published and re-reported five already-fixed findings; the nudge
2271
+ // had run correctly both times.
2272
+ //
2273
+ // So it goes on STDOUT, at the moment they are certainly reading, in the same block as
2274
+ // the acknowledgement. `memos` is absent against an older platform — then the old line
2275
+ // stands, which is the honest thing to say when we cannot know.
2276
+ const memos = json?.memos;
2277
+ const unread = Number(memos?.unread ?? 0);
2278
+ const actionable = Number(memos?.unread_actionable ?? 0);
2279
+ if (Number.isFinite(unread) && unread > 0) {
2280
+ const what = unread === 1 ? '1 reply is' : `${unread} replies are`;
2281
+ const tail = Number.isFinite(actionable) && actionable > 0 ? ` (${actionable} needing action)` : '';
2282
+ console.log(` ✉ ${what} already waiting for you${tail} — read them BEFORE your next change: octwin memos`);
2283
+ if (actionable > 0) {
2284
+ // The severity axis exists so an agent mid-build stops instead of finishing first.
2285
+ console.log(' One or more may already answer what you just reported.');
2286
+ }
2287
+ }
2288
+ else {
2289
+ console.log(' A reply arrives as a memo — this CLI will tell you when one is waiting.');
2290
+ }
2113
2291
  }
2114
2292
  /**
2115
2293
  * `octwin memos [--json] [--all]` — read what the platform has told you.
@@ -2571,13 +2749,13 @@ async function cmdChat(flags) {
2571
2749
  // isn't answering — the remaining turns would land out of context.
2572
2750
  await cancel();
2573
2751
  console.error(` turn ${i + 1} produced no render after ${Math.round(REPLY_TIMEOUT_MS / 1000)}s — stopping the script here.`);
2574
- process.exit(1);
2752
+ exitNow(1);
2575
2753
  }
2576
2754
  }
2577
2755
  await cancel();
2578
2756
  if (totalRenders === 0) {
2579
2757
  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.`);
2580
- process.exit(1);
2758
+ exitNow(1);
2581
2759
  }
2582
2760
  console.log(`\n(same --as '${from}' continues this conversation — timeline: octwin logs --as ${from})`);
2583
2761
  }
@@ -2619,7 +2797,7 @@ async function cmdMedia(flags) {
2619
2797
  }
2620
2798
  console.error(`✗ media generate failed (HTTP ${res.status})${errDetail(j)}`);
2621
2799
  printAuthHint(res.status, url);
2622
- process.exit(1);
2800
+ exitNow(1);
2623
2801
  }
2624
2802
  const r = JSON.parse(text);
2625
2803
  const absUrl = /^https?:/i.test(r.url) ? r.url : `${url}${r.url}`;
@@ -3290,7 +3468,7 @@ async function cmdOrdersWrite(flags) {
3290
3468
  console.error(` → allowed: ${allowed.join(', ')}`);
3291
3469
  else
3292
3470
  console.error(` → see the allowed set: octwin orders ${ref}`);
3293
- process.exit(1);
3471
+ exitNow(1);
3294
3472
  }
3295
3473
  if (status !== 200)
3296
3474
  writeFail(`move order ${ref} to '${to}'`, status, json, url);
@@ -3325,7 +3503,7 @@ async function cmdOrdersWrite(flags) {
3325
3503
  console.log(` gateway : ${gw.status ?? (gw.ok === false ? 'failed' : 'ok')}${gw.error || gw.message ? ` — ${gw.error ?? gw.message}` : ''}`);
3326
3504
  if (refused) {
3327
3505
  console.error('\n✗ the PAYMENT GATEWAY refused the refund — the order was updated but no money moved.');
3328
- process.exit(1);
3506
+ exitNow(1);
3329
3507
  }
3330
3508
  console.log('✓ Refund accepted.');
3331
3509
  }
@@ -3848,6 +4026,7 @@ function help() {
3848
4026
  octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
3849
4027
  octwin projects [--archived] [--json] # the --project slugs this token can name
3850
4028
  octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
4029
+ [--request-listing | --withdraw-listing] # public marketplace — opt-in, see: octwin help deploy
3851
4030
  octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
3852
4031
  octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
3853
4032
  octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
@@ -3923,8 +4102,21 @@ octwin projects rm <slug> [--yes]
3923
4102
  octwin projects rm scratch --yes
3924
4103
  Both verbs need the \`projects:write\` scope — a pack:deploy token does NOT confer it.`,
3925
4104
  deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
4105
+ [--request-listing | --withdraw-listing]
3926
4106
  Upload the pack bundle, validate server-side, install onto the project.
3927
- --seed additionally applies the pack's demo seed (streams progress).`,
4107
+ --seed additionally applies the pack's demo seed (streams progress).
4108
+
4109
+ A plain deploy says NOTHING about the public marketplace — it is a test loop, so it
4110
+ neither asks for a listing nor gives one up. The marketplace flags are opt-in:
4111
+
4112
+ --request-listing ask an operator to review this pack for the public marketplace
4113
+ (the pre-signup storefront at /packs). Requires 'public: true'
4114
+ under 'listing:' in manifest.yaml — the manifest states that the
4115
+ pack is a product, the flag is you choosing to ask.
4116
+ --withdraw-listing retract the request, including an approved listing.
4117
+
4118
+ An approval covers the CONTENT it was made against, so a later deploy that changes the
4119
+ pack returns it to the review queue on its own — no flag needed, and the CLI says so.`,
3928
4120
  seed: `octwin seed [--pack <packId>]
3929
4121
  Apply the pack's demo/reference data to the project it is installed on, without
3930
4122
  redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
@@ -4126,6 +4318,23 @@ async function main() {
4126
4318
  console.log(COMMAND_HELP[command]);
4127
4319
  return;
4128
4320
  }
4321
+ // Compute the trailing nudges BEFORE the command runs, so they survive a `die()` — see the
4322
+ // notice-channel block above. They are PRINTED after the command, by `flushNotices` here on the
4323
+ // success path and by `exitNow` on every failure path. Fail-silent by construction (each helper
4324
+ // swallows and returns []), and the KB + memo polls still ride only on commands that were
4325
+ // already going to hit the network.
4326
+ // SEQUENTIAL, not `Promise.all`. Two concurrent polls to the same host leave a socket checked
4327
+ // out of undici's pool, and `exitNow`'s `process.exit` then aborts on Windows with
4328
+ // `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c` — replacing the real
4329
+ // exit code with 127, so a failing `octwin deploy` in CI reported the wrong thing. Measured
4330
+ // 2026-08-26: one pre-command poll exits 1 cleanly, two concurrent ones abort. Awaiting them in
4331
+ // turn costs one extra round-trip on an already-networked command and keeps the exit code true.
4332
+ const networked = commandTouchesPlatform(command, flags);
4333
+ if (networked) {
4334
+ PENDING_NOTICES.push(...await kbStaleNotice(flags));
4335
+ PENDING_NOTICES.push(...await memosWaitingNotice(flags));
4336
+ }
4337
+ PENDING_NOTICES.push(...await outdatedNotice());
4129
4338
  switch (command) {
4130
4339
  case 'init':
4131
4340
  cmdInit(flags);
@@ -4209,13 +4418,18 @@ async function main() {
4209
4418
  break;
4210
4419
  default: die(`unknown command '${command}' — run \`octwin help\``);
4211
4420
  }
4212
- // Trailing, fail-silent, TTY-only notices. KB-drift only after commands that
4213
- // already hit the platform (so it's one extra tiny GET, never a new call on
4214
- // offline paths); CLI-upgrade always.
4215
- if (commandTouchesPlatform(command, flags))
4216
- await notifyIfKbStale(flags);
4217
- if (commandTouchesPlatform(command, flags))
4218
- await notifyIfMemosWaiting(flags);
4219
- await notifyIfOutdated();
4421
+ flushNotices();
4220
4422
  }
4221
- main().catch((err) => die(err?.message ?? String(err)));
4423
+ main().catch((err) => {
4424
+ // `CliExit` is our own controlled stop — the message is already printed and the notices already
4425
+ // flushed. Setting `exitCode` rather than calling `process.exit` is the whole point: the process
4426
+ // ends when the event loop drains, which is the only teardown that does not abort on Windows
4427
+ // after the notice polls have opened sockets. See `exitNow`.
4428
+ if (err instanceof CliExit) {
4429
+ process.exitCode = err.code;
4430
+ return;
4431
+ }
4432
+ console.error(`✗ ${err?.message ?? String(err)}`);
4433
+ flushNotices();
4434
+ process.exitCode = 1;
4435
+ });
@@ -58,6 +58,23 @@ export function loadAllowedRenderKeys(packDir) {
58
58
  }
59
59
  return { lookup, keys: out, nested };
60
60
  }
61
+ /** The tap verbs, in the order the platform's compiler picks them. */
62
+ const ON_SELECT_VERBS = ['invoke', 'resume', 'reply', 'agent'];
63
+ /**
64
+ * `{ if, then, else }` — mirrors `isIfFragment` in the platform's
65
+ * `interpreter/render-resolver.ts` and `schemas/render.ts`. Three copies is two too
66
+ * many, but this package is deliberately dependency-free from `src/platform`; the
67
+ * predicate is identical and `render-check.parity.test.ts` is what keeps it so.
68
+ */
69
+ function isIfFragment(v) {
70
+ return !!v && typeof v === 'object' && !Array.isArray(v) &&
71
+ typeof v.if === 'string' &&
72
+ Object.keys(v).every(k => k === 'if' || k === 'then' || k === 'else');
73
+ }
74
+ /** The `on_select` NODE itself — the one place an if-fragment is not evaluated. */
75
+ function isOnSelectPath(at) {
76
+ return at === 'on_select' || at.endsWith('.on_select');
77
+ }
61
78
  /**
62
79
  * Walk parsed YAML for objects carrying `render_intent` and report keys outside
63
80
  * that intent's allowed set. Walks the whole document rather than just `render:`
@@ -81,7 +98,50 @@ export function findRenderKeyViolations(doc, file, allowedByIntent, nestedByInte
81
98
  if (!value || typeof value !== 'object')
82
99
  return;
83
100
  const obj = value;
101
+ // An if-fragment stands in for the value it selects, so check its branches at the SAME
102
+ // path. Mirrors the platform's `checkNested`: without this the wrapper's own keys were
103
+ // reported against the slot it wraps, which rejected the one form that actually works.
104
+ if (isIfFragment(obj) && !isOnSelectPath(at)) {
105
+ for (const b of ['then', 'else']) {
106
+ if (obj[b] !== undefined)
107
+ walkNested(obj[b], intent, at, sets, [...path, b]);
108
+ }
109
+ return;
110
+ }
111
+ // …and at an `on_select` node it is the reported bug, because runtime does not evaluate
112
+ // it there. One sentence naming the two forms that do work.
113
+ if (isIfFragment(obj) && isOnSelectPath(at)) {
114
+ findings.push({
115
+ file, path: [...path], intent, keys: [], allowed: ON_SELECT_VERBS, nestedAt: at,
116
+ note: `render_intent '${intent}' at '${at}': '{ if, then, else }' is not evaluated inside ` +
117
+ `'on_select' — it fails at render time, not at validate. Wrap the WHOLE entry instead ` +
118
+ `({ if, then: { title, on_select }, else: {…} }), or give each row its own on_select in ` +
119
+ `data and point at it (on_select: '$item.on_select').`,
120
+ });
121
+ return;
122
+ }
84
123
  const allowed = sets[at];
124
+ // A missing verb is an absence, not an unknown key, so the key check below is blind to it —
125
+ // and the tap compiler throws on it at render. Only judged where the KB declares the slot,
126
+ // so a KB pulled before on_select was declared simply does not run this.
127
+ if (allowed && isOnSelectPath(at)) {
128
+ const present = ON_SELECT_VERBS.filter(v => obj[v] !== undefined);
129
+ if (present.length === 0) {
130
+ findings.push({
131
+ file, path: [...path], intent, keys: [], allowed: ON_SELECT_VERBS, nestedAt: at,
132
+ note: `render_intent '${intent}' at '${at}': 'on_select' needs exactly one of ` +
133
+ `${ON_SELECT_VERBS.join(' / ')} — it has none, so the tap has no target and ` +
134
+ `fails at render time.`,
135
+ });
136
+ }
137
+ else if (present.length > 1) {
138
+ findings.push({
139
+ file, path: [...path], intent, keys: [], allowed: ON_SELECT_VERBS, nestedAt: at,
140
+ note: `render_intent '${intent}' at '${at}': 'on_select' has more than one verb ` +
141
+ `(${present.join(', ')}); only '${present[0]}' would run. Keep one.`,
142
+ });
143
+ }
144
+ }
85
145
  if (allowed) {
86
146
  const bad = Object.keys(obj).filter(k => !allowed.includes(k));
87
147
  if (bad.length)
@@ -126,6 +186,10 @@ export function findRenderKeyViolations(doc, file, allowedByIntent, nestedByInte
126
186
  /** One-line human message per finding. */
127
187
  export function describeRenderFinding(f) {
128
188
  const at = f.line != null ? `:${f.line}` : (f.path.length ? ` (${f.path.join('.')})` : '');
189
+ // A pre-worded finding (absent verb, if-fragment on a tap payload) carries its own sentence;
190
+ // the key-list template below cannot say either one.
191
+ if (f.note)
192
+ return `${f.file}${at}: ${f.note}`;
129
193
  if (f.keys.length === 0) {
130
194
  return `${f.file}${at}: unknown render_intent '${f.intent}' — known intents: ${f.allowed.join(', ')}`;
131
195
  }
package/package.json CHANGED
@@ -1,37 +1,46 @@
1
- {
2
- "name": "octwin-cli",
3
- "version": "0.7.1",
4
- "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
- "type": "module",
6
- "bin": {
7
- "octwin": "dist/index.js"
8
- },
9
- "files": [
10
- "dist",
11
- "templates",
12
- "README.md",
13
- "CHANGELOG.md",
14
- "LICENSE"
15
- ],
16
- "engines": {
17
- "node": ">=22"
18
- },
19
- "scripts": {
20
- "build": "tsc -p tsconfig.json",
21
- "prepublishOnly": "npm run build"
22
- },
23
- "dependencies": {
24
- "yaml": "^2.6.0"
25
- },
26
- "devDependencies": {
27
- "@types/node": "^22.0.0",
28
- "typescript": "^5.7.0"
29
- },
30
- "publishConfig": {
31
- "access": "public"
32
- },
33
- "keywords": ["octwin", "cequens", "cli", "whatsapp", "chatbot", "pack", "conversational-ai", "yaml"],
34
- "author": "CEQUENS",
35
- "homepage": "https://www.npmjs.com/package/octwin-cli",
36
- "license": "MIT"
37
- }
1
+ {
2
+ "name": "octwin-cli",
3
+ "version": "0.7.3",
4
+ "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
+ "type": "module",
6
+ "bin": {
7
+ "octwin": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "templates",
12
+ "README.md",
13
+ "CHANGELOG.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "yaml": "^2.6.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^22.0.0",
28
+ "typescript": "^5.7.0"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "keywords": [
34
+ "octwin",
35
+ "cequens",
36
+ "cli",
37
+ "whatsapp",
38
+ "chatbot",
39
+ "pack",
40
+ "conversational-ai",
41
+ "yaml"
42
+ ],
43
+ "author": "CEQUENS",
44
+ "homepage": "https://www.npmjs.com/package/octwin-cli",
45
+ "license": "MIT"
46
+ }