octwin-cli 0.5.1 → 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/dist/index.js CHANGED
@@ -15,19 +15,19 @@
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
- * octwin cases [caseId] [--queues] # inspect casework (support tickets) — list / one case + timeline
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
28
28
  * octwin catalog [--readiness] # commerce products + stock + the WhatsApp catalog binding (catalog:read)
29
29
  * octwin scheduling [--slots <resourceRecordId>] # engine state / computed slots (scheduling:read)
30
- * octwin platform-kb [pull] [--dir .] # pull the platform capability reference for the authoring skill
30
+ * octwin platform-kb [pull] [--if-stale|--check] [--dir .] # the platform capability reference (no token needed)
31
31
  * octwin test [--dir .] # = validate --remote (the full platform check)
32
32
  *
33
33
  * Multi-turn testing: the platform keeps ONE open conversation per `--as` handle,
@@ -58,12 +58,20 @@ import { homedir } from 'node:os';
58
58
  import { fileURLToPath } from 'node:url';
59
59
  import { parse as parseYaml } from 'yaml';
60
60
  import { applyRenames } from './lib/rename.js';
61
- import { validatePackBundle } from './lib/validate.js';
61
+ import { validatePackBundle, describePackNameProblem, asPackName } from './lib/validate.js';
62
62
  import { loadAllowedRenderKeys, findRenderKeyViolations, describeRenderFinding } from './lib/render-check.js';
63
63
  import { loadPrimitiveArgSpecs, findArgViolations, describeArgFinding } from './lib/args-check.js';
64
+ import { yamlLineOf } from './lib/yaml-pos.js';
65
+ import { localhostFamilyHint } from './lib/net-hint.js';
66
+ import { loadBuiltinNames, findBuiltinViolations, describeBuiltinFinding } from './lib/builtin-check.js';
67
+ import { loadTemplateSpecs, findTemplateViolations, describeTemplateFinding } from './lib/template-check.js';
68
+ import { loadSystemEntities, findEntityViolations, describeEntityFinding } from './lib/entity-check.js';
69
+ import { loadDeclarationSpecs, findDeclarationViolations, describeDeclarationFinding } from './lib/declaration-check.js';
64
70
  import { describeKbLookup, findPlatformKbDir } from './lib/kb-path.js';
65
71
  import { classifyPackPath, isSkippedDir } from './lib/pack-source.js';
66
72
  import { readPage, morePageHint } from './lib/page.js';
73
+ import { kbOneLiner, buildKbIndexMarkdown, buildKbOutlineMarkdown, } from './lib/kb-index.js';
74
+ import { buildSymbols, linkExplainers, renderSymbolsMarkdown } from './lib/kb-symbols.js';
67
75
  // The in-package starter template ships alongside `dist/` and `src/` (both one
68
76
  // level under the package root), so `../templates/starter` resolves for the
69
77
  // built CLI and `tsx` dev alike.
@@ -157,7 +165,8 @@ async function fetchOrDie(url, init, what) {
157
165
  return await fetch(url, init);
158
166
  }
159
167
  catch (err) {
160
- 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}` : ''}`);
161
170
  }
162
171
  }
163
172
  /** One consistent explanation for auth failures on admin reads. A 401 can also
@@ -170,7 +179,7 @@ function authFailureHint(status, url) {
170
179
  }
171
180
  /**
172
181
  * Write VERBS need a different scope than the read they share a command with —
173
- * `octwin cases` is `cases:read`, `octwin cases note` is `cases:write` — so the
182
+ * `octwin work` is `work:read`, `octwin work note` is `work:write` — so the
174
183
  * requirement is resolved by `<command> <verb>` first, then by command.
175
184
  *
176
185
  * Keyed on the verb rather than duplicating whole commands, because the hint's
@@ -183,13 +192,17 @@ const VERB_REQUIREMENTS = {
183
192
  'records note': { scope: 'records:write', feature: 'records' },
184
193
  'records tasks': { scope: 'records:read', feature: 'tasks' },
185
194
  'records task': { scope: 'records:write', feature: 'tasks' },
186
- 'cases assign': { scope: 'cases:write', feature: 'cases' },
187
- 'cases note': { scope: 'cases:write', feature: 'cases' },
188
- 'cases transition': { scope: 'cases:write', feature: 'cases' },
189
- // `decide --dry-run` hits the PREVIEW route, which is `cases:read`. Naming the
195
+ 'work assign': { scope: 'work:write', feature: 'work' },
196
+ 'work note': { scope: 'work:write', feature: 'work' },
197
+ // The stage move rides the XRM records verb (`POST …/xrm/records/:id/stage`) —
198
+ // the work surface deliberately has no second transition spelling (P-02).
199
+ 'work stage': { scope: 'records:write', feature: 'records' },
200
+ // `decide --dry-run` hits the PREVIEW route, which is `work:read`. Naming the
190
201
  // write scope is still the right hint: the committing form is the default.
191
- 'cases decide': { scope: 'cases:write', feature: 'cases' },
192
- 'orders transition': { scope: 'orders:write', feature: 'orders' },
202
+ 'work decide': { scope: 'work:write', feature: 'work' },
203
+ // The stage move rides the XRM records verb (P-02), so the scope is records:write
204
+ // the read that resolves the reference still needs orders:read.
205
+ 'orders transition': { scope: 'records:write', feature: 'records' },
193
206
  'orders refund': { scope: 'orders:write', feature: 'orders' },
194
207
  'catalog availability': { scope: 'catalog:write', feature: 'catalog' },
195
208
  'catalog stock': { scope: 'catalog:write', feature: 'catalog' },
@@ -213,7 +226,12 @@ const COMMAND_REQUIREMENTS = {
213
226
  // same scope. Omitting it here meant a 403 on the one command that recovers a pack's
214
227
  // only source copy printed the generic hint WITHOUT naming the scope to grant.
215
228
  pull: { scope: 'pack:deploy' },
216
- 'platform-kb': { scope: 'pack:deploy' },
229
+ // `platform-kb` deliberately has NO requirement: the reference is platform stdlib,
230
+ // and the platform serves it anonymously at `/api/public/octwin-platform-kb` so a
231
+ // cold start needs no login. A token is still USED when present (the tenant-scoped
232
+ // route also works against platforms predating the public one) — but declaring a
233
+ // requirement here would print "needs pack:deploy" on a failure whose real cause is
234
+ // an unreachable instance.
217
235
  feedback: { scope: 'pack:deploy' },
218
236
  media: { scope: 'media:generate' },
219
237
  // The plan feature gates RECORD reads, not the entity list (`/xrm/entities` carries only
@@ -221,7 +239,7 @@ const COMMAND_REQUIREMENTS = {
221
239
  // plan for a 403 the plan did not cause.
222
240
  records: { scope: 'records:read', feature: 'records', featureAppliesTo: 'reading records (listing entities needs only the scope)' },
223
241
  analytics: { scope: 'records:read', feature: 'records' },
224
- cases: { scope: 'cases:read', feature: 'cases' },
242
+ work: { scope: 'work:read', feature: 'work' },
225
243
  logs: { scope: 'conversations:read' },
226
244
  orders: { scope: 'orders:read', feature: 'orders' },
227
245
  catalog: { scope: 'catalog:read', feature: 'catalog' },
@@ -234,7 +252,7 @@ const COMMAND_REQUIREMENTS = {
234
252
  };
235
253
  /** The command currently running — set once in `main()` so any failure printer can
236
254
  * name the scope that command needs without threading it through every call.
237
- * Carries the write VERB too (`cases note`), since that is what decides the scope. */
255
+ * Carries the write VERB too (`work note`), since that is what decides the scope. */
238
256
  let CURRENT_COMMAND;
239
257
  /** `→ needs the \`orders:read\` scope …` — the requirement line for the running
240
258
  * command, or '' when the command has no declared requirement. */
@@ -484,6 +502,13 @@ async function notifyIfOutdated() {
484
502
  }
485
503
  catch { /* a version check must never break the CLI */ }
486
504
  }
505
+ // ── platform-KB drift check (observe the pulled reference, fail-silent) ──
506
+ //
507
+ // `KbIndexEntry` / `KbEntriesDescriptor` mirror the platform's own types and live
508
+ // in `lib/kb-index.js` beside the renderers that consume them. `entries` (list
509
+ // catalogs only) describes how to enumerate a catalog so we can EXPLODE it into
510
+ // one file per entry; the PLATFORM supplies it rather than this CLI hardcoding
511
+ // per-catalog knowledge, so a platform that adds a catalog needs no CLI release.
487
512
  /** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
488
513
  * (content hash + per-entry index), or null if nothing has been pulled yet. */
