octwin-cli 0.5.1 → 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' },
@@ -213,7 +224,12 @@ const COMMAND_REQUIREMENTS = {
213
224
  // same scope. Omitting it here meant a 403 on the one command that recovers a pack's
214
225
  // only source copy printed the generic hint WITHOUT naming the scope to grant.
215
226
  pull: { scope: 'pack:deploy' },
216
- '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.
217
233
  feedback: { scope: 'pack:deploy' },
218
234
  media: { scope: 'media:generate' },
219
235
  // The plan feature gates RECORD reads, not the entity list (`/xrm/entities` carries only
@@ -221,7 +237,7 @@ const COMMAND_REQUIREMENTS = {
221
237
  // plan for a 403 the plan did not cause.
222
238
  records: { scope: 'records:read', feature: 'records', featureAppliesTo: 'reading records (listing entities needs only the scope)' },
223
239
  analytics: { scope: 'records:read', feature: 'records' },
224
- cases: { scope: 'cases:read', feature: 'cases' },
240
+ work: { scope: 'work:read', feature: 'work' },
225
241
  logs: { scope: 'conversations:read' },
226
242
  orders: { scope: 'orders:read', feature: 'orders' },
227
243
  catalog: { scope: 'catalog:read', feature: 'catalog' },
@@ -234,7 +250,7 @@ const COMMAND_REQUIREMENTS = {
234
250
  };
235
251
  /** The command currently running — set once in `main()` so any failure printer can
236
252
  * 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. */
253
+ * Carries the write VERB too (`work note`), since that is what decides the scope. */
238
254
  let CURRENT_COMMAND;
239
255
  /** `→ needs the \`orders:read\` scope …` — the requirement line for the running
240
256
  * command, or '' when the command has no declared requirement. */
@@ -484,6 +500,13 @@ async function notifyIfOutdated() {
484
500
  }
485
501
  catch { /* a version check must never break the CLI */ }
486
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.
487
510
  /** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
488
511
  * (content hash + per-entry index), or null if nothing has been pulled yet. */
489
512
  function readLocalKb(packDir) {
@@ -515,15 +538,44 @@ function diffKbIndex(prev, next) {
515
538
  const removed = prev.filter(e => !nextKeys.has(e.key)).map(e => e.key);
516
539
  return { added, removed, changed };
517
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
+ }
518
566
  /** Nudge (to stderr) when the platform's capability KB has changed since the last
519
567
  * `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
520
568
  * of the CLI: run only after commands that already hit the platform, so this adds
521
569
  * a single tiny `?meta=1` GET on top of work that was networked anyway. Never
522
570
  * 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. */
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. */
524
578
  async function notifyIfKbStale(flags) {
525
- if (!process.stdout.isTTY)
526
- return;
527
579
  try {
528
580
  const packDir = resolve(flags.dir ?? '.');
529
581
  const local = readLocalKb(packDir);
@@ -532,25 +584,20 @@ async function notifyIfKbStale(flags) {
532
584
  const t = resolveTargetOrNull(flags);
533
585
  if (!t)
534
586
  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_…');
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_…)');
550
597
  }
551
598
  return;
552
599
  }
553
- const meta = await res.json();
600
+ const meta = polled.meta;
554
601
  if (meta.content_hash && meta.content_hash !== local.content_hash) {
555
602
  // Per-entry summary (now that the index carries per-entry hashes) — the
556
603
  // exact list of what changed is one `octwin platform-kb` away.
@@ -566,7 +613,7 @@ async function notifyIfKbStale(flags) {
566
613
  summary = ` (${parts.join(' · ')})`;
567
614
  }
568
615
  console.error(`\n⬆ the platform capability reference changed since you last pulled it${summary}.`);
569
- console.error(' Refresh it: octwin platform-kb');
616
+ console.error(' Refresh it: octwin platform-kb --if-stale');
570
617
  }
571
618
  }
572
619
  catch { /* a KB check must never break the CLI */ }
@@ -584,7 +631,7 @@ function commandTouchesPlatform(command, flags) {
584
631
  case 'media':
585
632
  case 'pull':
586
633
  case 'records':
587
- case 'cases':
634
+ case 'work':
588
635
  case 'logs':
589
636
  case 'whoami':
590
637
  case 'feedback':
@@ -602,9 +649,14 @@ function commandTouchesPlatform(command, flags) {
602
649
  function cmdInit(flags) {
603
650
  const target = flags._[0] ?? die('usage: octwin init <dir> [--id my-pack]');
604
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.
605
656
  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`);
657
+ const idProblem = describePackNameProblem(id);
658
+ if (idProblem)
659
+ die(`${idProblem}\n → pass --id <pack-name>`);
608
660
  if (existsSync(dir) && readdirSync(dir).length > 0)
609
661
  die(`target '${dir}' is not empty`);
610
662
  if (!existsSync(TEMPLATE_DIR))
@@ -636,7 +688,14 @@ function cmdInit(flags) {
636
688
  }
637
689
  function localValidate(packDir) {
638
690
  const { files, blobs } = collectBundleFiles(packDir);
639
- 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
+ }
640
699
  const r = validatePackBundle(id, files, blobs);
641
700
  if (!r.ok) {
642
701
  for (const e of r.errors)
@@ -660,17 +719,36 @@ async function cmdValidate(flags) {
660
719
  return [];
661
720
  }
662
721
  });
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:
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:
667
725
  // the ✓ used to print above these blocks unconditionally while the per-check ✓s
668
726
  // lived inside the `if`s, so a KB-less run read as "one check, passed". An entire
669
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.
670
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 }));
671
749
  const render = loadAllowedRenderKeys(packDir);
672
750
  if (render.keys) {
673
- const findings = yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys));
751
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys)));
674
752
  if (findings.length) {
675
753
  console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
676
754
  for (const f of findings)
@@ -680,14 +758,13 @@ async function cmdValidate(flags) {
680
758
  console.log('✓ render intents use only fields the platform renders');
681
759
  }
682
760
  else {
683
- console.log(`⚠ ${describeKbLookup(render.lookup, 'render-intent fields')}`);
684
- skipped.push('render-intent fields');
761
+ noteSkip('render-intent fields', render.lookup);
685
762
  }
686
763
  // Primitive `args:` keys, same source and same contract. Cannot see inside a
687
764
  // `use:` template body (expansion is the platform's job); `--remote` covers that.
688
765
  const args = loadPrimitiveArgSpecs(packDir);
689
766
  if (args.specs) {
690
- const findings = yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs));
767
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs)));
691
768
  if (findings.length) {
692
769
  console.error(`✗ ${findings.length} primitive-argument error${findings.length === 1 ? '' : 's'}:`);
693
770
  for (const f of findings)
@@ -697,8 +774,104 @@ async function cmdValidate(flags) {
697
774
  console.log('✓ primitive arguments match their declared inputs');
698
775
  }
699
776
  else {
700
- console.log(`⚠ ${describeKbLookup(args.lookup, 'primitive arguments')}`);
701
- 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)}`);
702
875
  }
703
876
  // `--require-kb` is for CI, where a skip nobody reads is worse than a red build.
704
877
  if (skipped.length && flags['require-kb'] === true) {
@@ -708,7 +881,7 @@ async function cmdValidate(flags) {
708
881
  // The LAST line carries the skip. A reader who sees a ✓ and stops there is the
709
882
  // failure mode; a caveat printed ABOVE the ✓ does not fix it.
710
883
  if (skipped.length) {
711
- 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(', ')}.`);
712
885
  console.log(' Run `octwin platform-kb pull` (once, at your repo root — it covers every pack under it),');
713
886
  console.log(' or `octwin validate --remote` to have the platform run everything server-side.');
714
887
  }
@@ -838,6 +1011,24 @@ function resolveTargetOrNull(flags) {
838
1011
  const t = readTarget(flags);
839
1012
  return t.url && t.token ? t : null;
840
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
+ }
841
1032
  /** The raw resolution both wrappers share — may return empty url/token. */
842
1033
  function readTarget(flags) {
843
1034
  const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? savedDefaultUrl()).replace(/\/$/, '');
@@ -1231,35 +1422,10 @@ async function cmdStatus(flags) {
1231
1422
  function kbEntryFileName(name) {
1232
1423
  return name.replace(/[^A-Za-z0-9._-]/g, '_');
1233
1424
  }
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
- }
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']);
1263
1429
  /** Enumerate a catalog's entries per the platform-supplied descriptor. Handles both
1264
1430
  * collection shapes in use: an ARRAY of named objects (`primitives`, keyed by
1265
1431
  * `name`) and an OBJECT MAP keyed by entry name (`declarations`, `system-entities`).
@@ -1288,73 +1454,56 @@ function enumerateKbEntries(catalog, d) {
1288
1454
  }
1289
1455
  return [];
1290
1456
  }
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
1457
  async function cmdPlatformKb(flags) {
1351
1458
  const packDir = resolve(flags.dir ?? '.');
1352
- 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`');
1353
1462
  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');
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');
1358
1507
  const text = await res.text();
1359
1508
  if (!res.ok) {
1360
1509
  let j;
@@ -1405,6 +1554,9 @@ async function cmdPlatformKb(flags) {
1405
1554
  // (or an unexpected payload shape) fall back to the flat file.
1406
1555
  const byKey = new Map((bundle.index ?? []).map(e => [e.key, e]));
1407
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 = {};
1408
1560
  let catalogCount = 0;
1409
1561
  let entryCount = 0;
1410
1562
  for (const [key, val] of Object.entries(bundle.sources ?? {})) {
@@ -1415,6 +1567,7 @@ async function cmdPlatformKb(flags) {
1415
1567
  const entries = descriptor ? enumerateKbEntries(val, descriptor) : [];
1416
1568
  if (entries.length === 0) {
1417
1569
  writeFileSync(join(outDir, `${key}.json`), JSON.stringify(val, null, 2) + '\n', 'utf8');
1570
+ flatCatalogs[key] = val;
1418
1571
  continue;
1419
1572
  }
1420
1573
  const dir = join(outDir, key);
@@ -1422,17 +1575,57 @@ async function cmdPlatformKb(flags) {
1422
1575
  for (const entry of entries) {
1423
1576
  writeFileSync(join(dir, `${kbEntryFileName(entry.name)}.json`), JSON.stringify(entry.value, null, 2) + '\n', 'utf8');
1424
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
+ }
1425
1596
  exploded.set(key, entries);
1426
1597
  entryCount += entries.length;
1427
1598
  }
1428
- // The map the authoring skill reads first.
1429
- 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');
1430
1619
  // Persist `content_hash` too — the staleness observer (`notifyIfKbStale`) reads
1431
1620
  // it back and compares against the platform's current hash to nudge a re-pull.
1432
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');
1433
1622
  console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
1434
1623
  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.');
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
+ }
1436
1629
  console.log(' Every pack UNDER this directory finds it — `octwin validate` walks up to locate it,');
1437
1630
  console.log(' so one pull at a repo root covers a whole monorepo of packs.');
1438
1631
  // Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
@@ -1454,7 +1647,7 @@ async function cmdPlatformKb(flags) {
1454
1647
  }
1455
1648
  console.log(' The octwin-pack authoring skill reads these as the source of truth for what the platform supports.');
1456
1649
  }
1457
- // ── records / cases / logs / chat — headless inspect + test with the deploy token ────
1650
+ // ── records / work / logs / chat — headless inspect + test with the deploy token ────
1458
1651
  /** GET an admin endpoint with the deploy token; returns `{ status, json }`.
1459
1652
  * Dies (with the URL) on a network failure; auth failures return so the
1460
1653
  * caller can add command-specific context on top of `authFailureHint`. */
@@ -1616,9 +1809,9 @@ async function cmdRecords(flags) {
1616
1809
  const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&${pagingQs(flags)}`, t);
1617
1810
  if (status !== 200) {
1618
1811
  // 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.
1812
+ // worked records (worklist), not pack-declared XRM — point at the right command.
1620
1813
  if (entity === 'case' || entity === 'cases') {
1621
- 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`);
1622
1815
  }
1623
1816
  die(`could not read records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1624
1817
  }
@@ -2294,70 +2487,86 @@ async function cmdMedia(flags) {
2294
2487
  console.log(` saved → ${out}`);
2295
2488
  console.log(` Send it into a chat: octwin chat "here you go" --media ${out ?? r.media_id} --as <handle>`);
2296
2489
  }
2297
- /** Reserved leading words on `octwin cases` — see `RECORD_VERBS` for the rule. */
2298
- 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
+ }
2299
2504
  /**
2300
- * The write half of `octwin cases` — assign / note / transition / decide.
2505
+ * The write half of `octwin work` — assign / note / stage / decide.
2506
+ *
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).
2301
2510
  *
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"
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"
2305
2514
  * safe to run with a read-only token, which is exactly when an author wants it.
2306
2515
  */
2307
- async function cmdCasesWrite(flags) {
2516
+ async function cmdWorkWrite(flags) {
2308
2517
  const t = resolveTarget(flags);
2309
2518
  const { url } = t;
2310
2519
  const base = `${url}/api/self/p`;
2311
2520
  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}`);
2521
+ const id = flags._[1] ?? die(`usage: octwin work ${verb} <recordId> …`);
2522
+ const readBack = () => console.log(`\nRead it back: octwin work ${id}`);
2314
2523
  if (verb === 'assign') {
2315
2524
  // `--to none` unassigns (the route takes null); anything else must carry the
2316
2525
  // principal kind, because a bare uuid cannot say user-or-team.
2317
2526
  const to = typeof flags.to === 'string' ? flags.to
2318
- : 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');
2319
2528
  const assignee = to === 'none' ? null : to;
2320
2529
  if (assignee !== null && !/^(user|team):/.test(assignee)) {
2321
2530
  die(`--to must be 'user:<uuid>', 'team:<uuid>' or 'none' (got '${to}')`);
2322
2531
  }
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);
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);
2325
2534
  if (status !== 200)
2326
- writeFail(`assign case ${id}`, status, json, url, true);
2535
+ writeFail(`assign work item ${id}`, status, json, url, true);
2327
2536
  console.log(assignee === null ? '✓ Unassigned.' : `✓ Assigned to ${json?.assignee ?? assignee}.`);
2328
2537
  return;
2329
2538
  }
2330
2539
  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);
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);
2334
2543
  if (status !== 200)
2335
- writeFail(`note case ${id}`, status, json, url, true);
2336
- 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.');
2337
2546
  readBack();
2338
2547
  return;
2339
2548
  }
2340
- if (verb === 'transition') {
2549
+ if (verb === 'stage') {
2341
2550
  const to = typeof flags.to === 'string' ? flags.to
2342
- : die('usage: octwin cases transition <caseId> --to <status> [--note "..."]');
2343
- const body = { to_status: to };
2551
+ : die('usage: octwin work stage <recordId> --to <stage> [--note "..."]');
2552
+ const body = { to_stage: to };
2344
2553
  if (typeof flags.note === 'string')
2345
2554
  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);
2555
+ console.log(`→ Moving record ${id} to '${to}' …`);
2556
+ const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/stage`, body, t);
2348
2557
  if (status !== 200) {
2349
- // 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
2350
2559
  // leaving the author to guess the vocabulary.
2351
2560
  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);
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);
2354
2563
  }
2355
- console.log(`✓ Case is now '${json?.case?.status ?? to}'.`);
2564
+ console.log(`✓ Record is now '${json?.stage ?? to}'.`);
2356
2565
  return;
2357
2566
  }
2358
- // decide
2567
+ // decide — apply one of the entity's declared operator actions
2359
2568
  const action = typeof flags.action === 'string' ? flags.action
2360
- : 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]');
2361
2570
  const params = {};
2362
2571
  for (const pair of flagList(flags, 'param')) {
2363
2572
  const eq = pair.indexOf('=');
@@ -2369,39 +2578,41 @@ async function cmdCasesWrite(flags) {
2369
2578
  const body = { action, ...(Object.keys(params).length ? { params } : {}) };
2370
2579
  if (!dryRun && typeof flags.note === 'string')
2371
2580
  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' : ''}`;
2581
+ console.log(`→ ${dryRun ? 'Previewing' : 'Applying'} '${action}' on work item ${id} …`);
2582
+ const endpoint = `${base}/work/${encodeURIComponent(id)}/action${dryRun ? '/preview' : ''}`;
2374
2583
  const { status, json } = await apiSend('POST', endpoint, body, t);
2375
2584
  if (status === 404)
2376
- die(`case '${id}' not found`);
2585
+ die(`work item '${id}' not found`);
2377
2586
  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);
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);
2380
2589
  }
