octwin-cli 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * octwin pull <packId> [--dir <out>] [--version v] [--force] # write a DEPLOYED pack's source back to disk
18
18
  * octwin status [--dir .] # 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)
@@ -27,7 +27,7 @@
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,19 @@ 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 { loadBuiltinNames, findBuiltinViolations, describeBuiltinFinding } from './lib/builtin-check.js';
66
+ import { loadTemplateSpecs, findTemplateViolations, describeTemplateFinding } from './lib/template-check.js';
67
+ import { loadSystemEntities, findEntityViolations, describeEntityFinding } from './lib/entity-check.js';
68
+ import { loadDeclarationSpecs, findDeclarationViolations, describeDeclarationFinding } from './lib/declaration-check.js';
64
69
  import { describeKbLookup, findPlatformKbDir } from './lib/kb-path.js';
65
70
  import { classifyPackPath, isSkippedDir } from './lib/pack-source.js';
66
71
  import { readPage, morePageHint } from './lib/page.js';
72
+ import { kbOneLiner, buildKbIndexMarkdown, buildKbOutlineMarkdown, } from './lib/kb-index.js';
73
+ import { buildSymbols, linkExplainers, renderSymbolsMarkdown } from './lib/kb-symbols.js';
67
74
  // The in-package starter template ships alongside `dist/` and `src/` (both one
68
75
  // level under the package root), so `../templates/starter` resolves for the
69
76
  // built CLI and `tsx` dev alike.
@@ -170,7 +177,7 @@ function authFailureHint(status, url) {
170
177
  }
171
178
  /**
172
179
  * 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
180
+ * `octwin work` is `work:read`, `octwin work note` is `work:write` — so the
174
181
  * requirement is resolved by `<command> <verb>` first, then by command.
175
182
  *
176
183
  * Keyed on the verb rather than duplicating whole commands, because the hint's
@@ -183,13 +190,17 @@ const VERB_REQUIREMENTS = {
183
190
  'records note': { scope: 'records:write', feature: 'records' },
184
191
  'records tasks': { scope: 'records:read', feature: 'tasks' },
185
192
  '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
193
+ 'work assign': { scope: 'work:write', feature: 'work' },
194
+ 'work note': { scope: 'work:write', feature: 'work' },
195
+ // The stage move rides the XRM records verb (`POST …/xrm/records/:id/stage`) —
196
+ // the work surface deliberately has no second transition spelling (P-02).
197
+ 'work stage': { scope: 'records:write', feature: 'records' },
198
+ // `decide --dry-run` hits the PREVIEW route, which is `work:read`. Naming the
190
199
  // 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' },
200
+ 'work decide': { scope: 'work:write', feature: 'work' },
201
+ // The stage move rides the XRM records verb (P-02), so the scope is records:write
202
+ // the read that resolves the reference still needs orders:read.
203
+ 'orders transition': { scope: 'records:write', feature: 'records' },
193
204
  'orders refund': { scope: 'orders:write', feature: 'orders' },
194
205
  'catalog availability': { scope: 'catalog:write', feature: 'catalog' },
195
206
  'catalog stock': { scope: 'catalog:write', feature: 'catalog' },
@@ -197,9 +208,15 @@ const VERB_REQUIREMENTS = {
197
208
  'scheduling rule': { scope: 'scheduling:write' },
198
209
  'scheduling exception': { scope: 'scheduling:write' },
199
210
  'agents set': { scope: 'agents:write' },
211
+ // Creating and destroying a project are the same scope as editing one. Worth
212
+ // spelling out because the natural token for the deploy loop is `pack:deploy`,
213
+ // which does NOT confer this — that 403 is otherwise baffling.
214
+ 'projects create': { scope: 'projects:write' },
215
+ 'projects rm': { scope: 'projects:write' },
200
216
  };
201
217
  const COMMAND_REQUIREMENTS = {
202
218
  deploy: { scope: 'pack:deploy' },
219
+ seed: { scope: 'pack:deploy' },
203
220
  validate: { scope: 'pack:deploy' },
204
221
  status: { scope: 'pack:deploy' },
205
222
  test: { scope: 'pack:deploy' },
@@ -207,7 +224,12 @@ const COMMAND_REQUIREMENTS = {
207
224
  // same scope. Omitting it here meant a 403 on the one command that recovers a pack's
208
225
  // only source copy printed the generic hint WITHOUT naming the scope to grant.
209
226
  pull: { scope: 'pack:deploy' },
210
- 'platform-kb': { scope: 'pack:deploy' },
227
+ // `platform-kb` deliberately has NO requirement: the reference is platform stdlib,
228
+ // and the platform serves it anonymously at `/api/public/octwin-platform-kb` so a
229
+ // cold start needs no login. A token is still USED when present (the tenant-scoped
230
+ // route also works against platforms predating the public one) — but declaring a
231
+ // requirement here would print "needs pack:deploy" on a failure whose real cause is
232
+ // an unreachable instance.
211
233
  feedback: { scope: 'pack:deploy' },
212
234
  media: { scope: 'media:generate' },
213
235
  // The plan feature gates RECORD reads, not the entity list (`/xrm/entities` carries only
@@ -215,7 +237,7 @@ const COMMAND_REQUIREMENTS = {
215
237
  // plan for a 403 the plan did not cause.
216
238
  records: { scope: 'records:read', feature: 'records', featureAppliesTo: 'reading records (listing entities needs only the scope)' },
217
239
  analytics: { scope: 'records:read', feature: 'records' },
218
- cases: { scope: 'cases:read', feature: 'cases' },
240
+ work: { scope: 'work:read', feature: 'work' },
219
241
  logs: { scope: 'conversations:read' },
220
242
  orders: { scope: 'orders:read', feature: 'orders' },
221
243
  catalog: { scope: 'catalog:read', feature: 'catalog' },
@@ -228,7 +250,7 @@ const COMMAND_REQUIREMENTS = {
228
250
  };
229
251
  /** The command currently running — set once in `main()` so any failure printer can
230
252
  * name the scope that command needs without threading it through every call.
231
- * Carries the write VERB too (`cases note`), since that is what decides the scope. */
253
+ * Carries the write VERB too (`work note`), since that is what decides the scope. */
232
254
  let CURRENT_COMMAND;
233
255
  /** `→ needs the \`orders:read\` scope …` — the requirement line for the running
234
256
  * command, or '' when the command has no declared requirement. */
@@ -478,6 +500,13 @@ async function notifyIfOutdated() {
478
500
  }
479
501
  catch { /* a version check must never break the CLI */ }
480
502
  }
503
+ // ── platform-KB drift check (observe the pulled reference, fail-silent) ──
504
+ //
505
+ // `KbIndexEntry` / `KbEntriesDescriptor` mirror the platform's own types and live
506
+ // in `lib/kb-index.js` beside the renderers that consume them. `entries` (list
507
+ // catalogs only) describes how to enumerate a catalog so we can EXPLODE it into
508
+ // one file per entry; the PLATFORM supplies it rather than this CLI hardcoding
509
+ // per-catalog knowledge, so a platform that adds a catalog needs no CLI release.
481
510
  /** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
482
511
  * (content hash + per-entry index), or null if nothing has been pulled yet. */
