octwin-cli 0.6.0 → 0.6.1

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,28 @@ 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.6.1] - 2026-08-18
9
+
10
+ ### Fixed
11
+ - **`octwin status` worked for no pack at all, and had not since 2026-08-01.** It sent the manifest's
12
+ **bare** id (`clinic`) to a route that requires the qualified `<owner>.<name>` — and a manifest
13
+ cannot legally declare the qualified form, because the owner segment is attached at publish time
14
+ from the authenticated publisher (both the platform and this CLI reject a `.` in it). So the route
15
+ answered 404 for every pack, every time. It now resolves the owner from `whoami`'s `tenant_slug`,
16
+ and takes an explicit `octwin status <packId>` when you already know it (from `octwin agents` or
17
+ `octwin projects`, both of which print qualified ids). Reported by two pack authors, who lost time
18
+ to a command that said their pack was missing seconds after a deploy the platform had accepted.
19
+ - **A 404 from `octwin status` no longer always reads "not installed".** That one branch covered four
20
+ different failures — a malformed pack id, an unknown tenant, an unknown/pinned-elsewhere project,
21
+ and the genuine no-active-install — so the message named the *least* likely cause and sent an author
22
+ chasing a deploy that had already succeeded. The route says which; the CLI now relays it, and adds
23
+ the "run `octwin deploy` first" hint only to the case it fits.
24
+ - **`octwin logs` always reported "No conversations"**, including immediately after an `octwin chat`
25
+ that had plainly worked. It read `conversations` off the response; the platform moved that endpoint
26
+ to the shared page envelope on 2026-08-09 and answers `rows`, which every other list command here
27
+ already reads. Both authors filed this as replica lag or a cache gap — nothing lagged, the payload
28
+ was simply never read. `octwin logs <conversationId>` was unaffected.
29
+
8
30
  ## [0.6.0] - 2026-08-09
9
31
 
10
32
  ### Changed
package/README.md CHANGED
@@ -80,7 +80,7 @@ octwin status # "✓ live and current" once it's warm
80
80
  | `octwin analytics` | Stage-by-stage conversion for **any** entity declared with a `pipeline:` (`--overview` / `--milestones` / `--trends` / `--cost`; `--stage <id>` lists the records currently at a stage). Needs `records:read`. |
81
81
  | `octwin catalog` | Commerce products with price / availability / stock, plus the WhatsApp catalog binding. `--readiness` runs the Meta Graph checklist. Needs `catalog:read` + the `catalog` plan feature. |
82
82
  | `octwin scheduling` | The scheduling engine's state, or `--slots <resourceRecordId>` for the slots one bookable resource actually computes — how you verify the availability rules `deploy --seed` created. Needs `scheduling:read`. |
83
- | `octwin media generate "<prompt>"` | AI-generate an image, store it as a public asset, and print its `MEDIA-` handle + serve URL. `--out` downloads the bytes (WhatsApp renders only `.png`/`.jpg`); `--size`; `--json`. Pairs with `octwin chat --media` to drive media-collect flows. Needs `media:generate`. |
83
+ | `octwin media generate "<prompt>"` | AI-generate an image, store it as a public asset, and print its `MEDIA-` handle + serve URL. `--out` downloads the bytes (WhatsApp renders only `.png`/`.jpg`); `--json`. Pairs with `octwin chat --media` to drive media-collect flows. Needs `media:generate`. |
84
84
  | `octwin platform-kb pull` | Pull the platform's capability reference into `.octwin/platform-kb/` for the **`octwin-pack`** Claude Code authoring plugin: guides as markdown, plus **one JSON file per capability** (`primitives/record_list.json`, `render-intents/carousel.json`, `declarations/xrm.json`, …) and **three maps** — `INDEX.md` (the corpus, by family and size), `SYMBOLS.md` (every name → its exact file; grep this), `OUTLINE.md` (every heading with its line number). **No token needed** — the reference is served anonymously. `--if-stale` skips the download when your copy is current (cheap enough for every session); `--check` writes nothing and exits 0 current / 2 stale / 1 could-not-tell. |
85
85
  | `octwin test` | Alias for `octwin validate --remote` — the platform's full manifest + flow-DSL check. |
86
86
  | `octwin feedback` | Submit this pack's `FEEDBACK.md` to the platform team, with the pack version, your CLI version and the `content_hash` of the capability reference you pulled — the two facts that separate a real platform gap from something already fixed or a stale KB. |
package/dist/index.js CHANGED
@@ -15,13 +15,13 @@
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
17
  * octwin pull <packId> [--dir <out>] [--version v] [--force] # write a DEPLOYED pack's source back to disk
18
- * octwin status [--dir .] # did my deploy land? which version is live?
18
+ * octwin status [<packId>] # did my deploy land? which version is live?
19
19
  * octwin records [entity] [id] # inspect the pack's XRM data (records:read token)
20
20
  * octwin work [recordId] [--queues] # inspect the work inbox (worked records) — list / one item + timeline
21
21
  * octwin logs [conversationId] [--as h] [--json] # list conversations / show one's timeline
22
22
  * octwin chat "msg" [--as h] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn via the web channel (+ send media)
23
23
  * octwin chat --script <file> [--as h] # drive a WHOLE conversation, one turn per line (the reliable way to test a flow)
24
- * octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (media:generate scope)
24
+ * octwin media generate "<prompt>" [--out <file.png>] [--json] # AI-generate an image → MEDIA- handle (media:generate scope)
25
25
  * octwin agents [packId::agentId] [--prompt] # effective model/memory + which layer won; --prompt = the resolved system prompt
26
26
  * octwin orders [reference_id] # the orders a conversation produced — money breakdown + payment state (orders:read)
27
27
  * octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion, any pipelined entity
@@ -62,6 +62,7 @@ import { validatePackBundle, describePackNameProblem, asPackName } from './lib/v
62
62
  import { loadAllowedRenderKeys, findRenderKeyViolations, describeRenderFinding } from './lib/render-check.js';
63
63
  import { loadPrimitiveArgSpecs, findArgViolations, describeArgFinding } from './lib/args-check.js';
64
64
  import { yamlLineOf } from './lib/yaml-pos.js';
65
+ import { localhostFamilyHint } from './lib/net-hint.js';
65
66
  import { loadBuiltinNames, findBuiltinViolations, describeBuiltinFinding } from './lib/builtin-check.js';
66
67
  import { loadTemplateSpecs, findTemplateViolations, describeTemplateFinding } from './lib/template-check.js';
67
68
  import { loadSystemEntities, findEntityViolations, describeEntityFinding } from './lib/entity-check.js';
@@ -164,7 +165,8 @@ async function fetchOrDie(url, init, what) {
164
165
  return await fetch(url, init);
165
166
  }
166
167
  catch (err) {
167
- die(`${what} platform unreachable at ${url} (${err?.message ?? err})`);
168
+ const hint = localhostFamilyHint(url, err);
169
+ die(`${what} — platform unreachable at ${url} (${err?.message ?? err})${hint ? `\n ${hint}` : ''}`);
168
170
  }
169
171
  }
