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/CHANGELOG.md +21 -1
- package/README.md +5 -2
- package/dist/index.js +487 -250
- package/dist/lib/args-check.js +11 -10
- package/dist/lib/builtin-check.js +130 -0
- package/dist/lib/declaration-check.js +192 -0
- package/dist/lib/entity-check.js +150 -0
- package/dist/lib/kb-index.js +214 -0
- package/dist/lib/kb-path.js +17 -0
- package/dist/lib/kb-symbols.js +271 -0
- package/dist/lib/render-check.js +13 -12
- package/dist/lib/template-check.js +107 -0
- package/dist/lib/validate.js +33 -4
- package/dist/lib/yaml-pos.js +29 -0
- package/package.json +1 -1
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
|
|
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 .] #
|
|
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
|
|
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
|
-
'
|
|
187
|
-
'
|
|
188
|
-
|
|
189
|
-
//
|
|
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
|
-
'
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (`
|
|
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
|
|
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
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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 =
|
|
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 '
|
|
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
|
-
|
|
607
|
-
|
|
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.
|
|
664
|
-
// gitignored cache wiped by every pull,
|
|
665
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
701
|
-
|
|
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.
|
|
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
|
-
/**
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
|
|
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 content — never 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 =
|
|
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
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
//
|
|
1429
|
-
|
|
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(
|
|
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 /
|
|
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
|
-
//
|
|
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
|
|
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
|
|
2298
|
-
const
|
|
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
|
|
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 `
|
|
2303
|
-
* rather than `
|
|
2304
|
-
*
|
|
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
|
|
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
|
|
2313
|
-
const readBack = () => console.log(`\nRead it back: octwin
|
|
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
|
|
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}`}
|
|
2324
|
-
const { status, json } = await apiSend('PATCH', `${base}/
|
|
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
|
|
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
|
|
2332
|
-
console.log(`→ Adding a note to
|
|
2333
|
-
const { status, json } = await apiSend('POST', `${base}/
|
|
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
|
|
2336
|
-
console.log('✓ Note added to the
|
|
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 === '
|
|
2549
|
+
if (verb === 'stage') {
|
|
2341
2550
|
const to = typeof flags.to === 'string' ? flags.to
|
|
2342
|
-
: die('usage: octwin
|
|
2343
|
-
const body = {
|
|
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
|
|
2347
|
-
const { status, json } = await apiSend('POST', `${base}/
|
|
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
|
|
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
|
|
2353
|
-
writeFail(`move
|
|
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(`✓
|
|
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
|
|
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
|
|
2373
|
-
const endpoint = `${base}/
|
|
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(`
|
|
2585
|
+
die(`work item '${id}' not found`);
|
|
2377
2586
|
if (status !== 200) {
|
|
2378
|
-
console.error(` → the
|
|
2379
|
-
writeFail(`${dryRun ? 'preview' : 'apply'} '${action}' on
|
|
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}' —
|
|
2387
|
-
// `notified`
|
|
2388
|
-
//
|
|
2389
|
-
|
|
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
|
|
2393
|
-
* the aggregate inbox, one
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
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
|
|
2611
|
+
const recordId = flags._[0];
|
|
2401
2612
|
const asJson = flags.json === true;
|
|
2402
2613
|
if (!asJson)
|
|
2403
|
-
console.log(`→ Reading ${flags.queues === true ? '
|
|
2404
|
-
const
|
|
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}/
|
|
2623
|
+
const { status, json } = await apiGet(`${base}/work/queues`, t);
|
|
2413
2624
|
if (status !== 200)
|
|
2414
|
-
|
|
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(`
|
|
2421
|
-
for (const q of queues)
|
|
2422
|
-
|
|
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 (!
|
|
2428
|
-
const { status, json } = await apiGet(`${base}/
|
|
2640
|
+
if (!recordId) {
|
|
2641
|
+
const { status, json } = await apiGet(`${base}/work?${pagingQs(flags)}`, t);
|
|
2429
2642
|
if (status !== 200)
|
|
2430
|
-
|
|
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(`
|
|
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
|
|
2440
|
-
const sla =
|
|
2441
|
-
console.log(` #${
|
|
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
|
|
2656
|
+
const more = morePageHint(page, 'octwin work');
|
|
2444
2657
|
if (more)
|
|
2445
2658
|
console.log(more);
|
|
2446
|
-
console.log('\nOne
|
|
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}/
|
|
2662
|
+
const { status, json } = await apiGet(`${base}/work/${encodeURIComponent(recordId)}`, t);
|
|
2450
2663
|
if (status === 404)
|
|
2451
|
-
die(`
|
|
2664
|
+
die(`work item '${recordId}' not found`);
|
|
2452
2665
|
if (status !== 200)
|
|
2453
|
-
|
|
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
|
|
2459
|
-
console.log(
|
|
2460
|
-
console.log(` id: ${
|
|
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 (
|
|
2464
|
-
console.log(` conversation: ${
|
|
2465
|
-
if (
|
|
2466
|
-
console.log(` sla due: ${
|
|
2467
|
-
if (
|
|
2468
|
-
console.log(` fields: ${JSON.stringify(
|
|
2469
|
-
const events = (json?.
|
|
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
|
|
2476
|
-
if (
|
|
2477
|
-
console.log(`
|
|
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
|
|
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
|
|
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
|
-
|
|
2901
|
-
|
|
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?.
|
|
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
|
|
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>]
|
|
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
|
|
3470
|
-
octwin
|
|
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.
|
|
3534
|
-
|
|
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
|
-
|
|
3550
|
-
Inspect
|
|
3551
|
-
|
|
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 \`
|
|
3554
|
-
octwin
|
|
3555
|
-
octwin
|
|
3556
|
-
octwin
|
|
3557
|
-
octwin
|
|
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
|
|
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
|
|
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] [--
|
|
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 (`
|
|
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 '
|
|
3726
|
-
await
|
|
3962
|
+
case 'work':
|
|
3963
|
+
await cmdWork(flags);
|
|
3727
3964
|
break;
|
|
3728
3965
|
case 'logs':
|
|
3729
3966
|
await cmdLogs(flags);
|