483
512
  function readLocalKb(packDir) {
@@ -509,15 +538,44 @@ function diffKbIndex(prev, next) {
509
538
  const removed = prev.filter(e => !nextKeys.has(e.key)).map(e => e.key);
510
539
  return { added, removed, changed };
511
540
  }
541
+ /**
542
+ * Fetch the platform's KB identity (`?meta=1`) — the cheap poll behind both the
543
+ * drift nudge and `--if-stale`. Returns null on anything that is not a clean
544
+ * answer; the caller decides whether that is worth a word.
545
+ *
546
+ * `notAuthorized` is reported separately because it is the one failure with a
547
+ * fix the author can act on.
548
+ */
549
+ async function fetchKbMeta(t, timeoutMs = 2_000) {
550
+ const ep = kbEndpoint(t);
551
+ const ctrl = new AbortController();
552
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
553
+ try {
554
+ const res = await fetch(`${ep.url}?meta=1`, { headers: ep.headers, signal: ctrl.signal });
555
+ if (!res.ok)
556
+ return { ok: false, notAuthorized: res.status === 401 || res.status === 403 };
557
+ return { ok: true, meta: await res.json() };
558
+ }
559
+ catch {
560
+ return { ok: false, notAuthorized: false };
561
+ }
562
+ finally {
563
+ clearTimeout(timer);
564
+ }
565
+ }
512
566
  /** Nudge (to stderr) when the platform's capability KB has changed since the last
513
567
  * `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
514
568
  * of the CLI: run only after commands that already hit the platform, so this adds
515
569
  * a single tiny `?meta=1` GET on top of work that was networked anyway. Never
516
570
  * throws — observing must never break a command. No-op until the author has pulled
517
- * once (nothing to compare) or if the target/token can't be resolved. */
571
+ * once (nothing to compare) or if the target can't be resolved.
572
+ *
573
+ * NOT TTY-gated, deliberately. It was, on the reasoning that a nudge is for a
574
+ * human watching a terminal — but the primary reader of this CLI is now an
575
+ * authoring agent whose output is piped, and gating on `isTTY` meant the one
576
+ * reader that CANNOT notice a stale reference on its own was the only one never
577
+ * told. It is a single line on stderr, so piped stdout stays clean either way. */
518
578
  async function notifyIfKbStale(flags) {
519
- if (!process.stdout.isTTY)
520
- return;
521
579
  try {
522
580
  const packDir = resolve(flags.dir ?? '.');
523
581
  const local = readLocalKb(packDir);
@@ -526,25 +584,20 @@ async function notifyIfKbStale(flags) {
526
584
  const t = resolveTargetOrNull(flags);
527
585
  if (!t)
528
586
  return;
529
- const ctrl = new AbortController();
530
- const timer = setTimeout(() => ctrl.abort(), 2_000);
531
- const res = await fetch(`${t.url}/api/self/t/octwin-platform-kb?meta=1`, {
532
- headers: authHeaders(t), signal: ctrl.signal,
533
- });
534
- clearTimeout(timer);
535
- if (!res.ok) {
536
- // The meta poll needs `pack:deploy`, but this nudge rides on every networked
537
- // command so an author inspecting data with a narrow (`records:read`-only) token
538
- // got NO drift signal at all, silently, and a stale reference is exactly what makes
539
- // an author invent a primitive from memory. Say so once; stay silent for every other
540
- // failure (offline, timeout, a platform without the route).
541
- if (res.status === 401 || res.status === 403) {
542
- console.error('\nⓘ can\'t check whether the platform capability reference drifted — that needs a `pack:deploy` token.');
543
- console.error(' Refresh it directly with a deploy token: octwin platform-kb --token oct_…');
587
+ const polled = await fetchKbMeta(t);
588
+ if (!polled.ok) {
589
+ // The tenant-scoped meta poll needs `pack:deploy`, but this nudge rides on every
590
+ // networked command — so an author inspecting data with a narrow (`records:read`-only)
591
+ // token got NO drift signal at all, silently, and a stale reference is exactly what
592
+ // makes an author invent a primitive from memory. Say so once; stay silent for every
593
+ // other failure (offline, timeout, a platform without the route).
594
+ if (polled.notAuthorized) {
595
+ console.error('\nⓘ can\'t check whether the platform capability reference drifted that token lacks `pack:deploy`.');
596
+ console.error(' Check it without a token: octwin platform-kb --check (or refresh: octwin platform-kb --token oct_…)');
544
597
  }
545
598
  return;
546
599
  }
547
- const meta = await res.json();
600
+ const meta = polled.meta;
548
601
  if (meta.content_hash && meta.content_hash !== local.content_hash) {
549
602
  // Per-entry summary (now that the index carries per-entry hashes) — the
550
603
  // exact list of what changed is one `octwin platform-kb` away.
@@ -560,7 +613,7 @@ async function notifyIfKbStale(flags) {
560
613
  summary = ` (${parts.join(' · ')})`;
561
614
  }
562
615
  console.error(`\n⬆ the platform capability reference changed since you last pulled it${summary}.`);
563
- console.error(' Refresh it: octwin platform-kb');
616
+ console.error(' Refresh it: octwin platform-kb --if-stale');
564
617
  }
565
618
  }
566
619
  catch { /* a KB check must never break the CLI */ }
@@ -578,7 +631,7 @@ function commandTouchesPlatform(command, flags) {
578
631
  case 'media':
579
632
  case 'pull':
580
633
  case 'records':
581
- case 'cases':
634
+ case 'work':
582
635
  case 'logs':
583
636
  case 'whoami':
584
637
  case 'feedback':
@@ -587,7 +640,8 @@ function commandTouchesPlatform(command, flags) {
587
640
  case 'analytics':
588
641
  case 'catalog':
589
642
  case 'scheduling':
590
- case 'projects': return true;
643
+ case 'projects':
644
+ case 'seed': return true;
591
645
  default: return false;
592
646
  }
593
647
  }
@@ -595,9 +649,14 @@ function commandTouchesPlatform(command, flags) {
595
649
  function cmdInit(flags) {
596
650
  const target = flags._[0] ?? die('usage: octwin init <dir> [--id my-pack]');
597
651
  const dir = resolve(target);
652
+ // A BARE name, and it stays bare: the platform prefixes the workspace slug at publish,
653
+ // which is why `init` can stay offline (it has no idea which workspace this will deploy
654
+ // to, and no longer needs one) and why the same scaffold deploys into two workspaces as
655
+ // two packs with nothing to edit in between.
598
656
  const id = flags.id ?? target.replace(/[/\\]/g, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
599
- if (!/^[a-z][a-z0-9-]*$/.test(id))
600
- die(`pack id '${id}' must be lowercase ASCII with hyphens — pass --id`);
657
+ const idProblem = describePackNameProblem(id);
658
+ if (idProblem)
659
+ die(`${idProblem}\n → pass --id <pack-name>`);
601
660
  if (existsSync(dir) && readdirSync(dir).length > 0)
602
661
  die(`target '${dir}' is not empty`);
603
662
  if (!existsSync(TEMPLATE_DIR))
@@ -629,7 +688,14 @@ function cmdInit(flags) {
629
688
  }
630
689
  function localValidate(packDir) {
631
690
  const { files, blobs } = collectBundleFiles(packDir);
632
- const { id, version } = readManifestIdVersion(files);
691
+ const { id: rawId, version } = readManifestIdVersion(files);
692
+ // The manifest declares a BARE name; the platform prefixes your workspace at publish.
693
+ // Minted here so an unusable one is reported with the rest of the bundle's errors.
694
+ const id = asPackName(rawId);
695
+ if (!id) {
696
+ console.error(` ✗ ${describePackNameProblem(rawId)}`);
697
+ die('bundle validation failed (1 error)');
698
+ }
633
699
  const r = validatePackBundle(id, files, blobs);
634
700
  if (!r.ok) {
635
701
  for (const e of r.errors)
@@ -653,17 +719,36 @@ async function cmdValidate(flags) {
653
719
  return [];
654
720
  }
655
721
  });
656
- // Checks that need the pulled KB. Both DEGRADE when it is absent — the KB is a
657
- // gitignored cache wiped by every pull, and `platform-kb pull` needs a
658
- // `pack:deploy` scope a CI job may not have, so failing hard would break a fresh
659
- // clone before the author could act. But a skip is now ANNOUNCED, and remembered:
722
+ // Checks that need the pulled KB. All of them DEGRADE when it is absent — the KB
723
+ // is a gitignored cache wiped by every pull, so failing hard would break a fresh
724
+ // clone before the author could act. But a skip is ANNOUNCED, and remembered:
660
725
  // the ✓ used to print above these blocks unconditionally while the per-check ✓s
661
726
  // lived inside the `if`s, so a KB-less run read as "one check, passed". An entire
662
727
  // backlog batch reached production that way. The defect is the silence, not the skip.
728
+ //
729
+ // Reasons are COLLECTED rather than printed inline. When the KB is missing, every
730
+ // check skips for the identical reason, and six copies of one sentence is how a
731
+ // reader learns to scroll past the ⚠ block — which is the same failure as not
732
+ // printing it. One line, naming all six.
663
733
  const skipped = [];
734
+ const skipReasons = new Map();
735
+ const noteSkip = (label, lookup) => {
736
+ skipped.push(label);
737
+ // Group by the lookup's IDENTITY, then let `describeKbLookup` phrase the one
738
+ // line at print time — so the wording stays in the module that owns it and
739
+ // cannot drift into a doubled "SKIPPED — SKIPPED —".
740
+ const key = lookup.state === 'ok' ? 'ok'
741
+ : `${lookup.state}|${'dir' in lookup ? lookup.dir : ''}|${'reason' in lookup ? lookup.reason : ''}`;
742
+ const bucket = skipReasons.get(key) ?? { lookup, labels: [] };
743
+ bucket.labels.push(label);
744
+ skipReasons.set(key, bucket);
745
+ };
746
+ /** Stamp each finding with its source line — the walkers carry the node
747
+ * path; `files` holds the raw text the locator needs (E-08). */
748
+ const withLines = (fs) => fs.map(f => ({ ...f, line: files[f.file] ? yamlLineOf(files[f.file], f.path) : null }));
664
749
  const render = loadAllowedRenderKeys(packDir);
665
750
  if (render.keys) {
666
- const findings = yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys));
751
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys)));
667
752
  if (findings.length) {
668
753
  console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
669
754
  for (const f of findings)
@@ -673,14 +758,13 @@ async function cmdValidate(flags) {
673
758
  console.log('✓ render intents use only fields the platform renders');
674
759
  }
675
760
  else {
676
- console.log(`⚠ ${describeKbLookup(render.lookup, 'render-intent fields')}`);
677
- skipped.push('render-intent fields');
761
+ noteSkip('render-intent fields', render.lookup);
678
762
  }
679
763
  // Primitive `args:` keys, same source and same contract. Cannot see inside a
680
764
  // `use:` template body (expansion is the platform's job); `--remote` covers that.
681
765
  const args = loadPrimitiveArgSpecs(packDir);
682
766
  if (args.specs) {
683
- const findings = yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs));
767
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs)));
684
768
  if (findings.length) {
685
769
  console.error(`✗ ${findings.length} primitive-argument error${findings.length === 1 ? '' : 's'}:`);
686
770
  for (const f of findings)
@@ -690,8 +774,104 @@ async function cmdValidate(flags) {
690
774
  console.log('✓ primitive arguments match their declared inputs');
691
775
  }
692
776
  else {
693
- console.log(`⚠ ${describeKbLookup(args.lookup, 'primitive arguments')}`);
694
- skipped.push('primitive arguments');
777
+ noteSkip('primitive arguments', args.lookup);
778
+ }
779
+ // Expression builtins — the function set is CLOSED and generated from the
780
+ // runtime, so an invented `$fn(` is checkable here and nowhere else offline.
781
+ const builtins = loadBuiltinNames(packDir);
782
+ if (builtins.names) {
783
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findBuiltinViolations(doc, p, builtins.names)));
784
+ if (findings.length) {
785
+ console.error(`✗ ${findings.length} unknown expression function${findings.length === 1 ? '' : 's'}:`);
786
+ for (const f of findings)
787
+ console.error(` ✗ ${describeBuiltinFinding(f)}`);
788
+ die('fix these before deploying — the evaluator cannot resolve them, and it fails mid-conversation');
789
+ }
790
+ console.log('✓ every $function() in an expression exists');
791
+ }
792
+ else {
793
+ noteSkip('expression functions', builtins.lookup);
794
+ }
795
+ // `use:` templates. A pack's OWN templates shadow the platform's, so they are
796
+ // named here and skipped — this check has no schema for them.
797
+ const templates = loadTemplateSpecs(packDir);
798
+ if (templates.specs) {
799
+ // A pack template is `templates/<name>.template.yaml` — the `.template`
800
+ // segment is part of the convention the expander scans for, NOT part of the
801
+ // name a `use:` writes. Capturing it would leave every pack that shadows a
802
+ // platform template (kaiian shadows `field_prompt_render`) reported as using
803
+ // one that does not exist.
804
+ const packTemplates = new Set(Object.keys(files)
805
+ .map(p => /^templates\/(.+)\.template\.ya?ml$/i.exec(p.replace(/\\/g, '/'))?.[1])
806
+ .filter((n) => !!n));
807
+ const findings = yamlDocs().flatMap(([p, doc]) => findTemplateViolations(doc, p, templates.specs, packTemplates));
808
+ if (findings.length) {
809
+ console.error(`✗ ${findings.length} template error${findings.length === 1 ? '' : 's'}:`);
810
+ for (const f of findings)
811
+ console.error(` ✗ ${describeTemplateFinding(f)}`);
812
+ die('fix these before deploying — a template param that does not exist arrives as undefined, and renders as a blank');
813
+ }
814
+ console.log('✓ `use:` templates and their params exist');
815
+ }
816
+ else {
817
+ noteSkip('`use:` templates', templates.lookup);
818
+ }
819
+ // Reserved XRM entity keys — a boot error, which means the pack deploys clean
820
+ // and then fails to load on the first inbound message.
821
+ const system = loadSystemEntities(packDir);
822
+ if (system.entities) {
823
+ const findings = yamlDocs()
824
+ .filter(([p]) => /(^|[/\\])xrm\.ya?ml$/i.test(p))
825
+ .flatMap(([p, doc]) => findEntityViolations(doc, p, system.entities, system.rules));
826
+ if (findings.length) {
827
+ console.error(`✗ ${findings.length} reserved-entity error${findings.length === 1 ? '' : 's'}:`);
828
+ for (const f of findings)
829
+ console.error(` ✗ ${describeEntityFinding(f)}`);
830
+ die('fix these before deploying — these fail at BOOT, after a deploy that reported success');
831
+ }
832
+ // Name the narrower promise when the catalog-level rules are absent (a KB
833
+ // pulled before they were published) — a ✓ that reads wider than what ran is
834
+ // the failure this file's skip contract exists to prevent.
835
+ console.log(system.rules
836
+ ? '✓ no entity collides with a reserved platform key, and no extension overrides a platform-owned one'
837
+ : '✓ no entity collides with a reserved platform key (re-pull for the `contact`/extension-override rules)');
838
+ }
839
+ else {
840
+ noteSkip('reserved entity keys', system.lookup);
841
+ }
842
+ // The declaration files themselves, against the published JSON Schemas.
843
+ // Deliberately narrow (see declaration-check.ts) — it walks away from anything
844
+ // it cannot read rather than guessing.
845
+ const decls = loadDeclarationSpecs(packDir);
846
+ if (decls.specs) {
847
+ const findings = yamlDocs().flatMap(([p, doc]) => {
848
+ const base = p.replace(/\\/g, '/').split('/').pop() ?? p;
849
+ const spec = decls.specs.get(base);
850
+ // Only a file at the PACK ROOT is a declaration — `flows/tools/xrm.yaml`
851
+ // would be a flow that happens to share a name.
852
+ if (!spec || p.replace(/\\/g, '/').includes('/'))
853
+ return [];
854
+ return findDeclarationViolations(doc, spec).map(f => ({ ...f, file: p }));
855
+ });
856
+ if (findings.length) {
857
+ console.error(`✗ ${findings.length} declaration error${findings.length === 1 ? '' : 's'}:`);
858
+ for (const f of findings)
859
+ console.error(` ✗ ${describeDeclarationFinding(f)}`);
860
+ die('fix these before deploying — a declaration file is parsed strictly, and an unknown key is rejected');
861
+ }
862
+ // Deliberately narrow wording. Most of `xrm.yaml`'s field shapes are a Zod
863
+ // union, which renders as `anyOf` and which this check walks away from by
864
+ // design — so "matches its schema" would be a promise it does not keep, and
865
+ // an over-claimed ✓ is how an author stops reading `--remote` output.
866
+ console.log('✓ declaration files carry no unknown or missing keys (unions are left to --remote)');
867
+ }
868
+ else {
869
+ noteSkip('declaration schemas', decls.lookup);
870
+ }
871
+ // One ⚠ per distinct reason, naming every check it cost.
872
+ for (const { lookup, labels } of skipReasons.values()) {
873
+ const what = labels.length === 1 ? labels[0] : `${labels.length} checks (${labels.join(', ')})`;
874
+ console.log(`⚠ ${describeKbLookup(lookup, what)}`);
695
875
  }