2381
2590
  if (dryRun) {
2382
2591
  console.log('Preview (nothing was committed):');
2383
2592
  console.log(JSON.stringify(json, null, 2));
2384
2593
  return;
2385
2594
  }
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.');
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.');
2390
2600
  readBack();
2391
2601
  }
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);
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);
2397
2608
  const t = resolveTarget(flags);
2398
2609
  const { url } = t;
2399
2610
  const base = `${url}/api/self/p`;
2400
- const caseId = flags._[0];
2611
+ const recordId = flags._[0];
2401
2612
  const asJson = flags.json === true;
2402
2613
  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) => {
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) => {
2405
2616
  // A 403 here can also be an RBAC gap the scope hint can't see — a role whose
2406
2617
  // grants don't reach the queue passes the scope gate and still gets nothing.
2407
2618
  if (status === 403)
@@ -2409,72 +2620,74 @@ async function cmdCases(flags) {
2409
2620
  die(`could not read ${what} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
2410
2621
  };
2411
2622
  if (flags.queues === true) {
2412
- const { status, json } = await apiGet(`${base}/case-queues`, t);
2623
+ const { status, json } = await apiGet(`${base}/work/queues`, t);
2413
2624
  if (status !== 200)
2414
- caseFail('case queues', status, json);
2625
+ workFail('work queues', status, json);
2415
2626
  if (asJson) {
2416
2627
  console.log(JSON.stringify(json, null, 2));
2417
2628
  return;
2418
2629
  }
2419
2630
  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`);
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
+ }
2423
2636
  if (json?.unrouted_open_count)
2424
2637
  console.log(` (unrouted: ${json.unrouted_open_count} open)`);
2425
2638
  return;
2426
2639
  }