489
514
  function readLocalKb(packDir) {
@@ -515,15 +540,44 @@ function diffKbIndex(prev, next) {
515
540
  const removed = prev.filter(e => !nextKeys.has(e.key)).map(e => e.key);
516
541
  return { added, removed, changed };
517
542
  }
543
+ /**
544
+ * Fetch the platform's KB identity (`?meta=1`) — the cheap poll behind both the
545
+ * drift nudge and `--if-stale`. Returns null on anything that is not a clean
546
+ * answer; the caller decides whether that is worth a word.
547
+ *
548
+ * `notAuthorized` is reported separately because it is the one failure with a
549
+ * fix the author can act on.
550
+ */
551
+ async function fetchKbMeta(t, timeoutMs = 2_000) {
552
+ const ep = kbEndpoint(t);
553
+ const ctrl = new AbortController();
554
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
555
+ try {
556
+ const res = await fetch(`${ep.url}?meta=1`, { headers: ep.headers, signal: ctrl.signal });
557
+ if (!res.ok)
558
+ return { ok: false, notAuthorized: res.status === 401 || res.status === 403 };
559
+ return { ok: true, meta: await res.json() };
560
+ }
561
+ catch {
562
+ return { ok: false, notAuthorized: false };
563
+ }
564
+ finally {
565
+ clearTimeout(timer);
566
+ }
567
+ }
518
568
  /** Nudge (to stderr) when the platform's capability KB has changed since the last
519
569
  * `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
520
570
  * of the CLI: run only after commands that already hit the platform, so this adds
521
571
  * a single tiny `?meta=1` GET on top of work that was networked anyway. Never
522
572
  * throws — observing must never break a command. No-op until the author has pulled
523
- * once (nothing to compare) or if the target/token can't be resolved. */
573
+ * once (nothing to compare) or if the target can't be resolved.
574
+ *
575
+ * NOT TTY-gated, deliberately. It was, on the reasoning that a nudge is for a
576
+ * human watching a terminal — but the primary reader of this CLI is now an
577
+ * authoring agent whose output is piped, and gating on `isTTY` meant the one
578
+ * reader that CANNOT notice a stale reference on its own was the only one never
579
+ * told. It is a single line on stderr, so piped stdout stays clean either way. */
524
580
  async function notifyIfKbStale(flags) {
525
- if (!process.stdout.isTTY)
526
- return;
527
581
  try {
528
582
  const packDir = resolve(flags.dir ?? '.');
529
583
  const local = readLocalKb(packDir);
@@ -532,25 +586,20 @@ async function notifyIfKbStale(flags) {
532
586
  const t = resolveTargetOrNull(flags);
533
587
  if (!t)
534
588
  return;
535
- const ctrl = new AbortController();
536
- const timer = setTimeout(() => ctrl.abort(), 2_000);
537
- const res = await fetch(`${t.url}/api/self/t/octwin-platform-kb?meta=1`, {
538
- headers: authHeaders(t), signal: ctrl.signal,
539
- });
540
- clearTimeout(timer);
541
- if (!res.ok) {
542
- // The meta poll needs `pack:deploy`, but this nudge rides on every networked
543
- // command so an author inspecting data with a narrow (`records:read`-only) token
544
- // got NO drift signal at all, silently, and a stale reference is exactly what makes
545
- // an author invent a primitive from memory. Say so once; stay silent for every other
546
- // failure (offline, timeout, a platform without the route).
547
- if (res.status === 401 || res.status === 403) {
548
- console.error('\nⓘ can\'t check whether the platform capability reference drifted — that needs a `pack:deploy` token.');
549
- console.error(' Refresh it directly with a deploy token: octwin platform-kb --token oct_…');
589
+ const polled = await fetchKbMeta(t);
590
+ if (!polled.ok) {
591
+ // The tenant-scoped meta poll needs `pack:deploy`, but this nudge rides on every
592
+ // networked command — so an author inspecting data with a narrow (`records:read`-only)
593
+ // token got NO drift signal at all, silently, and a stale reference is exactly what
594
+ // makes an author invent a primitive from memory. Say so once; stay silent for every
595
+ // other failure (offline, timeout, a platform without the route).
596
+ 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_…)');
550
599
  }
551
600
  return;
552
601
  }
553
- const meta = await res.json();
602
+ const meta = polled.meta;
554
603
  if (meta.content_hash && meta.content_hash !== local.content_hash) {
555
604
  // Per-entry summary (now that the index carries per-entry hashes) — the
556
605
  // exact list of what changed is one `octwin platform-kb` away.
@@ -566,7 +615,7 @@ async function notifyIfKbStale(flags) {
566
615
  summary = ` (${parts.join(' · ')})`;
567
616
  }
568
617
  console.error(`\n⬆ the platform capability reference changed since you last pulled it${summary}.`);
569
- console.error(' Refresh it: octwin platform-kb');
618
+ console.error(' Refresh it: octwin platform-kb --if-stale');
570
619
  }
571
620
  }
572
621
  catch { /* a KB check must never break the CLI */ }
@@ -584,7 +633,7 @@ function commandTouchesPlatform(command, flags) {
584
633
  case 'media':
585
634
  case 'pull':
586
635
  case 'records':
587
- case 'cases':
636
+ case 'work':
588
637
  case 'logs':
589
638
  case 'whoami':
590
639
  case 'feedback':
@@ -602,9 +651,14 @@ function commandTouchesPlatform(command, flags) {
602
651
  function cmdInit(flags) {
603
652
  const target = flags._[0] ?? die('usage: octwin init <dir> [--id my-pack]');
604
653
  const dir = resolve(target);
654
+ // A BARE name, and it stays bare: the platform prefixes the workspace slug at publish,
655
+ // which is why `init` can stay offline (it has no idea which workspace this will deploy
656
+ // to, and no longer needs one) and why the same scaffold deploys into two workspaces as
657
+ // two packs with nothing to edit in between.
605
658
  const id = flags.id ?? target.replace(/[/\\]/g, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
606
- if (!/^[a-z][a-z0-9-]*$/.test(id))
607
- die(`pack id '${id}' must be lowercase ASCII with hyphens — pass --id`);
659
+ const idProblem = describePackNameProblem(id);
660
+ if (idProblem)
661
+ die(`${idProblem}\n → pass --id <pack-name>`);
608
662
  if (existsSync(dir) && readdirSync(dir).length > 0)
609
663
  die(`target '${dir}' is not empty`);
610
664
  if (!existsSync(TEMPLATE_DIR))
@@ -636,7 +690,14 @@ function cmdInit(flags) {
636
690
  }
637
691
  function localValidate(packDir) {
638
692
  const { files, blobs } = collectBundleFiles(packDir);
639
- const { id, version } = readManifestIdVersion(files);
693
+ const { id: rawId, version } = readManifestIdVersion(files);
694
+ // The manifest declares a BARE name; the platform prefixes your workspace at publish.
695
+ // Minted here so an unusable one is reported with the rest of the bundle's errors.
696
+ const id = asPackName(rawId);
697
+ if (!id) {
698
+ console.error(` ✗ ${describePackNameProblem(rawId)}`);
699
+ die('bundle validation failed (1 error)');
700
+ }
640
701
  const r = validatePackBundle(id, files, blobs);
641
702
  if (!r.ok) {
642
703
  for (const e of r.errors)
@@ -660,17 +721,36 @@ async function cmdValidate(flags) {
660
721
  return [];
661
722
  }
662
723
  });
663
- // Checks that need the pulled KB. Both DEGRADE when it is absent — the KB is a
664
- // gitignored cache wiped by every pull, and `platform-kb pull` needs a
665
- // `pack:deploy` scope a CI job may not have, so failing hard would break a fresh
666
- // clone before the author could act. But a skip is now ANNOUNCED, and remembered:
724
+ // Checks that need the pulled KB. All of them DEGRADE when it is absent — the KB
725
+ // is a gitignored cache wiped by every pull, so failing hard would break a fresh
726
+ // clone before the author could act. But a skip is ANNOUNCED, and remembered:
667
727
  // the ✓ used to print above these blocks unconditionally while the per-check ✓s
668
728
  // lived inside the `if`s, so a KB-less run read as "one check, passed". An entire
669
729
  // backlog batch reached production that way. The defect is the silence, not the skip.
730
+ //
731
+ // Reasons are COLLECTED rather than printed inline. When the KB is missing, every
732
+ // check skips for the identical reason, and six copies of one sentence is how a
733
+ // reader learns to scroll past the ⚠ block — which is the same failure as not
734
+ // printing it. One line, naming all six.
670
735
  const skipped = [];
736
+ const skipReasons = new Map();
737
+ const noteSkip = (label, lookup) => {
738
+ skipped.push(label);
739
+ // Group by the lookup's IDENTITY, then let `describeKbLookup` phrase the one
740
+ // line at print time — so the wording stays in the module that owns it and
741
+ // cannot drift into a doubled "SKIPPED — SKIPPED —".
742
+ const key = lookup.state === 'ok' ? 'ok'
743
+ : `${lookup.state}|${'dir' in lookup ? lookup.dir : ''}|${'reason' in lookup ? lookup.reason : ''}`;
744
+ const bucket = skipReasons.get(key) ?? { lookup, labels: [] };
745
+ bucket.labels.push(label);
746
+ skipReasons.set(key, bucket);
747
+ };
748
+ /** Stamp each finding with its source line — the walkers carry the node
749
+ * path; `files` holds the raw text the locator needs (E-08). */
750
+ const withLines = (fs) => fs.map(f => ({ ...f, line: files[f.file] ? yamlLineOf(files[f.file], f.path) : null }));
671
751
  const render = loadAllowedRenderKeys(packDir);
672
752
  if (render.keys) {
673
- const findings = yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys));
753
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys, render.nested)));
674
754
  if (findings.length) {
675
755
  console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
676
756
  for (const f of findings)
@@ -680,14 +760,13 @@ async function cmdValidate(flags) {
680
760
  console.log('✓ render intents use only fields the platform renders');
681
761
  }
682
762
  else {
683
- console.log(`⚠ ${describeKbLookup(render.lookup, 'render-intent fields')}`);
684
- skipped.push('render-intent fields');
763
+ noteSkip('render-intent fields', render.lookup);
685
764
  }
686
765
  // Primitive `args:` keys, same source and same contract. Cannot see inside a
687
766
  // `use:` template body (expansion is the platform's job); `--remote` covers that.
688
767
  const args = loadPrimitiveArgSpecs(packDir);
689
768
  if (args.specs) {
690
- const findings = yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs));
769
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs)));
691
770
  if (findings.length) {
692
771
  console.error(`✗ ${findings.length} primitive-argument error${findings.length === 1 ? '' : 's'}:`);
693
772
  for (const f of findings)
@@ -697,8 +776,104 @@ async function cmdValidate(flags) {
697
776
  console.log('✓ primitive arguments match their declared inputs');
698
777
  }
699
778
  else {
700
- console.log(`⚠ ${describeKbLookup(args.lookup, 'primitive arguments')}`);
701
- skipped.push('primitive arguments');
779
+ noteSkip('primitive arguments', args.lookup);
780
+ }
781
+ // Expression builtins — the function set is CLOSED and generated from the
782
+ // runtime, so an invented `$fn(` is checkable here and nowhere else offline.
783
+ const builtins = loadBuiltinNames(packDir);
784
+ if (builtins.names) {
785
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findBuiltinViolations(doc, p, builtins.names)));
786
+ if (findings.length) {
787
+ console.error(`✗ ${findings.length} unknown expression function${findings.length === 1 ? '' : 's'}:`);
788
+ for (const f of findings)
789
+ console.error(` ✗ ${describeBuiltinFinding(f)}`);
790
+ die('fix these before deploying — the evaluator cannot resolve them, and it fails mid-conversation');
791
+ }
792
+ console.log('✓ every $function() in an expression exists');
793
+ }
794
+ else {
795
+ noteSkip('expression functions', builtins.lookup);
796
+ }
797
+ // `use:` templates. A pack's OWN templates shadow the platform's, so they are
798
+ // named here and skipped — this check has no schema for them.
799
+ const templates = loadTemplateSpecs(packDir);
800
+ if (templates.specs) {
801
+ // A pack template is `templates/<name>.template.yaml` — the `.template`
802
+ // segment is part of the convention the expander scans for, NOT part of the
803
+ // name a `use:` writes. Capturing it would leave every pack that shadows a
804
+ // platform template (kaiian shadows `field_prompt_render`) reported as using
805
+ // one that does not exist.
806
+ const packTemplates = new Set(Object.keys(files)
807
+ .map(p => /^templates\/(.+)\.template\.ya?ml$/i.exec(p.replace(/\\/g, '/'))?.[1])
808
+ .filter((n) => !!n));
809
+ const findings = yamlDocs().flatMap(([p, doc]) => findTemplateViolations(doc, p, templates.specs, packTemplates));
810
+ if (findings.length) {
811
+ console.error(`✗ ${findings.length} template error${findings.length === 1 ? '' : 's'}:`);
812
+ for (const f of findings)
813
+ console.error(` ✗ ${describeTemplateFinding(f)}`);
814
+ die('fix these before deploying — a template param that does not exist arrives as undefined, and renders as a blank');
815
+ }
816
+ console.log('✓ `use:` templates and their params exist');
817
+ }
818
+ else {
819
+ noteSkip('`use:` templates', templates.lookup);
820
+ }
821
+ // Reserved XRM entity keys — a boot error, which means the pack deploys clean
822
+ // and then fails to load on the first inbound message.
823
+ const system = loadSystemEntities(packDir);
824
+ if (system.entities) {
825
+ const findings = yamlDocs()
826
+ .filter(([p]) => /(^|[/\\])xrm\.ya?ml$/i.test(p))
827
+ .flatMap(([p, doc]) => findEntityViolations(doc, p, system.entities, system.rules));
828
+ if (findings.length) {
829
+ console.error(`✗ ${findings.length} reserved-entity error${findings.length === 1 ? '' : 's'}:`);
830
+ for (const f of findings)
831
+ console.error(` ✗ ${describeEntityFinding(f)}`);
832
+ die('fix these before deploying — these fail at BOOT, after a deploy that reported success');
833
+ }
834
+ // Name the narrower promise when the catalog-level rules are absent (a KB
835
+ // pulled before they were published) — a ✓ that reads wider than what ran is
836
+ // the failure this file's skip contract exists to prevent.
837
+ console.log(system.rules
838
+ ? '✓ no entity collides with a reserved platform key, and no extension overrides a platform-owned one'
839
+ : '✓ no entity collides with a reserved platform key (re-pull for the `contact`/extension-override rules)');
840
+ }
841
+ else {
842
+ noteSkip('reserved entity keys', system.lookup);
843
+ }
844
+ // The declaration files themselves, against the published JSON Schemas.
845
+ // Deliberately narrow (see declaration-check.ts) — it walks away from anything
846
+ // it cannot read rather than guessing.
847
+ const decls = loadDeclarationSpecs(packDir);
848
+ if (decls.specs) {
849
+ const findings = yamlDocs().flatMap(([p, doc]) => {
850
+ const base = p.replace(/\\/g, '/').split('/').pop() ?? p;
851
+ const spec = decls.specs.get(base);
852
+ // Only a file at the PACK ROOT is a declaration — `flows/tools/xrm.yaml`
853
+ // would be a flow that happens to share a name.
854
+ if (!spec || p.replace(/\\/g, '/').includes('/'))
855
+ return [];
856
+ return findDeclarationViolations(doc, spec).map(f => ({ ...f, file: p }));
857
+ });
858
+ if (findings.length) {
859
+ console.error(`✗ ${findings.length} declaration error${findings.length === 1 ? '' : 's'}:`);
860
+ for (const f of findings)
861
+ console.error(` ✗ ${describeDeclarationFinding(f)}`);
862
+ die('fix these before deploying — a declaration file is parsed strictly, and an unknown key is rejected');
863
+ }
864
+ // Deliberately narrow wording. Most of `xrm.yaml`'s field shapes are a Zod
865
+ // union, which renders as `anyOf` and which this check walks away from by
866
+ // design — so "matches its schema" would be a promise it does not keep, and
867
+ // an over-claimed ✓ is how an author stops reading `--remote` output.
868
+ console.log('✓ declaration files carry no unknown or missing keys (unions are left to --remote)');
869
+ }
870
+ else {
871
+ noteSkip('declaration schemas', decls.lookup);
872
+ }
873
+ // One ⚠ per distinct reason, naming every check it cost.
874
+ for (const { lookup, labels } of skipReasons.values()) {
875
+ const what = labels.length === 1 ? labels[0] : `${labels.length} checks (${labels.join(', ')})`;
876
+ console.log(`⚠ ${describeKbLookup(lookup, what)}`);
702
877
  }