696
876
  // `--require-kb` is for CI, where a skip nobody reads is worse than a red build.
697
877
  if (skipped.length && flags['require-kb'] === true) {
@@ -701,7 +881,7 @@ async function cmdValidate(flags) {
701
881
  // The LAST line carries the skip. A reader who sees a ✓ and stops there is the
702
882
  // failure mode; a caveat printed ABOVE the ✓ does not fix it.
703
883
  if (skipped.length) {
704
- console.log(`\n⚠ ${id}@${version} passed the checks that RAN — ${skipped.join(' and ')} ${skipped.length === 1 ? 'was' : 'were'} skipped.`);
884
+ console.log(`\n⚠ ${id}@${version} passed the checks that RAN — ${skipped.length} skipped: ${skipped.join(', ')}.`);
705
885
  console.log(' Run `octwin platform-kb pull` (once, at your repo root — it covers every pack under it),');
706
886
  console.log(' or `octwin validate --remote` to have the platform run everything server-side.');
707
887
  }
@@ -730,9 +910,25 @@ async function cmdValidate(flags) {
730
910
  json = text;
731
911
  }
732
912
  if (!res.ok) {
733
- // 404 older platform without the endpoint.
734
- if (res.status === 404)
735
- die('this platform has no /packs/validate endpoint yet (older version) deploy runs the full check');
913
+ // A 404 here is AMBIGUOUS and must not be collapsed. The route resolves the
914
+ // tenant and the project BEFORE it validates anything, so a 404 is usually an
915
+ // unknown `--tenant`/`--project` and a token's project PIN answers 404 by
916
+ // design (an out-of-pin project is deliberately indistinguishable from one that
917
+ // does not exist). Reporting all of those as "older platform" sends the author
918
+ // hunting for a version mismatch that does not exist.
919
+ //
920
+ // The two are told apart by the BODY, not the status: the platform has no
921
+ // custom not-found handler, so a missing route is Fastify's default
922
+ // `{ statusCode, error: 'Not Found', message: 'Route … not found' }`, whereas
923
+ // `resolveTenantOr404`/`resolveProjectOr404` send a bare `{ error: "<what> not
924
+ // found" }`. The server's own message already names the slug it tried, so the
925
+ // hint carries the fix rather than repeating the target.
926
+ if (res.status === 404) {
927
+ const routeMissing = typeof json !== 'object' || json === null || json.error === 'Not Found';
928
+ if (routeMissing)
929
+ die('this platform has no /packs/validate endpoint yet (older version) — deploy runs the full check');
930
+ die(`remote validate${errDetail(json)} — check --tenant/--project (or PACK_TENANT/PACK_PROJECT); \`octwin projects\` lists what this token can reach`);
931
+ }
736
932
  console.error(`✗ remote validate failed (HTTP ${res.status})`);
737
933
  printAuthHint(res.status, url);
738
934
  console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
@@ -815,6 +1011,24 @@ function resolveTargetOrNull(flags) {
815
1011
  const t = readTarget(flags);
816
1012
  return t.url && t.token ? t : null;
817
1013
  }
1014
+ /**
1015
+ * Where to read the platform capability reference from, and how.
1016
+ *
1017
+ * The KB is tenant-independent platform stdlib, and the platform now serves it
1018
+ * anonymously at `/api/public/octwin-platform-kb` precisely so a COLD START does
1019
+ * not need a login: `.octwin/` is gitignored, so a fresh clone — or a fresh
1020
+ * Claude Code session — has no reference at all, and an authoring agent's first
1021
+ * useful question came after a credential round-trip it had no way to satisfy.
1022
+ *
1023
+ * A token still WINS when one is available: the tenant-scoped route is the one
1024
+ * that has always existed, it works against platforms that predate the public
1025
+ * rung, and using it keeps the author's own instance the source of truth.
1026
+ */
1027
+ function kbEndpoint(t) {
1028
+ return t.token
1029
+ ? { url: `${t.url}/api/self/t/octwin-platform-kb`, headers: authHeaders(t), anonymous: false }
1030
+ : { url: `${t.url}/api/public/octwin-platform-kb`, headers: {}, anonymous: true };
1031
+ }
818
1032
  /** The raw resolution both wrappers share — may return empty url/token. */
819
1033
  function readTarget(flags) {
820
1034
  const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? savedDefaultUrl()).replace(/\/$/, '');
@@ -1015,6 +1229,71 @@ function printDeploySuccess(id, version, t, r) {
1015
1229
  printPublicListing(r?.public_listing, r?.public_review_note);
1016
1230
  console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
1017
1231
  }
1232
+ /**
1233
+ * `octwin seed [--pack <id>]` — apply the pack's demo/reference data to the project it
1234
+ * is installed on, without redeploying.
1235
+ *
1236
+ * Exists because seeding used to be reachable only as `deploy --seed`: the platform's
1237
+ * seed endpoint was keyed on an install id, guarded `requirePlatformAdmin`, and carried
1238
+ * no tenant/project segments — so the `/api/self/**` rewrite could not reach it and a
1239
+ * `pack:deploy` token never could. Re-seeding meant a full redeploy, or asking an
1240
+ * operator.
1241
+ *
1242
+ * Reuses `readDeployProgress` verbatim: the platform emits ONE seed-progress vocabulary
1243
+ * now (`stage:'seed'` with a `kind`), so a second reader would only be a second thing to
1244
+ * keep in step.
1245
+ */
1246
+ async function cmdSeed(flags) {
1247
+ const t = resolveTarget(flags);
1248
+ const { url } = t;
1249
+ const packId = typeof flags.pack === 'string' ? flags.pack : undefined;
1250
+ console.log(`→ Seeding ${packId ?? 'the installed pack'} on ${targetLabel(t)} …`);
1251
+ const res = await fetchOrDie(`${url}/api/self/p/packs/seed`, {
1252
+ method: 'POST',
1253
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
1254
+ body: JSON.stringify(packId ? { pack_id: packId } : {}),
1255
+ }, 'seed');
1256
+ if (res.ok && (res.headers.get('content-type') ?? '').includes('text/event-stream') && res.body) {
1257
+ const { terminal: final, stepErrors } = await readDeployProgress(res.body);
1258
+ if (!final || final.stage === 'error')
1259
+ die(`seed failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
1260
+ console.log(`
1261
+ ✓ ${final.message ?? 'seed complete'}`);
1262
+ printSeedCounts(final.result?.seeded);
1263
+ if (stepErrors.length) {
1264
+ // A kind failed but the rest ran — the reconcile softens each step. Say which,
1265
+ // and exit non-zero so a scripted `seed && chat` doesn't read as clean.
1266
+ console.error(`
1267
+ ⚠ ${stepErrors.length} step${stepErrors.length === 1 ? '' : 's'} failed — data may be incomplete:`);
1268
+ for (const e of stepErrors)
1269
+ console.error(` • ${e}`);
1270
+ process.exit(1);
1271
+ }
1272
+ return;
1273
+ }
1274
+ const text = await res.text();
1275
+ let json;
1276
+ try {
1277
+ json = JSON.parse(text);
1278
+ }
1279
+ catch {
1280
+ json = text;
1281
+ }
1282
+ if (!res.ok) {
1283
+ console.error(`✗ seed failed (HTTP ${res.status})${errDetail(json)}`);
1284
+ printAuthHint(res.status, url);
1285
+ process.exit(1);
1286
+ }
1287
+ console.log('✓ seed complete');
1288
+ printSeedCounts(json?.seeded);
1289
+ }
1290
+ /** Per-kind counts, one line each. Prints nothing when the pack declared nothing. */
1291
+ function printSeedCounts(seeded) {
1292
+ for (const [kind, counts] of Object.entries(seeded ?? {})) {
1293
+ const detail = Object.entries(counts).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(' · ');
1294
+ console.log(` ${kind.padEnd(11)} ${detail || '—'}`);
1295
+ }
1296
+ }
1018
1297
  async function cmdDeploy(flags) {
1019
1298
  const packDir = resolve(flags.dir ?? '.');
1020
1299
  const t = resolveTarget(flags);
@@ -1143,35 +1422,10 @@ async function cmdStatus(flags) {
1143
1422
  function kbEntryFileName(name) {
1144
1423
  return name.replace(/[^A-Za-z0-9._-]/g, '_');
1145
1424
  }
1146
- /** First sentence (or a hard clamp) of a possibly-long `describe`INDEX.md needs
1147
- * one scannable line per entry, not the whole contract.
1148
- *
1149
- * Sentence detection ignores punctuation nested in brackets: primitive `describe`
1150
- * text routinely inlines an envelope shape (`… { rows, total, …, refs? } …`) whose
1151
- * `?` would otherwise cut the summary off mid-brace. */
1152
- function kbOneLiner(text, max = 160) {
1153
- if (typeof text !== 'string' || !text.trim())
1154
- return '';
1155
- const flat = text.replace(/\s+/g, ' ').trim();
1156
- let depth = 0;
1157
- let end = -1;
1158
- for (let i = 0; i < flat.length; i++) {
1159
- const ch = flat[i];
1160
- if (ch === '{' || ch === '(' || ch === '[')
1161
- depth++;
1162
- else if (ch === '}' || ch === ')' || ch === ']')
1163
- depth = Math.max(0, depth - 1);
1164
- else if (depth === 0 && (ch === '.' || ch === '!' || ch === '?')) {
1165
- const next = flat[i + 1];
1166
- if (next === undefined || next === ' ') {
1167
- end = i + 1;
1168
- break;
1169
- }
1170
- }
1171
- }
1172
- const line = end >= 40 ? flat.slice(0, end) : flat;
1173
- return line.length > max ? line.slice(0, max - 1).trimEnd() + '…' : line;
1174
- }
1425
+ /** Envelope keys that are bookkeeping, not catalog-level contentnever worth a
1426
+ * `_catalog.json` of their own. Anything else in the envelope is a real rule that
1427
+ * would otherwise be dropped by the explode. */
1428
+ const CATALOG_ENVELOPE_NOISE = new Set(['version', 'description', 'source']);
1175
1429
  /** Enumerate a catalog's entries per the platform-supplied descriptor. Handles both
1176
1430
  * collection shapes in use: an ARRAY of named objects (`primitives`, keyed by
1177
1431
  * `name`) and an OBJECT MAP keyed by entry name (`declarations`, `system-entities`).
@@ -1200,73 +1454,56 @@ function enumerateKbEntries(catalog, d) {
1200
1454
  }
1201
1455
  return [];
1202
1456
  }
1203
- /**
1204
- * Build `INDEX.md` — the map an authoring agent reads FIRST.
1205
- *
1206
- * The KB is ~800 KB across three dozen files; reading it whole costs more context
1207
- * than the pack being authored. This index is one ~7k-token read that names every
1208
- * doc and every catalog entry with a one-line summary and its exact path, so the
1209
- * agent can jump straight to the ~600-token file it actually needs.
1210
- */
1211
- function buildKbIndexMarkdown(bundle, exploded) {
1212
- const index = bundle.index ?? [];
1213
- const docs = index.filter(e => e.kind === 'doc');
1214
- const catalogs = index.filter(e => e.kind === 'catalog');
1215
- const L = [];
1216
- L.push('# Octwin platform capability reference — INDEX');
1217
- L.push('');
1218
- L.push(`Reference version ${bundle.version ?? '?'} · content_hash \`${bundle.content_hash ?? '?'}\` · pulled ${bundle.generated_at ?? '?'}`);
1219
- L.push('');
1220
- L.push('**This is the map. Read it, then open only the specific file you need — never a whole catalog.**');
1221
- L.push('Everything the platform supports is here; if a step, function, field, or render intent is NOT in');
1222
- L.push('this index, it does not exist for a pure-YAML pack. Do not fill a gap from memory.');
1223
- L.push('');
1224
- L.push('## Start here');
1225
- L.push('');
1226
- L.push('1. `craft-capabilities.md` — how this reference fits together.');
1227
- L.push('2. `craft-ux.md` — what a *good* pack looks like (home hub, rich cards, confirm-before-commit).');
1228
- L.push('3. `craft-flows.md` — the flow DSL in practice.');
1229
- L.push('4. Then the tables below, on demand.');
1230
- L.push('');
1231
- L.push('## Guides & reference docs');
1232
- L.push('');
1233
- L.push('| Doc | Read it for | File |');
1234
- L.push('|---|---|---|');
1235
- for (const d of docs)
1236
- L.push(`| ${d.title ?? d.key} | ${kbOneLiner(d.summary)} | \`${d.key}.md\` |`);
1237
- L.push('');
1238
- L.push('## Catalogs — exact machine-readable schemas');
1239
- L.push('');
1240
- for (const c of catalogs) {
1241
- const entries = exploded.get(c.key);
1242
- L.push(`### ${c.title ?? c.key}`);
1243
- L.push('');
1244
- L.push(kbOneLiner(c.summary, 400));
1245
- L.push('');
1246
- if (!entries || entries.length === 0) {
1247
- L.push(`Single document: \`${c.key}.json\``);
1248
- L.push('');
1249
- continue;
1250
- }
1251
- L.push(`${entries.length} entries in \`${c.key}/\` — one file each.`);
1252
- L.push('');
1253
- L.push('| Entry | What it does | File |');
1254
- L.push('|---|---|---|');
1255
- for (const e of entries) {
1256
- L.push(`| \`${e.name}\` | ${e.summary.replace(/\|/g, '\\|')} | \`${c.key}/${kbEntryFileName(e.name)}.json\` |`);
1257
- }
1258
- L.push('');
1259
- }
1260
- return L.join('\n') + '\n';
1261
- }
1262
1457
  async function cmdPlatformKb(flags) {
1263
1458
  const packDir = resolve(flags.dir ?? '.');
1264
- const t = resolveTarget(flags);
1459
+ const t = readTarget(flags);
1460
+ if (!t.url)
1461
+ die('no platform url — pass --url <url>, set PACK_PLATFORM_URL, or run `octwin login`');
1265
1462
  const { url } = t;
1266
- console.log(`→ Pulling the platform capability reference from ${url} …`);
1267
- const res = await fetchOrDie(`${url}/api/self/t/octwin-platform-kb`, {
1268
- headers: authHeaders(t),
1269
- }, 'platform-kb pull');
1463
+ const ep = kbEndpoint(t);
1464
+ // ── --check: report staleness as an EXIT CODE, write nothing ──────────
1465
+ //
1466
+ // For a loop that wants to branch on "is my reference current?" without
1467
+ // parsing prose. 0 = current · 2 = stale (or never pulled) · 1 = could not
1468
+ // tell. Three codes, not two: an agent that treats "unreachable" as "stale"
1469
+ // re-pulls forever against an instance that is down.
1470
+ if (flags.check === true) {
1471
+ const local = readLocalKb(packDir);
1472
+ const polled = await fetchKbMeta(t, 10_000);
1473
+ if (!polled.ok) {
1474
+ console.error(polled.notAuthorized
1475
+ ? '✗ cannot check — the platform refused the token, and this instance serves no anonymous reference.'
1476
+ : `✗ cannot check — ${url} did not answer.`);
1477
+ process.exit(1);
1478
+ }
1479
+ const remote = polled.meta.content_hash;
1480
+ if (!local?.content_hash) {
1481
+ console.log(`⬆ no reference pulled yet (platform is at ${remote ?? '?'}) — run \`octwin platform-kb pull\`.`);
1482
+ process.exit(2);
1483
+ }
1484
+ if (remote && remote !== local.content_hash) {
1485
+ console.log(`⬆ stale: local ${local.content_hash} → platform ${remote}. Run \`octwin platform-kb pull\`.`);
1486
+ process.exit(2);
1487
+ }
1488
+ console.log(`✓ current (${local.content_hash}).`);
1489
+ return;
1490
+ }
1491
+ // ── --if-stale: make "pull at the start of every session" free ────────
1492
+ //
1493
+ // One small `?meta=1` GET instead of ~1 MB, and it is what lets the skill say
1494
+ // "pull every session" without that costing a megabyte per session. An
1495
+ // unreachable platform is NOT treated as current: it falls through to the real
1496
+ // pull, which fails loudly with the actual error.
1497
+ if (flags['if-stale'] === true) {
1498
+ const local = readLocalKb(packDir);
1499
+ const polled = await fetchKbMeta(t, 10_000);
1500
+ if (polled.ok && local?.content_hash && polled.meta.content_hash === local.content_hash) {
1501
+ console.log(`✓ capability reference already current (${local.content_hash}) — nothing to pull.`);
1502
+ return;
1503
+ }
1504
+ }
1505
+ console.log(`→ Pulling the platform capability reference from ${url}${ep.anonymous ? ' (anonymous — no token needed for the reference)' : ''} …`);
1506
+ const res = await fetchOrDie(ep.url, { headers: ep.headers }, 'platform-kb pull');
1270
1507
  const text = await res.text();
1271
1508
  if (!res.ok) {
1272
1509
  let j;
@@ -1317,6 +1554,9 @@ async function cmdPlatformKb(flags) {
1317
1554
  // (or an unexpected payload shape) fall back to the flat file.
1318
1555
  const byKey = new Map((bundle.index ?? []).map(e => [e.key, e]));
1319
1556
  const exploded = new Map();
1557
+ /** Catalogs written as ONE file — the flow schema is the only one today, and the
1558
+ * symbol router still mines it for node op-keys. */
1559
+ const flatCatalogs = {};
1320
1560
  let catalogCount = 0;
1321
1561
  let entryCount = 0;
1322
1562
  for (const [key, val] of Object.entries(bundle.sources ?? {})) {
@@ -1327,6 +1567,7 @@ async function cmdPlatformKb(flags) {
1327
1567
  const entries = descriptor ? enumerateKbEntries(val, descriptor) : [];
1328
1568
  if (entries.length === 0) {
1329
1569
  writeFileSync(join(outDir, `${key}.json`), JSON.stringify(val, null, 2) + '\n', 'utf8');
1570
+ flatCatalogs[key] = val;
1330
1571
  continue;
1331
1572
  }
1332
1573
  const dir = join(outDir, key);
@@ -1334,17 +1575,57 @@ async function cmdPlatformKb(flags) {
1334
1575
  for (const entry of entries) {
1335
1576
  writeFileSync(join(dir, `${kbEntryFileName(entry.name)}.json`), JSON.stringify(entry.value, null, 2) + '\n', 'utf8');
1336
1577
  }
1578
+ // The ENVELOPE, when it carries anything beyond the collection.
1579
+ //
1580
+ // Exploding a catalog drops everything that is not an entry — which was fine
1581
+ // while the envelope held only `version`/`description`/`source`, and silently
1582
+ // wrong the moment a catalog published a rule that belongs to the whole set
1583
+ // rather than to one member. `system-entities` does exactly that: the reserved
1584
+ // entity keys, the reserved prefix, and the keys an `extends: system` entity
1585
+ // may not override are properties of the CATALOG. Written as `_catalog.json`
1586
+ // so the offline checks can read them.
1587
+ //
1588
+ // The `_` prefix is load-bearing: every loader that reads one of these
1589
+ // directories iterates `*.json`, so a sibling that is not an entry must be
1590
+ // skippable by name. See the `isEntryFile` guard the checks share.
1591
+ const envelope = Object.fromEntries(Object.entries(val)
1592
+ .filter(([k]) => k !== descriptor.at && !CATALOG_ENVELOPE_NOISE.has(k)));
1593
+ if (Object.keys(envelope).length > 0) {
1594
+ writeFileSync(join(dir, '_catalog.json'), JSON.stringify(envelope, null, 2) + '\n', 'utf8');
1595
+ }
1337
1596
  exploded.set(key, entries);
1338
1597
  entryCount += entries.length;
1339
1598
  }
1340
- // The map the authoring skill reads first.
1341
- writeFileSync(join(outDir, 'INDEX.md'), buildKbIndexMarkdown(bundle, exploded), 'utf8');
1599
+ // ── the three maps ────────────────────────────────────────────────────
1600
+ //
1601
+ // Three, not one, because an author arrives with three different things in
1602
+ // hand: a QUESTION (INDEX), a NAME (SYMBOLS), or a doc too heavy to read whole
1603
+ // (OUTLINE). One file answering all three is the file nobody can afford to
1604
+ // read — which is what INDEX.md had become at ~30 KB, most of it entry rows
1605
+ // that belong in a grep target.
1606
+ writeFileSync(join(outDir, 'INDEX.md'), buildKbIndexMarkdown(bundle, new Map([...exploded].map(([k, v]) => [k, v.length]))), 'utf8');
1607
+ // SYMBOLS.md — every addressable name → the file that defines it. Derived from
1608
+ // the catalogs just written, so it needs nothing extra over the wire.
1609
+ const symbols = buildSymbols(exploded, (catalogKey, entryName) => `${catalogKey}/${kbEntryFileName(entryName)}.json`, flatCatalogs);
1610
+ linkExplainers(symbols, new Map((bundle.index ?? [])
1611
+ .filter(e => e.kind === 'doc' && e.sections?.length)
1612
+ .map(e => [e.key, e.sections])));
1613
+ writeFileSync(join(outDir, 'SYMBOLS.md'), renderSymbolsMarkdown(symbols, bundle.content_hash), 'utf8');
1614
+ // OUTLINE.md — only when the platform published section data. An empty outline
1615
+ // would read as "these docs have no sections", which is worse than its absence.
1616
+ const outline = buildKbOutlineMarkdown(bundle);
1617
+ if (outline)
1618
+ writeFileSync(join(outDir, 'OUTLINE.md'), outline, 'utf8');
1342
1619
  // Persist `content_hash` too — the staleness observer (`notifyIfKbStale`) reads
1343
1620
  // it back and compares against the platform's current hash to nudge a re-pull.
1344
1621
  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');
1345
1622
  console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
1346
1623
  console.log(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`);
1347
- console.log(' Start at INDEX.md it maps every doc and every catalog entry to its file.');
1624
+ console.log(` Three maps: INDEX.md (the corpus) · SYMBOLS.md (${symbols.length} names their file, grep it)`
1625
+ + `${outline ? ' · OUTLINE.md (every heading, with line numbers)' : ''}`);
1626
+ if (!outline) {
1627
+ console.log(' (no OUTLINE.md — this platform publishes no doc section data; upgrade it for line-addressable docs.)');
1628
+ }
1348
1629
  console.log(' Every pack UNDER this directory finds it — `octwin validate` walks up to locate it,');
1349
1630
  console.log(' so one pull at a repo root covers a whole monorepo of packs.');
1350
1631
  // Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
@@ -1366,7 +1647,7 @@ async function cmdPlatformKb(flags) {
1366
1647
  }
1367
1648
  console.log(' The octwin-pack authoring skill reads these as the source of truth for what the platform supports.');
1368
1649
  }
1369
- // ── records / cases / logs / chat — headless inspect + test with the deploy token ────
1650
+ // ── records / work / logs / chat — headless inspect + test with the deploy token ────
1370
1651
  /** GET an admin endpoint with the deploy token; returns `{ status, json }`.
1371
1652
  * Dies (with the URL) on a network failure; auth failures return so the
1372
1653
  * caller can add command-specific context on top of `authFailureHint`. */
@@ -1528,9 +1809,9 @@ async function cmdRecords(flags) {
1528
1809
  const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&${pagingQs(flags)}`, t);
1529
1810
  if (status !== 200) {
1530
1811
  // Always show the server's reason (it names the unknown entity). Cases are
1531
- // casework (worklist), not pack-declared XRM — point at the right command.
1812
+ // worked records (worklist), not pack-declared XRM — point at the right command.
1532
1813
  if (entity === 'case' || entity === 'cases') {
1533
- console.error(` '${entity}' is casework (worklist), not a pack-declared XRM entity — inspect tickets with: octwin cases`);
1814
+ console.error(` '${entity}' is a worked record (worklist), not a pack-declared XRM entity — inspect it with: octwin work`);
1534
1815
  }
1535
1816
  die(`could not read records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1536
1817
  }
@@ -2206,70 +2487,86 @@ async function cmdMedia(flags) {
2206
2487
  console.log(` saved → ${out}`);
2207
2488
  console.log(` Send it into a chat: octwin chat "here you go" --media ${out ?? r.media_id} --as <handle>`);
2208
2489
  }
2209
- /** Reserved leading words on `octwin cases` — see `RECORD_VERBS` for the rule. */
2210
- const CASE_VERBS = new Set(['assign', 'note', 'transition', 'decide']);
2490
+ /** Reserved leading words on `octwin work` — see `RECORD_VERBS` for the rule. */
2491
+ const WORK_VERBS = new Set(['assign', 'note', 'stage', 'decide']);
2492
+ /** A localized label bag (or legacy bare string) → one printable string.
2493
+ * The work routes ship labels as full bags so each consumer picks; the CLI
2494
+ * prefers English and falls back to whatever the bag has. */
2495
+ function pickLabel(v) {
2496
+ if (typeof v === 'string')
2497
+ return v;
2498
+ if (v && typeof v === 'object') {
2499
+ const bag = v;
2500
+ return bag.en ?? Object.values(bag)[0] ?? null;
2501
+ }
2502
+ return null;
2503
+ }
2211
2504
  /**
2212
- * The write half of `octwin cases` — assign / note / transition / decide.
2505
+ * The write half of `octwin work` — assign / note / stage / decide.
2213
2506
  *
2214
- * `decide --dry-run` routes to the PREVIEW endpoint, which sits behind `cases:read`
2215
- * rather than `cases:write`: it renders the customer-facing copy and the resulting
2216
- * status without committing. That makes "show me what this disposition would do"
2507
+ * `stage` is deliberately the XRM records verb (`POST …/xrm/records/:id/stage`):
2508
+ * the work surface carries no second transition spelling a stage move has one
2509
+ * verb platform-wide (consolidation P-02).
2510
+ *
2511
+ * `decide --dry-run` routes to the PREVIEW endpoint, which sits behind `work:read`
2512
+ * rather than `work:write`: it renders the customer-facing copy and the resulting
2513
+ * stage without committing. That makes "show me what this disposition would do"
2217
2514
  * safe to run with a read-only token, which is exactly when an author wants it.
2218
2515
  */
2219
- async function cmdCasesWrite(flags) {
2516
+ async function cmdWorkWrite(flags) {
2220
2517
  const t = resolveTarget(flags);
2221
2518
  const { url } = t;
2222
2519
  const base = `${url}/api/self/p`;
2223
2520
  const verb = flags._[0];
2224
- const id = flags._[1] ?? die(`usage: octwin cases ${verb} <caseId> …`);
2225
- const readBack = () => console.log(`\nRead it back: octwin cases ${id}`);
2521
+ const id = flags._[1] ?? die(`usage: octwin work ${verb} <recordId> …`);
2522
+ const readBack = () => console.log(`\nRead it back: octwin work ${id}`);
2226
2523
  if (verb === 'assign') {
2227
2524
  // `--to none` unassigns (the route takes null); anything else must carry the
2228
2525
  // principal kind, because a bare uuid cannot say user-or-team.
2229
2526
  const to = typeof flags.to === 'string' ? flags.to
2230
- : die('usage: octwin cases assign <caseId> --to user:<uuid>|team:<uuid>|none');
2527
+ : die('usage: octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none');
2231
2528
  const assignee = to === 'none' ? null : to;
2232
2529
  if (assignee !== null && !/^(user|team):/.test(assignee)) {
2233
2530
  die(`--to must be 'user:<uuid>', 'team:<uuid>' or 'none' (got '${to}')`);
2234
2531
  }
2235
- console.log(`→ ${assignee === null ? 'Unassigning' : `Assigning to ${assignee}`} case ${id} …`);
2236
- const { status, json } = await apiSend('PATCH', `${base}/cases/${encodeURIComponent(id)}/assign`, { assignee }, t);
2532
+ console.log(`→ ${assignee === null ? 'Unassigning' : `Assigning to ${assignee}`} work item ${id} …`);
2533
+ const { status, json } = await apiSend('PATCH', `${base}/work/${encodeURIComponent(id)}/assign`, { assignee }, t);
2237
2534
  if (status !== 200)
2238
- writeFail(`assign case ${id}`, status, json, url, true);
2535
+ writeFail(`assign work item ${id}`, status, json, url, true);
2239
2536
  console.log(assignee === null ? '✓ Unassigned.' : `✓ Assigned to ${json?.assignee ?? assignee}.`);
2240
2537
  return;
2241
2538
  }
2242
2539
  if (verb === 'note') {
2243
- const note = flags._[2] ?? die('usage: octwin cases note <caseId> "the note text"');
2244
- console.log(`→ Adding a note to case ${id} …`);
2245
- const { status, json } = await apiSend('POST', `${base}/cases/${encodeURIComponent(id)}/note`, { note }, t);
2540
+ const note = flags._[2] ?? die('usage: octwin work note <recordId> "the note text"');
2541
+ console.log(`→ Adding a note to work item ${id} …`);
2542
+ const { status, json } = await apiSend('POST', `${base}/work/${encodeURIComponent(id)}/note`, { note }, t);
2246
2543
  if (status !== 200)
2247
- writeFail(`note case ${id}`, status, json, url, true);
2248
- console.log('✓ Note added to the case timeline.');
2544
+ writeFail(`note work item ${id}`, status, json, url, true);
2545
+ console.log('✓ Note added to the record timeline.');
2249
2546
  readBack();
2250
2547
  return;
2251
2548
  }
2252
- if (verb === 'transition') {
2549
+ if (verb === 'stage') {
2253
2550
  const to = typeof flags.to === 'string' ? flags.to
2254
- : die('usage: octwin cases transition <caseId> --to <status> [--note "..."]');
2255
- const body = { to_status: to };
2551
+ : die('usage: octwin work stage <recordId> --to <stage> [--note "..."]');
2552
+ const body = { to_stage: to };
2256
2553
  if (typeof flags.note === 'string')
2257
2554
  body.note = flags.note;
2258
- console.log(`→ Moving case ${id} to '${to}' …`);
2259
- const { status, json } = await apiSend('POST', `${base}/cases/${encodeURIComponent(id)}/transition`, body, t);
2555
+ console.log(`→ Moving record ${id} to '${to}' …`);
2556
+ const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/stage`, body, t);
2260
2557
  if (status !== 200) {
2261
- // The case detail read carries the legal targets; point at it rather than
2558
+ // The work detail read carries the legal targets; point at it rather than
2262
2559
  // leaving the author to guess the vocabulary.
2263
2560
  if (status === 400)
2264
- console.error(` → legal targets for this case: octwin cases ${id} (see its workflow)`);
2265
- writeFail(`move case ${id} to '${to}'`, status, json, url, true);
2561
+ console.error(` → legal targets for this record: octwin work ${id} (see its workflow)`);
2562
+ writeFail(`move record ${id} to '${to}'`, status, json, url, true);
2266
2563
  }
2267
- console.log(`✓ Case is now '${json?.case?.status ?? to}'.`);
2564
+ console.log(`✓ Record is now '${json?.stage ?? to}'.`);
2268
2565
  return;
2269
2566
  }
2270
- // decide
2567
+ // decide — apply one of the entity's declared operator actions
2271
2568
  const action = typeof flags.action === 'string' ? flags.action
2272
- : die('usage: octwin cases decide <caseId> --action <action> [--param k=v] [--note "..."] [--dry-run]');
2569
+ : die('usage: octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]');
2273
2570
  const params = {};
2274
2571
  for (const pair of flagList(flags, 'param')) {
2275
2572
  const eq = pair.indexOf('=');
@@ -2281,39 +2578,41 @@ async function cmdCasesWrite(flags) {
2281
2578
  const body = { action, ...(Object.keys(params).length ? { params } : {}) };
2282
2579
  if (!dryRun && typeof flags.note === 'string')
2283
2580
  body.internal_note = flags.note;
2284
- console.log(`→ ${dryRun ? 'Previewing' : 'Applying'} '${action}' on case ${id} …`);
2285
- const endpoint = `${base}/cases/${encodeURIComponent(id)}/decision${dryRun ? '/preview' : ''}`;
2581
+ console.log(`→ ${dryRun ? 'Previewing' : 'Applying'} '${action}' on work item ${id} …`);
2582
+ const endpoint = `${base}/work/${encodeURIComponent(id)}/action${dryRun ? '/preview' : ''}`;
2286
2583
  const { status, json } = await apiSend('POST', endpoint, body, t);
2287
2584
  if (status === 404)
2288
- die(`case '${id}' not found`);
2585
+ die(`work item '${id}' not found`);
2289
2586
  if (status !== 200) {
2290
- console.error(` → the case's applicable actions are listed by: octwin cases ${id}`);
2291
- writeFail(`${dryRun ? 'preview' : 'apply'} '${action}' on case ${id}`, status, json, url, true);
2587
+ console.error(` → the item's applicable actions are listed by: octwin work ${id}`);
2588
+ writeFail(`${dryRun ? 'preview' : 'apply'} '${action}' on work item ${id}`, status, json, url, true);
2292
2589
  }
2293
2590
  if (dryRun) {
2294
2591
  console.log('Preview (nothing was committed):');
2295
2592
  console.log(JSON.stringify(json, null, 2));
2296
2593
  return;
2297
2594
  }
2298
- console.log(`✓ Applied '${action}' — case is now '${json?.case?.status ?? '?'}'.`);
2299
- // `notified` is the customer-facing half; silence here usually means the
2300
- // disposition had no message template, which is easy to mistake for a failure.
2301
- console.log(json?.notified ? ' ✓ the customer was notified.' : ' ⓘ no customer notification was sent by this action.');
2595
+ console.log(`✓ Applied '${action}'${json?.to_stage ? ` record is now '${json.to_stage}'` : ''}.`);
2596
+ // `relayed`/`notified` are the customer-facing half; silence here usually means
2597
+ // the action had no message template, which is easy to mistake for a failure.
2598
+ const reached = json?.relayed || json?.notified;
2599
+ console.log(reached ? ' ✓ the customer was notified.' : ' ⓘ no customer notification was sent by this action.');
2302
2600
  readBack();
2303
2601
  }
2304
- /** `octwin cases [caseId] [--queues]` — inspect casework (support tickets):
2305
- * the aggregate inbox, one case + its timeline, or the queue list. */
2306
- async function cmdCases(flags) {
2307
- if (typeof flags._[0] === 'string' && CASE_VERBS.has(flags._[0]))
2308
- return cmdCasesWrite(flags);
2602
+ /** `octwin work [recordId] [--queues]` — inspect the work inbox (every entity the
2603
+ * pack declares worked): the aggregate inbox, one item + its timeline, or the
2604
+ * queue list. */
2605
+ async function cmdWork(flags) {
2606
+ if (typeof flags._[0] === 'string' && WORK_VERBS.has(flags._[0]))
2607
+ return cmdWorkWrite(flags);
2309
2608
  const t = resolveTarget(flags);
2310
2609
  const { url } = t;
2311
2610
  const base = `${url}/api/self/p`;
2312
- const caseId = flags._[0];
2611
+ const recordId = flags._[0];
2313
2612
  const asJson = flags.json === true;
2314
2613
  if (!asJson)
2315
- console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${targetLabel(t)} …`);
2316
- const caseFail = (what, status, json) => {
2614
+ console.log(`→ Reading ${flags.queues === true ? 'work queues' : recordId ? `work item ${recordId}` : 'the work inbox'} from ${targetLabel(t)} …`);
2615
+ const workFail = (what, status, json) => {
2317
2616
  // A 403 here can also be an RBAC gap the scope hint can't see — a role whose
2318
2617
  // grants don't reach the queue passes the scope gate and still gets nothing.
2319
2618
  if (status === 403)
@@ -2321,72 +2620,74 @@ async function cmdCases(flags) {
2321
2620
  die(`could not read ${what} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
2322
2621
  };
2323
2622
  if (flags.queues === true) {
2324
- const { status, json } = await apiGet(`${base}/case-queues`, t);
2623
+ const { status, json } = await apiGet(`${base}/work/queues`, t);
2325
2624
  if (status !== 200)
2326
- caseFail('case queues', status, json);
2625
+ workFail('work queues', status, json);
2327
2626
  if (asJson) {
2328
2627
  console.log(JSON.stringify(json, null, 2));
2329
2628
  return;
2330
2629
  }
2331
2630
  const queues = (json?.queues ?? []);
2332
- console.log(`Case queues in ${targetLabel(t)}:`);
2333
- for (const q of queues)
2334
- console.log(` ${q.key}${q.name ? ` (${q.name})` : ''} ${q.open_count} open`);
2631
+ console.log(`Work queues in ${targetLabel(t)}:`);
2632
+ for (const q of queues) {
2633
+ const name = pickLabel(q.name);
2634
+ console.log(` ${q.key}${name ? ` (${name})` : ''} ${q.open_count} open`);
2635
+ }
2335
2636
  if (json?.unrouted_open_count)
2336
2637
  console.log(` (unrouted: ${json.unrouted_open_count} open)`);
2337
2638
  return;
2338
2639
  }
2339
- if (!caseId) {
2340
- const { status, json } = await apiGet(`${base}/cases?${pagingQs(flags)}`, t);
2640
+ if (!recordId) {
2641
+ const { status, json } = await apiGet(`${base}/work?${pagingQs(flags)}`, t);
2341
2642
  if (status !== 200)
2342
- caseFail('cases', status, json);
2643
+ workFail('the work inbox', status, json);
2343
2644
  if (asJson) {
2344
2645
  console.log(JSON.stringify(json, null, 2));
2345
2646
  return;
2346
2647
  }
2347
2648
  const page = readPage(json);
2348
- console.log(`Cases in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
2649
+ console.log(`Work items in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
2349
2650
  if (page.rows.length === 0)
2350
2651
  console.log(' (none)');
2351
- for (const c of page.rows) {
2352
- const sla = c.sla_due_at ? ` sla:${c.sla_due_at}` : '';
2353
- console.log(` #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}${c.queue_key ? ` q:${c.queue_key}` : ''}${sla} ${c.id}`);
2652
+ for (const w of page.rows) {
2653
+ const sla = w.sla_due_at ? ` sla:${w.sla_due_at}` : '';
2654
+ 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}`);
2354
2655
  }
2355
- const more = morePageHint(page, 'octwin cases');
2656
+ const more = morePageHint(page, 'octwin work');
2356
2657
  if (more)
2357
2658
  console.log(more);
2358
- console.log('\nOne case + timeline: octwin cases <caseId> queues: octwin cases --queues');
2659
+ console.log('\nOne item + timeline: octwin work <recordId> queues: octwin work --queues');
2359
2660
  return;
2360
2661
  }
2361
- const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`, t);
2662
+ const { status, json } = await apiGet(`${base}/work/${encodeURIComponent(recordId)}`, t);
2362
2663
  if (status === 404)
2363
- die(`case '${caseId}' not found`);
2664
+ die(`work item '${recordId}' not found`);
2364
2665
  if (status !== 200)
2365
- caseFail('case', status, json);
2666
+ workFail('work item', status, json);
2366
2667
  if (asJson) {
2367
2668
  console.log(JSON.stringify(json, null, 2));
2368
2669
  return;
2369
2670
  }
2370
- const c = json?.case ?? {};
2371
- console.log(`Case #${c.case_number ?? '?'} ${c.type} [${c.status}] ${c.priority}`);
2372
- console.log(` id: ${c.id} queue: ${c.queue_key ?? '(unrouted)'} assignee: ${c.assignee_principal ?? '(none)'}`);
2671
+ const w = json?.item ?? {};
2672
+ console.log(`${pickLabel(json?.entity_label) ?? w.entity} #${w.record_number ?? '?'}${w.type ? ` ${w.type}` : ''} [${w.stage ?? '?'}] ${w.priority}`);
2673
+ console.log(` id: ${w.record_id} queue: ${w.queue_key ?? '(unrouted)'} assignee: ${w.assignee_principal ?? '(none)'}`);
2373
2674
  if (json?.contact)
2374
2675
  console.log(` contact: ${json.contact.display_name ?? json.contact.channel_contact_handle ?? json.contact.id}`);
2375
- if (c.conversation_id)
2376
- console.log(` conversation: ${c.conversation_id} (octwin logs ${c.conversation_id})`);
2377
- if (c.sla_due_at)
2378
- console.log(` sla due: ${c.sla_due_at}`);
2379
- if (c.fields && Object.keys(c.fields).length > 0)
2380
- console.log(` fields: ${JSON.stringify(c.fields)}`);
2381
- const events = (json?.events ?? []);
2676
+ if (w.conversation_id)
2677
+ console.log(` conversation: ${w.conversation_id} (octwin logs ${w.conversation_id})`);
2678
+ if (w.sla_due_at)
2679
+ console.log(` sla due: ${w.sla_due_at}`);
2680
+ if (w.fields && Object.keys(w.fields).length > 0)
2681
+ console.log(` fields: ${JSON.stringify(w.fields)}`);
2682
+ const events = (json?.timeline ?? []);
2382
2683
  console.log(` Timeline (${events.length}):`);
2383
2684
  for (const e of events) {
2384
2685
  const payload = e.payload && Object.keys(e.payload).length > 0 ? ` ${JSON.stringify(e.payload)}` : '';
2385
2686
  console.log(` ${e.ts ?? ''} ${e.kind}${e.actor ? ` (${e.actor})` : ''}${payload}`);
2386
2687
  }
2387
- const dispositions = (json?.dispositions ?? []);
2388
- if (dispositions.length > 0) {
2389
- console.log(` Decisions: ${dispositions.map((d) => `${d.action}${d.next_status ? `→${d.next_status}` : ''}`).join(', ')}`);
2688
+ const actions = (json?.actions ?? []);
2689
+ if (actions.length > 0) {
2690
+ console.log(` Actions: ${actions.map((a) => `${a.action}${a.to_stage ? `→${a.to_stage}` : ''}`).join(', ')}`);
2390
2691
  }
2391
2692
  }
2392
2693
  // ── money formatting (orders / catalog) ─────────────────────────────────────
@@ -2523,6 +2824,10 @@ async function cmdAgentsWrite(flags) {
2523
2824
  * (`/api/self/t/`), unlike `agents` — the list is a property of the workspace.
2524
2825
  */
2525
2826
  async function cmdProjects(flags) {
2827
+ if (flags._[0] === 'create')
2828
+ return cmdProjectsCreate(flags);
2829
+ if (flags._[0] === 'rm')
2830
+ return cmdProjectsRm(flags);
2526
2831
  const t = resolveTarget(flags);
2527
2832
  const { url } = t;
2528
2833
  const asJson = flags.json === true;
@@ -2558,6 +2863,128 @@ async function cmdProjects(flags) {
2558
2863
  console.log('\nUse one as: octwin deploy --project <slug>');
2559
2864
  if (!archived)
2560
2865
  console.log('Archived too: octwin projects --archived');
2866
+ console.log('New one: octwin projects create "<name>"');
2867
+ }
2868
+ /**
2869
+ * `octwin projects create "<name>" [--slug <slug>] [--pack <packId>]`
2870
+ *
2871
+ * The missing half of the deploy loop. `octwin deploy` has always needed a project
2872
+ * that already exists, and the CLI could only LIST them — so standing up a throwaway
2873
+ * end-to-end deployment meant opening the console or asking an operator. With this,
2874
+ * a full disposable environment is two commands:
2875
+ *
2876
+ * octwin projects create "Scratch" # → slug `scratch`
2877
+ * octwin deploy --project scratch --seed # publish + install + demo data
2878
+ * octwin chat "hi" --project scratch # talk to it
2879
+ * octwin projects rm scratch --yes # throw it away
2880
+ *
2881
+ * A demo is deliberately NOT a special kind of thing — it is an ordinary project in
2882
+ * the developer's own workspace, so it inherits their plan, entitlements, RBAC and
2883
+ * teardown with no bespoke lifecycle to keep honest.
2884
+ *
2885
+ * `packs: []` is the default because the very next step is normally `octwin deploy`,
2886
+ * which publishes the working tree AND installs it. `--pack` is for an ALREADY
2887
+ * published pack (it resolves through `pack_registry` and fails fast if absent).
2888
+ */
2889
+ async function cmdProjectsCreate(flags) {
2890
+ // Argument check BEFORE `resolveTarget`, so a missing name reports the usage line
2891
+ // rather than "no platform url" — an argument mistake must not be masked by a
2892
+ // config one the author may not even have.
2893
+ const name = flags._[1];
2894
+ if (!name)
2895
+ die('usage: octwin projects create "<name>" [--slug <slug>] [--pack <packId>]');
2896
+ const t = resolveTarget(flags);
2897
+ const { url } = t;
2898
+ // The Project URL is DERIVED from the name and uniquified server-side unless the
2899
+ // caller pins one — same contract the console's create form uses, so the two
2900
+ // cannot disagree about what slug a given name produces.
2901
+ const body = { name, packs: flags.pack ? [flags.pack] : [] };
2902
+ if (typeof flags.slug === 'string')
2903
+ body.slug = flags.slug;
2904
+ console.log(`→ Creating project "${name}" in ${targetLabel(t)} …`);
2905
+ const { status, json } = await apiSend('POST', `${url}/api/self/t/projects`, body, t);
2906
+ // 402 is the plan cap, and it is the ONE failure here with a non-obvious fix, so it
2907
+ // gets the server's own sentence rather than a generic write failure.
2908
+ if (status === 402)
2909
+ die(`${json?.error ?? 'project limit reached'} — free the slot with \`octwin projects rm <slug> --yes\`, or upgrade the plan.`);
2910
+ if (status !== 200 && status !== 201)
2911
+ writeFail(`create project "${name}"`, status, json, url);
2912
+ if (flags.json === true) {
2913
+ console.log(JSON.stringify(json, null, 2));
2914
+ return;
2915
+ }
2916
+ const slug = json?.slug ?? flags.slug ?? '(unknown)';
2917
+ const installed = (json?.installed_packs ?? []);
2918
+ console.log(`✓ Project created — ${slug}`);
2919
+ for (const p of installed)
2920
+ console.log(` installed ${p.pack_id}@${p.version}`);
2921
+ console.log('\nNext:');
2922
+ console.log(` octwin deploy --project ${slug} --seed`);
2923
+ console.log(` octwin chat "hi" --project ${slug}`);
2924
+ }
2925
+ /**
2926
+ * `octwin projects rm <slug> --yes`
2927
+ *
2928
+ * HARD delete — the row and everything the FK graph cascades from it (conversations,
2929
+ * contacts, records, installs, webhooks). Not the archive verb; there is no undo.
2930
+ *
2931
+ * `--yes` is required rather than prompted because the CLI is non-interactive by
2932
+ * design (it runs under `npx`, in scripts and in CI, where a prompt reads EOF and a
2933
+ * "safe" default would be a lie). Without it this prints the same impact preview the
2934
+ * console's confirm dialog shows — derived from `pg_constraint`, not a hand-written
2935
+ * list — and stops. That makes the dry run the DEFAULT, which is the right way round
2936
+ * for an irreversible verb.
2937
+ */
2938
+ async function cmdProjectsRm(flags) {
2939
+ const slug = flags._[1];
2940
+ if (!slug)
2941
+ die('usage: octwin projects rm <slug> --yes (omit --yes to preview what it destroys)');
2942
+ const t = resolveTarget(flags);
2943
+ const { url } = t;
2944
+ const preview = await apiGet(`${url}/api/self/t/projects/${encodeURIComponent(slug)}/preview-hard-delete`, t);
2945
+ if (preview.status === 404)
2946
+ die(`no project '${slug}' in ${targetLabel(t)} — \`octwin projects\` lists them`);
2947
+ if (preview.status !== 200)
2948
+ die(`could not preview the delete (HTTP ${preview.status})${errDetail(preview.json)}${authFailureDetail(preview.status, url)}`);
2949
+ if (flags.json === true && flags.yes !== true) {
2950
+ console.log(JSON.stringify(preview.json, null, 2));
2951
+ return;
2952
+ }
2953
+ // Shapes come from `HardDeletePreview` (routes/_hard-delete-preview.ts) — the same
2954
+ // payload the console's confirm dialog renders, so the two can't disagree about
2955
+ // what a delete costs.
2956
+ const tables = (preview.json?.tables ?? []);
2957
+ const hits = tables.filter(r => r.count > 0);
2958
+ const totals = preview.json?.totals ?? {};
2959
+ console.log(`Deleting project ${slug} from ${targetLabel(t)} destroys:`);
2960
+ if (hits.length === 0)
2961
+ console.log(' (nothing — the project has no rows yet)');
2962
+ for (const r of hits) {
2963
+ const mark = r.disposition === 'cascade' ? '' : ` [${r.disposition}]`;
2964
+ console.log(` ${r.count}${r.capped ? '+' : ''}\t${r.schema}.${r.table}${mark}`);
2965
+ }
2966
+ if (hits.length > 0) {
2967
+ console.log(` — ${totals.rows_deleted}${totals.rows_deleted_capped ? '+' : ''} rows across ${totals.tables_affected} tables`);
2968
+ }
2969
+ // Side effects no FK walk can see (storage blobs, agent memory, Meta registrations).
2970
+ // Anything not `deleted` is what SURVIVES the delete — the part worth reading.
2971
+ const residue = (preview.json?.residue ?? []);
2972
+ const surviving = residue.filter(r => r.disposition !== 'deleted');
2973
+ if (surviving.length > 0) {
2974
+ console.log('\nNot removed by the cascade:');
2975
+ for (const r of surviving)
2976
+ console.log(` [${r.disposition}] ${r.label}${r.count != null ? ` (${r.count})` : ''} — ${r.detail}`);
2977
+ }
2978
+ if (totals.blocked > 0)
2979
+ console.log(`\n! ${totals.blocked} table(s) would BLOCK this delete.`);
2980
+ if (flags.yes !== true) {
2981
+ console.log('\nNothing was deleted. Re-run with --yes to go through with it.');
2982
+ return;
2983
+ }
2984
+ const { status, json } = await apiSend('DELETE', `${url}/api/self/t/projects/${encodeURIComponent(slug)}/hard`, undefined, t);
2985
+ if (status !== 200 && status !== 204)
2986
+ writeFail(`delete project '${slug}'`, status, json, url);
2987
+ console.log(`\n✓ Deleted ${slug}.`);
2561
2988
  }
2562
2989
  async function cmdAgents(flags) {
2563
2990
  if (flags._[0] === 'set')
@@ -2677,21 +3104,32 @@ async function cmdOrdersWrite(flags) {
2677
3104
  if (verb === 'transition') {
2678
3105
  const to = typeof flags.to === 'string' ? flags.to
2679
3106
  : die('usage: octwin orders transition <reference_id> --to <status>');
3107
+ // A stage move has ONE verb platform-wide — `POST …/xrm/records/:id/stage`
3108
+ // (`POST …/orders/:ref/transition` was retired, consolidation P-02). The order
3109
+ // detail read resolves the opaque reference to the record id and carries the
3110
+ // legal targets for the failure hint.
2680
3111
  console.log(`→ Moving order ${ref} to '${to}' …`);
2681
- const { status, json } = await apiSend('POST', `${base}/${encodeURIComponent(ref)}/transition`, { to_status: to }, t);
2682
- if (status === 404)
3112
+ const detail = await apiGet(`${base}/${encodeURIComponent(ref)}`, t);
3113
+ if (detail.status === 404)
2683
3114
  die(`order '${ref}' not found (pass the opaque reference_id, not the #number)`);
2684
- if (status === 409) {
3115
+ if (detail.status !== 200)
3116
+ writeFail(`read order ${ref}`, detail.status, detail.json, url);
3117
+ const recordId = detail.json?.order?.id;
3118
+ if (!recordId)
3119
+ die(`order '${ref}' carries no record id — cannot move its stage`);
3120
+ const { status, json } = await apiSend('POST', `${url}/api/self/p/xrm/records/${encodeURIComponent(recordId)}/stage`, { to_stage: to }, t);
3121
+ if (status === 400 || status === 409) {
2685
3122
  console.error(`✗ '${to}' is not a legal move for this order.`);
2686
- if (Array.isArray(json?.transitions))
2687
- console.error(` → allowed: ${json.transitions.join(', ') || '(none)'}`);
3123
+ const allowed = (detail.json?.transitions ?? []);
3124
+ if (allowed.length > 0)
3125
+ console.error(` → allowed: ${allowed.join(', ')}`);
2688
3126
  else
2689
3127
  console.error(` → see the allowed set: octwin orders ${ref}`);
2690
3128
  process.exit(1);
2691
3129
  }
2692
3130
  if (status !== 200)
2693
3131
  writeFail(`move order ${ref} to '${to}'`, status, json, url);
2694
- console.log(`✓ Order is now '${json?.order?.status ?? to}'.`);
3132
+ console.log(`✓ Order is now '${json?.stage ?? to}'.`);
2695
3133
  return;
2696
3134
  }
2697
3135
  // refund — irreversible, and money. `--force` rather than a prompt: the CLI is
@@ -3236,7 +3674,7 @@ function help() {
3236
3674
  octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
3237
3675
  octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
3238
3676
  octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
3239
- octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
3677
+ octwin work [recordId] [--queues] [--json] # inspect the work inbox (worked records) + timelines
3240
3678
  octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
3241
3679
  octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
3242
3680
  octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
@@ -3245,15 +3683,15 @@ function help() {
3245
3683
  octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
3246
3684
  octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
3247
3685
  octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
3248
- octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
3686
+ octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
3249
3687
  octwin test [--dir .] # = validate --remote (the full platform check)
3250
3688
  octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
3251
3689
 
3252
3690
  Writes — exercise the state your pack creates (each needs the matching :write scope):
3253
3691
  octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
3254
3692
  octwin records tasks | task complete <taskId> [--outcome done|cancelled]
3255
- octwin cases assign <id> --to user:<uuid>|none | note <id> "…" | transition <id> --to <status>
3256
- octwin cases decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
3693
+ octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
3694
+ octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
3257
3695
  octwin orders transition <ref> --to <status> | refund <ref> --force
3258
3696
  octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
3259
3697
  octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
@@ -3287,15 +3725,37 @@ const COMMAND_HELP = {
3287
3725
  projects: `octwin projects [--archived] [--json]
3288
3726
  List the workspace's projects — the slugs every --project flag takes, with the
3289
3727
  plan's project cap. --archived includes archived ones. A pack:deploy token
3290
- reaches this (it names a project in every other command).`,
3728
+ reaches this (it names a project in every other command).
3729
+
3730
+ octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
3731
+ Create a project. The URL slug is derived from the name unless --slug pins one.
3732
+ --pack installs an ALREADY-published pack; the usual next step is instead
3733
+ \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
3734
+
3735
+ octwin projects rm <slug> [--yes]
3736
+ HARD delete — the project and everything cascading from it (conversations,
3737
+ contacts, records, installs). No undo, and not the same as archiving.
3738
+ WITHOUT --yes it only previews what would be destroyed, so the dry run is the
3739
+ default. Together these make a disposable end-to-end environment:
3740
+ octwin projects create "Scratch" && octwin deploy --project scratch --seed
3741
+ octwin chat "hi" --project scratch
3742
+ octwin projects rm scratch --yes
3743
+ Both verbs need the \`projects:write\` scope — a pack:deploy token does NOT confer it.`,
3291
3744
  deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
3292
3745
  Upload the pack bundle, validate server-side, install onto the project.
3293
3746
  --seed additionally applies the pack's demo seed (streams progress).`,
3747
+ seed: `octwin seed [--pack <packId>]
3748
+ Apply the pack's demo/reference data to the project it is installed on, without
3749
+ redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
3750
+ and the demo operator topology. Reports what each kind produced.
3751
+ Idempotent and safe to re-run — records upsert, and existing media is REUSED rather
3752
+ than regenerated, so a second pass costs nothing. --pack is only needed when a
3753
+ project somehow runs more than one.`,
3294
3754
  status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
3295
3755
  Show installed vs live version + the flow list for this pack.`,
3296
3756
  records: `octwin records [entity] [id] [--limit 50] [--offset n]
3297
- Inspect the pack's XRM data. No args = list entities. Cases/tickets are
3298
- casework, not XRM use \`octwin cases\` for those.
3757
+ Inspect the pack's XRM data. No args = list entities. Worked records (cases,
3758
+ tickets, anything routed to a queue) read best through \`octwin work\`.
3299
3759
 
3300
3760
  WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
3301
3761
  octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
@@ -3310,19 +3770,21 @@ const COMMAND_HELP = {
3310
3770
  \`patch\` needs --entity even though it has an id: the route resolves the field
3311
3771
  validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
3312
3772
  VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
3313
- cases: `octwin cases [caseId] [--queues] [--limit 50] [--offset n] [--json]
3314
- Inspect casework (support tickets): the inbox, one case + its timeline
3315
- (+ applicable decisions), or --queues for queue keys + open counts.
3773
+ work: `octwin work [recordId] [--queues] [--limit 50] [--offset n] [--json]
3774
+ Inspect the work inbox — every entity the pack declares worked (cases, orders
3775
+ needing review, applications, …): the inbox, one item + its timeline
3776
+ (+ applicable actions), or --queues for queue keys + open counts.
3316
3777
 
3317
- WRITES (need \`cases:write\`):
3318
- octwin cases assign <caseId> --to user:<uuid>|team:<uuid>|none
3319
- octwin cases note <caseId> "the note text"
3320
- octwin cases transition <caseId> --to <status> [--note "..."]
3321
- octwin cases decide <caseId> --action <action> [--param k=v] [--note "..."] [--dry-run]
3778
+ WRITES (need \`work:write\`; \`stage\` needs \`records:write\`):
3779
+ octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none
3780
+ octwin work note <recordId> "the note text"
3781
+ octwin work stage <recordId> --to <stage> [--note "..."]
3782
+ octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]
3322
3783
 
3323
- \`decide\` applies one of the case's declared dispositions — \`octwin cases <id>\`
3784
+ \`decide\` applies one of the entity's declared operator actions — \`octwin work <id>\`
3324
3785
  lists them with their params. --dry-run previews the customer-facing copy and the
3325
- resulting status WITHOUT committing (that route needs only \`cases:read\`).`,
3786
+ resulting stage WITHOUT committing (that route needs only \`work:read\`).
3787
+ \`stage\` is the XRM records verb (one transition spelling platform-wide).`,
3326
3788
  logs: `octwin logs [conversationId] [--as <handle>] [--json]
3327
3789
  No id = recent conversations (handle, status, last activity; --as filters).
3328
3790
  With id = the full event timeline including what each turn rendered.
@@ -3430,9 +3892,20 @@ const COMMAND_HELP = {
3430
3892
 
3431
3893
  --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
3432
3894
  \`--slots\` is how you check what a rule actually produces.`,
3433
- 'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
3895
+ 'platform-kb': `octwin platform-kb [pull] [--if-stale] [--check] [--dir .] [--url <url>] [--token <t>]
3434
3896
  Pull the platform capability reference (markdown + JSON catalogs) into
3435
- .octwin/platform-kb/ for the octwin-pack authoring skill.`,
3897
+ .octwin/platform-kb/ for the octwin-pack authoring skill, plus three maps:
3898
+ INDEX.md (the corpus) · SYMBOLS.md (every name -> its file; grep this) ·
3899
+ OUTLINE.md (every heading with its line number).
3900
+
3901
+ NO TOKEN NEEDED — the reference is platform stdlib and is served anonymously.
3902
+ A token is used when you have one (it also works against older platforms).
3903
+
3904
+ --if-stale poll the platform's content_hash first and skip the download when
3905
+ nothing changed. Cheap enough to run at the start of every session.
3906
+ --check report only, write nothing. Exit 0 = current, 2 = stale or never
3907
+ pulled, 1 = could not tell (offline / refused). For scripts and
3908
+ agent loops that want to branch without parsing prose.`,
3436
3909
  test: `octwin test [--dir .]
3437
3910
  Alias for \`octwin validate --remote\` — the full platform check.`,
3438
3911
  feedback: `octwin feedback [--dir .]
@@ -3449,7 +3922,7 @@ async function main() {
3449
3922
  const [command, ...rest] = process.argv.slice(2);
3450
3923
  const flags = parseFlags(rest);
3451
3924
  // So an auth failure can name the scope THIS invocation needs. A leading write
3452
- // verb changes the answer (`cases` reads, `cases note` writes), so it rides along
3925
+ // verb changes the answer (`work` reads, `work note` writes), so it rides along
3453
3926
  // when the first positional is one — `VERB_REQUIREMENTS` is keyed that way.
3454
3927
  const leadingVerb = flags._[0];
3455
3928
  CURRENT_COMMAND = (typeof leadingVerb === 'string' && command && `${command} ${leadingVerb}` in VERB_REQUIREMENTS)
@@ -3486,8 +3959,8 @@ async function main() {
3486
3959
  case 'records':
3487
3960
  await cmdRecords(flags);
3488
3961
  break;
3489
- case 'cases':
3490
- await cmdCases(flags);
3962
+ case 'work':
3963
+ await cmdWork(flags);
3491
3964
  break;
3492
3965
  case 'logs':
3493
3966
  await cmdLogs(flags);
@@ -3498,6 +3971,9 @@ async function main() {
3498
3971
  case 'media':
3499
3972
  await cmdMedia(flags);
3500
3973
  break;
3974
+ case 'seed':
3975
+ await cmdSeed(flags);
3976
+ break;
3501
3977
  case 'projects':
3502
3978
  await cmdProjects(flags);
3503
3979
  break;