2427
- if (!caseId) {
2428
- const { status, json } = await apiGet(`${base}/cases?${pagingQs(flags)}`, t);
2640
+ if (!recordId) {
2641
+ const { status, json } = await apiGet(`${base}/work?${pagingQs(flags)}`, t);
2429
2642
  if (status !== 200)
2430
- caseFail('cases', status, json);
2643
+ workFail('the work inbox', status, json);
2431
2644
  if (asJson) {
2432
2645
  console.log(JSON.stringify(json, null, 2));
2433
2646
  return;
2434
2647
  }
2435
2648
  const page = readPage(json);
2436
- 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`);
2437
2650
  if (page.rows.length === 0)
2438
2651
  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}`);
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}`);
2442
2655
  }
2443
- const more = morePageHint(page, 'octwin cases');
2656
+ const more = morePageHint(page, 'octwin work');
2444
2657
  if (more)
2445
2658
  console.log(more);
2446
- 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');
2447
2660
  return;
2448
2661
  }
2449
- const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`, t);
2662
+ const { status, json } = await apiGet(`${base}/work/${encodeURIComponent(recordId)}`, t);
2450
2663
  if (status === 404)
2451
- die(`case '${caseId}' not found`);
2664
+ die(`work item '${recordId}' not found`);
2452
2665
  if (status !== 200)