703
878
  // `--require-kb` is for CI, where a skip nobody reads is worse than a red build.
704
879
  if (skipped.length && flags['require-kb'] === true) {
@@ -708,7 +883,7 @@ async function cmdValidate(flags) {
708
883
  // The LAST line carries the skip. A reader who sees a ✓ and stops there is the
709
884
  // failure mode; a caveat printed ABOVE the ✓ does not fix it.
710
885
  if (skipped.length) {
711
- console.log(`\n⚠ ${id}@${version} passed the checks that RAN — ${skipped.join(' and ')} ${skipped.length === 1 ? 'was' : 'were'} skipped.`);
886
+ console.log(`\n⚠ ${id}@${version} passed the checks that RAN — ${skipped.length} skipped: ${skipped.join(', ')}.`);
712
887
  console.log(' Run `octwin platform-kb pull` (once, at your repo root — it covers every pack under it),');
713
888
  console.log(' or `octwin validate --remote` to have the platform run everything server-side.');
714
889
  }
@@ -723,10 +898,16 @@ async function cmdValidate(flags) {
723
898
  const t = resolveTarget(flags);
724
899
  const { url } = t;
725
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.
726
904
  const res = await fetchOrDie(`${url}/api/self/p/packs/validate`, {
727
905
  method: 'POST',
728
906
  headers: { 'content-type': 'application/json', ...authHeaders(t) },
729
- body: JSON.stringify({ files, blobs }),
907
+ body: JSON.stringify({
908
+ files, blobs,
909
+ ...(flags['strict-primitives'] === true ? { strict_primitives: true } : {}),
910
+ }),
730
911
  }, 'remote validate');
731
912
  const text = await res.text();
732
913
  let json;
@@ -838,6 +1019,24 @@ function resolveTargetOrNull(flags) {
838
1019
  const t = readTarget(flags);
839
1020
  return t.url && t.token ? t : null;
840
1021
  }
1022
+ /**
1023
+ * Where to read the platform capability reference from, and how.
1024
+ *
1025
+ * The KB is tenant-independent platform stdlib, and the platform now serves it
1026
+ * anonymously at `/api/public/octwin-platform-kb` precisely so a COLD START does
1027
+ * not need a login: `.octwin/` is gitignored, so a fresh clone — or a fresh
1028
+ * Claude Code session — has no reference at all, and an authoring agent's first
1029
+ * useful question came after a credential round-trip it had no way to satisfy.
1030
+ *
1031
+ * A token still WINS when one is available: the tenant-scoped route is the one
1032
+ * that has always existed, it works against platforms that predate the public
1033
+ * rung, and using it keeps the author's own instance the source of truth.
1034
+ */
1035
+ function kbEndpoint(t) {
1036
+ return t.token
1037
+ ? { url: `${t.url}/api/self/t/octwin-platform-kb`, headers: authHeaders(t), anonymous: false }
1038
+ : { url: `${t.url}/api/public/octwin-platform-kb`, headers: {}, anonymous: true };
1039
+ }
841
1040
  /** The raw resolution both wrappers share — may return empty url/token. */
842
1041
  function readTarget(flags) {
843
1042
  const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? savedDefaultUrl()).replace(/\/$/, '');
@@ -1066,13 +1265,13 @@ async function cmdSeed(flags) {
1066
1265
  const { terminal: final, stepErrors } = await readDeployProgress(res.body);
1067
1266
  if (!final || final.stage === 'error')
1068
1267
  die(`seed failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
1069
- console.log(`
1268
+ console.log(`
1070
1269
  ✓ ${final.message ?? 'seed complete'}`);
1071
1270
  printSeedCounts(final.result?.seeded);
1072
1271
  if (stepErrors.length) {
1073
1272
  // A kind failed but the rest ran — the reconcile softens each step. Say which,
1074
1273
  // and exit non-zero so a scripted `seed && chat` doesn't read as clean.
1075
- console.error(`
1274
+ console.error(`
1076
1275
  ⚠ ${stepErrors.length} step${stepErrors.length === 1 ? '' : 's'} failed — data may be incomplete:`);
1077
1276
  for (const e of stepErrors)
1078
1277
  console.error(` • ${e}`);
@@ -1154,6 +1353,33 @@ async function cmdDeploy(flags) {
1154
1353
  }
1155
1354
  printDeploySuccess(id, version, t, json);
1156
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
+ }
1157
1383
  async function cmdStatus(flags) {
1158
1384
  const packDir = resolve(flags.dir ?? '.');
1159
1385
  const t = resolveTarget(flags);
@@ -1164,8 +1390,11 @@ async function cmdStatus(flags) {
1164
1390
  const doc = parseYaml(readFileSync(manifestPath, 'utf8'));
1165
1391
  if (typeof doc?.id !== 'string')
1166
1392
  die('manifest.yaml must declare a string `id`');
1167
- const id = doc.id;
1393
+ const bare = doc.id;
1168
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);
1169
1398
  console.log(`→ Checking ${id}@${localVersion} on ${targetLabel(t)} @ ${url} …`);
1170
1399
  const res = await fetchOrDie(`${url}/api/self/p/packs/${id}/runtime`, {
1171
1400
  headers: authHeaders(t),
@@ -1179,8 +1408,16 @@ async function cmdStatus(flags) {
1179
1408
  json = text;
1180
1409
  }
1181
1410
  if (!res.ok) {
1182
- if (res.status === 404)
1183
- 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
+ }
1184
1421
  console.error(`✗ status check failed (HTTP ${res.status})`);
1185
1422
  printAuthHint(res.status, url);
1186
1423
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
@@ -1231,35 +1468,10 @@ async function cmdStatus(flags) {
1231
1468
  function kbEntryFileName(name) {
1232
1469
  return name.replace(/[^A-Za-z0-9._-]/g, '_');
1233
1470
  }
1234
- /** First sentence (or a hard clamp) of a possibly-long `describe`INDEX.md needs
1235
- * one scannable line per entry, not the whole contract.
1236
- *
1237
- * Sentence detection ignores punctuation nested in brackets: primitive `describe`
1238
- * text routinely inlines an envelope shape (`… { rows, total, …, refs? } …`) whose
1239
- * `?` would otherwise cut the summary off mid-brace. */
1240
- function kbOneLiner(text, max = 160) {
1241
- if (typeof text !== 'string' || !text.trim())
1242
- return '';
1243
- const flat = text.replace(/\s+/g, ' ').trim();
1244
- let depth = 0;
1245
- let end = -1;
1246
- for (let i = 0; i < flat.length; i++) {
1247
- const ch = flat[i];
1248
- if (ch === '{' || ch === '(' || ch === '[')
1249
- depth++;
1250
- else if (ch === '}' || ch === ')' || ch === ']')
1251
- depth = Math.max(0, depth - 1);
1252
- else if (depth === 0 && (ch === '.' || ch === '!' || ch === '?')) {
1253
- const next = flat[i + 1];
1254
- if (next === undefined || next === ' ') {
1255
- end = i + 1;
1256
- break;
1257
- }
1258
- }
1259
- }
1260
- const line = end >= 40 ? flat.slice(0, end) : flat;
1261
- return line.length > max ? line.slice(0, max - 1).trimEnd() + '…' : line;
1262
- }
1471
+ /** Envelope keys that are bookkeeping, not catalog-level contentnever worth a
1472
+ * `_catalog.json` of their own. Anything else in the envelope is a real rule that
1473
+ * would otherwise be dropped by the explode. */
1474
+ const CATALOG_ENVELOPE_NOISE = new Set(['version', 'description', 'source']);
1263
1475
  /** Enumerate a catalog's entries per the platform-supplied descriptor. Handles both
1264
1476
  * collection shapes in use: an ARRAY of named objects (`primitives`, keyed by
1265
1477
  * `name`) and an OBJECT MAP keyed by entry name (`declarations`, `system-entities`).
@@ -1288,73 +1500,56 @@ function enumerateKbEntries(catalog, d) {
1288
1500
  }
1289
1501
  return [];
1290
1502
  }
1291
- /**
1292
- * Build `INDEX.md` — the map an authoring agent reads FIRST.
1293
- *
1294
- * The KB is ~800 KB across three dozen files; reading it whole costs more context
1295
- * than the pack being authored. This index is one ~7k-token read that names every
1296
- * doc and every catalog entry with a one-line summary and its exact path, so the
1297
- * agent can jump straight to the ~600-token file it actually needs.
1298
- */
1299
- function buildKbIndexMarkdown(bundle, exploded) {
1300
- const index = bundle.index ?? [];
1301
- const docs = index.filter(e => e.kind === 'doc');
1302
- const catalogs = index.filter(e => e.kind === 'catalog');
1303
- const L = [];
1304
- L.push('# Octwin platform capability reference — INDEX');
1305
- L.push('');
1306
- L.push(`Reference version ${bundle.version ?? '?'} · content_hash \`${bundle.content_hash ?? '?'}\` · pulled ${bundle.generated_at ?? '?'}`);
1307
- L.push('');
1308
- L.push('**This is the map. Read it, then open only the specific file you need — never a whole catalog.**');
1309
- L.push('Everything the platform supports is here; if a step, function, field, or render intent is NOT in');
1310
- L.push('this index, it does not exist for a pure-YAML pack. Do not fill a gap from memory.');
1311
- L.push('');
1312
- L.push('## Start here');
1313
- L.push('');
1314
- L.push('1. `craft-capabilities.md` — how this reference fits together.');
1315
- L.push('2. `craft-ux.md` — what a *good* pack looks like (home hub, rich cards, confirm-before-commit).');
1316
- L.push('3. `craft-flows.md` — the flow DSL in practice.');
1317
- L.push('4. Then the tables below, on demand.');
1318
- L.push('');
1319
- L.push('## Guides & reference docs');
1320
- L.push('');
1321
- L.push('| Doc | Read it for | File |');
1322
- L.push('|---|---|---|');
1323
- for (const d of docs)
1324
- L.push(`| ${d.title ?? d.key} | ${kbOneLiner(d.summary)} | \`${d.key}.md\` |`);
1325
- L.push('');
1326
- L.push('## Catalogs — exact machine-readable schemas');
1327
- L.push('');
1328
- for (const c of catalogs) {
1329
- const entries = exploded.get(c.key);
1330
- L.push(`### ${c.title ?? c.key}`);
1331
- L.push('');
1332
- L.push(kbOneLiner(c.summary, 400));
1333
- L.push('');
1334
- if (!entries || entries.length === 0) {
1335
- L.push(`Single document: \`${c.key}.json\``);
1336
- L.push('');
1337
- continue;
1338
- }
1339
- L.push(`${entries.length} entries in \`${c.key}/\` — one file each.`);
1340
- L.push('');
1341
- L.push('| Entry | What it does | File |');
1342
- L.push('|---|---|---|');
1343
- for (const e of entries) {
1344
- L.push(`| \`${e.name}\` | ${e.summary.replace(/\|/g, '\\|')} | \`${c.key}/${kbEntryFileName(e.name)}.json\` |`);
1345
- }
1346
- L.push('');
1347
- }
1348
- return L.join('\n') + '\n';
1349
- }
1350
1503
  async function cmdPlatformKb(flags) {
1351
1504
  const packDir = resolve(flags.dir ?? '.');
1352
- const t = resolveTarget(flags);
1505
+ const t = readTarget(flags);
1506
+ if (!t.url)
1507
+ die('no platform url — pass --url <url>, set PACK_PLATFORM_URL, or run `octwin login`');
1353
1508
  const { url } = t;
1354
- console.log(`→ Pulling the platform capability reference from ${url} …`);
1355
- const res = await fetchOrDie(`${url}/api/self/t/octwin-platform-kb`, {
1356
- headers: authHeaders(t),
1357
- }, 'platform-kb pull');
1509
+ const ep = kbEndpoint(t);
1510
+ // ── --check: report staleness as an EXIT CODE, write nothing ──────────
1511
+ //
1512
+ // For a loop that wants to branch on "is my reference current?" without
1513
+ // parsing prose. 0 = current · 2 = stale (or never pulled) · 1 = could not
1514
+ // tell. Three codes, not two: an agent that treats "unreachable" as "stale"
1515
+ // re-pulls forever against an instance that is down.
1516
+ if (flags.check === true) {
1517
+ const local = readLocalKb(packDir);
1518
+ const polled = await fetchKbMeta(t, 10_000);
1519
+ if (!polled.ok) {
1520
+ console.error(polled.notAuthorized
1521
+ ? '✗ cannot check — the platform refused the token, and this instance serves no anonymous reference.'
1522
+ : `✗ cannot check — ${url} did not answer.`);
1523
+ process.exit(1);
1524
+ }
1525
+ const remote = polled.meta.content_hash;
1526
+ if (!local?.content_hash) {
1527
+ console.log(`⬆ no reference pulled yet (platform is at ${remote ?? '?'}) — run \`octwin platform-kb pull\`.`);
1528
+ process.exit(2);
1529
+ }
1530
+ if (remote && remote !== local.content_hash) {
1531
+ console.log(`⬆ stale: local ${local.content_hash} → platform ${remote}. Run \`octwin platform-kb pull\`.`);
1532
+ process.exit(2);
1533
+ }
1534
+ console.log(`✓ current (${local.content_hash}).`);
1535
+ return;
1536
+ }
1537
+ // ── --if-stale: make "pull at the start of every session" free ────────
1538
+ //
1539
+ // One small `?meta=1` GET instead of ~1 MB, and it is what lets the skill say
1540
+ // "pull every session" without that costing a megabyte per session. An
1541
+ // unreachable platform is NOT treated as current: it falls through to the real
1542
+ // pull, which fails loudly with the actual error.
1543
+ if (flags['if-stale'] === true) {
1544
+ const local = readLocalKb(packDir);
1545
+ const polled = await fetchKbMeta(t, 10_000);
1546
+ if (polled.ok && local?.content_hash && polled.meta.content_hash === local.content_hash) {
1547
+ console.log(`✓ capability reference already current (${local.content_hash}) — nothing to pull.`);
1548
+ return;
1549
+ }
1550
+ }
1551
+ console.log(`→ Pulling the platform capability reference from ${url}${ep.anonymous ? ' (anonymous — no token needed for the reference)' : ''} …`);
1552
+ const res = await fetchOrDie(ep.url, { headers: ep.headers }, 'platform-kb pull');
1358
1553
  const text = await res.text();
1359
1554
  if (!res.ok) {
1360
1555
  let j;
@@ -1405,6 +1600,9 @@ async function cmdPlatformKb(flags) {
1405
1600
  // (or an unexpected payload shape) fall back to the flat file.
1406
1601
  const byKey = new Map((bundle.index ?? []).map(e => [e.key, e]));
1407
1602
  const exploded = new Map();
1603
+ /** Catalogs written as ONE file — the flow schema is the only one today, and the
1604
+ * symbol router still mines it for node op-keys. */
1605
+ const flatCatalogs = {};
1408
1606
  let catalogCount = 0;
1409
1607
  let entryCount = 0;
1410
1608
  for (const [key, val] of Object.entries(bundle.sources ?? {})) {
@@ -1415,6 +1613,7 @@ async function cmdPlatformKb(flags) {
1415
1613
  const entries = descriptor ? enumerateKbEntries(val, descriptor) : [];
1416
1614
  if (entries.length === 0) {
1417
1615
  writeFileSync(join(outDir, `${key}.json`), JSON.stringify(val, null, 2) + '\n', 'utf8');
1616
+ flatCatalogs[key] = val;
1418
1617
  continue;
1419
1618
  }
1420
1619
  const dir = join(outDir, key);
@@ -1422,17 +1621,57 @@ async function cmdPlatformKb(flags) {
1422
1621
  for (const entry of entries) {
1423
1622
  writeFileSync(join(dir, `${kbEntryFileName(entry.name)}.json`), JSON.stringify(entry.value, null, 2) + '\n', 'utf8');
1424
1623
  }
1624
+ // The ENVELOPE, when it carries anything beyond the collection.
1625
+ //
1626
+ // Exploding a catalog drops everything that is not an entry — which was fine
1627
+ // while the envelope held only `version`/`description`/`source`, and silently
1628
+ // wrong the moment a catalog published a rule that belongs to the whole set
1629
+ // rather than to one member. `system-entities` does exactly that: the reserved
1630
+ // entity keys, the reserved prefix, and the keys an `extends: system` entity
1631
+ // may not override are properties of the CATALOG. Written as `_catalog.json`
1632
+ // so the offline checks can read them.
1633
+ //
1634
+ // The `_` prefix is load-bearing: every loader that reads one of these
1635
+ // directories iterates `*.json`, so a sibling that is not an entry must be
1636
+ // skippable by name. See the `isEntryFile` guard the checks share.
1637
+ const envelope = Object.fromEntries(Object.entries(val)
1638
+ .filter(([k]) => k !== descriptor.at && !CATALOG_ENVELOPE_NOISE.has(k)));
1639
+ if (Object.keys(envelope).length > 0) {
1640
+ writeFileSync(join(dir, '_catalog.json'), JSON.stringify(envelope, null, 2) + '\n', 'utf8');
1641
+ }
1425
1642
  exploded.set(key, entries);
1426
1643
  entryCount += entries.length;
1427
1644
  }
1428
- // The map the authoring skill reads first.
1429
- writeFileSync(join(outDir, 'INDEX.md'), buildKbIndexMarkdown(bundle, exploded), 'utf8');
1645
+ // ── the three maps ────────────────────────────────────────────────────
1646
+ //
1647
+ // Three, not one, because an author arrives with three different things in
1648
+ // hand: a QUESTION (INDEX), a NAME (SYMBOLS), or a doc too heavy to read whole
1649
+ // (OUTLINE). One file answering all three is the file nobody can afford to
1650
+ // read — which is what INDEX.md had become at ~30 KB, most of it entry rows
1651
+ // that belong in a grep target.
1652
+ writeFileSync(join(outDir, 'INDEX.md'), buildKbIndexMarkdown(bundle, new Map([...exploded].map(([k, v]) => [k, v.length]))), 'utf8');
1653
+ // SYMBOLS.md — every addressable name → the file that defines it. Derived from
1654
+ // the catalogs just written, so it needs nothing extra over the wire.
1655
+ const symbols = buildSymbols(exploded, (catalogKey, entryName) => `${catalogKey}/${kbEntryFileName(entryName)}.json`, flatCatalogs);
1656
+ linkExplainers(symbols, new Map((bundle.index ?? [])
1657
+ .filter(e => e.kind === 'doc' && e.sections?.length)
1658
+ .map(e => [e.key, e.sections])));
1659
+ writeFileSync(join(outDir, 'SYMBOLS.md'), renderSymbolsMarkdown(symbols, bundle.content_hash), 'utf8');
1660
+ // OUTLINE.md — only when the platform published section data. An empty outline
1661
+ // would read as "these docs have no sections", which is worse than its absence.
1662
+ const outline = buildKbOutlineMarkdown(bundle);
1663
+ if (outline)
1664
+ writeFileSync(join(outDir, 'OUTLINE.md'), outline, 'utf8');
1430
1665
  // Persist `content_hash` too — the staleness observer (`notifyIfKbStale`) reads
1431
1666
  // it back and compares against the platform's current hash to nudge a re-pull.
1432
1667
  writeFileSync(join(outDir, 'index.json'), JSON.stringify({ version: bundle.version, content_hash: bundle.content_hash, generated_at: bundle.generated_at, index: bundle.index }, null, 2) + '\n', 'utf8');
1433
1668
  console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
1434
1669
  console.log(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`);
1435
- console.log(' Start at INDEX.md it maps every doc and every catalog entry to its file.');
1670
+ console.log(` Three maps: INDEX.md (the corpus) · SYMBOLS.md (${symbols.length} names their file, grep it)`
1671
+ + `${outline ? ' · OUTLINE.md (every heading, with line numbers)' : ''}`);
1672
+ if (!outline) {
1673
+ console.log(' (no OUTLINE.md — this platform publishes no doc section data; upgrade it for line-addressable docs.)');
1674
+ }
1436
1675
  console.log(' Every pack UNDER this directory finds it — `octwin validate` walks up to locate it,');
1437
1676
  console.log(' so one pull at a repo root covers a whole monorepo of packs.');
1438
1677
  // Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
@@ -1454,7 +1693,7 @@ async function cmdPlatformKb(flags) {
1454
1693
  }
1455
1694
  console.log(' The octwin-pack authoring skill reads these as the source of truth for what the platform supports.');
1456
1695
  }
1457
- // ── records / cases / logs / chat — headless inspect + test with the deploy token ────
1696
+ // ── records / work / logs / chat — headless inspect + test with the deploy token ────
1458
1697
  /** GET an admin endpoint with the deploy token; returns `{ status, json }`.
1459
1698
  * Dies (with the URL) on a network failure; auth failures return so the
1460
1699
  * caller can add command-specific context on top of `authFailureHint`. */
@@ -1616,9 +1855,9 @@ async function cmdRecords(flags) {
1616
1855
  const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&${pagingQs(flags)}`, t);
1617
1856
  if (status !== 200) {
1618
1857
  // Always show the server's reason (it names the unknown entity). Cases are
1619
- // casework (worklist), not pack-declared XRM — point at the right command.
1858
+ // worked records (worklist), not pack-declared XRM — point at the right command.
1620
1859
  if (entity === 'case' || entity === 'cases') {
1621
- console.error(` '${entity}' is casework (worklist), not a pack-declared XRM entity — inspect tickets with: octwin cases`);
1860
+ console.error(` '${entity}' is a worked record (worklist), not a pack-declared XRM entity — inspect it with: octwin work`);
1622
1861
  }
1623
1862
  die(`could not read records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1624
1863
  }
@@ -1835,7 +2074,11 @@ async function cmdLogs(flags) {
1835
2074
  const { status, json } = await apiGet(`${base}/conversations?limit=50`, t);
1836
2075
  if (status !== 200)
1837
2076
  die(`could not read conversations (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1838
- 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 ?? []);
1839
2082
  if (asHandle)
1840
2083
  convs = convs.filter((c) => c.contact?.channel_contact_handle === asHandle);
1841
2084
  if (convs.length === 0) {
@@ -2233,29 +2476,32 @@ async function cmdChat(flags) {
2233
2476
  }
2234
2477
  console.log(`\n(same --as '${from}' continues this conversation — timeline: octwin logs --as ${from})`);
2235
2478
  }
2236
- /** `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]`
2479
+ /** `octwin media generate "<prompt>" [--out <file.png>] [--json]`
2237
2480
  * — AI-generate an image on the platform (needs a `media:generate`-scoped token),
2238
2481
  * store it as a public asset, and return its `MEDIA-` handle + serve URL. `--out`
2239
2482
  * downloads the bytes to a file (WhatsApp renders only `.png`/`.jpg`); the paired
2240
- * `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. */
2241
2488
  async function cmdMedia(flags) {
2242
2489
  const sub = flags._[0];
2243
2490
  if (sub !== 'generate')
2244
- die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
2491
+ die('usage: octwin media generate "<prompt>" [--out <file.png>] [--json]');
2245
2492
  const t = resolveTarget(flags);
2246
2493
  const { url } = t;
2247
2494
  const prompt = flags._[1];
2248
2495
  if (!prompt)
2249
- die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
2496
+ die('usage: octwin media generate "<prompt>" [--out <file.png>] [--json]');
2250
2497
  const asJson = flags.json === true;
2251
- const size = typeof flags.size === 'string' ? flags.size : undefined;
2252
2498
  const out = typeof flags.out === 'string' ? flags.out : undefined;
2253
2499
  if (!asJson)
2254
2500
  console.log(`→ Generating an image on ${targetLabel(t)} @ ${url} …`);
2255
2501
  const res = await fetchOrDie(`${url}/api/self/p/media/generate`, {
2256
2502
  method: 'POST',
2257
2503
  headers: { 'content-type': 'application/json', ...authHeaders(t) },
2258
- body: JSON.stringify({ prompt, ...(size ? { size } : {}) }),
2504
+ body: JSON.stringify({ prompt }),
2259
2505
  }, 'media generate');
2260
2506
  const text = await res.text();
2261
2507
  if (!res.ok) {
@@ -2284,80 +2530,96 @@ async function cmdMedia(flags) {
2284
2530
  }
2285
2531
  }
2286
2532
  if (asJson) {
2287
- 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 }));
2288
2534
  return;
2289
2535
  }
2290
- 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)`);
2291
2537
  console.log(` id: ${r.media_id}`);
2292
2538
  console.log(` url: ${absUrl}`);
2293
2539
  if (out)
2294
2540
  console.log(` saved → ${out}`);
2295
2541
  console.log(` Send it into a chat: octwin chat "here you go" --media ${out ?? r.media_id} --as <handle>`);
2296
2542
  }
2297
- /** Reserved leading words on `octwin cases` — see `RECORD_VERBS` for the rule. */
2298
- const CASE_VERBS = new Set(['assign', 'note', 'transition', 'decide']);
2543
+ /** Reserved leading words on `octwin work` — see `RECORD_VERBS` for the rule. */
2544
+ const WORK_VERBS = new Set(['assign', 'note', 'stage', 'decide']);
2545
+ /** A localized label bag (or legacy bare string) → one printable string.
2546
+ * The work routes ship labels as full bags so each consumer picks; the CLI
2547
+ * prefers English and falls back to whatever the bag has. */
2548
+ function pickLabel(v) {
2549
+ if (typeof v === 'string')
2550
+ return v;
2551
+ if (v && typeof v === 'object') {
2552
+ const bag = v;
2553
+ return bag.en ?? Object.values(bag)[0] ?? null;
2554
+ }
2555
+ return null;
2556
+ }
2299
2557
  /**
2300
- * The write half of `octwin cases` — assign / note / transition / decide.
2558
+ * The write half of `octwin work` — assign / note / stage / decide.
2301
2559
  *
2302
- * `decide --dry-run` routes to the PREVIEW endpoint, which sits behind `cases:read`
2303
- * rather than `cases:write`: it renders the customer-facing copy and the resulting
2304
- * status without committing. That makes "show me what this disposition would do"
2560
+ * `stage` is deliberately the XRM records verb (`POST …/xrm/records/:id/stage`):
2561
+ * the work surface carries no second transition spelling a stage move has one
2562
+ * verb platform-wide (consolidation P-02).
2563
+ *
2564
+ * `decide --dry-run` routes to the PREVIEW endpoint, which sits behind `work:read`
2565
+ * rather than `work:write`: it renders the customer-facing copy and the resulting
2566
+ * stage without committing. That makes "show me what this disposition would do"
2305
2567
  * safe to run with a read-only token, which is exactly when an author wants it.
2306
2568
  */
2307
- async function cmdCasesWrite(flags) {
2569
+ async function cmdWorkWrite(flags) {
2308
2570
  const t = resolveTarget(flags);
2309
2571
  const { url } = t;
2310
2572
  const base = `${url}/api/self/p`;
2311
2573
  const verb = flags._[0];
2312
- const id = flags._[1] ?? die(`usage: octwin cases ${verb} <caseId> …`);
2313
- const readBack = () => console.log(`\nRead it back: octwin cases ${id}`);
2574
+ const id = flags._[1] ?? die(`usage: octwin work ${verb} <recordId> …`);
2575
+ const readBack = () => console.log(`\nRead it back: octwin work ${id}`);
2314
2576
  if (verb === 'assign') {
2315
2577
  // `--to none` unassigns (the route takes null); anything else must carry the
2316
2578
  // principal kind, because a bare uuid cannot say user-or-team.
2317
2579
  const to = typeof flags.to === 'string' ? flags.to
2318
- : die('usage: octwin cases assign <caseId> --to user:<uuid>|team:<uuid>|none');
2580
+ : die('usage: octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none');
2319
2581
  const assignee = to === 'none' ? null : to;
2320
2582
  if (assignee !== null && !/^(user|team):/.test(assignee)) {
2321
2583
  die(`--to must be 'user:<uuid>', 'team:<uuid>' or 'none' (got '${to}')`);
2322
2584
  }
2323
- console.log(`→ ${assignee === null ? 'Unassigning' : `Assigning to ${assignee}`} case ${id} …`);
2324
- const { status, json } = await apiSend('PATCH', `${base}/cases/${encodeURIComponent(id)}/assign`, { assignee }, t);
2585
+ console.log(`→ ${assignee === null ? 'Unassigning' : `Assigning to ${assignee}`} work item ${id} …`);
2586
+ const { status, json } = await apiSend('PATCH', `${base}/work/${encodeURIComponent(id)}/assign`, { assignee }, t);
2325
2587
  if (status !== 200)
2326
- writeFail(`assign case ${id}`, status, json, url, true);
2588
+ writeFail(`assign work item ${id}`, status, json, url, true);
2327
2589
  console.log(assignee === null ? '✓ Unassigned.' : `✓ Assigned to ${json?.assignee ?? assignee}.`);
2328
2590
  return;
2329
2591
  }
2330
2592
  if (verb === 'note') {
2331
- const note = flags._[2] ?? die('usage: octwin cases note <caseId> "the note text"');
2332
- console.log(`→ Adding a note to case ${id} …`);
2333
- const { status, json } = await apiSend('POST', `${base}/cases/${encodeURIComponent(id)}/note`, { note }, t);
2593
+ const note = flags._[2] ?? die('usage: octwin work note <recordId> "the note text"');
2594
+ console.log(`→ Adding a note to work item ${id} …`);
2595
+ const { status, json } = await apiSend('POST', `${base}/work/${encodeURIComponent(id)}/note`, { note }, t);
2334
2596
  if (status !== 200)
2335
- writeFail(`note case ${id}`, status, json, url, true);
2336
- console.log('✓ Note added to the case timeline.');
2597
+ writeFail(`note work item ${id}`, status, json, url, true);
2598
+ console.log('✓ Note added to the record timeline.');
2337
2599
  readBack();
2338
2600
  return;
2339
2601
  }
2340
- if (verb === 'transition') {
2602
+ if (verb === 'stage') {
2341
2603
  const to = typeof flags.to === 'string' ? flags.to
2342
- : die('usage: octwin cases transition <caseId> --to <status> [--note "..."]');
2343
- const body = { to_status: to };
2604
+ : die('usage: octwin work stage <recordId> --to <stage> [--note "..."]');
2605
+ const body = { to_stage: to };
2344
2606
  if (typeof flags.note === 'string')
2345
2607
  body.note = flags.note;
2346
- console.log(`→ Moving case ${id} to '${to}' …`);
2347
- const { status, json } = await apiSend('POST', `${base}/cases/${encodeURIComponent(id)}/transition`, body, t);
2608
+ console.log(`→ Moving record ${id} to '${to}' …`);
2609
+ const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/stage`, body, t);
2348
2610
  if (status !== 200) {
2349
- // The case detail read carries the legal targets; point at it rather than
2611
+ // The work detail read carries the legal targets; point at it rather than
2350
2612
  // leaving the author to guess the vocabulary.
2351
2613
  if (status === 400)
2352
- console.error(` → legal targets for this case: octwin cases ${id} (see its workflow)`);
2353
- writeFail(`move case ${id} to '${to}'`, status, json, url, true);
2614
+ console.error(` → legal targets for this record: octwin work ${id} (see its workflow)`);
2615
+ writeFail(`move record ${id} to '${to}'`, status, json, url, true);
2354
2616
  }
2355
- console.log(`✓ Case is now '${json?.case?.status ?? to}'.`);
2617
+ console.log(`✓ Record is now '${json?.stage ?? to}'.`);
2356
2618
  return;
2357
2619
  }
2358
- // decide
2620
+ // decide — apply one of the entity's declared operator actions
2359
2621
  const action = typeof flags.action === 'string' ? flags.action
2360
- : die('usage: octwin cases decide <caseId> --action <action> [--param k=v] [--note "..."] [--dry-run]');
2622
+ : die('usage: octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]');
2361
2623
  const params = {};
2362
2624
  for (const pair of flagList(flags, 'param')) {
2363
2625
  const eq = pair.indexOf('=');
@@ -2369,39 +2631,41 @@ async function cmdCasesWrite(flags) {
2369
2631
  const body = { action, ...(Object.keys(params).length ? { params } : {}) };
2370
2632
  if (!dryRun && typeof flags.note === 'string')
2371
2633
  body.internal_note = flags.note;
2372
- console.log(`→ ${dryRun ? 'Previewing' : 'Applying'} '${action}' on case ${id} …`);
2373
- const endpoint = `${base}/cases/${encodeURIComponent(id)}/decision${dryRun ? '/preview' : ''}`;
2634
+ console.log(`→ ${dryRun ? 'Previewing' : 'Applying'} '${action}' on work item ${id} …`);
2635
+ const endpoint = `${base}/work/${encodeURIComponent(id)}/action${dryRun ? '/preview' : ''}`;
2374
2636
  const { status, json } = await apiSend('POST', endpoint, body, t);
2375
2637
  if (status === 404)
2376
- die(`case '${id}' not found`);
2638
+ die(`work item '${id}' not found`);
2377
2639
  if (status !== 200) {
2378
- console.error(` → the case's applicable actions are listed by: octwin cases ${id}`);
2379
- writeFail(`${dryRun ? 'preview' : 'apply'} '${action}' on case ${id}`, status, json, url, true);
2640
+ console.error(` → the item's applicable actions are listed by: octwin work ${id}`);
2641
+ writeFail(`${dryRun ? 'preview' : 'apply'} '${action}' on work item ${id}`, status, json, url, true);
2380
2642
  }
2381
2643
  if (dryRun) {
2382
2644
  console.log('Preview (nothing was committed):');
2383
2645
  console.log(JSON.stringify(json, null, 2));
2384
2646
  return;
2385
2647
  }
2386
- console.log(`✓ Applied '${action}' — case is now '${json?.case?.status ?? '?'}'.`);
2387
- // `notified` is the customer-facing half; silence here usually means the
2388
- // disposition had no message template, which is easy to mistake for a failure.
2389
- console.log(json?.notified ? ' ✓ the customer was notified.' : ' ⓘ no customer notification was sent by this action.');
2648
+ console.log(`✓ Applied '${action}'${json?.to_stage ? ` record is now '${json.to_stage}'` : ''}.`);
2649
+ // `relayed`/`notified` are the customer-facing half; silence here usually means
2650
+ // the action had no message template, which is easy to mistake for a failure.
2651
+ const reached = json?.relayed || json?.notified;
2652
+ console.log(reached ? ' ✓ the customer was notified.' : ' ⓘ no customer notification was sent by this action.');
2390
2653
  readBack();
2391
2654
  }
2392
- /** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
2393
- * the aggregate inbox, one case + its timeline, or the queue list. */
2394
- async function cmdCases(flags) {
2395
- if (typeof flags._[0] === 'string' && CASE_VERBS.has(flags._[0]))
2396
- return cmdCasesWrite(flags);
2655
+ /** `octwin work [recordId] [--queues]` — inspect the work inbox (every entity the
2656
+ * pack declares worked): the aggregate inbox, one item + its timeline, or the
2657
+ * queue list. */
2658
+ async function cmdWork(flags) {
2659
+ if (typeof flags._[0] === 'string' && WORK_VERBS.has(flags._[0]))
2660
+ return cmdWorkWrite(flags);
2397
2661
  const t = resolveTarget(flags);
2398
2662
  const { url } = t;
2399
2663
  const base = `${url}/api/self/p`;
2400
- const caseId = flags._[0];
2664
+ const recordId = flags._[0];
2401
2665
  const asJson = flags.json === true;
2402
2666
  if (!asJson)
2403
- console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${targetLabel(t)} …`);
2404
- const caseFail = (what, status, json) => {
2667
+ console.log(`→ Reading ${flags.queues === true ? 'work queues' : recordId ? `work item ${recordId}` : 'the work inbox'} from ${targetLabel(t)} …`);
2668
+ const workFail = (what, status, json) => {
2405
2669
  // A 403 here can also be an RBAC gap the scope hint can't see — a role whose
2406
2670
  // grants don't reach the queue passes the scope gate and still gets nothing.
2407
2671
  if (status === 403)
@@ -2409,72 +2673,74 @@ async function cmdCases(flags) {
2409
2673
  die(`could not read ${what} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
2410
2674
  };
2411
2675
  if (flags.queues === true) {
2412
- const { status, json } = await apiGet(`${base}/case-queues`, t);
2676
+ const { status, json } = await apiGet(`${base}/work/queues`, t);
2413
2677
  if (status !== 200)
2414
- caseFail('case queues', status, json);
2678
+ workFail('work queues', status, json);
2415
2679
  if (asJson) {
2416
2680
  console.log(JSON.stringify(json, null, 2));
2417
2681
  return;
2418
2682
  }
2419
2683
  const queues = (json?.queues ?? []);
2420
- console.log(`Case queues in ${targetLabel(t)}:`);
2421
- for (const q of queues)
2422
- console.log(` ${q.key}${q.name ? ` (${q.name})` : ''} ${q.open_count} open`);
2684
+ console.log(`Work queues in ${targetLabel(t)}:`);
2685
+ for (const q of queues) {
2686
+ const name = pickLabel(q.name);
2687
+ console.log(` ${q.key}${name ? ` (${name})` : ''} ${q.open_count} open`);
2688
+ }
2423
2689
  if (json?.unrouted_open_count)
2424
2690
  console.log(` (unrouted: ${json.unrouted_open_count} open)`);
2425
2691
  return;
2426
2692
  }
2427
- if (!caseId) {
2428
- const { status, json } = await apiGet(`${base}/cases?${pagingQs(flags)}`, t);
2693
+ if (!recordId) {
2694
+ const { status, json } = await apiGet(`${base}/work?${pagingQs(flags)}`, t);
2429
2695
  if (status !== 200)
2430
- caseFail('cases', status, json);
2696
+ workFail('the work inbox', status, json);
2431
2697
  if (asJson) {
2432
2698
  console.log(JSON.stringify(json, null, 2));
2433
2699
  return;
2434
2700
  }
2435
2701
  const page = readPage(json);
2436
- console.log(`Cases in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
2702
+ console.log(`Work items in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
2437
2703
  if (page.rows.length === 0)
2438
2704
  console.log(' (none)');
2439
- for (const c of page.rows) {
2440
- const sla = c.sla_due_at ? ` sla:${c.sla_due_at}` : '';
2441
- console.log(` #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}${c.queue_key ? ` q:${c.queue_key}` : ''}${sla} ${c.id}`);
2705
+ for (const w of page.rows) {
2706
+ const sla = w.sla_due_at ? ` sla:${w.sla_due_at}` : '';
2707
+ console.log(` #${w.record_number ?? '?'} ${w.entity}${w.type ? `/${w.type}` : ''} [${w.stage ?? '?'}] ${w.priority}${w.queue_key ? ` q:${w.queue_key}` : ''}${sla} ${w.record_id}`);
2442
2708
  }
2443
- const more = morePageHint(page, 'octwin cases');
2709
+ const more = morePageHint(page, 'octwin work');
2444
2710
  if (more)
2445
2711
  console.log(more);
2446
- console.log('\nOne case + timeline: octwin cases <caseId> queues: octwin cases --queues');
2712
+ console.log('\nOne item + timeline: octwin work <recordId> queues: octwin work --queues');
2447
2713
  return;
2448
2714
  }
2449
- const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`, t);
2715
+ const { status, json } = await apiGet(`${base}/work/${encodeURIComponent(recordId)}`, t);
2450
2716
  if (status === 404)
2451
- die(`case '${caseId}' not found`);
2717
+ die(`work item '${recordId}' not found`);
2452
2718
  if (status !== 200)
2453
- caseFail('case', status, json);
2719
+ workFail('work item', status, json);
2454
2720
  if (asJson) {
2455
2721
  console.log(JSON.stringify(json, null, 2));
2456
2722
  return;
2457
2723
  }
2458
- const c = json?.case ?? {};
2459
- console.log(`Case #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}`);
2460
- console.log(` id: ${c.id} queue: ${c.queue_key ?? '(unrouted)'} assignee: ${c.assignee_principal ?? '(none)'}`);
2724
+ const w = json?.item ?? {};
2725
+ console.log(`${pickLabel(json?.entity_label) ?? w.entity} #${w.record_number ?? '?'}${w.type ? ` ${w.type}` : ''} [${w.stage ?? '?'}] ${w.priority}`);
2726
+ console.log(` id: ${w.record_id} queue: ${w.queue_key ?? '(unrouted)'} assignee: ${w.assignee_principal ?? '(none)'}`);
2461
2727
  if (json?.contact)
2462
2728
  console.log(` contact: ${json.contact.display_name ?? json.contact.channel_contact_handle ?? json.contact.id}`);
2463
- if (c.conversation_id)
2464
- console.log(` conversation: ${c.conversation_id} (octwin logs ${c.conversation_id})`);
2465
- if (c.sla_due_at)
2466
- console.log(` sla due: ${c.sla_due_at}`);
2467
- if (c.fields && Object.keys(c.fields).length > 0)
2468
- console.log(` fields: ${JSON.stringify(c.fields)}`);
2469
- const events = (json?.events ?? []);
2729
+ if (w.conversation_id)
2730
+ console.log(` conversation: ${w.conversation_id} (octwin logs ${w.conversation_id})`);
2731
+ if (w.sla_due_at)
2732
+ console.log(` sla due: ${w.sla_due_at}`);
2733
+ if (w.fields && Object.keys(w.fields).length > 0)
2734
+ console.log(` fields: ${JSON.stringify(w.fields)}`);
2735
+ const events = (json?.timeline ?? []);
2470
2736
  console.log(` Timeline (${events.length}):`);
2471
2737
  for (const e of events) {
2472
2738
  const payload = e.payload && Object.keys(e.payload).length > 0 ? ` ${JSON.stringify(e.payload)}` : '';
2473
2739
  console.log(` ${e.ts ?? ''} ${e.kind}${e.actor ? ` (${e.actor})` : ''}${payload}`);
2474
2740
  }
2475
- const dispositions = (json?.dispositions ?? []);
2476
- if (dispositions.length > 0) {
2477
- console.log(` Decisions: ${dispositions.map((d) => `${d.action}${d.next_status ? `→${d.next_status}` : ''}`).join(', ')}`);
2741
+ const actions = (json?.actions ?? []);
2742
+ if (actions.length > 0) {
2743
+ console.log(` Actions: ${actions.map((a) => `${a.action}${a.to_stage ? `→${a.to_stage}` : ''}`).join(', ')}`);
2478
2744
  }
2479
2745
  }
2480
2746
  // ── money formatting (orders / catalog) ─────────────────────────────────────
@@ -2891,21 +3157,32 @@ async function cmdOrdersWrite(flags) {
2891
3157
  if (verb === 'transition') {
2892
3158
  const to = typeof flags.to === 'string' ? flags.to
2893
3159
  : die('usage: octwin orders transition <reference_id> --to <status>');
3160
+ // A stage move has ONE verb platform-wide — `POST …/xrm/records/:id/stage`
3161
+ // (`POST …/orders/:ref/transition` was retired, consolidation P-02). The order
3162
+ // detail read resolves the opaque reference to the record id and carries the
3163
+ // legal targets for the failure hint.
2894
3164
  console.log(`→ Moving order ${ref} to '${to}' …`);
2895
- const { status, json } = await apiSend('POST', `${base}/${encodeURIComponent(ref)}/transition`, { to_status: to }, t);
2896
- if (status === 404)
3165
+ const detail = await apiGet(`${base}/${encodeURIComponent(ref)}`, t);
3166
+ if (detail.status === 404)
2897
3167
  die(`order '${ref}' not found (pass the opaque reference_id, not the #number)`);
2898
- if (status === 409) {
3168
+ if (detail.status !== 200)
3169
+ writeFail(`read order ${ref}`, detail.status, detail.json, url);
3170
+ const recordId = detail.json?.order?.id;
3171
+ if (!recordId)
3172
+ die(`order '${ref}' carries no record id — cannot move its stage`);
3173
+ const { status, json } = await apiSend('POST', `${url}/api/self/p/xrm/records/${encodeURIComponent(recordId)}/stage`, { to_stage: to }, t);
3174
+ if (status === 400 || status === 409) {
2899
3175
  console.error(`✗ '${to}' is not a legal move for this order.`);
2900
- if (Array.isArray(json?.transitions))
2901
- console.error(` → allowed: ${json.transitions.join(', ') || '(none)'}`);
3176
+ const allowed = (detail.json?.transitions ?? []);
3177
+ if (allowed.length > 0)
3178
+ console.error(` → allowed: ${allowed.join(', ')}`);
2902
3179
  else
2903
3180
  console.error(` → see the allowed set: octwin orders ${ref}`);
2904
3181
  process.exit(1);
2905
3182
  }
2906
3183
  if (status !== 200)
2907
3184
  writeFail(`move order ${ref} to '${to}'`, status, json, url);
2908
- console.log(`✓ Order is now '${json?.order?.status ?? to}'.`);
3185
+ console.log(`✓ Order is now '${json?.stage ?? to}'.`);
2909
3186
  return;
2910
3187
  }
2911
3188
  // refund — irreversible, and money. `--force` rather than a prompt: the CLI is
@@ -3290,7 +3567,7 @@ async function cmdSchedulingWrite(flags) {
3290
3567
  console.log(JSON.stringify(json, null, 2));
3291
3568
  return;
3292
3569
  }
3293
- if (json?.has_scheduling === false) {
3570
+ if (json?.has_data === false) {
3294
3571
  console.log('This pack declares no scheduling.');
3295
3572
  return;
3296
3573
  }
@@ -3365,7 +3642,7 @@ async function cmdSchedulingWrite(flags) {
3365
3642
  const { status, json } = await apiSend('POST', `${base}/availability/${path}`, body, t);
3366
3643
  if (status !== 201 && status !== 200)
3367
3644
  writeFail(`add the ${noun}`, status, json, url);
3368
- if (json?.has_scheduling === false)
3645
+ if (json?.has_data === false)
3369
3646
  die('this pack declares no scheduling');
3370
3647
  const created = json?.rule ?? json?.exception ?? {};
3371
3648
  console.log(`✓ ${noun[0].toUpperCase()}${noun.slice(1)} added — ${created.id ?? '(no id returned)'}`);
@@ -3386,30 +3663,42 @@ async function cmdScheduling(flags) {
3386
3663
  if (flags.slots === true)
3387
3664
  die('usage: octwin scheduling --slots <resourceRecordId> (the record id of a bookable resource)');
3388
3665
  if (resourceId) {
3389
- const q = new URLSearchParams({ include_booked: '1' }); // full occupancy, as the operator preview does
3390
- if (typeof flags.from === 'string')
3391
- q.set('from', flags.from);
3392
- if (typeof flags.days === 'string')
3393
- 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);
3394
3673
  if (!asJson)
3395
3674
  console.log(`→ Computing slots for resource ${resourceId} in ${targetLabel(t)} …`);
3396
- const { status, json } = await apiGet(`${base}/resources/${encodeURIComponent(resourceId)}/slots?${q.toString()}`, t);
3397
- if (status === 404)
3398
- 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);
3399
3676
  if (status === 400)
3400
- die(`that record isn't a bookable resource${errDetail(json)}`);
3677
+ die(`could not compute slots${errDetail(json)}`);
3401
3678
  if (status !== 200)
3402
3679
  die(`could not compute slots (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
3403
3680
  if (asJson) {
3404
3681
  console.log(JSON.stringify(json, null, 2));
3405
3682
  return;
3406
3683
  }
3407
- if (json?.has_scheduling === false) {
3684
+ if (json?.has_data === false) {
3408
3685
  console.log('This pack declares no `scheduling.yaml` — nothing to schedule.');
3409
3686
  return;
3410
3687
  }
3411
- const slots = (json?.slots ?? []);
3412
- 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
+ }
3413
3702
  if (slots.length === 0)
3414
3703
  console.log(' (none — no availability rules cover this window; `octwin deploy --seed` seeds the demo rules)');
3415
3704
  for (const s of slots) {
@@ -3426,7 +3715,7 @@ async function cmdScheduling(flags) {
3426
3715
  console.log(JSON.stringify(json, null, 2));
3427
3716
  return;
3428
3717
  }
3429
- if (json?.has_scheduling === false) {
3718
+ if (json?.has_data === false) {
3430
3719
  console.log('This pack declares no `scheduling.yaml` — nothing to schedule.');
3431
3720
  return;
3432
3721
  }
@@ -3438,254 +3727,274 @@ async function cmdScheduling(flags) {
3438
3727
  console.log('\nSlots for one resource: octwin scheduling --slots <resourceRecordId> (ids: octwin records <entity>)');
3439
3728
  }
3440
3729
  function help() {
3441
- console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
3442
-
3443
- octwin --version # print the CLI version (+ any upgrade notice)
3444
- octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
3445
- octwin validate [--dir .] [--remote] [--require-kb] # --remote runs the platform's FULL schema check + lint (all errors at once)
3446
- octwin login --url <platformUrl> --token oct_… # a deploy token from the console
3447
- octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
3448
- octwin projects [--archived] [--json] # the --project slugs this token can name
3449
- octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
3450
- octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
3451
- octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
3452
- octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
3453
- octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
3454
- octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
3455
- octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
3456
- octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
3457
- octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
3458
- octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
3459
- octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
3460
- octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
3461
- octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
3462
- octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
3463
- octwin test [--dir .] # = validate --remote (the full platform check)
3464
- octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
3465
-
3466
- Writes — exercise the state your pack creates (each needs the matching :write scope):
3467
- octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
3468
- octwin records tasks | task complete <taskId> [--outcome done|cancelled]
3469
- octwin cases assign <id> --to user:<uuid>|none | note <id> "…" | transition <id> --to <status>
3470
- octwin cases decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
3471
- octwin orders transition <ref> --to <status> | refund <ref> --force
3472
- octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
3473
- octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
3474
- octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
3475
-
3476
- Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
3477
- \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
3478
- button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
3479
- Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
3480
- octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
3481
- 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).
3482
3771
  Per-command usage: octwin <command> --help`);
3483
3772
  }
3484
3773
  /** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
3485
3774
  * network/auth work (a --help that 401s is worse than no help at all). */
3486
3775
  const COMMAND_HELP = {
3487
- init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
3776
+ init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
3488
3777
  Scaffold a pure-YAML starter pack into <dir>.`,
3489
- validate: `octwin validate [--dir .] [--remote] [--require-kb]
3490
- Offline structural check, plus two checks driven by the pulled capability
3491
- reference (render-intent fields, primitive arguments). Those two SKIP when the
3492
- reference is missing — the run says so, and --require-kb turns the skip into a
3493
- failure for CI. --remote additionally runs the platform's FULL manifest +
3494
- flow-DSL validation and its flow lint (all errors at once) — same check as deploy.`,
3495
- login: `octwin login --url <platformUrl> --token oct_…
3496
- Save a deploy token (console Settings API tokens) for that platform url,
3497
- 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
3498
3790
  workspace + project pin + scopes the token reaches.`,
3499
- whoami: `octwin whoami [--url <url>] [--tenant <slug>]
3791
+ whoami: `octwin whoami [--url <url>] [--tenant <slug>]
3500
3792
  Verify the resolved token authenticates against the tenant.`,
3501
- projects: `octwin projects [--archived] [--json]
3502
- List the workspace's projects — the slugs every --project flag takes, with the
3503
- plan's project cap. --archived includes archived ones. A pack:deploy token
3504
- reaches this (it names a project in every other command).
3505
-
3506
- octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
3507
- Create a project. The URL slug is derived from the name unless --slug pins one.
3508
- --pack installs an ALREADY-published pack; the usual next step is instead
3509
- \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
3510
-
3511
- octwin projects rm <slug> [--yes]
3512
- HARD delete — the project and everything cascading from it (conversations,
3513
- contacts, records, installs). No undo, and not the same as archiving.
3514
- WITHOUT --yes it only previews what would be destroyed, so the dry run is the
3515
- default. Together these make a disposable end-to-end environment:
3516
- octwin projects create "Scratch" && octwin deploy --project scratch --seed
3517
- octwin chat "hi" --project scratch
3518
- 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
3519
3811
  Both verbs need the \`projects:write\` scope — a pack:deploy token does NOT confer it.`,
3520
- deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
3521
- 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.
3522
3814
  --seed additionally applies the pack's demo seed (streams progress).`,
3523
- seed: `octwin seed [--pack <packId>]
3524
- Apply the pack's demo/reference data to the project it is installed on, without
3525
- redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
3526
- and the demo operator topology. Reports what each kind produced.
3527
- Idempotent and safe to re-run — records upsert, and existing media is REUSED rather
3528
- 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
3529
3821
  project somehow runs more than one.`,
3530
- status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
3531
- Show installed vs live version + the flow list for this pack.`,
3532
- records: `octwin records [entity] [id] [--limit 50] [--offset n]
3533
- Inspect the pack's XRM data. No args = list entities. Cases/tickets are
3534
- casework, not XRMuse \`octwin cases\` for those.
3535
-
3536
- WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
3537
- octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
3538
- octwin records patch <recordId> --entity <entity> --set field=value
3539
- octwin records stage <recordId> --to <stage> [--note "..."]
3540
- octwin records note <recordId> "the note text"
3541
- octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
3542
- octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
3543
-
3544
- --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
3545
- sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
3546
- \`patch\` needs --entity even though it has an id: the route resolves the field
3547
- 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
3548
3844
  VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
3549
- cases: `octwin cases [caseId] [--queues] [--limit 50] [--offset n] [--json]
3550
- Inspect casework (support tickets): the inbox, one case + its timeline
3551
- (+ applicable decisions), or --queues for queue keys + open counts.
3552
-
3553
- WRITES (need \`cases:write\`):
3554
- octwin cases assign <caseId> --to user:<uuid>|team:<uuid>|none
3555
- octwin cases note <caseId> "the note text"
3556
- octwin cases transition <caseId> --to <status> [--note "..."]
3557
- octwin cases decide <caseId> --action <action> [--param k=v] [--note "..."] [--dry-run]
3558
-
3559
- \`decide\` applies one of the case's declared dispositions — \`octwin cases <id>\`
3560
- lists them with their params. --dry-run previews the customer-facing copy and the
3561
- resulting status WITHOUT committing (that route needs only \`cases:read\`).`,
3562
- logs: `octwin logs [conversationId] [--as <handle>] [--json]
3563
- No id = recent conversations (handle, status, last activity; --as filters).
3564
- With id = the full event timeline including what each turn rendered.
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\`).
3859
+ \`stage\` is the XRM records verb (one transition spelling platform-wide).`,
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.
3565
3863
  --json = raw events (verbatim payloads).`,
3566
- pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
3567
- Write a DEPLOYED pack's source back to disk — the inverse of deploy.
3568
- A pack pushed with 'octwin deploy' lives on the platform as an artifact the
3569
- runtime serves but nothing hands back, so its only source copy is the machine
3570
- that pushed it. Pull it, fix it, redeploy it.
3571
- Defaults to the version installed on the target project; --version overrides.
3572
- --dir defaults to ./<packId>; a non-empty dir needs --force.
3573
- 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.
3574
3872
  You may pull a pack your tenant OWNS (deployed); an operator token pulls any.`,
3575
- chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
3576
- octwin chat --script <file> [--as <handle>] [--json]
3577
- Drive ONE turn through the dev web channel and print every render with its
3578
- tap ids. Same --as handle = same conversation (multi-turn works).
3579
- --tap presses a rendered button/list row instead of sending text.
3580
- --media uploads a local file (or a media id from 'media generate --json') as
3581
- an image/document/audio inbound — any "message" rides as its caption; feeds a
3582
- running media-collect flow (e.g. activate-app).
3583
- --json dumps the raw SSE envelopes for the turn.
3584
-
3585
- --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
3586
- process over one connection — waiting for each turn to settle before sending
3587
- the next. Use this for any multi-step flow: chaining shell invocations races
3588
- the agent loop, because a turn ends on a quiet gap that can arrive while the
3589
- server is still working (the symptom is placeholder-filled fields or a second
3590
- workflow run). Blank lines and # comments are skipped:
3591
-
3592
- # book an appointment end to end
3593
- احجز موعد
3594
- tap:t:invoke:book-appointment:doctor_id=D1
3595
- 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
3596
3894
  tap:t:resume:book-appointment:run_id=R1;_ctl_approved=true`,
3597
- media: `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]
3598
- AI-generate an image (needs a media:generate-scoped token), store it as a
3599
- public asset, and print its MEDIA- handle + serve URL. --out downloads the
3600
- bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
3601
- width, height, bytes }. Pair with 'octwin chat --media' to drive media flows.`,
3602
- agents: `octwin agents [packId::agentId] [--prompt] [--json]
3603
- No args = the roster with each agent's EFFECTIVE model and which layer set it.
3604
- With an agent = every governed setting (model / memory.last_messages /
3605
- working_memory) plus the layer that won — an operator PLATFORM default can
3606
- override what your manifest declares, and this is where you see that.
3607
- --prompt = the exact system prompt the LLM sees for this project (pack
3608
- instructions + platform protocol + any project overlay). Needs agents:read.
3609
- The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
3610
-
3611
- WRITES (need \`agents:write\`):
3612
- octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
3613
- [--enable-tool <toolId>] [--disable-tool <toolId>]
3614
-
3615
- Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
3616
- 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
3617
3915
  ids refuses --model with a 403 — the platform default governs there.`,
3618
- orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
3619
- No args = the order list (#number, status/payment, total, contact). With a
3620
- reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
3621
- payment_ref, and the allowed status transitions. Needs orders:read + the
3622
- \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
3623
- so \`pending\` on a gateway-less workspace is expected, not a bug.
3624
-
3625
- WRITES (need \`orders:write\`):
3626
- octwin orders transition <reference_id> --to <status>
3627
- octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
3628
-
3629
- Refund is irreversible and moves money, hence --force. The route answers 200 even
3630
- when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
3631
- 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
3632
3930
  \`captured\` state can be refunded; \`payment_status\` is never settable directly.`,
3633
- analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
3634
- No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
3635
- With an entity = stage-by-stage conversion (default --funnel) over the last 30
3636
- 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
3637
3935
  range-filtered). Needs records:read + a \`view\` grant on \`record.<entity>\`.`,
3638
- catalog: `octwin catalog [--readiness] [--json]
3639
- The commerce \`product\` records + price, availability, stock (null = not
3640
- inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
3641
- Graph checklist (LIVE Graph calls; needs a bound access token). Needs
3642
- catalog:read + the \`catalog\` plan feature.
3643
-
3644
- WRITES (need \`catalog:write\`):
3645
- octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
3646
- octwin catalog stock <retailerId> [--set-on-hand <n>]
3647
-
3648
- \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
3649
- inventory-tracked (always sellable), which is different from 0. Lowering on_hand
3650
- 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
3651
3949
  products and the Meta catalog binding/sync stay in the console.`,
3652
- scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
3653
- No args = the engine state (bookable resource types, upcoming slots, booked
3654
- seats). --slots <recordId> computes the slots for one bookable resource
3655
- (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
3656
- the availability rules a \`deploy --seed\` created. Needs scheduling:read.
3657
-
3658
- RULES (list needs scheduling:read; add/rm need scheduling:write):
3659
- octwin scheduling rules --resource <resourceRecordId>
3660
- octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
3661
- [--slot-minutes 30] [--capacity 1]
3662
- octwin scheduling rule rm <ruleId>
3663
- octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
3664
- [--start 09:00 --end 13:00]
3665
- octwin scheduling exception rm <exceptionId>
3666
-
3667
- --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
3668
3966
  \`--slots\` is how you check what a rule actually produces.`,
3669
- 'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
3670
- Pull the platform capability reference (markdown + JSON catalogs) into
3671
- .octwin/platform-kb/ for the octwin-pack authoring skill.`,
3672
- test: `octwin test [--dir .]
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
3980
+ agent loops that want to branch without parsing prose.`,
3981
+ test: `octwin test [--dir .]
3673
3982
  Alias for \`octwin validate --remote\` — the full platform check.`,
3674
- feedback: `octwin feedback [--dir .]
3675
- Submit this pack's FEEDBACK.md to the platform team.
3676
- The octwin-pack skill writes that file in its last step — findings grouped by
3677
- owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
3678
- you to paste it into a chat.
3679
- Attaches the pack id + version from manifest.yaml, this CLI's version, and the
3680
- content_hash of the capability reference in .octwin/platform-kb/ — triage needs
3681
- 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
3682
3991
  "you were reading a stale reference". Needs the \`pack:deploy\` scope.`,
3683
3992
  };
3684
3993
  async function main() {
3685
3994
  const [command, ...rest] = process.argv.slice(2);
3686
3995
  const flags = parseFlags(rest);
3687
3996
  // So an auth failure can name the scope THIS invocation needs. A leading write
3688
- // verb changes the answer (`cases` reads, `cases note` writes), so it rides along
3997
+ // verb changes the answer (`work` reads, `work note` writes), so it rides along
3689
3998
  // when the first positional is one — `VERB_REQUIREMENTS` is keyed that way.
3690
3999
  const leadingVerb = flags._[0];
3691
4000
  CURRENT_COMMAND = (typeof leadingVerb === 'string' && command && `${command} ${leadingVerb}` in VERB_REQUIREMENTS)
@@ -3722,8 +4031,8 @@ async function main() {
3722
4031
  case 'records':
3723
4032
  await cmdRecords(flags);
3724
4033
  break;
3725
- case 'cases':
3726
- await cmdCases(flags);
4034
+ case 'work':
4035
+ await cmdWork(flags);
3727
4036
  break;
3728
4037
  case 'logs':
3729
4038
  await cmdLogs(flags);