170
172
  /** One consistent explanation for auth failures on admin reads. A 401 can also
@@ -748,7 +750,7 @@ async function cmdValidate(flags) {
748
750
  const withLines = (fs) => fs.map(f => ({ ...f, line: files[f.file] ? yamlLineOf(files[f.file], f.path) : null }));
749
751
  const render = loadAllowedRenderKeys(packDir);
750
752
  if (render.keys) {
751
- const findings = withLines(yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys)));
753
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys, render.nested)));
752
754
  if (findings.length) {
753
755
  console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
754
756
  for (const f of findings)
@@ -896,10 +898,16 @@ async function cmdValidate(flags) {
896
898
  const t = resolveTarget(flags);
897
899
  const { url } = t;
898
900
  console.log(`→ Validating against ${targetLabel(t)} @ ${url} …`);
901
+ // `--strict-primitives` (opt-in): the server additionally type-checks LITERAL
902
+ // `args:` values against each primitive's per-key input schema. Expression
903
+ // strings are always exempt — this judges only plain YAML scalars.
899
904
  const res = await fetchOrDie(`${url}/api/self/p/packs/validate`, {
900
905
  method: 'POST',
901
906
  headers: { 'content-type': 'application/json', ...authHeaders(t) },
902
- body: JSON.stringify({ files, blobs }),
907
+ body: JSON.stringify({
908
+ files, blobs,
909
+ ...(flags['strict-primitives'] === true ? { strict_primitives: true } : {}),
910
+ }),
903
911
  }, 'remote validate');
904
912
  const text = await res.text();
905
913
  let json;
@@ -1257,13 +1265,13 @@ async function cmdSeed(flags) {
1257
1265
  const { terminal: final, stepErrors } = await readDeployProgress(res.body);
1258
1266
  if (!final || final.stage === 'error')
1259
1267
  die(`seed failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
1260
- console.log(`
1268
+ console.log(`
1261
1269
  ✓ ${final.message ?? 'seed complete'}`);
1262
1270
  printSeedCounts(final.result?.seeded);
1263
1271
  if (stepErrors.length) {
1264
1272
  // A kind failed but the rest ran — the reconcile softens each step. Say which,
1265
1273
  // and exit non-zero so a scripted `seed && chat` doesn't read as clean.
1266
- console.error(`
1274
+ console.error(`
1267
1275
  ⚠ ${stepErrors.length} step${stepErrors.length === 1 ? '' : 's'} failed — data may be incomplete:`);
1268
1276
  for (const e of stepErrors)
1269
1277
  console.error(` • ${e}`);
@@ -1345,6 +1353,33 @@ async function cmdDeploy(flags) {
1345
1353
  }
1346
1354
  printDeploySuccess(id, version, t, json);
1347
1355
  }
1356
+ /**
1357
+ * The QUALIFIED pack id (`<owner>.<name>`) for a manifest's bare name.
1358
+ *
1359
+ * A manifest declares a bare name and cannot legally declare anything else — the platform
1360
+ * and this CLI both reject a `.` in it, because the owner segment is attached at publish
1361
+ * time from the authenticated publisher. Every route that takes a pack id in its PATH
1362
+ * requires the qualified form, so a command holding only a manifest has to ask who it is.
1363
+ *
1364
+ * `whoami`'s `tenant_slug` IS the owner segment. Without this, `octwin status` sent the bare
1365
+ * name, `asPackId` rejected it, and the route's 404 was reported as "not installed" — so the
1366
+ * command was structurally broken for every pack from the day ids gained owners
1367
+ * (2026-08-01) until this was fixed, while the KB still taught it as step 4 of the loop.
1368
+ */
1369
+ async function qualifiedPackId(t, bareName) {
1370
+ if (bareName.includes('.'))
1371
+ return bareName; // already qualified (explicit override)
1372
+ const res = await fetchOrDie(`${t.url}/api/self/t/whoami`, { headers: authHeaders(t) }, 'workspace lookup');
1373
+ if (!res.ok) {
1374
+ printAuthHint(res.status, t.url);
1375
+ die(`could not resolve your workspace to qualify the pack id (HTTP ${res.status}) — pass the full '<owner>.${bareName}' instead`);
1376
+ }
1377
+ const j = await res.json();
1378
+ if (typeof j.tenant_slug !== 'string' || !j.tenant_slug) {
1379
+ die(`the platform did not report a workspace slug — pass the full '<owner>.${bareName}' instead`);
1380
+ }
1381
+ return `${j.tenant_slug}.${bareName}`;
1382
+ }
1348
1383
  async function cmdStatus(flags) {
1349
1384
  const packDir = resolve(flags.dir ?? '.');
1350
1385
  const t = resolveTarget(flags);
@@ -1355,8 +1390,11 @@ async function cmdStatus(flags) {
1355
1390
  const doc = parseYaml(readFileSync(manifestPath, 'utf8'));
1356
1391
  if (typeof doc?.id !== 'string')
1357
1392
  die('manifest.yaml must declare a string `id`');
1358
- const id = doc.id;
1393
+ const bare = doc.id;
1359
1394
  const localVersion = typeof doc?.version === 'string' ? doc.version : '?';
1395
+ // An explicit `octwin status <packId>` wins, so an author who already knows the qualified
1396
+ // id (from `octwin agents` / `octwin projects`) can skip the lookup.
1397
+ const id = await qualifiedPackId(t, flags._[1] ?? bare);
1360
1398
  console.log(`→ Checking ${id}@${localVersion} on ${targetLabel(t)} @ ${url} …`);
1361
1399
  const res = await fetchOrDie(`${url}/api/self/p/packs/${id}/runtime`, {
1362
1400
  headers: authHeaders(t),
@@ -1370,8 +1408,16 @@ async function cmdStatus(flags) {
1370
1408
  json = text;
1371
1409
  }
1372
1410
  if (!res.ok) {
1373
- if (res.status === 404)
1374
- die(`'${id}' is not installed on ${targetLabel(t)} yet run \`octwin deploy\` first`);
1411
+ // A 404 here means one of four different things, and collapsing them into "not
1412
+ // installed" sent one author chasing a deploy that had already succeeded. The route
1413
+ // says which; relay it and only add the deploy hint to the case it fits.
1414
+ if (res.status === 404) {
1415
+ const why = typeof json === 'object' && json !== null && typeof json.error === 'string' ? json.error : text;
1416
+ const notInstalled = /no active install|not installed/i.test(why);
1417
+ die(notInstalled
1418
+ ? `${why}\n Run \`octwin deploy\` first.`
1419
+ : `status check failed (HTTP 404) — ${why}`);
1420
+ }
1375
1421
  console.error(`✗ status check failed (HTTP ${res.status})`);
1376
1422
  printAuthHint(res.status, url);
1377
1423
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
@@ -2028,7 +2074,11 @@ async function cmdLogs(flags) {
2028
2074
  const { status, json } = await apiGet(`${base}/conversations?limit=50`, t);
2029
2075
  if (status !== 200)
2030
2076
  die(`could not read conversations (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
2031
- let convs = (json?.conversations ?? []);
2077
+ // `rows` the shared `makePage` envelope every other list command here reads. This one
2078
+ // read `json.conversations`, a noun the route stopped emitting on 2026-08-09, so `convs`
2079
+ // was ALWAYS empty and the command reported "No conversations yet" seconds after a chat
2080
+ // that had plainly worked. Two pack authors filed it as a replica-lag bug; nothing lagged.
2081
+ let convs = (json?.rows ?? []);
2032
2082
  if (asHandle)
2033
2083
  convs = convs.filter((c) => c.contact?.channel_contact_handle === asHandle);
2034
2084
  if (convs.length === 0) {
@@ -2426,29 +2476,32 @@ async function cmdChat(flags) {
2426
2476
  }
2427
2477
  console.log(`\n(same --as '${from}' continues this conversation — timeline: octwin logs --as ${from})`);
2428
2478
  }
2429
- /** `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]`
2479
+ /** `octwin media generate "<prompt>" [--out <file.png>] [--json]`
2430
2480
  * — AI-generate an image on the platform (needs a `media:generate`-scoped token),
2431
2481
  * store it as a public asset, and return its `MEDIA-` handle + serve URL. `--out`
2432
2482
  * downloads the bytes to a file (WhatsApp renders only `.png`/`.jpg`); the paired
2433
- * `octwin chat --media <file|id>` feeds it into a running media-collect flow. */
2483
+ * `octwin chat --media <file|id>` feeds it into a running media-collect flow.
2484
+ *
2485
+ * A `--size` flag was accepted until 2026-08-10. The platform never forwarded it
2486
+ * to the provider, and stored resolution is decided at ingest regardless, so it
2487
+ * only ever changed the (fabricated) width/height the command printed back. */
2434
2488
  async function cmdMedia(flags) {
2435
2489
  const sub = flags._[0];
2436
2490
  if (sub !== 'generate')
2437
- die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
2491
+ die('usage: octwin media generate "<prompt>" [--out <file.png>] [--json]');
2438
2492
  const t = resolveTarget(flags);
2439
2493
  const { url } = t;
2440
2494
  const prompt = flags._[1];
2441
2495
  if (!prompt)
2442
- die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
2496
+ die('usage: octwin media generate "<prompt>" [--out <file.png>] [--json]');
2443
2497
  const asJson = flags.json === true;
2444
- const size = typeof flags.size === 'string' ? flags.size : undefined;
2445
2498
  const out = typeof flags.out === 'string' ? flags.out : undefined;
2446
2499
  if (!asJson)
2447
2500
  console.log(`→ Generating an image on ${targetLabel(t)} @ ${url} …`);
2448
2501
  const res = await fetchOrDie(`${url}/api/self/p/media/generate`, {
2449
2502
  method: 'POST',
2450
2503
  headers: { 'content-type': 'application/json', ...authHeaders(t) },
2451
- body: JSON.stringify({ prompt, ...(size ? { size } : {}) }),
2504
+ body: JSON.stringify({ prompt }),
2452
2505
  }, 'media generate');
2453
2506
  const text = await res.text();
2454
2507
  if (!res.ok) {
@@ -2477,10 +2530,10 @@ async function cmdMedia(flags) {
2477
2530
  }
2478
2531
  }
2479
2532
  if (asJson) {
2480
- console.log(JSON.stringify({ media_id: r.media_id, url: absUrl, mime: r.mime, width: r.width, height: r.height, bytes: r.bytes }));
2533
+ console.log(JSON.stringify({ media_id: r.media_id, url: absUrl, mime: r.mime, bytes: r.bytes }));
2481
2534
  return;
2482
2535
  }
2483
- console.log(`✓ Generated ${r.media_ref} (${r.width}×${r.height}, ${r.mime}, ${r.bytes} bytes)`);
2536
+ console.log(`✓ Generated ${r.media_ref} (${r.mime}, ${r.bytes} bytes)`);
2484
2537
  console.log(` id: ${r.media_id}`);
2485
2538
  console.log(` url: ${absUrl}`);
2486
2539
  if (out)
@@ -3514,7 +3567,7 @@ async function cmdSchedulingWrite(flags) {
3514
3567
  console.log(JSON.stringify(json, null, 2));
3515
3568
  return;
3516
3569
  }
3517
- if (json?.has_scheduling === false) {
3570
+ if (json?.has_data === false) {
3518
3571
  console.log('This pack declares no scheduling.');
3519
3572
  return;
3520
3573
  }
@@ -3589,7 +3642,7 @@ async function cmdSchedulingWrite(flags) {
3589
3642
  const { status, json } = await apiSend('POST', `${base}/availability/${path}`, body, t);
3590
3643
  if (status !== 201 && status !== 200)
3591
3644
  writeFail(`add the ${noun}`, status, json, url);
3592
- if (json?.has_scheduling === false)
3645
+ if (json?.has_data === false)
3593
3646
  die('this pack declares no scheduling');
3594
3647
  const created = json?.rule ?? json?.exception ?? {};
3595
3648
  console.log(`✓ ${noun[0].toUpperCase()}${noun.slice(1)} added — ${created.id ?? '(no id returned)'}`);
@@ -3610,30 +3663,42 @@ async function cmdScheduling(flags) {
3610
3663
  if (flags.slots === true)
3611
3664
  die('usage: octwin scheduling --slots <resourceRecordId> (the record id of a bookable resource)');
3612
3665
  if (resourceId) {
3613
- const q = new URLSearchParams({ include_booked: '1' }); // full occupancy, as the operator preview does
3614
- if (typeof flags.from === 'string')
3615
- q.set('from', flags.from);
3616
- if (typeof flags.days === 'string')
3617
- q.set('days', flags.days); // server-clamped to 1–31
3666
+ // The range is `from`/`to`; `--days` is kept as the ergonomic flag and converted
3667
+ // here, since a CLI user thinks in "the next 7 days".
3668
+ const from = typeof flags.from === 'string' ? flags.from : new Date().toISOString().slice(0, 10);
3669
+ const days = typeof flags.days === 'string' ? Math.max(Number(flags.days) || 1, 1) : 7;
3670
+ const to = new Date(Date.parse(`${from}T00:00:00Z`) + (days - 1) * 86_400_000).toISOString().slice(0, 10);
3671
+ const q = new URLSearchParams({ include_booked: '1', from, to }); // full occupancy, as the operator preview does
3672
+ q.append('resource_id', resourceId);
3618
3673
  if (!asJson)
3619
3674
  console.log(`→ Computing slots for resource ${resourceId} in ${targetLabel(t)} …`);
3620
- const { status, json } = await apiGet(`${base}/resources/${encodeURIComponent(resourceId)}/slots?${q.toString()}`, t);
3621
- if (status === 404)
3622
- die(`resource '${resourceId}' not found (pass an XRM record id — \`octwin records <entity>\` lists them)`);
3675
+ const { status, json } = await apiGet(`${base}/slots?${q.toString()}`, t);
3623
3676
  if (status === 400)
3624
- die(`that record isn't a bookable resource${errDetail(json)}`);
3677
+ die(`could not compute slots${errDetail(json)}`);
3625
3678
  if (status !== 200)
3626
3679
  die(`could not compute slots (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
3627
3680
  if (asJson) {
3628
3681
  console.log(JSON.stringify(json, null, 2));
3629
3682
  return;
3630
3683
  }
3631
- if (json?.has_scheduling === false) {
3684
+ if (json?.has_data === false) {
3632
3685
  console.log('This pack declares no `scheduling.yaml` — nothing to schedule.');
3633
3686
  return;
3634
3687
  }
3635
- const slots = (json?.slots ?? []);
3636
- console.log(`Slots for ${resourceId} (timezone ${json?.timezone ?? '?'}): ${slots.length}`);
3688
+ // A resource the server could not use comes back named, with a reason, rather
3689
+ // than as an HTTP status one call may carry several resources.
3690
+ const bad = (json?.unresolved ?? []).find((u) => u.resource_id === resourceId);
3691
+ if (bad?.reason === 'not_found') {
3692
+ die(`resource '${resourceId}' not found (pass an XRM record id — \`octwin records <entity>\` lists them)`);
3693
+ }
3694
+ if (bad?.reason === 'not_bookable')
3695
+ die(`that record isn't a bookable resource`);
3696
+ const res = (json?.resources ?? [])[0];
3697
+ const slots = (res?.slots ?? []);
3698
+ console.log(`Slots for ${resourceId} (timezone ${res?.timezone ?? '?'} — from ${res?.timezone_source ?? '?'}): ${slots.length}`);
3699
+ if (res?.timezone_source === 'pack_default' && json?.pack_timezone_declared === false) {
3700
+ console.log(' ⚠ The pack declares no `timezone:`, so these are UTC — a clock nobody chose.');
3701
+ }
3637
3702
  if (slots.length === 0)
3638
3703
  console.log(' (none — no availability rules cover this window; `octwin deploy --seed` seeds the demo rules)');
3639
3704
  for (const s of slots) {
@@ -3650,7 +3715,7 @@ async function cmdScheduling(flags) {
3650
3715
  console.log(JSON.stringify(json, null, 2));
3651
3716
  return;
3652
3717
  }
3653
- if (json?.has_scheduling === false) {
3718
+ if (json?.has_data === false) {
3654
3719
  console.log('This pack declares no `scheduling.yaml` — nothing to schedule.');
3655
3720
  return;
3656
3721
  }
@@ -3662,260 +3727,267 @@ async function cmdScheduling(flags) {
3662
3727
  console.log('\nSlots for one resource: octwin scheduling --slots <resourceRecordId> (ids: octwin records <entity>)');
3663
3728
  }
3664
3729
  function help() {
3665
- console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
3666
-
3667
- octwin --version # print the CLI version (+ any upgrade notice)
3668
- octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
3669
- octwin validate [--dir .] [--remote] [--require-kb] # --remote runs the platform's FULL schema check + lint (all errors at once)
3670
- octwin login --url <platformUrl> --token oct_… # a deploy token from the console
3671
- octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
3672
- octwin projects [--archived] [--json] # the --project slugs this token can name
3673
- octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
3674
- octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
3675
- octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
3676
- octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
3677
- octwin work [recordId] [--queues] [--json] # inspect the work inbox (worked records) + timelines
3678
- octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
3679
- octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
3680
- octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
3681
- octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
3682
- octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
3683
- octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
3684
- octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
3685
- octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
3686
- octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
3687
- octwin test [--dir .] # = validate --remote (the full platform check)
3688
- octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
3689
-
3690
- Writes — exercise the state your pack creates (each needs the matching :write scope):
3691
- octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
3692
- octwin records tasks | task complete <taskId> [--outcome done|cancelled]
3693
- octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
3694
- octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
3695
- octwin orders transition <ref> --to <status> | refund <ref> --force
3696
- octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
3697
- octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
3698
- octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
3699
-
3700
- Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
3701
- \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
3702
- button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
3703
- Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
3704
- octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
3705
- Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
3730
+ console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
3731
+
3732
+ octwin --version # print the CLI version (+ any upgrade notice)
3733
+ octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
3734
+ octwin validate [--dir .] [--remote] [--require-kb] # --remote runs the platform's FULL schema check + lint (all errors at once)
3735
+ octwin login --url <platformUrl> --token oct_… # a deploy token from the console
3736
+ octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
3737
+ octwin projects [--archived] [--json] # the --project slugs this token can name
3738
+ octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
3739
+ octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
3740
+ octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
3741
+ octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
3742
+ octwin work [recordId] [--queues] [--json] # inspect the work inbox (worked records) + timelines
3743
+ octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
3744
+ octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
3745
+ octwin media generate "<prompt>" [--out <file.png>] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
3746
+ octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
3747
+ octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
3748
+ octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
3749
+ octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
3750
+ octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
3751
+ octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
3752
+ octwin test [--dir .] # = validate --remote (the full platform check)
3753
+ octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
3754
+
3755
+ Writes — exercise the state your pack creates (each needs the matching :write scope):
3756
+ octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
3757
+ octwin records tasks | task complete <taskId> [--outcome done|cancelled]
3758
+ octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
3759
+ octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
3760
+ octwin orders transition <ref> --to <status> | refund <ref> --force
3761
+ octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
3762
+ octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
3763
+ octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
3764
+
3765
+ Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
3766
+ \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
3767
+ button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
3768
+ Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
3769
+ octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
3770
+ Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
3706
3771
  Per-command usage: octwin <command> --help`);
3707
3772
  }
3708
3773
  /** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
3709
3774
  * network/auth work (a --help that 401s is worse than no help at all). */
3710
3775
  const COMMAND_HELP = {
3711
- init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
3776
+ init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
3712
3777
  Scaffold a pure-YAML starter pack into <dir>.`,
3713
- validate: `octwin validate [--dir .] [--remote] [--require-kb]
3714
- Offline structural check, plus two checks driven by the pulled capability
3715
- reference (render-intent fields, primitive arguments). Those two SKIP when the
3716
- reference is missing — the run says so, and --require-kb turns the skip into a
3717
- failure for CI. --remote additionally runs the platform's FULL manifest +
3718
- flow-DSL validation and its flow lint (all errors at once) — same check as deploy.`,
3719
- login: `octwin login --url <platformUrl> --token oct_…
3720
- Save a deploy token (console Settings API tokens) for that platform url,
3721
- make that url the DEFAULT deploy target for every later command, and echo the
3778
+ validate: `octwin validate [--dir .] [--remote] [--require-kb] [--strict-primitives]
3779
+ Offline structural check, plus two checks driven by the pulled capability
3780
+ reference (render-intent fields, primitive arguments). Those two SKIP when the
3781
+ reference is missing — the run says so, and --require-kb turns the skip into a
3782
+ failure for CI. --remote additionally runs the platform's FULL manifest +
3783
+ flow-DSL validation and its flow lint (all errors at once) — same check as deploy.
3784
+ --strict-primitives (with --remote) additionally type-checks LITERAL args:
3785
+ values against each primitive's declared input schema; expression strings
3786
+ ('$found.id', '{$t(…)}') are always exempt.`,
3787
+ login: `octwin login --url <platformUrl> --token oct_…
3788
+ Save a deploy token (console → Settings → API tokens) for that platform url,
3789
+ make that url the DEFAULT deploy target for every later command, and echo the
3722
3790
  workspace + project pin + scopes the token reaches.`,
3723
- whoami: `octwin whoami [--url <url>] [--tenant <slug>]
3791
+ whoami: `octwin whoami [--url <url>] [--tenant <slug>]
3724
3792
  Verify the resolved token authenticates against the tenant.`,
3725
- projects: `octwin projects [--archived] [--json]
3726
- List the workspace's projects — the slugs every --project flag takes, with the
3727
- plan's project cap. --archived includes archived ones. A pack:deploy token
3728
- reaches this (it names a project in every other command).
3729
-
3730
- octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
3731
- Create a project. The URL slug is derived from the name unless --slug pins one.
3732
- --pack installs an ALREADY-published pack; the usual next step is instead
3733
- \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
3734
-
3735
- octwin projects rm <slug> [--yes]
3736
- HARD delete — the project and everything cascading from it (conversations,
3737
- contacts, records, installs). No undo, and not the same as archiving.
3738
- WITHOUT --yes it only previews what would be destroyed, so the dry run is the
3739
- default. Together these make a disposable end-to-end environment:
3740
- octwin projects create "Scratch" && octwin deploy --project scratch --seed
3741
- octwin chat "hi" --project scratch
3742
- octwin projects rm scratch --yes
3793
+ projects: `octwin projects [--archived] [--json]
3794
+ List the workspace's projects — the slugs every --project flag takes, with the
3795
+ plan's project cap. --archived includes archived ones. A pack:deploy token
3796
+ reaches this (it names a project in every other command).
3797
+
3798
+ octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
3799
+ Create a project. The URL slug is derived from the name unless --slug pins one.
3800
+ --pack installs an ALREADY-published pack; the usual next step is instead
3801
+ \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
3802
+
3803
+ octwin projects rm <slug> [--yes]
3804
+ HARD delete — the project and everything cascading from it (conversations,
3805
+ contacts, records, installs). No undo, and not the same as archiving.
3806
+ WITHOUT --yes it only previews what would be destroyed, so the dry run is the
3807
+ default. Together these make a disposable end-to-end environment:
3808
+ octwin projects create "Scratch" && octwin deploy --project scratch --seed
3809
+ octwin chat "hi" --project scratch
3810
+ octwin projects rm scratch --yes
3743
3811
  Both verbs need the \`projects:write\` scope — a pack:deploy token does NOT confer it.`,
3744
- deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
3745
- Upload the pack bundle, validate server-side, install onto the project.
3812
+ deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
3813
+ Upload the pack bundle, validate server-side, install onto the project.
3746
3814
  --seed additionally applies the pack's demo seed (streams progress).`,
3747
- seed: `octwin seed [--pack <packId>]
3748
- Apply the pack's demo/reference data to the project it is installed on, without
3749
- redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
3750
- and the demo operator topology. Reports what each kind produced.
3751
- Idempotent and safe to re-run — records upsert, and existing media is REUSED rather
3752
- than regenerated, so a second pass costs nothing. --pack is only needed when a
3815
+ seed: `octwin seed [--pack <packId>]
3816
+ Apply the pack's demo/reference data to the project it is installed on, without
3817
+ redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
3818
+ and the demo operator topology. Reports what each kind produced.
3819
+ Idempotent and safe to re-run — records upsert, and existing media is REUSED rather
3820
+ than regenerated, so a second pass costs nothing. --pack is only needed when a
3753
3821
  project somehow runs more than one.`,
3754
- status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
3755
- Show installed vs live version + the flow list for this pack.`,
3756
- records: `octwin records [entity] [id] [--limit 50] [--offset n]
3757
- Inspect the pack's XRM data. No args = list entities. Worked records (cases,
3758
- tickets, anything routed to a queue) read best through \`octwin work\`.
3759
-
3760
- WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
3761
- octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
3762
- octwin records patch <recordId> --entity <entity> --set field=value
3763
- octwin records stage <recordId> --to <stage> [--note "..."]
3764
- octwin records note <recordId> "the note text"
3765
- octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
3766
- octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
3767
-
3768
- --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
3769
- sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
3770
- \`patch\` needs --entity even though it has an id: the route resolves the field
3771
- validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
3822
+ status: `octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
3823
+ Show installed vs live version + the flow list for this pack.
3824
+ The pack id is read from manifest.yaml and QUALIFIED with your workspace slug
3825
+ (a manifest declares a bare name; the owner is attached when you publish). Pass
3826
+ <packId> explicitly to skip that lookup \`octwin agents\` and \`octwin projects\`
3827
+ both print the qualified form.`,
3828
+ records: `octwin records [entity] [id] [--limit 50] [--offset n]
3829
+ Inspect the pack's XRM data. No args = list entities. Worked records (cases,
3830
+ tickets, anything routed to a queue) read best through \`octwin work\`.
3831
+
3832
+ WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
3833
+ octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
3834
+ octwin records patch <recordId> --entity <entity> --set field=value
3835
+ octwin records stage <recordId> --to <stage> [--note "..."]
3836
+ octwin records note <recordId> "the note text"
3837
+ octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
3838
+ octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
3839
+
3840
+ --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
3841
+ sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
3842
+ \`patch\` needs --entity even though it has an id: the route resolves the field
3843
+ validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
3772
3844
  VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
3773
- work: `octwin work [recordId] [--queues] [--limit 50] [--offset n] [--json]
3774
- Inspect the work inbox — every entity the pack declares worked (cases, orders
3775
- needing review, applications, …): the inbox, one item + its timeline
3776
- (+ applicable actions), or --queues for queue keys + open counts.
3777
-
3778
- WRITES (need \`work:write\`; \`stage\` needs \`records:write\`):
3779
- octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none
3780
- octwin work note <recordId> "the note text"
3781
- octwin work stage <recordId> --to <stage> [--note "..."]
3782
- octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]
3783
-
3784
- \`decide\` applies one of the entity's declared operator actions — \`octwin work <id>\`
3785
- lists them with their params. --dry-run previews the customer-facing copy and the
3786
- resulting stage WITHOUT committing (that route needs only \`work:read\`).
3845
+ work: `octwin work [recordId] [--queues] [--limit 50] [--offset n] [--json]
3846
+ Inspect the work inbox — every entity the pack declares worked (cases, orders
3847
+ needing review, applications, …): the inbox, one item + its timeline
3848
+ (+ applicable actions), or --queues for queue keys + open counts.
3849
+
3850
+ WRITES (need \`work:write\`; \`stage\` needs \`records:write\`):
3851
+ octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none
3852
+ octwin work note <recordId> "the note text"
3853
+ octwin work stage <recordId> --to <stage> [--note "..."]
3854
+ octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]
3855
+
3856
+ \`decide\` applies one of the entity's declared operator actions — \`octwin work <id>\`
3857
+ lists them with their params. --dry-run previews the customer-facing copy and the
3858
+ resulting stage WITHOUT committing (that route needs only \`work:read\`).
3787
3859
  \`stage\` is the XRM records verb (one transition spelling platform-wide).`,
3788
- logs: `octwin logs [conversationId] [--as <handle>] [--json]
3789
- No id = recent conversations (handle, status, last activity; --as filters).
3790
- With id = the full event timeline including what each turn rendered.
3860
+ logs: `octwin logs [conversationId] [--as <handle>] [--json]
3861
+ No id = recent conversations (handle, status, last activity; --as filters).
3862
+ With id = the full event timeline including what each turn rendered.
3791
3863
  --json = raw events (verbatim payloads).`,
3792
- pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
3793
- Write a DEPLOYED pack's source back to disk — the inverse of deploy.
3794
- A pack pushed with 'octwin deploy' lives on the platform as an artifact the
3795
- runtime serves but nothing hands back, so its only source copy is the machine
3796
- that pushed it. Pull it, fix it, redeploy it.
3797
- Defaults to the version installed on the target project; --version overrides.
3798
- --dir defaults to ./<packId>; a non-empty dir needs --force.
3799
- The pulled dir redeploys where it came from — the target is your saved login.
3864
+ pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
3865
+ Write a DEPLOYED pack's source back to disk — the inverse of deploy.
3866
+ A pack pushed with 'octwin deploy' lives on the platform as an artifact the
3867
+ runtime serves but nothing hands back, so its only source copy is the machine
3868
+ that pushed it. Pull it, fix it, redeploy it.
3869
+ Defaults to the version installed on the target project; --version overrides.
3870
+ --dir defaults to ./<packId>; a non-empty dir needs --force.
3871
+ The pulled dir redeploys where it came from — the target is your saved login.
3800
3872
  You may pull a pack your tenant OWNS (deployed); an operator token pulls any.`,
3801
- chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
3802
- octwin chat --script <file> [--as <handle>] [--json]
3803
- Drive ONE turn through the dev web channel and print every render with its
3804
- tap ids. Same --as handle = same conversation (multi-turn works).
3805
- --tap presses a rendered button/list row instead of sending text.
3806
- --media uploads a local file (or a media id from 'media generate --json') as
3807
- an image/document/audio inbound — any "message" rides as its caption; feeds a
3808
- running media-collect flow (e.g. activate-app).
3809
- --json dumps the raw SSE envelopes for the turn.
3810
-
3811
- --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
3812
- process over one connection — waiting for each turn to settle before sending
3813
- the next. Use this for any multi-step flow: chaining shell invocations races
3814
- the agent loop, because a turn ends on a quiet gap that can arrive while the
3815
- server is still working (the symptom is placeholder-filled fields or a second
3816
- workflow run). Blank lines and # comments are skipped:
3817
-
3818
- # book an appointment end to end
3819
- احجز موعد
3820
- tap:t:invoke:book-appointment:doctor_id=D1
3821
- media:./licence.jpg | here is my licence
3873
+ chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
3874
+ octwin chat --script <file> [--as <handle>] [--json]
3875
+ Drive ONE turn through the dev web channel and print every render with its
3876
+ tap ids. Same --as handle = same conversation (multi-turn works).
3877
+ --tap presses a rendered button/list row instead of sending text.
3878
+ --media uploads a local file (or a media id from 'media generate --json') as
3879
+ an image/document/audio inbound — any "message" rides as its caption; feeds a
3880
+ running media-collect flow (e.g. activate-app).
3881
+ --json dumps the raw SSE envelopes for the turn.
3882
+
3883
+ --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
3884
+ process over one connection — waiting for each turn to settle before sending
3885
+ the next. Use this for any multi-step flow: chaining shell invocations races
3886
+ the agent loop, because a turn ends on a quiet gap that can arrive while the
3887
+ server is still working (the symptom is placeholder-filled fields or a second
3888
+ workflow run). Blank lines and # comments are skipped:
3889
+
3890
+ # book an appointment end to end
3891
+ احجز موعد
3892
+ tap:t:invoke:book-appointment:doctor_id=D1
3893
+ media:./licence.jpg | here is my licence
3822
3894
  tap:t:resume:book-appointment:run_id=R1;_ctl_approved=true`,
3823
- media: `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]
3824
- AI-generate an image (needs a media:generate-scoped token), store it as a
3825
- public asset, and print its MEDIA- handle + serve URL. --out downloads the
3826
- bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
3827
- width, height, bytes }. Pair with 'octwin chat --media' to drive media flows.`,
3828
- agents: `octwin agents [packId::agentId] [--prompt] [--json]
3829
- No args = the roster with each agent's EFFECTIVE model and which layer set it.
3830
- With an agent = every governed setting (model / memory.last_messages /
3831
- working_memory) plus the layer that won — an operator PLATFORM default can
3832
- override what your manifest declares, and this is where you see that.
3833
- --prompt = the exact system prompt the LLM sees for this project (pack
3834
- instructions + platform protocol + any project overlay). Needs agents:read.
3835
- The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
3836
-
3837
- WRITES (need \`agents:write\`):
3838
- octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
3839
- [--enable-tool <toolId>] [--disable-tool <toolId>]
3840
-
3841
- Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
3842
- so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
3895
+ media: `octwin media generate "<prompt>" [--out <file.png>] [--json]
3896
+ AI-generate an image (needs a media:generate-scoped token), store it as a
3897
+ public asset, and print its MEDIA- handle + serve URL. --out downloads the
3898
+ bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
3899
+ bytes }. Pair with 'octwin chat --media' to drive media flows.`,
3900
+ agents: `octwin agents [packId::agentId] [--prompt] [--json]
3901
+ No args = the roster with each agent's EFFECTIVE model and which layer set it.
3902
+ With an agent = every governed setting (model / memory.last_messages /
3903
+ working_memory) plus the layer that won — an operator PLATFORM default can
3904
+ override what your manifest declares, and this is where you see that.
3905
+ --prompt = the exact system prompt the LLM sees for this project (pack
3906
+ instructions + platform protocol + any project overlay). Needs agents:read.
3907
+ The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
3908
+
3909
+ WRITES (need \`agents:write\`):
3910
+ octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
3911
+ [--enable-tool <toolId>] [--disable-tool <toolId>]
3912
+
3913
+ Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
3914
+ so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
3843
3915
  ids refuses --model with a 403 — the platform default governs there.`,
3844
- orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
3845
- No args = the order list (#number, status/payment, total, contact). With a
3846
- reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
3847
- payment_ref, and the allowed status transitions. Needs orders:read + the
3848
- \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
3849
- so \`pending\` on a gateway-less workspace is expected, not a bug.
3850
-
3851
- WRITES (need \`orders:write\`):
3852
- octwin orders transition <reference_id> --to <status>
3853
- octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
3854
-
3855
- Refund is irreversible and moves money, hence --force. The route answers 200 even
3856
- when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
3857
- on a refusal rather than reporting a refund that never happened. Only a payment in
3916
+ orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
3917
+ No args = the order list (#number, status/payment, total, contact). With a
3918
+ reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
3919
+ payment_ref, and the allowed status transitions. Needs orders:read + the
3920
+ \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
3921
+ so \`pending\` on a gateway-less workspace is expected, not a bug.
3922
+
3923
+ WRITES (need \`orders:write\`):
3924
+ octwin orders transition <reference_id> --to <status>
3925
+ octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
3926
+
3927
+ Refund is irreversible and moves money, hence --force. The route answers 200 even
3928
+ when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
3929
+ on a refusal rather than reporting a refund that never happened. Only a payment in
3858
3930
  \`captured\` state can be refunded; \`payment_status\` is never settable directly.`,
3859
- analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
3860
- No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
3861
- With an entity = stage-by-stage conversion (default --funnel) over the last 30
3862
- days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
3931
+ analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
3932
+ No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
3933
+ With an entity = stage-by-stage conversion (default --funnel) over the last 30
3934
+ days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
3863
3935
  range-filtered). Needs records:read + a \`view\` grant on \`record.<entity>\`.`,
3864
- catalog: `octwin catalog [--readiness] [--json]
3865
- The commerce \`product\` records + price, availability, stock (null = not
3866
- inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
3867
- Graph checklist (LIVE Graph calls; needs a bound access token). Needs
3868
- catalog:read + the \`catalog\` plan feature.
3869
-
3870
- WRITES (need \`catalog:write\`):
3871
- octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
3872
- octwin catalog stock <retailerId> [--set-on-hand <n>]
3873
-
3874
- \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
3875
- inventory-tracked (always sellable), which is different from 0. Lowering on_hand
3876
- below the units already reserved for open carts is refused. Creating/deleting
3936
+ catalog: `octwin catalog [--readiness] [--json]
3937
+ The commerce \`product\` records + price, availability, stock (null = not
3938
+ inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
3939
+ Graph checklist (LIVE Graph calls; needs a bound access token). Needs
3940
+ catalog:read + the \`catalog\` plan feature.
3941
+
3942
+ WRITES (need \`catalog:write\`):
3943
+ octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
3944
+ octwin catalog stock <retailerId> [--set-on-hand <n>]
3945
+
3946
+ \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
3947
+ inventory-tracked (always sellable), which is different from 0. Lowering on_hand
3948
+ below the units already reserved for open carts is refused. Creating/deleting
3877
3949
  products and the Meta catalog binding/sync stay in the console.`,
3878
- scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
3879
- No args = the engine state (bookable resource types, upcoming slots, booked
3880
- seats). --slots <recordId> computes the slots for one bookable resource
3881
- (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
3882
- the availability rules a \`deploy --seed\` created. Needs scheduling:read.
3883
-
3884
- RULES (list needs scheduling:read; add/rm need scheduling:write):
3885
- octwin scheduling rules --resource <resourceRecordId>
3886
- octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
3887
- [--slot-minutes 30] [--capacity 1]
3888
- octwin scheduling rule rm <ruleId>
3889
- octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
3890
- [--start 09:00 --end 13:00]
3891
- octwin scheduling exception rm <exceptionId>
3892
-
3893
- --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
3950
+ scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
3951
+ No args = the engine state (bookable resource types, upcoming slots, booked
3952
+ seats). --slots <recordId> computes the slots for one bookable resource
3953
+ (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
3954
+ the availability rules a \`deploy --seed\` created. Needs scheduling:read.
3955
+
3956
+ RULES (list needs scheduling:read; add/rm need scheduling:write):
3957
+ octwin scheduling rules --resource <resourceRecordId>
3958
+ octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
3959
+ [--slot-minutes 30] [--capacity 1]
3960
+ octwin scheduling rule rm <ruleId>
3961
+ octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
3962
+ [--start 09:00 --end 13:00]
3963
+ octwin scheduling exception rm <exceptionId>
3964
+
3965
+ --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
3894
3966
  \`--slots\` is how you check what a rule actually produces.`,
3895
- 'platform-kb': `octwin platform-kb [pull] [--if-stale] [--check] [--dir .] [--url <url>] [--token <t>]
3896
- Pull the platform capability reference (markdown + JSON catalogs) into
3897
- .octwin/platform-kb/ for the octwin-pack authoring skill, plus three maps:
3898
- INDEX.md (the corpus) · SYMBOLS.md (every name -> its file; grep this) ·
3899
- OUTLINE.md (every heading with its line number).
3900
-
3901
- NO TOKEN NEEDED — the reference is platform stdlib and is served anonymously.
3902
- A token is used when you have one (it also works against older platforms).
3903
-
3904
- --if-stale poll the platform's content_hash first and skip the download when
3905
- nothing changed. Cheap enough to run at the start of every session.
3906
- --check report only, write nothing. Exit 0 = current, 2 = stale or never
3907
- pulled, 1 = could not tell (offline / refused). For scripts and
3967
+ 'platform-kb': `octwin platform-kb [pull] [--if-stale] [--check] [--dir .] [--url <url>] [--token <t>]
3968
+ Pull the platform capability reference (markdown + JSON catalogs) into
3969
+ .octwin/platform-kb/ for the octwin-pack authoring skill, plus three maps:
3970
+ INDEX.md (the corpus) · SYMBOLS.md (every name -> its file; grep this) ·
3971
+ OUTLINE.md (every heading with its line number).
3972
+
3973
+ NO TOKEN NEEDED — the reference is platform stdlib and is served anonymously.
3974
+ A token is used when you have one (it also works against older platforms).
3975
+
3976
+ --if-stale poll the platform's content_hash first and skip the download when
3977
+ nothing changed. Cheap enough to run at the start of every session.
3978
+ --check report only, write nothing. Exit 0 = current, 2 = stale or never
3979
+ pulled, 1 = could not tell (offline / refused). For scripts and
3908
3980
  agent loops that want to branch without parsing prose.`,
3909
- test: `octwin test [--dir .]
3981
+ test: `octwin test [--dir .]
3910
3982
  Alias for \`octwin validate --remote\` — the full platform check.`,
3911
- feedback: `octwin feedback [--dir .]
3912
- Submit this pack's FEEDBACK.md to the platform team.
3913
- The octwin-pack skill writes that file in its last step — findings grouped by
3914
- owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
3915
- you to paste it into a chat.
3916
- Attaches the pack id + version from manifest.yaml, this CLI's version, and the
3917
- content_hash of the capability reference in .octwin/platform-kb/ — triage needs
3918
- the last two to tell "the platform is wrong" from "that was already fixed" or
3983
+ feedback: `octwin feedback [--dir .]
3984
+ Submit this pack's FEEDBACK.md to the platform team.
3985
+ The octwin-pack skill writes that file in its last step — findings grouped by
3986
+ owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
3987
+ you to paste it into a chat.
3988
+ Attaches the pack id + version from manifest.yaml, this CLI's version, and the
3989
+ content_hash of the capability reference in .octwin/platform-kb/ — triage needs
3990
+ the last two to tell "the platform is wrong" from "that was already fixed" or
3919
3991
  "you were reading a stale reference". Needs the \`pack:deploy\` scope.`,
3920
3992
  };
3921
3993
  async function main() {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Turn "the platform is unreachable" into a hint the author can act on.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `fetch('http://localhost:3000')` can be refused while the platform is running
7
+ * and answering. The server binds IPv4-only (`host: '0.0.0.0'` in `src/server.ts`),
8
+ * Node resolves `localhost` to `::1` first, and whether the connection then falls
9
+ * back to `127.0.0.1` is decided by Node's `autoSelectFamily`, NOT by our code.
10
+ *
11
+ * Measured on node v24.12.0, fetching an IPv4-only bind over `localhost`:
12
+ *
13
+ * autoSelectFamily=true (the default here) -> 200
14
+ * autoSelectFamily=false -> fetch failed / ECONNREFUSED
15
+ *
16
+ * So on a runtime where that default is off — or with
17
+ * `--no-network-family-autoselection` / that flag in `NODE_OPTIONS` — every command
18
+ * reports the platform as down while `--url http://127.0.0.1:3000` and `curl` both
19
+ * work. That reading is what costs the time: a pack author's first act is to run the
20
+ * platform locally, and "unreachable" sends them to look at the server.
21
+ *
22
+ * This module only explains; it changes no addresses. Rewriting the host inside
23
+ * `readTarget` would also change the key saved credentials are stored under, and
24
+ * moving the server to `host: '::'` can fail to bind where IPv6 is disabled — so
25
+ * neither belongs in a diagnostic path.
26
+ */
27
+ /** A `localhost` HTTP(S) origin — the only case where the IPv4/IPv6 split applies. */
28
+ function isLocalhostUrl(url) {
29
+ try {
30
+ return new URL(url).hostname.toLowerCase() === 'localhost';
31
+ }
32
+ catch {
33
+ return false; // not a URL we can reason about; no hint to give
34
+ }
35
+ }
36
+ /** Did the connection get refused, as opposed to timing out / DNS / TLS? */
37
+ function isConnectionRefused(err) {
38
+ const seen = new Set();
39
+ for (let e = err; e && !seen.has(e); e = e.cause) {
40
+ seen.add(e);
41
+ if (e.code === 'ECONNREFUSED' || e.code === 'ECONNRESET')
42
+ return true;
43
+ }
44
+ return false;
45
+ }
46
+ /**
47
+ * The extra sentence to append when a failure looks like the IPv6-`localhost`
48
+ * trap, or `null` when it does not — a hint printed on unrelated failures (a
49
+ * genuinely stopped server, a wrong port) is worse than none, because it sends
50
+ * the author to fix an address that was never the problem.
51
+ */
52
+ export function localhostFamilyHint(url, err) {
53
+ if (!isLocalhostUrl(url) || !isConnectionRefused(err))
54
+ return null;
55
+ const ipv4 = url.replace(/(^https?:\/\/)localhost\b/i, '$1127.0.0.1');
56
+ return `if the platform IS running, this is the IPv6 'localhost' trap — Node tried ::1 and the server binds IPv4 only. Retry with --url ${ipv4}`;
57
+ }
@@ -30,9 +30,10 @@ import { lookupKbSubdir, isEntryFile } from './kb-path.js';
30
30
  * "checked, nothing wrong" from "could not check". See `kb-path.ts`.
31
31
  */
32
32
  export function loadAllowedRenderKeys(packDir) {
33
+ const nested = new Map();
33
34
  const lookup = lookupKbSubdir(packDir, 'render-intents', dir => readdirSync(dir).some(isEntryFile));
34
35
  if (lookup.state !== 'ok')
35
- return { lookup, keys: null };
36
+ return { lookup, keys: null, nested };
36
37
  const dir = lookup.dir;
37
38
  const out = new Map();
38
39
  try {
@@ -42,16 +43,20 @@ export function loadAllowedRenderKeys(packDir) {
42
43
  const entry = JSON.parse(readFileSync(join(dir, file), 'utf8'));
43
44
  if (entry.render_intent && Array.isArray(entry.allowed_keys)) {
44
45
  out.set(entry.render_intent, entry.allowed_keys);
46
+ // Optional: a KB pulled before 2026-08-09 has no nested sets, and the nested check simply
47
+ // does not run for that intent. Absent is skipped, never treated as "nothing is allowed".
48
+ if (entry.nested_allowed_keys)
49
+ nested.set(entry.render_intent, entry.nested_allowed_keys);
45
50
  }
46
51
  }
47
52
  }
48
53
  catch (err) {
49
- return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, keys: null };
54
+ return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, keys: null, nested };
50
55
  }
51
56
  if (out.size === 0) {
52
- return { lookup: { state: 'malformed', dir, reason: 'no render-intent entries parsed' }, keys: null };
57
+ return { lookup: { state: 'malformed', dir, reason: 'no render-intent entries parsed' }, keys: null, nested };
53
58
  }
54
- return { lookup, keys: out };
59
+ return { lookup, keys: out, nested };
55
60
  }
56
61
  /**
57
62
  * Walk parsed YAML for objects carrying `render_intent` and report keys outside
@@ -59,8 +64,32 @@ export function loadAllowedRenderKeys(packDir) {
59
64
  * nodes: intents nest (an `auto_collection` carries `empty`/`single`/`multi`
60
65
  * sub-intents), and a stray key is just as invisible there.
61
66
  */
62
- export function findRenderKeyViolations(doc, file, allowedByIntent) {
67
+ export function findRenderKeyViolations(doc, file, allowedByIntent, nestedByIntent = new Map()) {
63
68
  const findings = [];
69
+ /**
70
+ * Descend a declared nested path, mirroring the platform's `checkNested`.
71
+ *
72
+ * Descends only where the KB DECLARES a shape, and skips non-objects — so an expression string
73
+ * (`item_template: '$tpl'`) and an undeclared bag (`items:`, pack-supplied records) are both left
74
+ * alone, exactly as at load time.
75
+ */
76
+ const walkNested = (value, intent, at, sets, path) => {
77
+ if (Array.isArray(value)) {
78
+ value.forEach((v, i) => walkNested(v, intent, `${at}[]`, sets, [...path, i]));
79
+ return;
80
+ }
81
+ if (!value || typeof value !== 'object')
82
+ return;
83
+ const obj = value;
84
+ const allowed = sets[at];
85
+ if (allowed) {
86
+ const bad = Object.keys(obj).filter(k => !allowed.includes(k));
87
+ if (bad.length)
88
+ findings.push({ file, path: [...path], intent, keys: bad, allowed, nestedAt: at });
89
+ }
90
+ for (const [k, v] of Object.entries(obj))
91
+ walkNested(v, intent, `${at}.${k}`, sets, [...path, k]);
92
+ };
64
93
  const walk = (node, path) => {
65
94
  if (Array.isArray(node)) {
66
95
  node.forEach((v, i) => walk(v, [...path, i]));
@@ -78,6 +107,14 @@ export function findRenderKeyViolations(doc, file, allowedByIntent) {
78
107
  const bad = Object.keys(obj).filter(k => !allowed.includes(k));
79
108
  if (bad.length)
80
109
  findings.push({ file, path: [...path], intent: obj.render_intent, keys: bad, allowed });
110
+ const sets = nestedByIntent.get(obj.render_intent);
111
+ if (sets) {
112
+ for (const [k, v] of Object.entries(obj)) {
113
+ if (k === 'render_intent' || !allowed.includes(k))
114
+ continue;
115
+ walkNested(v, obj.render_intent, k, sets, [...path, k]);
116
+ }
117
+ }
81
118
  }
82
119
  }
83
120
  for (const [k, v] of Object.entries(obj))
@@ -93,6 +130,7 @@ export function describeRenderFinding(f) {
93
130
  return `${f.file}${at}: unknown render_intent '${f.intent}' — known intents: ${f.allowed.join(', ')}`;
94
131
  }
95
132
  const plural = f.keys.length === 1 ? 'field' : 'fields';
96
- return `${f.file}${at}: render_intent '${f.intent}' has unknown ${plural} ${f.keys.map(k => `'${k}'`).join(', ')} ` +
133
+ const where = f.nestedAt ? `'${f.intent}' at '${f.nestedAt}'` : `'${f.intent}'`;
134
+ return `${f.file}${at}: render_intent ${where} has unknown ${plural} ${f.keys.map(k => `'${k}'`).join(', ')} ` +
97
135
  `— silently dropped at render. Allowed: ${f.allowed.join(', ')}`;
98
136
  }
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "octwin-cli",
3
- "version": "0.6.0",
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": ">=20"
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.6.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
+ }
@@ -14,6 +14,16 @@ description: 'A hello bot — edit this to build your pack.'
14
14
  supported_channels: [whatsapp, web]
15
15
  required_adapters: [messaging]
16
16
 
17
+ # What to do with a VOICE NOTE. WhatsApp cannot stop customers sending them, and an
18
+ # ABSENT block is not "nothing happens" — it means `passthrough`, i.e. no
19
+ # speech-to-text, so the agent gets "a voice message arrived, no transcript" and has
20
+ # to improvise. `transcribe` is the right default for a conversational pack; use
21
+ # `passthrough` when you handle the audio ref yourself, or `decline` (with a
22
+ # `notice:`) to ask for text instead.
23
+ inbound_preprocessing:
24
+ voicenote:
25
+ mode: transcribe
26
+
17
27
  # Default locale for platform-emitted copy + this pack's `$t()` lookups.
18
28
  default_settings:
19
29
  locale: ar