2453
- caseFail('case', status, json);
2666
+ workFail('work item', status, json);
2454
2667
  if (asJson) {
2455
2668
  console.log(JSON.stringify(json, null, 2));
2456
2669
  return;
2457
2670
  }
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)'}`);
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)'}`);
2461
2674
  if (json?.contact)
2462
2675
  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 ?? []);
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 ?? []);
2470
2683
  console.log(` Timeline (${events.length}):`);
2471
2684
  for (const e of events) {
2472
2685
  const payload = e.payload && Object.keys(e.payload).length > 0 ? ` ${JSON.stringify(e.payload)}` : '';
2473
2686
  console.log(` ${e.ts ?? ''} ${e.kind}${e.actor ? ` (${e.actor})` : ''}${payload}`);
2474
2687
  }
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(', ')}`);
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(', ')}`);
2478
2691
  }
2479
2692
  }
2480
2693
  // ── money formatting (orders / catalog) ─────────────────────────────────────
@@ -2891,21 +3104,32 @@ async function cmdOrdersWrite(flags) {
2891
3104
  if (verb === 'transition') {
2892
3105
  const to = typeof flags.to === 'string' ? flags.to
2893
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.
2894
3111
  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)
3112
+ const detail = await apiGet(`${base}/${encodeURIComponent(ref)}`, t);
3113
+ if (detail.status === 404)
2897
3114
  die(`order '${ref}' not found (pass the opaque reference_id, not the #number)`);
2898
- 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) {
2899
3122
  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)'}`);
3123
+ const allowed = (detail.json?.transitions ?? []);
3124
+ if (allowed.length > 0)
3125
+ console.error(` → allowed: ${allowed.join(', ')}`);
2902
3126
  else
2903
3127
  console.error(` → see the allowed set: octwin orders ${ref}`);
2904
3128
  process.exit(1);
2905
3129
  }
2906
3130
  if (status !== 200)
2907
3131
  writeFail(`move order ${ref} to '${to}'`, status, json, url);
2908
- console.log(`✓ Order is now '${json?.order?.status ?? to}'.`);
3132
+ console.log(`✓ Order is now '${json?.stage ?? to}'.`);
2909
3133
  return;
2910
3134
  }
2911
3135
  // refund — irreversible, and money. `--force` rather than a prompt: the CLI is
@@ -3450,7 +3674,7 @@ function help() {
3450
3674
  octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
3451
3675
  octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
3452
3676
  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
3677
+ octwin work [recordId] [--queues] [--json] # inspect the work inbox (worked records) + timelines
3454
3678
  octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
3455
3679
  octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
3456
3680
  octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
@@ -3459,15 +3683,15 @@ function help() {
3459
3683
  octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
3460
3684
  octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
3461
3685
  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>]
3686
+ octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
3463
3687
  octwin test [--dir .] # = validate --remote (the full platform check)
3464
3688
  octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
3465
3689
 
3466
3690
  Writes — exercise the state your pack creates (each needs the matching :write scope):
3467
3691
  octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
3468
3692
  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
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
3471
3695
  octwin orders transition <ref> --to <status> | refund <ref> --force
3472
3696
  octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
3473
3697
  octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
@@ -3530,8 +3754,8 @@ octwin projects rm <slug> [--yes]
3530
3754
  status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
3531
3755
  Show installed vs live version + the flow list for this pack.`,
3532
3756
  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 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\`.
3535
3759
 
3536
3760
  WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
3537
3761
  octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
@@ -3546,19 +3770,21 @@ octwin projects rm <slug> [--yes]
3546
3770
  \`patch\` needs --entity even though it has an id: the route resolves the field
3547
3771
  validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
3548
3772
  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.
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.
3552
3777
 
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]
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]
3558
3783
 
3559
- \`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>\`
3560
3785
  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\`).`,
3786
+ resulting stage WITHOUT committing (that route needs only \`work:read\`).
3787
+ \`stage\` is the XRM records verb (one transition spelling platform-wide).`,
3562
3788
  logs: `octwin logs [conversationId] [--as <handle>] [--json]
3563
3789
  No id = recent conversations (handle, status, last activity; --as filters).
3564
3790
  With id = the full event timeline including what each turn rendered.
@@ -3666,9 +3892,20 @@ octwin projects rm <slug> [--yes]
3666
3892
 
3667
3893
  --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
3668
3894
  \`--slots\` is how you check what a rule actually produces.`,
3669
- '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>]
3670
3896
  Pull the platform capability reference (markdown + JSON catalogs) into
3671
- .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.`,
3672
3909
  test: `octwin test [--dir .]
3673
3910
  Alias for \`octwin validate --remote\` — the full platform check.`,
3674
3911
  feedback: `octwin feedback [--dir .]
@@ -3685,7 +3922,7 @@ async function main() {
3685
3922
  const [command, ...rest] = process.argv.slice(2);
3686
3923
  const flags = parseFlags(rest);
3687
3924
  // 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
3925
+ // verb changes the answer (`work` reads, `work note` writes), so it rides along
3689
3926
  // when the first positional is one — `VERB_REQUIREMENTS` is keyed that way.
3690
3927
  const leadingVerb = flags._[0];
3691
3928
  CURRENT_COMMAND = (typeof leadingVerb === 'string' && command && `${command} ${leadingVerb}` in VERB_REQUIREMENTS)
@@ -3722,8 +3959,8 @@ async function main() {
3722
3959
  case 'records':
3723
3960
  await cmdRecords(flags);
3724
3961
  break;
3725
- case 'cases':
3726
- await cmdCases(flags);
3962
+ case 'work':
3963
+ await cmdWork(flags);
3727
3964
  break;
3728
3965
  case 'logs':
3729
3966
  await cmdLogs(flags);