octwin-cli 0.8.15 → 0.8.17

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/cli.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * octwin login --url <platformUrl> [--token oct_…] # no token → approve in a browser
14
14
  * octwin whoami [--url <url>] [--tenant <slug>]
15
15
  * octwin projects [--archived] # the --project slugs this token can name
16
- * octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
16
+ * octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed] [--force]
17
17
  * [--request-listing | --withdraw-listing] # marketplace listing — opt-in, see `deploy` help
18
18
  * octwin pull <packId> [--dir <out>] [--version v] [--force] # write a DEPLOYED pack's source back to disk
19
19
  * octwin status [<packId>] # did my deploy land? which version is live?
@@ -64,6 +64,8 @@ import { homedir, hostname, platform } from 'node:os';
64
64
  import { fileURLToPath } from 'node:url';
65
65
  import { parse as parseYaml } from 'yaml';
66
66
  import { applyRenames } from './lib/rename.js';
67
+ import { packIdFromDir } from './lib/init-id.js';
68
+ import { recipeModules, recipeStarters, planRecipe, recipeNeeds } from './lib/recipe.js';
67
69
  import { validatePackBundle, describePackNameProblem, asPackName } from './lib/validate.js';
68
70
  import { loadAllowedRenderKeys, findRenderKeyViolations, describeRenderFinding } from './lib/render-check.js';
69
71
  import { loadPrimitiveArgSpecs, findArgViolations, describeArgFinding, findArgWarnings, describeArgWarning, } from './lib/args-check.js';
@@ -76,9 +78,12 @@ import { loadBuiltinNames, findBuiltinViolations, describeBuiltinFinding } from
76
78
  import { loadTemplateSpecs, findTemplateViolations, describeTemplateFinding } from './lib/template-check.js';
77
79
  import { loadSystemEntities, findEntityViolations, describeEntityFinding } from './lib/entity-check.js';
78
80
  import { loadDeclarationSpecs, findDeclarationViolations, describeDeclarationFinding } from './lib/declaration-check.js';
81
+ import { collectJourneyIds, findJourneyViolations, describeJourneyFinding, isFlowFile } from './lib/journey-check.js';
79
82
  import { findPlatformKbDir } from './lib/kb-path.js';
80
83
  import { reportChecks } from './lib/outcome.js';
81
84
  import { classifyPackPath, isSkippedDir } from './lib/pack-source.js';
85
+ import { BASE_FILE, deployBaseOf, hashBundle, planPull, readBase, writeBase } from './lib/base.js';
86
+ import { formatChecksRefusal } from './lib/refusal.js';
82
87
  import { readPage, morePageHint } from './lib/page.js';
83
88
  import { chatLabel, answersTheTurn } from './lib/chat-line.js';
84
89
  import { kbOneLiner, buildKbIndexMarkdown, buildKbOutlineMarkdown, } from './lib/kb-index.js';
@@ -291,6 +296,9 @@ const COMMAND_REQUIREMENTS = {
291
296
  // derived from record + journey data, and the route is gated accordingly, so the
292
297
  // Read-only token preset already reaches this.
293
298
  performance: { scope: 'records:read' },
299
+ charts: { scope: 'records:read', feature: 'records' },
300
+ handoff: { scope: 'work:read', feature: 'live_chat' },
301
+ surveys: { scope: 'records:read', feature: 'records' },
294
302
  // `usage` is deliberately absent: its route is `requireTenantAccess` only, so any
295
303
  // valid token reaches it. Declaring a requirement would print "needs the X scope"
296
304
  // on a failure whose real cause is an unreachable instance — the same reasoning as
@@ -914,6 +922,9 @@ function commandTouchesPlatform(command, flags) {
914
922
  case 'journeys':
915
923
  case 'performance':
916
924
  case 'usage':
925
+ case 'charts':
926
+ case 'surveys':
927
+ case 'handoff':
917
928
  case 'projects':
918
929
  case 'seed': return true;
919
930
  default: return false;
@@ -927,7 +938,7 @@ function cmdInit(flags) {
927
938
  // which is why `init` can stay offline (it has no idea which workspace this will deploy
928
939
  // to, and no longer needs one) and why the same scaffold deploys into two workspaces as
929
940
  // two packs with nothing to edit in between.
930
- const id = flags.id ?? target.replace(/[/\\]/g, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
941
+ const id = flags.id ?? packIdFromDir(target);
931
942
  const idProblem = describePackNameProblem(id);
932
943
  if (idProblem)
933
944
  die(`${idProblem}\n → pass --id <pack-name>`);
@@ -948,17 +959,127 @@ function cmdInit(flags) {
948
959
  // the token carries its own tenant + optional project pin.
949
960
  writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.pack-bundles/\n.octwin/\n', 'utf8');
950
961
  if (!existsSync(join(dir, 'README.md'))) {
951
- writeFileSync(join(dir, 'README.md'), `# ${id}\n\nA pure-YAML pack for the Octwin platform.\n\n- \`flows/tools/home.flow.yaml\` — the menu hub (your front door); add a row + tool per journey\n- Edit \`manifest.yaml\`, \`prompts/identity.md\`\n- \`octwin validate --remote\` — full platform check before deploy\n- \`octwin deploy\` — deploy + install onto your tenant\n`, 'utf8');
962
+ writeFileSync(join(dir, 'README.md'), [
963
+ `# ${id}`, '',
964
+ 'A pure-YAML pack for the Octwin platform, in Arabic and English.', '',
965
+ '- `flows/tools/home.flow.yaml` — the menu (your front door); add a row per journey',
966
+ '- `flows/tools/request.flow.yaml` + `xrm.yaml` — a visit request, saved as a record; replace it with your own',
967
+ '- `journeys/request.journey.yaml` — the funnel the owner reads on the Performance page',
968
+ '- `octwin platform-kb pull` — the authoring guides and recipes; start with `.octwin/platform-kb/INDEX.md`',
969
+ '- `octwin recipe` — list the recipes; `octwin recipe <module>` adds one to this pack',
970
+ '- `octwin validate` — the platform\'s full check, before every deploy',
971
+ '- `octwin deploy --seed` — deploy, install, and add the demo rows',
972
+ '',
973
+ ].join('\n'), 'utf8');
952
974
  }
953
975
  out.print(`✓ Scaffolded pure-YAML pack '${id}' at ${dir}`);
954
976
  out.print('\nNext:');
955
977
  out.print(` cd ${target}`);
956
978
  out.print(' git init && git add -A && git commit -m "init pack"');
957
- out.print(' # edit manifest.yaml / flows / prompts, then:');
958
- out.print(' octwin validate');
959
- out.print(' # point the CLI at your platform (the token carries the tenant):');
960
- out.print(' octwin login --url <platformUrl> --token <deploy-token>');
961
- out.print(' octwin deploy');
979
+ out.print(' octwin platform-kb pull # the authoring guides + recipes — read .octwin/platform-kb/INDEX.md first');
980
+ out.print(' octwin recipe # the recipes you can add; `octwin recipe <module>` writes one in');
981
+ out.print(' octwin validate # the platform\'s FULL check — no login needed');
982
+ out.print(' # point the CLI at your platform (the token carries the tenant), then deploy with the demo rows:');
983
+ out.print(' octwin login --url <platformUrl>');
984
+ out.print(' octwin deploy --seed');
985
+ }
986
+ /**
987
+ * `octwin recipe [<module>] [--dir .] [--dry-run]` — a KB recipe's starter files into this pack.
988
+ *
989
+ * Read from the PULLED guide (`lib/recipe.ts`), so a recipe has one copy — the one the platform holds
990
+ * to its full deploy check together with every other recipe, on the `init` starter. Nothing is
991
+ * overwritten: a file the pack already has is named, and a declaration it already has (an `xrm.yaml`
992
+ * holding the author's own entities) gets the recipe's part printed to merge. With no module it lists
993
+ * the recipes the pulled KB carries.
994
+ */
995
+ function cmdRecipe(flags) {
996
+ const packDir = resolve(flags.dir ?? '.');
997
+ const kbDir = findPlatformKbDir(packDir)
998
+ ?? die('no pulled KB here or above — run `octwin platform-kb pull` first (a recipe is read from it)');
999
+ let index = [];
1000
+ try {
1001
+ index = JSON.parse(readFileSync(join(kbDir, 'index.json'), 'utf8')).index ?? [];
1002
+ }
1003
+ catch {
1004
+ die(`${join(kbDir, 'index.json')} could not be read — re-run \`octwin platform-kb pull\``);
1005
+ }
1006
+ const modules = recipeModules(index);
1007
+ const module = flags._[0]?.toLowerCase();
1008
+ if (!module) {
1009
+ if (modules.size === 0)
1010
+ die('the pulled KB names no recipe — re-run `octwin platform-kb pull` (an older platform published none)');
1011
+ out.print('Recipes in the pulled KB — `octwin recipe <module>` writes one into this pack:');
1012
+ for (const [m, key] of modules)
1013
+ out.print(` ${m.padEnd(14)} guide:${key}#${m}-recipe`);
1014
+ return;
1015
+ }
1016
+ const key = modules.get(module) ?? die(`no recipe '${module}' in the pulled KB — known: ${[...modules.keys()].join(', ')}`);
1017
+ const starters = recipeStarters(readFileSync(join(kbDir, `${key}.md`), 'utf8'), module)
1018
+ ?? die(`${key}.md has no '## ${module} recipe' section — re-run \`octwin platform-kb pull\``);
1019
+ const manifestPath = join(packDir, 'manifest.yaml');
1020
+ if (!existsSync(manifestPath))
1021
+ die(`no manifest.yaml in ${packDir} — run this inside a pack, or pass --dir`);
1022
+ const manifest = parseYaml(readFileSync(manifestPath, 'utf8'));
1023
+ const { actions, flows } = planRecipe(starters, p => existsSync(join(packDir, p)), String(manifest.id ?? ''));
1024
+ const dry = flags['dry-run'] === true;
1025
+ out.print(`${dry ? 'Would apply' : 'Applying'} the ${module} recipe (guide:${key}#${module}-recipe) to ${packDir}`);
1026
+ for (const a of actions) {
1027
+ if (a.act === 'write') {
1028
+ if (!dry) {
1029
+ mkdirSync(dirname(join(packDir, a.path)), { recursive: true });
1030
+ writeFileSync(join(packDir, a.path), a.body, 'utf8');
1031
+ }
1032
+ out.print(` ✓ ${dry ? 'would write' : 'wrote'} ${a.path}`);
1033
+ }
1034
+ else if (a.act === 'exists') {
1035
+ out.print(` • ${a.path} already exists — left as it is (compare it with guide:${key}#${module}-starter)`);
1036
+ }
1037
+ }
1038
+ for (const a of actions) {
1039
+ if (a.act === 'merge') {
1040
+ out.print(`\n${a.path} already exists — merge this part into it${a.note ? ` (${a.note})` : ''}:`);
1041
+ out.print(a.body.trimEnd());
1042
+ }
1043
+ else if (a.act === 'manifest') {
1044
+ out.print('\nAdd to manifest.yaml:');
1045
+ out.print(a.body.trimEnd());
1046
+ }
1047
+ }
1048
+ const listed = (v) => (Array.isArray(v) ? v.map(String) : []);
1049
+ const newFlows = flows.filter(f => !listed(manifest.flows).includes(f));
1050
+ const newTools = flows.filter(f => !listed(manifest.agents?.[0]?.tools).includes(f));
1051
+ if (newFlows.length || newTools.length) {
1052
+ out.print('\nList the new flows in manifest.yaml:');
1053
+ if (newFlows.length)
1054
+ out.print(` flows: ${newFlows.map(f => `- ${f}`).join(' ')}`);
1055
+ if (newTools.length)
1056
+ out.print(` agents[0].tools: ${newTools.map(f => `- ${f}`).join(' ')}`);
1057
+ }
1058
+ // What this recipe's buttons and `track:` ids need that the pack does not ship yet — other recipes,
1059
+ // derived from their files (`recipeNeeds`). Said now, so the author does not meet the chain as one
1060
+ // validate error after another.
1061
+ const all = new Map();
1062
+ for (const [m, k] of modules) {
1063
+ const s = existsSync(join(kbDir, `${k}.md`)) ? recipeStarters(readFileSync(join(kbDir, `${k}.md`), 'utf8'), m) : null;
1064
+ if (s)
1065
+ all.set(m, s);
1066
+ }
1067
+ const journeyDir = join(packDir, 'journeys');
1068
+ const journeys = existsSync(journeyDir)
1069
+ ? readdirSync(journeyDir).filter(f => f.endsWith('.journey.yaml'))
1070
+ .map(f => [`journeys/${f}`, parseYaml(readFileSync(join(journeyDir, f), 'utf8'))])
1071
+ : [];
1072
+ const { needs, unresolved } = recipeNeeds(module, all, {
1073
+ flows: new Set([...listed(manifest.flows), ...flows]), journeys,
1074
+ });
1075
+ if (needs.length) {
1076
+ out.print(`\nThis recipe also needs ${needs.length === 1 ? 'another recipe' : `${needs.length} other recipes`} — its buttons and \`track:\` ids name what they ship:`);
1077
+ for (const n of needs)
1078
+ out.print(` octwin recipe ${n}`);
1079
+ }
1080
+ for (const u of unresolved)
1081
+ out.print(` ! ${u}`);
1082
+ out.print(`\nThen read guide:${key}#${module}-ingredients and #${module}-rules, add the home row it names, and run \`octwin validate\`.`);
962
1083
  }
963
1084
  function localValidate(packDir) {
964
1085
  const { files, blobs } = collectBundleFiles(packDir);
@@ -1194,6 +1315,25 @@ async function cmdValidate(flags) {
1194
1315
  : { kind: 'passed', line: 'declaration files carry no unknown or missing keys (unions are left to --remote)' };
1195
1316
  },
1196
1317
  },
1318
+ {
1319
+ // Journey ids named by `track:` / `do: track_event`, against the pack's OWN `journeys/`.
1320
+ // Needs no KB — the declared set is in the bundle — so it never skips. Last, because a
1321
+ // malformed journey file is the declaration check's finding, not a wall of these.
1322
+ label: 'journey ids',
1323
+ run: () => {
1324
+ const ids = collectJourneyIds(yamlDocs());
1325
+ const findings = withLines(yamlDocs()
1326
+ .filter(([p]) => isFlowFile(p))
1327
+ .flatMap(([p, doc]) => findJourneyViolations(doc, p, ids)));
1328
+ return findings.length
1329
+ ? {
1330
+ kind: 'findings', noun: 'undeclared journey id',
1331
+ lines: findings.map(describeJourneyFinding),
1332
+ hint: 'fix these before deploying — the platform refuses them too, and at runtime a `track:` throws mid-conversation while `track_event` takes its `failed` port',
1333
+ }
1334
+ : { kind: 'passed', line: 'every literal journey id in `track:` / `track_event` is declared in journeys/' };
1335
+ },
1336
+ },
1197
1337
  ];
1198
1338
  const skipped = reportChecks(checks, {
1199
1339
  log: (l) => out.print(l),
@@ -1639,16 +1779,20 @@ async function cmdWhoami(flags) {
1639
1779
  * You may pull a pack YOUR tenant owns (an operator token may pull any). A pack
1640
1780
  * you merely installed from the marketplace is not yours to read.
1641
1781
  *
1642
- * Writes into `<out>/` and refuses a non-empty directory unless `--force`, so it
1643
- * can't quietly clobber local edits. The pulled directory is immediately
1644
- * `octwin deploy`-able back to where it came from — the target is the saved
1645
- * login, so nothing machine-specific needs to land in the pack dir.
1782
+ * Writes into the pack folder you are standing in when it IS this pack (its
1783
+ * `.octwin-base.json` or its manifest says so), else into `./<packId>` — `--dir`
1784
+ * overrides. It replaces only files that still match the folder's base: a file you
1785
+ * changed since your last pull or deploy stops it (`planPull`), unless `--force`. It
1786
+ * then writes the new base, so the next `octwin deploy` is judged against what you
1787
+ * pulled. The pulled directory is immediately `octwin deploy`-able back to where it
1788
+ * came from — the target is the saved login, so nothing machine-specific needs to
1789
+ * land in the pack dir.
1646
1790
  */
1647
1791
  async function cmdPull(flags) {
1648
1792
  const packId = flags._[0] ?? '';
1649
1793
  if (!packId)
1650
1794
  die('usage: octwin pull <packId> [--dir <out>] [--version <v>] [--force]');
1651
- const outDir = resolve(flags.dir ?? packId);
1795
+ const outDir = pullTarget(flags, packId);
1652
1796
  const t = resolveTarget(flags);
1653
1797
  const { url } = t;
1654
1798
  const qs = typeof flags.version === 'string' ? `?version=${encodeURIComponent(flags.version)}` : '';
@@ -1676,8 +1820,23 @@ async function cmdPull(flags) {
1676
1820
  const total = Object.keys(files).length + Object.keys(blobs).length;
1677
1821
  if (total === 0)
1678
1822
  die(`${packId}@${json.version} has no files — nothing to write`);
1679
- if (existsSync(outDir) && readdirSync(outDir).length > 0 && flags.force !== true) {
1680
- die(`${outDir} is not empty — pass --force to overwrite it`);
1823
+ // Replace only what still matches the base — never the author's own edits (`planPull`).
1824
+ const incoming = hashBundle(files, blobs);
1825
+ const prior = readBase(outDir);
1826
+ const base = prior?.pack_id === json.pack_id ? prior : null;
1827
+ const here = existsSync(outDir) ? collectBundleFiles(outDir) : { files: {}, blobs: {} };
1828
+ const { atRisk, remove } = planPull({ local: hashBundle(here.files, here.blobs), incoming, base });
1829
+ if (atRisk.length > 0 && flags.force !== true) {
1830
+ out.err(`✗ ${outDir} has changes the live ${json.version} does not — pulling would overwrite them:`);
1831
+ for (const rel of atRisk.slice(0, 15))
1832
+ out.err(` • ${rel}`);
1833
+ if (atRisk.length > 15)
1834
+ out.err(` … and ${atRisk.length - 15} more`);
1835
+ out.err(base
1836
+ ? ' Save them first (git commit, or copy the folder), then pull with --force and apply them again.'
1837
+ : ` This folder has no ${BASE_FILE} (the record of its last pull or deploy), so your edits cannot be told `
1838
+ + 'apart from an old version. Save them first (git commit, or copy the folder), then pull with --force.');
1839
+ exitNow(1);
1681
1840
  }
1682
1841
  for (const [rel, body] of Object.entries(files)) {
1683
1842
  const p = join(outDir, rel);
@@ -1689,9 +1848,35 @@ async function cmdPull(flags) {
1689
1848
  mkdirSync(dirname(p), { recursive: true });
1690
1849
  writeFileSync(p, Buffer.from(b64, 'base64'));
1691
1850
  }
1851
+ // The live version no longer has these — left behind, the next deploy would bring them back.
1852
+ for (const rel of remove)
1853
+ rmSync(join(outDir, rel), { force: true });
1854
+ writeBase(outDir, { pack_id: json.pack_id, version: json.version, content_sha: json.content_sha, files: incoming });
1692
1855
  out.print(`✓ Pulled ${json.pack_id}@${json.version} → ${outDir}`);
1693
- out.print(` ${Object.keys(files).length} file(s)${Object.keys(blobs).length ? `, ${Object.keys(blobs).length} blob(s)` : ''} content_sha ${String(json.content_sha).slice(0, 12)}…`);
1694
- out.print(`\nFix it, then: octwin deploy --dir ${outDir}`);
1856
+ out.print(` ${Object.keys(files).length} file(s)${Object.keys(blobs).length ? `, ${Object.keys(blobs).length} blob(s)` : ''}`
1857
+ + `${remove.length ? `, ${remove.length} removed` : ''} content_sha ${String(json.content_sha).slice(0, 12)}…`);
1858
+ out.print(`\nFix it, then: octwin deploy${outDir === resolve('.') ? '' : ` --dir ${outDir}`}`);
1859
+ }
1860
+ /**
1861
+ * Where `pull` writes: `--dir`, else the folder you are in when it is this pack (its base names it, or
1862
+ * its manifest declares the pack's name — the folder a refused deploy was run from), else `./<packId>`.
1863
+ */
1864
+ function pullTarget(flags, packId) {
1865
+ if (typeof flags.dir === 'string')
1866
+ return resolve(flags.dir);
1867
+ const here = resolve('.');
1868
+ if (readBase(here)?.pack_id === packId)
1869
+ return here;
1870
+ const manifest = join(here, 'manifest.yaml');
1871
+ if (existsSync(manifest)) {
1872
+ try {
1873
+ const id = parseYaml(readFileSync(manifest, 'utf8'))?.id;
1874
+ if (typeof id === 'string' && id === packId.slice(packId.indexOf('.') + 1))
1875
+ return here;
1876
+ }
1877
+ catch { /* not a manifest we can read — not this pack */ }
1878
+ }
1879
+ return resolve(packId);
1695
1880
  }
1696
1881
  /**
1697
1882
  * Read the deploy SSE stream, printing each progress frame's message live, and
@@ -1849,6 +2034,9 @@ function printDeploySuccess(id, version, t, r, listing, problems = 0) {
1849
2034
  out.print(problems > 0
1850
2035
  ? `⚠ Deployed ${id}@${version} onto ${targetLabel(t)} WITH ${problems} failure(s) — data may be incomplete`
1851
2036
  : `✓ Deployed ${id}@${version} and installed onto ${targetLabel(t)}`);
2037
+ if (r?.forced_over) {
2038
+ out.print(` ⚠ --force replaced ${r.forced_over === 'downgrade' ? 'a higher live version' : 'a version someone else published after your copy'}.`);
2039
+ }
1852
2040
  if (r?.warning)
1853
2041
  out.print(` ⚠ ${r.warning}`);
1854
2042
  // This read `r.summary.records` / `.rules` / `.failed` and had been DEAD: the deploy route's
@@ -1908,7 +2096,7 @@ async function cmdSeed(flags) {
1908
2096
  if (stepErrors.length) {
1909
2097
  // A kind failed but the rest ran — the reconcile softens each step. Say which,
1910
2098
  // and exit non-zero so a scripted `seed && chat` doesn't read as clean.
1911
- out.err(`
2099
+ out.err(`
1912
2100
  ⚠ ${stepErrors.length} step${stepErrors.length === 1 ? '' : 's'} failed — data may be incomplete:`);
1913
2101
  for (const e of stepErrors)
1914
2102
  out.err(` • ${e}`);
@@ -1954,7 +2142,12 @@ async function cmdDeploy(flags) {
1954
2142
  const endpoint = `${url}/api/self/p/packs/deploy`;
1955
2143
  const seed = flags.seed === true;
1956
2144
  const listing = resolveListingFlags(flags);
1957
- const extras = [seed ? 'with demo seed' : null, listing ? LISTING_LABEL[listing] : null].filter(Boolean);
2145
+ // What this copy was made from: the platform refuses when someone published since (`live_moved`).
2146
+ const base = deployBaseOf(readBase(packDir));
2147
+ const force = flags.force === true;
2148
+ const extras = [
2149
+ seed ? 'with demo seed' : null, listing ? LISTING_LABEL[listing] : null, force ? 'forced over any newer live version' : null,
2150
+ ].filter(Boolean);
1958
2151
  out.print(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${extras.length ? ` — ${extras.join(', ')}` : ''} …`);
1959
2152
  const res = await fetchOrDie(endpoint, {
1960
2153
  method: 'POST',
@@ -1963,7 +2156,9 @@ async function cmdDeploy(flags) {
1963
2156
  headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
1964
2157
  // `listing` is omitted entirely unless asked for: an ordinary deploy says NOTHING about the
1965
2158
  // marketplace, which is what keeps a test loop out of the operator review queue.
1966
- body: JSON.stringify({ files, blobs, seed, ...(listing ? { listing } : {}) }),
2159
+ body: JSON.stringify({
2160
+ files, blobs, seed, ...(listing ? { listing } : {}), ...(base ? { base } : {}), ...(force ? { force: true } : {}),
2161
+ }),
1967
2162
  }, 'deploy');
1968
2163
  // Streaming path — live install + seed progress (image generation can take a
1969
2164
  // while, so `--seed` prints per-record / per-image lines as they happen).
@@ -1971,6 +2166,7 @@ async function cmdDeploy(flags) {
1971
2166
  const { terminal: final, stepErrors } = await readDeployProgress(res.body);
1972
2167
  if (!final || final.stage === 'error')
1973
2168
  die(`deploy failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
2169
+ rememberDeploy(packDir, final, files, blobs);
1974
2170
  // The count goes IN, so the headline itself carries the verdict. It used to be
1975
2171
  // printed underneath a `✓ Deployed` line, which is what a reporting author read.
1976
2172
  printDeploySuccess(id, version, t, final, { intent: listing, packDir }, stepErrors.length);
@@ -1996,11 +2192,28 @@ async function cmdDeploy(flags) {
1996
2192
  json = text;
1997
2193
  }
1998
2194
  if (!res.ok) {
2195
+ // Someone published since this copy was made, or it is an older version: nothing changed on the
2196
+ // platform. The message says what happened and the way out (pull, or --force) — print it as words.
2197
+ if (res.status === 409 && (json?.code === 'live_moved' || json?.code === 'downgrade')) {
2198
+ out.err(`✗ ${json.error}`);
2199
+ if (json.code === 'live_moved' && typeof flags.dir === 'string') {
2200
+ out.err(` This copy is in ${flags.dir} — add \`--dir ${flags.dir}\` to the pull.`);
2201
+ }
2202
+ exitNow(1);
2203
+ }
2204
+ // The checks refused it: say what keeps running, then what to fix (`formatChecksRefusal`).
2205
+ const refusal = res.status === 400 ? formatChecksRefusal(json) : null;
2206
+ if (refusal) {
2207
+ for (const line of refusal)
2208
+ out.err(line);
2209
+ exitNow(1);
2210
+ }
1999
2211
  out.err(`✗ deploy failed (HTTP ${res.status})`);
2000
2212
  printAuthHint(res.status, url);
2001
2213
  out.err(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
2002
2214
  exitNow(1);
2003
2215
  }
2216
+ rememberDeploy(packDir, json, files, blobs);
2004
2217
  // This path had NO step-error check at all. With no SSE frames to read, the only
2005
2218
  // evidence a seed kind threw is `seed_failed` in the body — so a broken seed printed
2006
2219
  // a clean `✓ Deployed` here even after the streaming path was fixed in 0.1.14.
@@ -2013,6 +2226,15 @@ async function cmdDeploy(flags) {
2013
2226
  exitNow(1);
2014
2227
  }
2015
2228
  }
2229
+ /**
2230
+ * After a deploy: this folder's new base is what the project now runs. Written even when a seed step
2231
+ * failed — the pack itself IS installed. A platform too old to report `content_sha` leaves the base alone.
2232
+ */
2233
+ function rememberDeploy(packDir, r, files, blobs) {
2234
+ if (typeof r?.content_sha !== 'string' || typeof r?.pack_id !== 'string' || typeof r?.version !== 'string')
2235
+ return;
2236
+ writeBase(packDir, { pack_id: r.pack_id, version: r.version, content_sha: r.content_sha, files: hashBundle(files, blobs) });
2237
+ }
2016
2238
  /**
2017
2239
  * The QUALIFIED pack id (`<owner>.<name>`) for a manifest's bare name.
2018
2240
  *
@@ -2092,6 +2314,13 @@ async function cmdStatus(flags) {
2092
2314
  const shortSha = (s) => (typeof s === 'string' && s ? s.slice(0, 12) + '…' : '(none)');
2093
2315
  out.print(` live on instance : registered=${json.registered} loaded=${shortSha(json.loaded_content_sha)}`);
2094
2316
  out.print(` catalog artifact : ${shortSha(json.catalog_content_sha)}${json.origin ? ` origin=${json.origin}` : ''}`);
2317
+ // The copy on disk against what runs: the question a deploy is about to be judged on (`live_moved`).
2318
+ const base = readBase(packDir);
2319
+ out.print(` your copy : ${!base || base.pack_id !== id
2320
+ ? `no ${BASE_FILE} — run \`octwin pull ${id}\` once, so a deploy can tell when someone else published`
2321
+ : base.content_sha === json.catalog_content_sha
2322
+ ? `from ${base.version} (${shortSha(base.content_sha)}) — the live content`
2323
+ : `from ${base.version} (${shortSha(base.content_sha)}) — the live content MOVED since; run \`octwin pull ${id}\` before you deploy`}`);
2095
2324
  out.print(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
2096
2325
  // Every line above is process-global — `registered` is true for a pack ANY
2097
2326
  // project on this instance loaded. `dispatches` is the project-scoped answer to
@@ -2322,9 +2551,10 @@ async function cmdPlatformKb(flags) {
2322
2551
  writeFileSync(join(outDir, 'OUTLINE.md'), outline, 'utf8');
2323
2552
  // Persist `content_hash` too — the staleness observer (`notifyIfKbStale`) reads
2324
2553
  // it back and compares against the platform's current hash to nudge a re-pull.
2325
- 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');
2554
+ writeFileSync(join(outDir, 'index.json'), JSON.stringify({ version: bundle.version, content_hash: bundle.content_hash, generated_at: bundle.generated_at, platform_version: bundle.platform_version, index: bundle.index }, null, 2) + '\n', 'utf8');
2326
2555
  out.print(`✓ Pulled the Octwin platform KB → ${outDir}`);
2327
- out.print(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`);
2556
+ out.print(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`
2557
+ + `, published by platform ${bundle.platform_version ?? 'unknown'}`);
2328
2558
  out.print(` Three maps: INDEX.md (the corpus) · SYMBOLS.md (${symbols.length} names → their file, grep it)`
2329
2559
  + `${outline ? ' · OUTLINE.md (every heading, with line numbers)' : ''}`);
2330
2560
  if (!outline) {
@@ -4092,12 +4322,15 @@ async function cmdAgents(flags) {
4092
4322
  * docs/BACKLOG.md → "Headless payment settle" (a `--settle` flag lands here once
4093
4323
  * the platform grows a manual-driver-only reconcile endpoint). */
4094
4324
  function printPaymentNote(paymentStatus) {
4095
- if (paymentStatus !== 'pending' && paymentStatus !== 'none')
4096
- return;
4097
- out.print(` → payment_status '${paymentStatus}' is expected without a live gateway: the forward payment`);
4098
- out.print(' lifecycle is WEBHOOK-owned (not patchable), and the default `manual` driver has no hosted');
4099
- out.print(' checkout — so `payment_request` takes its `empty` port and the flow should offer');
4100
- out.print(' pay-on-delivery. Refund is the one operator-driven move.');
4325
+ if (paymentStatus === 'none') {
4326
+ out.print(" → payment_status 'none': no payment was opened. Without a hosted-checkout gateway");
4327
+ out.print(' `payment_request` takes its `empty` port and leaves the order alone — the flow should offer');
4328
+ out.print(' pay-on-delivery. Refund is the one operator-driven move.');
4329
+ }
4330
+ else if (paymentStatus === 'pending') {
4331
+ out.print(" → payment_status 'pending': a checkout was opened and the gateway's webhook has not");
4332
+ out.print(' confirmed it yet. The forward payment lifecycle is WEBHOOK-owned (not patchable).');
4333
+ }
4101
4334
  }
4102
4335
  /** `octwin orders [referenceId] [--status s] [--payment p] [--limit n] [--json]` —
4103
4336
  * the orders a conversation created: money breakdown, payment state, allowed
@@ -4108,8 +4341,8 @@ const ORDER_VERBS = new Set(['transition', 'refund']);
4108
4341
  * The write half of `octwin orders` — fulfilment transitions and refunds.
4109
4342
  *
4110
4343
  * `payment_status` is deliberately NOT settable: the forward payment lifecycle is
4111
- * webhook-owned, which is why `pending` on a gateway-less workspace is expected
4112
- * rather than a bug (`printPaymentNote`).
4344
+ * webhook-owned: `none` is an order no payment was opened for (no gateway), `pending` one whose
4345
+ * checkout the gateway has not confirmed yet (`printPaymentNote`).
4113
4346
  */
4114
4347
  async function cmdOrdersWrite(flags) {
4115
4348
  const t = resolveTarget(flags);
@@ -4700,7 +4933,7 @@ async function cmdScheduling(flags) {
4700
4933
  *
4701
4934
  * `pause` / `resume` rather than a literal `status active|paused`: the route body
4702
4935
  * takes the status, but the RBAC layer it calls checks the ACTION
4703
- * (`assertCan(…, action: 'pause' | 'resume')`), and `VERB_REQUIREMENTS` is keyed
4936
+ * (`assertMay(…, action: 'pause' | 'resume')`), and `VERB_REQUIREMENTS` is keyed
4704
4937
  * per verb — so one verb per intent makes both the permission hint and the 403
4705
4938
  * say the right thing.
4706
4939
  */
@@ -4800,11 +5033,26 @@ async function cmdAutomationWrite(flags) {
4800
5033
  return;
4801
5034
  }
4802
5035
  const r = json?.result ?? {};
4803
- out.print(`✓ campaign '${typed}': matched ${r.matched ?? 0}, enqueued ${r.enqueued ?? 0}`);
4804
- if ((r.enqueued ?? 0) < (r.matched ?? 0))
4805
- out.print(' partial: some matched contacts were not enqueued (cooldown, or no reachable channel)');
5036
+ const already = Number(r.already_queued ?? 0);
5037
+ out.print(`✓ campaign '${typed}': matched ${r.matched ?? 0}, enqueued ${r.enqueued ?? 0}${already ? `, ${already} already had it` : ''} [${r.stage ?? '?'}]`);
5038
+ // A campaign that stopped half-way is FINISHED by the same command: it queues only the contacts
5039
+ // with no delivery yet, so `already` is the earlier send's work, not a skip.
5040
+ if (already)
5041
+ out.print(' finished an earlier send — each contact gets this campaign once');
5042
+ if ((r.enqueued ?? 0) + already < (r.matched ?? 0))
5043
+ out.print(' partial: some matched records were not enqueued (records sharing a contact, or a record with none)');
4806
5044
  out.print(' enqueued ≠ delivered — watch the sends land with `octwin logs`');
4807
5045
  }
5046
+ /**
5047
+ * A campaign's delivery progress — the per-status counts of the reminders it queued. Not sent yet
5048
+ * (a draft) is all zeros, and reads as a dash rather than "sent 0".
5049
+ */
5050
+ function campaignProgressLine(p) {
5051
+ const n = (k) => Number(p?.[k] ?? 0);
5052
+ if (n('sent') + n('failed') + n('pending') + n('cancelled') === 0)
5053
+ return '—'.padEnd(28);
5054
+ return `sent ${n('sent')} · failed ${n('failed')} · pending ${n('pending')}${n('cancelled') ? ` · cancelled ${n('cancelled')}` : ''}`.padEnd(28);
5055
+ }
4808
5056
  /**
4809
5057
  * `octwin automation [--campaigns] [--json]` — the jobs a pack's declarations
4810
5058
  * produced, with their last result, plus the health counts.
@@ -4838,15 +5086,17 @@ async function cmdAutomation(flags) {
4838
5086
  const page = readPage(json);
4839
5087
  out.print(`Campaigns in ${targetLabel(t)}: ${page.total ?? page.rows.length}`);
4840
5088
  if (page.rows.length === 0)
4841
- out.print(' (none — a campaign comes from a `campaigns:` block in the pack\'s automation declaration)');
5089
+ out.print(' (none — a flow drafts one with the `campaign_create` step)');
5090
+ // What the route returns, field for field: `{ id, record_number, name, entity, stage, progress }`.
5091
+ // It read `label` / `key` / `audience_size` / `matched` until 2026-10-11 — none of which a campaign
5092
+ // row carries — so every row printed its uuid twice and "—".
4842
5093
  for (const c of page.rows) {
4843
- const label = pickLabel(c.label) ?? c.key ?? c.id;
4844
- out.print(` ${String(label).padEnd(28)} ${String(c.audience_size ?? c.matched ?? '—').padStart(6)} contact(s) ${c.key ?? c.id}`);
5094
+ out.print(` ${`#${c.record_number ?? '?'}`.padEnd(6)} ${String(c.name ?? '(no name)').padEnd(28)} ${String(c.stage ?? '—').padEnd(9)} ${campaignProgressLine(c.progress)} ${c.id}`);
4845
5095
  }
4846
5096
  const more = morePageHint(page, 'octwin automation campaigns');
4847
5097
  if (more)
4848
5098
  out.print(more);
4849
- out.print('\nSend one: octwin automation send <campaignId> (needs automation:write)');
5099
+ out.print('\nSend a draft, or finish one stuck at `sending`: octwin automation send <campaignId> (needs automation:write; each contact gets it once)');
4850
5100
  return;
4851
5101
  }
4852
5102
  if (!asJson)
@@ -4864,7 +5114,7 @@ async function cmdAutomation(flags) {
4864
5114
  const page = readPage(jobs.json);
4865
5115
  const h = health.status === 200 ? (health.json ?? {}) : {};
4866
5116
  out.print(`Automation in ${targetLabel(t)}: ${h.total ?? page.rows.length} job(s)`
4867
- + ` — ${h.active ?? '?'} active, ${h.paused ?? '?'} paused, ${h.failing ?? '?'} failing, ${h.never_ran ?? '?'} never ran`);
5117
+ + ` — ${h.active ?? '?'} active, ${h.paused ?? '?'} paused, ${h.failing ?? '?'} failing, ${h.never_checked ?? '?'} never checked`);
4868
5118
  if (page.rows.length === 0) {
4869
5119
  out.print(' (none — jobs are DERIVED from the pack\'s automation declaration, not created here.');
4870
5120
  out.print(' No `automation.yaml` block → no jobs. `octwin deploy` installs them.)');
@@ -4872,7 +5122,9 @@ async function cmdAutomation(flags) {
4872
5122
  }
4873
5123
  for (const j of page.rows) {
4874
5124
  const r = j.last_result ?? {};
4875
- const ran = j.last_run_at ? `last ${j.last_run_at}` : 'never ran';
5125
+ // A job runs only when its look finds work, so "not run yet" is normal; "never checked" is the dead one.
5126
+ const ran = (j.last_run_at ? `last run ${j.last_run_at}` : 'not run yet')
5127
+ + (j.last_checked_at ? `, checked ${j.last_checked_at}` : ', never checked');
4876
5128
  const result = j.last_result
4877
5129
  ? ` matched ${r.matched ?? 0}/acted ${r.acted ?? 0}${(r.errors ?? 0) > 0 ? `/ERRORS ${r.errors}` : ''}`
4878
5130
  : '';
@@ -5334,8 +5586,214 @@ async function cmdJourneys(flags) {
5334
5586
  }
5335
5587
  // ── performance: the project's business indicators ──────────────────────────
5336
5588
  /**
5337
- * `octwin performance [--detail] [--json]` — the indicators the project's own
5338
- * declarations produce: value, conversion, duration, per journey.
5589
+ * `octwin charts [<key>] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--json]` — the pack's own `charts:`
5590
+ * (xrm.yaml), as the owner's dashboard draws them: the list, or one chart's figure, parts or points.
5591
+ * Needs `records:read`; a `view` grant on each chart's entity decides which ones the token sees.
5592
+ */
5593
+ async function cmdCharts(flags) {
5594
+ const t = resolveTarget(flags);
5595
+ const { url } = t;
5596
+ const base = `${url}/api/self/p/xrm/charts`;
5597
+ const key = flags._[0];
5598
+ const asJson = flags.json === true;
5599
+ if (!key) {
5600
+ if (!asJson)
5601
+ out.print(`→ Reading the pack's charts from ${targetLabel(t)} …`);
5602
+ const { status, json } = await apiGet(`${base}`, t);
5603
+ if (status !== 200)
5604
+ die(`could not read charts (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5605
+ if (asJson) {
5606
+ out.result(json);
5607
+ return;
5608
+ }
5609
+ const charts = (json?.charts ?? []);
5610
+ if (charts.length === 0) {
5611
+ out.print('No charts — a pack declares them under `charts:` in xrm.yaml (craft-measure, "Your own charts"), or this token may not view their entities.');
5612
+ return;
5613
+ }
5614
+ for (const c of charts)
5615
+ out.print(` ${c.key.padEnd(24)} ${(pickLabel(c.label) ?? c.key).padEnd(32)} ${c.show} · ${c.entity}`);
5616
+ out.print('\n`octwin charts <key>` draws one.');
5617
+ return;
5618
+ }
5619
+ const q = new URLSearchParams();
5620
+ if (typeof flags.from === 'string')
5621
+ q.set('from', flags.from);
5622
+ if (typeof flags.to === 'string')
5623
+ q.set('to', flags.to);
5624
+ const qs = q.toString() ? `?${q}` : '';
5625
+ if (!asJson)
5626
+ out.print(`→ Reading chart '${key}' from ${targetLabel(t)} …`);
5627
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(key)}${qs}`, t);
5628
+ if (status !== 200)
5629
+ die(`could not read chart '${key}' (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5630
+ if (asJson) {
5631
+ out.result(json);
5632
+ return;
5633
+ }
5634
+ if (json?.has_data !== true) {
5635
+ out.print(`No figure for '${key}' — its entity has no records in this window, or this token may not view it.`);
5636
+ return;
5637
+ }
5638
+ const c = json.chart;
5639
+ out.print(`${pickLabel(c.label) ?? c.key} ${json.range?.from ?? '?'} → ${json.range?.to ?? '?'}`);
5640
+ if (c.value !== undefined)
5641
+ out.print(` ${c.value ?? '—'}${c.ratio ? ` (${c.ratio.of ?? '—'} ÷ ${c.ratio.per ?? '—'})` : ''}`);
5642
+ for (const p of c.parts ?? [])
5643
+ out.print(` ${String(pickLabel(p.label) ?? p.key).padEnd(28)} ${String(p.value ?? '—').padStart(10)} n=${p.count}`);
5644
+ if (c.cut)
5645
+ out.print(` … top ${c.cut.kept} of ${c.cut.groups}${c.cut.other ? `; the rest: ${c.cut.other.value ?? '—'} (n=${c.cut.other.count})` : ''}`);
5646
+ for (const p of c.points ?? [])
5647
+ out.print(` ${String(p.bucket).padEnd(12)} ${String(p.value ?? '—').padStart(10)} n=${p.count}`);
5648
+ }
5649
+ /**
5650
+ * `octwin handoff [<id>] [--bucket waiting|mine|all|ended] [--limit n] [--offset n] [--json]` — the
5651
+ * Live chat desk, READ-ONLY: the handoffs in a bucket, or one with its transcript (who said what —
5652
+ * customer, AI Agent, operator, platform). Needs `work:read` and the `live_chat` plan feature.
5653
+ *
5654
+ * Accepting, answering and returning a handoff stay in the console: those routes need a signed-in
5655
+ * PERSON to hold the conversation (`accepting needs a signed-in person`), and a token is not one.
5656
+ */
5657
+ async function cmdHandoff(flags) {
5658
+ const t = resolveTarget(flags);
5659
+ const { url } = t;
5660
+ const base = `${url}/api/self/p/handoff`;
5661
+ const id = flags._[0];
5662
+ const asJson = flags.json === true;
5663
+ if (!id) {
5664
+ const q = new URLSearchParams();
5665
+ if (typeof flags.bucket === 'string')
5666
+ q.set('bucket', flags.bucket);
5667
+ if (flags.limit !== undefined)
5668
+ q.set('limit', String(flags.limit));
5669
+ if (flags.offset !== undefined)
5670
+ q.set('offset', String(flags.offset));
5671
+ const qs = q.toString() ? `?${q}` : '';
5672
+ if (!asJson)
5673
+ out.print(`→ Reading the Live chat desk from ${targetLabel(t)} …`);
5674
+ const { status, json } = await apiGet(`${base}${qs}`, t);
5675
+ if (status !== 200)
5676
+ die(`could not read the Live chat desk (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5677
+ if (asJson) {
5678
+ out.result(json);
5679
+ return;
5680
+ }
5681
+ if (json?.has_data === false) {
5682
+ out.print(`No Live chat on this project — ${json?.reason ?? 'the pack works no handoff'} (craft-escalation, the Handoff recipe).`);
5683
+ return;
5684
+ }
5685
+ const rows = (json?.rows ?? []);
5686
+ if (rows.length === 0) {
5687
+ out.print('No handoffs in this view (--bucket waiting|mine|all|ended).');
5688
+ return;
5689
+ }
5690
+ for (const r of rows) {
5691
+ const who = r.contact?.display_name ?? r.contact?.channel_contact_handle ?? '—';
5692
+ out.print(` #${String(r.handoff_number).padEnd(5)} ${r.stage.padEnd(10)} ${String(r.channel ?? '—').padEnd(9)} ${(pickLabel(r.queue?.name) ?? r.queue_key ?? '—').padEnd(18)} ${who} ${r.id}`);
5693
+ }
5694
+ out.print('\n`octwin handoff <id>` shows one with its transcript.');
5695
+ return;
5696
+ }
5697
+ if (!asJson)
5698
+ out.print(`→ Reading handoff ${id} from ${targetLabel(t)} …`);
5699
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(id)}`, t);
5700
+ if (status !== 200)
5701
+ die(`could not read handoff ${id} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5702
+ if (asJson) {
5703
+ out.result(json);
5704
+ return;
5705
+ }
5706
+ const h = json?.handoff ?? {};
5707
+ out.print(`Handoff #${h.handoff_number ?? '?'} — ${h.stage ?? '?'}${h.queue_key ? ` · queue ${h.queue_key}` : ''}${h.reason ? ` · ${h.reason}` : ''}`);
5708
+ if (h.summary)
5709
+ out.print(` ${h.summary}`);
5710
+ for (const line of (json?.transcript ?? [])) {
5711
+ out.print(` ${String(line.ts).slice(11, 16)} ${line.role === 'operator' && line.author ? `operator: ${line.author}` : line.role} ${line.body}`);
5712
+ }
5713
+ if (json?.has_more)
5714
+ out.print(' … earlier lines in the console (Live chat).');
5715
+ }
5716
+ /**
5717
+ * `octwin surveys [<key>] [--limit n] [--offset n] [--json]` — the declared surveys with their
5718
+ * standing (responses, the headline score under its model), or one survey: its questions, the
5719
+ * rollup and the recent responses. The same reads as the console's Surveys page. Needs `records:read`.
5720
+ */
5721
+ async function cmdSurveys(flags) {
5722
+ const t = resolveTarget(flags);
5723
+ const { url } = t;
5724
+ const base = `${url}/api/self/p/surveys`;
5725
+ const key = flags._[0];
5726
+ const asJson = flags.json === true;
5727
+ const score = (s) => s?.value == null ? '—' : `${s.value}${s.unit === 'pct' ? '%' : ''} (${s.model})`;
5728
+ if (!key) {
5729
+ if (!asJson)
5730
+ out.print(`→ Reading surveys from ${targetLabel(t)} …`);
5731
+ const { status, json } = await apiGet(`${base}`, t);
5732
+ if (status !== 200)
5733
+ die(`could not read surveys (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5734
+ if (asJson) {
5735
+ out.result(json);
5736
+ return;
5737
+ }
5738
+ const rows = (json?.rows ?? []);
5739
+ if (rows.length === 0) {
5740
+ out.print('No surveys — a pack declares them in surveys.yaml (craft-surveys).');
5741
+ return;
5742
+ }
5743
+ for (const r of rows)
5744
+ out.print(` ${r.key.padEnd(22)} ${(pickLabel(r.label) ?? r.key).padEnd(30)} responses ${String(r.responses ?? '—').padStart(5)} score ${score(r.score)}`);
5745
+ out.print('\n`octwin surveys <key>` shows one — its questions, rollup and recent answers.');
5746
+ return;
5747
+ }
5748
+ const q = new URLSearchParams();
5749
+ if (flags.limit !== undefined)
5750
+ q.set('limit', String(flags.limit));
5751
+ if (flags.offset !== undefined)
5752
+ q.set('offset', String(flags.offset));
5753
+ const qs = q.toString() ? `?${q}` : '';
5754
+ if (!asJson)
5755
+ out.print(`→ Reading survey '${key}' from ${targetLabel(t)} …`);
5756
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(key)}${qs}`, t);
5757
+ if (status !== 200)
5758
+ die(`could not read survey '${key}' (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5759
+ if (asJson) {
5760
+ out.result(json);
5761
+ return;
5762
+ }
5763
+ const sv = json?.survey ?? {};
5764
+ const a = json?.analytics;
5765
+ out.print(`${pickLabel(sv.label) ?? key}${sv.subject ? ` about: ${sv.subject}` : ''}`);
5766
+ for (const qq of sv.questions ?? [])
5767
+ out.print(` • ${qq.key} (${qq.type}${qq.required ? ', required' : ''})`);
5768
+ out.print(a ? ` responses ${a.responses} · scored ${a.scored} · score ${score(a.score)}` : ' rollup unavailable — the pack has not loaded on this project');
5769
+ const rows = (json?.responses?.rows ?? []);
5770
+ for (const r of rows)
5771
+ out.print(` ${String(r.created_at).slice(0, 16)} score ${r.score ?? '—'} ${JSON.stringify(r.answers)}`);
5772
+ }
5773
+ /**
5774
+ * `--range <days>` and `--channel <whatsapp|web>` as the route's own `?range=` / `?channel=` — the
5775
+ * console's two controls. Until 2026-10-11 the CLI read only `--detail`, so `--channel web` printed
5776
+ * the all-channel band as if it were the web's (an unknown flag was ignored then, not refused).
5777
+ */
5778
+ function performanceQuery(flags) {
5779
+ const p = new URLSearchParams();
5780
+ if (flags.range !== undefined) {
5781
+ const days = Number(flags.range);
5782
+ if (!Number.isInteger(days) || days <= 0)
5783
+ die('--range takes a whole number of days, e.g. --range 30');
5784
+ p.set('range', String(days));
5785
+ }
5786
+ if (flags.channel !== undefined) {
5787
+ if (typeof flags.channel !== 'string')
5788
+ die('--channel takes a channel name, e.g. --channel whatsapp');
5789
+ p.set('channel', flags.channel);
5790
+ }
5791
+ const s = p.toString();
5792
+ return s ? `?${s}` : '';
5793
+ }
5794
+ /**
5795
+ * `octwin performance [--detail] [--range <days>] [--channel <ch>] [--json]` — the indicators the
5796
+ * project's own declarations produce: value, conversion, duration, per journey.
5339
5797
  *
5340
5798
  * Needs `records:read` — **not** a `performance:*` scope, which does not exist.
5341
5799
  * That means the Read-only token preset already reaches this.
@@ -5351,9 +5809,10 @@ async function cmdPerformance(flags) {
5351
5809
  // Two explicit calls rather than `apiGet(detail ? … : base)`: the route guard's
5352
5810
  // extractor only reads a template literal in the FIRST argument position, so a
5353
5811
  // ternary hides both URLs from it.
5812
+ const qs = performanceQuery(flags);
5354
5813
  const { status, json } = detail
5355
- ? await apiGet(`${base}/detail`, t)
5356
- : await apiGet(`${base}`, t);
5814
+ ? await apiGet(`${base}/detail${qs}`, t)
5815
+ : await apiGet(`${base}${qs}`, t);
5357
5816
  if (status !== 200)
5358
5817
  die(`could not read performance (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5359
5818
  if (asJson) {
@@ -5373,14 +5832,24 @@ async function cmdPerformance(flags) {
5373
5832
  out.print(' (none)');
5374
5833
  for (const i of inds) {
5375
5834
  const unit = i.unit === 'pct' ? '%' : '';
5376
- // `delta_pct` is signed and against the PREVIOUS window — sign it explicitly so
5377
- // a fall is never read as a rise.
5378
- const delta = i.delta_pct == null ? '' : ` ${i.delta_pct >= 0 ? '+' : ''}${i.delta_pct}% vs prev`;
5835
+ // `change` is the band's ONE change rule, already derived by the platform: a relative % for
5836
+ // a count, money or duration, POINTS for a rate, score or NPS (a % of a % misleads), and
5837
+ // signed the way the number moved — sign it explicitly so a fall is never read as a rise.
5838
+ const c = i.change ?? { kind: 'none' };
5839
+ const sign = (n) => `${n > 0 ? '+' : ''}${n}`;
5840
+ const delta = c.kind === 'relative' ? ` ${sign(c.pct)}% vs prev`
5841
+ : c.kind === 'points' ? ` ${sign(c.delta)}${i.unit === 'pct' ? ' pts' : ''} vs prev`
5842
+ : c.kind === 'from_zero' ? ' new vs prev'
5843
+ : c.kind === 'flat' ? ' no change vs prev'
5844
+ : '';
5379
5845
  const frac = i.numerator != null && i.denominator != null
5380
5846
  ? ` (${i.numerator}/${i.denominator}${i.denominator_of ? ` ${i.denominator_of}` : ''})`
5381
5847
  : '';
5848
+ // A band narrowed to one channel says so per figure: one that cannot be narrowed (capacity) is
5849
+ // the all-channel figure, and the line says it rather than letting it pass as the channel's.
5850
+ const scope = i.channel?.kind === 'not_narrowable' ? ' (all channels)' : '';
5382
5851
  out.print(` ${String(pickLabel(i.heading) ?? i.kind).padEnd(16)} ${String(pickLabel(i.label) ?? '').padEnd(20)}`
5383
- + ` ${String(i.value ?? '—').padStart(9)}${unit}${delta}${frac}`);
5852
+ + ` ${String(i.value ?? '—').padStart(9)}${unit}${delta}${frac}${scope}`);
5384
5853
  if (i.why)
5385
5854
  out.print(` why: ${i.why}`);
5386
5855
  if (i.biggest_dropoff)
@@ -5456,427 +5925,461 @@ async function cmdUsage(flags) {
5456
5925
  out.print('\nThis is MODEL spend. WhatsApp/Meta message billing is operator-only — not reachable by an API token.');
5457
5926
  }
5458
5927
  function help() {
5459
- out.print(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
5460
-
5461
- octwin --version # print the CLI version (+ any upgrade notice)
5462
- octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
5463
- octwin validate [--dir .] [--remote | --offline] [--require-kb] # the platform's FULL check by default — no login needed
5464
- octwin login --url <platformUrl> # approve in a browser; the CLI collects the token
5465
- octwin login --url <platformUrl> --token oct_… # or paste a deploy token from the console
5466
- octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
5467
- octwin projects [--archived] [--json] # the --project slugs this token can name
5468
- octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
5469
- [--request-listing | --withdraw-listing] # public marketplace — opt-in, see: octwin help deploy
5470
- octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
5471
- octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
5472
- octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
5473
- octwin work [recordId] [--queues] [--unrouted] [--json] # inspect the work inbox (worked records) + timelines
5474
- # --queues: per-queue open counts + an UNROUTED warning · --unrouted: only the items in no queue
5475
- octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
5476
- octwin chat "message" [--as <handle>] [--tap <tap-id>] [--press <label>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
5477
- octwin media generate "<prompt>" [--out <file.png>] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
5478
- octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
5479
- octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
5480
- octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
5481
- octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
5482
- octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
5483
- octwin automation [campaigns] [--json] # the jobs your declarations produced + health, last result each
5484
- octwin integrations [--json] # declared connections BESIDE what is configured (the silent-never-fires check)
5485
- octwin integrations deliveries [<id>] | events # the outbound delivery log / inbound events
5486
- octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition] [--stage <id>]
5487
- octwin performance [--detail] [--json] # the project's business indicators (value/conversion/duration)
5488
- octwin usage [--json] # model calls, tokens and cost (project if pinned, else workspace)
5489
- octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
5490
- octwin test [--dir .] # = validate --remote (the full platform check)
5491
- octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
5492
- octwin memos [--all] [--json] # read the platform's replies + notices (a reply to your feedback lands here)
5493
-
5494
- Writes — exercise the state your pack creates (each needs the matching :write scope):
5495
- octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…", archive|restore <id>
5496
- octwin records tasks | task complete <taskId> [--outcome done|cancelled]
5497
- octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
5498
- octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
5499
- octwin orders transition <ref> --to <status> | refund <ref> --force
5500
- octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
5501
- octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
5502
- octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
5503
- octwin automation run <jobId> | pause <jobId> | resume <jobId> | send <campaignId>
5504
- octwin integrations test <key> # a LIVE call to the connection's health: operation
5505
- octwin integrations retry|cancel|send-now <deliveryId>
5506
- (octwin integrations preflight <key> needs only integrations:read — it makes no call)
5507
-
5508
- Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
5509
- \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
5510
- button/row with \`--tap "<tap-id>"\` (chat prints every tap id), or by its label with
5511
- \`--press "<label>"\` — the way through a form or checkout, whose ids change.
5512
- Signing in: \`octwin login --url <u>\` prints a link + code to approve in a browser. To pick your own
5513
- scopes instead, mint a token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
5514
- octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
5515
- Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
5928
+ out.print(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
5929
+
5930
+ octwin --version # print the CLI version (+ any upgrade notice)
5931
+ octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
5932
+ octwin recipe [<module>] [--dir .] [--dry-run] # a KB recipe's starter files into this pack (no module = the list)
5933
+ octwin validate [--dir .] [--remote | --offline] [--require-kb] [--strict-primitives] # the platform's FULL check by default — no login needed
5934
+ octwin login --url <platformUrl> # approve in a browser; the CLI collects the token
5935
+ octwin login --url <platformUrl> --token oct_… # or paste a deploy token from the console
5936
+ octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
5937
+ octwin projects [--archived] [--json] # the --project slugs this token can name
5938
+ octwin projects create "<name>" [--slug s] [--pack p] | rm <slug> [--yes] # a disposable project (needs projects:write)
5939
+ octwin seed [--reset] [--pack <packId>] # apply the pack's demo data without redeploying
5940
+ octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed] [--force]
5941
+ [--request-listing | --withdraw-listing] # public marketplace — opt-in, see: octwin help deploy
5942
+ octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
5943
+ octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
5944
+ octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
5945
+ octwin work [recordId] [--queues] [--unrouted] [--json] # inspect the work inbox (worked records) + timelines
5946
+ # --queues: per-queue open counts + an UNROUTED warning · --unrouted: only the items in no queue
5947
+ octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
5948
+ octwin chat "message" [--as <handle>] [--tap <tap-id>] [--press <label>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
5949
+ octwin media generate "<prompt>" [--out <file.png>] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
5950
+ octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
5951
+ octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
5952
+ octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
5953
+ octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
5954
+ octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
5955
+ octwin automation [campaigns] [--json] # the jobs your declarations produced + health, last result each
5956
+ octwin integrations [--json] # declared connections BESIDE what is configured (the silent-never-fires check)
5957
+ octwin integrations deliveries [<id>] | events # the outbound delivery log / inbound events
5958
+ octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition] [--stage <id>]
5959
+ octwin performance [--detail] [--range <days>] [--channel <ch>] [--json] # the project's business indicators (value/conversion/duration)
5960
+ octwin usage [--json] # model calls, tokens and cost (project if pinned, else workspace)
5961
+ octwin charts [<key>] [--from d] [--to d] [--json] # the pack's own charts: — the list, or one chart's figure
5962
+ octwin handoff [<id>] [--bucket waiting|mine|all|ended] [--limit n] [--offset n] [--json] # the Live chat desk, read-only
5963
+ octwin surveys [<key>] [--limit n] [--offset n] [--json] # surveys with their score; one survey's questions, rollup, answers
5964
+ octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
5965
+ octwin test [--dir .] [--require-kb] [--strict-primitives] # = validate --remote (the full platform check)
5966
+ octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
5967
+ octwin memos [--all] [--json] # read the platform's replies + notices (a reply to your feedback lands here)
5968
+
5969
+ Writes — exercise the state your pack creates (each needs the matching :write scope):
5970
+ octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…", archive|restore <id>
5971
+ octwin records tasks | task complete <taskId> [--outcome done|cancelled]
5972
+ octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
5973
+ octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
5974
+ octwin orders transition <ref> --to <status> | refund <ref> --force
5975
+ octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
5976
+ octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
5977
+ octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
5978
+ octwin automation run <jobId> | pause <jobId> | resume <jobId> | send <campaignId>
5979
+ octwin integrations test <key> # a LIVE call to the connection's health: operation
5980
+ octwin integrations retry|cancel|send-now <deliveryId>
5981
+ (octwin integrations preflight <key> needs only integrations:read — it makes no call)
5982
+
5983
+ Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
5984
+ \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
5985
+ button/row with \`--tap "<tap-id>"\` (chat prints every tap id), or by its label with
5986
+ \`--press "<label>"\` — the way through a form or checkout, whose ids change.
5987
+ Signing in: \`octwin login --url <u>\` prints a link + code to approve in a browser. To pick your own
5988
+ scopes instead, mint a token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
5989
+ octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
5990
+ Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
5516
5991
  Per-command usage: octwin <command> --help`);
5517
5992
  }
5518
5993
  /** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
5519
5994
  * network/auth work (a --help that 401s is worse than no help at all). */
5520
5995
  const COMMAND_HELP = {
5521
- init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
5996
+ init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
5522
5997
  Scaffold a pure-YAML starter pack into <dir>.`,
5523
- validate: `octwin validate [--dir .] [--remote | --offline] [--require-kb] [--strict-primitives]
5524
- First the fast OFFLINE checks (structure, YAML, and the checks driven by the
5525
- pulled capability reference — those SKIP when the reference is missing, and
5526
- --require-kb turns the skip into a failure for CI). Then, BY DEFAULT, the
5527
- platform's FULL validation — the same check deploy runs, all errors at once.
5528
-
5529
- The full check needs NO login: without a token + --project it goes to the
5530
- platform's public validate route. With a token AND --project (or PACK_PROJECT)
5531
- it goes through your workspace, which also confirms that project exists.
5532
-
5533
- When the platform cannot be reached (no url, offline, rate-limited) the run
5534
- ends with "only the OFFLINE check ran — a PARTIAL check" and exits 0.
5535
- --remote REQUIRES the full check: not reaching it is an error. --offline runs
5536
- only the partial subset. --strict-primitives additionally type-checks LITERAL
5537
- args: values against each primitive's declared input schema; expression
5998
+ validate: `octwin validate [--dir .] [--remote | --offline] [--require-kb] [--strict-primitives]
5999
+ First the fast OFFLINE checks (structure, YAML, and the checks driven by the
6000
+ pulled capability reference — those SKIP when the reference is missing, and
6001
+ --require-kb turns the skip into a failure for CI). Then, BY DEFAULT, the
6002
+ platform's FULL validation — the same check deploy runs, all errors at once.
6003
+
6004
+ The full check needs NO login: without a token + --project it goes to the
6005
+ platform's public validate route. With a token AND --project (or PACK_PROJECT)
6006
+ it goes through your workspace, which also confirms that project exists.
6007
+
6008
+ When the platform cannot be reached (no url, offline, rate-limited) the run
6009
+ ends with "only the OFFLINE check ran — a PARTIAL check" and exits 0.
6010
+ --remote REQUIRES the full check: not reaching it is an error. --offline runs
6011
+ only the partial subset. --strict-primitives additionally type-checks LITERAL
6012
+ args: values against each primitive's declared input schema; expression
5538
6013
  strings ('$found.id', '{$t(…)}') are always exempt.`,
5539
- login: `octwin login --url <platformUrl> [--token oct_…] [--wait <seconds>] [--json]
5540
- Sign in to a platform and make that url the DEFAULT deploy target for every
5541
- later command.
5542
-
5543
- WITH --token: saves a token you minted yourself (console → Settings → API
5544
- tokens). Unchanged, and still the right answer for CI — there, skip login and
5545
- set PACK_PLATFORM_URL + PACK_TOKEN instead.
5546
-
5547
- WITHOUT --token: prints a link to approve in a browser. A workspace admin opens
5548
- it, checks which machine is asking, and clicks Approve; the CLI then collects a
5549
- 30-day token by itself, so no token is ever pasted between people. The code is
5550
- printed too — it is what the page shows, so you can tell it is your own request.
5551
- Approve only a link you opened because YOU ran this command: a link someone
5552
- sends you would connect THEIR machine to your workspace.
5553
-
5554
- The wait is bounded (90s by default) and RESUMABLE: if nobody has approved yet
5555
- it exits 75 and keeps the request, so running \`octwin login\` again picks up the
6014
+ login: `octwin login --url <platformUrl> [--token oct_…] [--wait <seconds>] [--json]
6015
+ Sign in to a platform and make that url the DEFAULT deploy target for every
6016
+ later command.
6017
+
6018
+ WITH --token: saves a token you minted yourself (console → Settings → API
6019
+ tokens). Unchanged, and still the right answer for CI — there, skip login and
6020
+ set PACK_PLATFORM_URL + PACK_TOKEN instead.
6021
+
6022
+ WITHOUT --token: prints a link to approve in a browser. A workspace admin opens
6023
+ it, checks which machine is asking, and clicks Approve; the CLI then collects a
6024
+ 30-day token by itself, so no token is ever pasted between people. The code is
6025
+ printed too — it is what the page shows, so you can tell it is your own request.
6026
+ Approve only a link you opened because YOU ran this command: a link someone
6027
+ sends you would connect THEIR machine to your workspace.
6028
+
6029
+ The wait is bounded (90s by default) and RESUMABLE: if nobody has approved yet
6030
+ it exits 75 and keeps the request, so running \`octwin login\` again picks up the
5556
6031
  same code. --wait 0 prints the code and returns at once.`,
5557
- whoami: `octwin whoami [--url <url>] [--tenant <slug>]
6032
+ whoami: `octwin whoami [--url <url>] [--tenant <slug>]
5558
6033
  Verify the resolved token authenticates against the tenant.`,
5559
- projects: `octwin projects [--archived] [--json]
5560
- List the workspace's projects — the slugs every --project flag takes, with the
5561
- plan's project cap. --archived includes archived ones. A pack:deploy token
5562
- reaches this (it names a project in every other command).
5563
-
5564
- octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
5565
- Create a project. The URL slug is derived from the name unless --slug pins one.
5566
- --pack installs an ALREADY-published pack; the usual next step is instead
5567
- \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
5568
-
5569
- octwin projects rm <slug> [--yes]
5570
- HARD delete — the project and everything cascading from it (conversations,
5571
- contacts, records, installs). No undo, and not the same as archiving.
5572
- WITHOUT --yes it only previews what would be destroyed, so the dry run is the
5573
- default. Together these make a disposable end-to-end environment:
5574
- octwin projects create "Scratch" && octwin deploy --project scratch --seed
5575
- octwin chat "hi" --project scratch
5576
- octwin projects rm scratch --yes
6034
+ projects: `octwin projects [--archived] [--json]
6035
+ List the workspace's projects — the slugs every --project flag takes, with the
6036
+ plan's project cap. --archived includes archived ones. A pack:deploy token
6037
+ reaches this (it names a project in every other command).
6038
+
6039
+ octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
6040
+ Create a project. The URL slug is derived from the name unless --slug pins one.
6041
+ --pack installs an ALREADY-published pack; the usual next step is instead
6042
+ \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
6043
+
6044
+ octwin projects rm <slug> [--yes]
6045
+ HARD delete — the project and everything cascading from it (conversations,
6046
+ contacts, records, installs). No undo, and not the same as archiving.
6047
+ WITHOUT --yes it only previews what would be destroyed, so the dry run is the
6048
+ default. Together these make a disposable end-to-end environment:
6049
+ octwin projects create "Scratch" && octwin deploy --project scratch --seed
6050
+ octwin chat "hi" --project scratch
6051
+ octwin projects rm scratch --yes
5577
6052
  Both verbs need the \`projects:write\` scope — a pack:deploy token does NOT confer it.`,
5578
- deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
5579
- [--request-listing | --withdraw-listing]
5580
- Upload the pack bundle, validate server-side, install onto the project.
5581
- --seed additionally applies the pack's demo seed (streams progress).
5582
-
5583
- A plain deploy says NOTHING about the public marketplace — it is a test loop, so it
5584
- neither asks for a listing nor gives one up. The marketplace flags are opt-in:
5585
-
5586
- --request-listing ask an operator to review this pack for the public marketplace
5587
- (the pre-signup storefront at /packs). Requires 'public: true'
5588
- under 'listing:' in manifest.yaml — the manifest states that the
5589
- pack is a product, the flag is you choosing to ask.
5590
- --withdraw-listing retract the request, including an approved listing.
5591
-
5592
- An approval covers the CONTENT it was made against, so a later deploy that changes the
6053
+ deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed] [--force]
6054
+ [--request-listing | --withdraw-listing]
6055
+ Upload the pack bundle, validate server-side, install onto the project.
6056
+ --seed additionally applies the pack's demo seed (streams progress).
6057
+
6058
+ A deploy sends what this copy was made from (${BASE_FILE}, written by every pull and
6059
+ deploy). When someone published since — Tavi in the console, another deploy, a repo
6060
+ import — it is refused and NOTHING changes: run 'octwin pull <packId>', which updates
6061
+ this folder (it stops if you changed a file since, so you can save your edit first),
6062
+ then deploy again. A version lower than the live one is refused the same way.
6063
+ --force deploy anyway, replacing the newer or higher live version.
6064
+
6065
+ A plain deploy says NOTHING about the public marketplace — it is a test loop, so it
6066
+ neither asks for a listing nor gives one up. The marketplace flags are opt-in:
6067
+
6068
+ --request-listing ask an operator to review this pack for the public marketplace
6069
+ (the pre-signup storefront at /packs). Requires 'public: true'
6070
+ under 'listing:' in manifest.yaml — the manifest states that the
6071
+ pack is a product, the flag is you choosing to ask.
6072
+ --withdraw-listing retract the request, including an approved listing.
6073
+
6074
+ An approval covers the CONTENT it was made against, so a later deploy that changes the
5593
6075
  pack returns it to the review queue on its own — no flag needed, and the CLI says so.`,
5594
- seed: `octwin seed [--pack <packId>] [--reset]
5595
- Apply the pack's demo/reference data to the project it is installed on, without
5596
- redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
5597
- and the demo operator topology. Reports what each kind produced.
5598
- Idempotent and safe to re-run — each demo row is found again by its handle (its
5599
- \`as:\`, natural key or title) and updated, and existing media is REUSED rather
5600
- than regenerated, so a second pass costs nothing. Demo rows are DATA: no \`on:\`
5601
- reaction fires, and a row takes its \`stage:\` as written.
5602
- --reset archive every row the pack's demo loaded, then load it again. Needed once
5603
- for demo rows seeded before 2026-10-05 (the seed says so), and a clean
5604
- slate whenever you want one. Records your customers made are not touched.
6076
+ seed: `octwin seed [--pack <packId>] [--reset]
6077
+ Apply the pack's demo/reference data to the project it is installed on, without
6078
+ redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
6079
+ and the demo operator topology. Reports what each kind produced.
6080
+ Idempotent and safe to re-run — each demo row is found again by its handle (its
6081
+ \`as:\`, natural key or title) and updated, and existing media is REUSED rather
6082
+ than regenerated, so a second pass costs nothing. Demo rows are DATA: no \`on:\`
6083
+ reaction fires, and a row takes its \`stage:\` as written.
6084
+ --reset archive every row the pack's demo loaded, then load it again. Needed once
6085
+ for demo rows seeded before 2026-10-05 (the seed says so), and a clean
6086
+ slate whenever you want one. Records your customers made are not touched.
5605
6087
  --pack is only needed when a project somehow runs more than one.`,
5606
- status: `octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
5607
- Show installed vs live version + the flow list for this pack.
5608
- The pack id is read from manifest.yaml and QUALIFIED with your workspace slug
5609
- (a manifest declares a bare name; the owner is attached when you publish). Pass
5610
- <packId> explicitly to skip that lookup — \`octwin agents\` and \`octwin projects\`
6088
+ status: `octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
6089
+ Show installed vs live version + the flow list for this pack, and whether your copy
6090
+ (${BASE_FILE}) is still the live content — if not, pull before you deploy.
6091
+ The pack id is read from manifest.yaml and QUALIFIED with your workspace slug
6092
+ (a manifest declares a bare name; the owner is attached when you publish). Pass
6093
+ <packId> explicitly to skip that lookup — \`octwin agents\` and \`octwin projects\`
5611
6094
  both print the qualified form.`,
5612
- records: `octwin records [entity] [id] [--limit 50] [--offset n] [--json]
5613
- Inspect the pack's XRM data. No args = list entities. Worked records (cases,
5614
- tickets, anything routed to a queue) read best through \`octwin work\`.
5615
-
5616
- SEARCH:
5617
- octwin records <entity> --search "<words>" # ranked, best match first, each row with its score (0–1)
5618
- Works for an entity that declares \`search:\` in xrm.yaml (fuzzy, plus semantic when declared) —
5619
- the same search the console's search box runs. Any other entity answers "not searchable".
5620
-
5621
- WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
5622
- octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
5623
- octwin records patch <recordId> --entity <entity> --set field=value
5624
- octwin records stage <recordId> --to <stage> [--note "..."]
5625
- octwin records note <recordId> "the note text"
5626
- octwin records archive <recordId> # out of every list and count (test data too)
5627
- octwin records restore <recordId> # undo an archive
5628
- octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
5629
- octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
5630
-
5631
- There is NO delete — on the platform or here. \`archive\` is how a record leaves use:
5632
- it fires the entity's \`on.archived\` reactions, exactly like the console's Archive,
5633
- and \`restore\` brings it back.
5634
-
5635
- --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
5636
- sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
5637
- \`patch\` needs --entity even though it has an id: the route resolves the field
5638
- validator from it. A leading \`create/patch/stage/note/archive/restore/tasks/task\` is
6095
+ records: `octwin records [entity] [id] [--limit 50] [--offset n] [--json]
6096
+ Inspect the pack's XRM data. No args = list entities. Worked records (cases,
6097
+ tickets, anything routed to a queue) read best through \`octwin work\`.
6098
+
6099
+ SEARCH:
6100
+ octwin records <entity> --search "<words>" # ranked, best match first, each row with its score (0–1)
6101
+ Works for an entity that declares \`search:\` in xrm.yaml (fuzzy, plus semantic when declared) —
6102
+ the same search the console's search box runs. Any other entity answers "not searchable".
6103
+
6104
+ WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
6105
+ octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
6106
+ octwin records patch <recordId> --entity <entity> --set field=value
6107
+ octwin records stage <recordId> --to <stage> [--note "..."]
6108
+ octwin records note <recordId> "the note text"
6109
+ octwin records archive <recordId> # out of every list and count (test data too)
6110
+ octwin records restore <recordId> # undo an archive
6111
+ octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
6112
+ octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
6113
+
6114
+ There is NO delete — on the platform or here. \`archive\` is how a record leaves use:
6115
+ it fires the entity's \`on.archived\` reactions, exactly like the console's Archive,
6116
+ and \`restore\` brings it back.
6117
+
6118
+ --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
6119
+ sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
6120
+ \`patch\` needs --entity even though it has an id: the route resolves the field
6121
+ validator from it. A leading \`create/patch/stage/note/archive/restore/tasks/task\` is
5639
6122
  read as a VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
5640
- work: `octwin work [recordId] [--queues] [--unrouted] [--limit 50] [--offset n] [--json]
5641
- Inspect the work inbox — every entity the pack declares worked (cases, orders
5642
- needing review, applications, …): the inbox, one item + its timeline
5643
- (+ applicable actions), or --queues for queue keys + open counts.
5644
-
5645
- WRITES (need \`work:write\`; \`stage\` needs \`records:write\`):
5646
- octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none
5647
- octwin work note <recordId> "the note text"
5648
- octwin work stage <recordId> --to <stage> [--note "..."]
5649
- octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]
5650
-
5651
- \`decide\` applies one of the entity's declared operator actions — \`octwin work <id>\`
5652
- lists them with their params. --dry-run previews the customer-facing copy and the
5653
- resulting stage WITHOUT committing (that route needs only \`work:read\`).
6123
+ work: `octwin work [recordId] [--queues] [--unrouted] [--limit 50] [--offset n] [--json]
6124
+ Inspect the work inbox — every entity the pack declares worked (cases, orders
6125
+ needing review, applications, …): the inbox, one item + its timeline
6126
+ (+ applicable actions), or --queues for queue keys + open counts.
6127
+
6128
+ WRITES (need \`work:write\`; \`stage\` needs \`records:write\`):
6129
+ octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none
6130
+ octwin work note <recordId> "the note text"
6131
+ octwin work stage <recordId> --to <stage> [--note "..."]
6132
+ octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]
6133
+
6134
+ \`decide\` applies one of the entity's declared operator actions — \`octwin work <id>\`
6135
+ lists them with their params. --dry-run previews the customer-facing copy and the
6136
+ resulting stage WITHOUT committing (that route needs only \`work:read\`).
5654
6137
  \`stage\` is the XRM records verb (one transition spelling platform-wide).`,
5655
- logs: `octwin logs [conversationId] [--as <handle>] [--json]
5656
- No id = recent conversations (handle, status, last activity; --as filters).
5657
- With id = the full event timeline including what each turn rendered.
6138
+ logs: `octwin logs [conversationId] [--as <handle>] [--json]
6139
+ No id = recent conversations (handle, status, last activity; --as filters).
6140
+ With id = the full event timeline including what each turn rendered.
5658
6141
  --json = raw events (verbatim payloads).`,
5659
- pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
5660
- Write a DEPLOYED pack's source back to disk — the inverse of deploy.
5661
- A pack pushed with 'octwin deploy' lives on the platform as an artifact the
5662
- runtime serves but nothing hands back, so its only source copy is the machine
5663
- that pushed it. Pull it, fix it, redeploy it.
5664
- Defaults to the version installed on the target project; --version overrides.
5665
- --dir defaults to ./<packId>; a non-empty dir needs --force.
5666
- The pulled dir redeploys where it came from — the target is your saved login.
5667
- You may pull a pack your tenant OWNS (deployed); an operator token pulls any.`,
5668
- chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--press <label>] [--media <file|id>] [--json]
5669
- octwin chat --script <file> [--as <handle>] [--json]
5670
- Drive ONE turn through the dev web channel and print every render with its
5671
- tap ids. Same --as handle = same conversation (multi-turn works).
5672
- --tap presses a rendered button/list row instead of sending text.
5673
- --press does the same BY ITS LABEL, in the reply the conversation is waiting on
5674
- — for a form or checkout step, whose tap id carries a run id that changes.
5675
- --media uploads a local file (or a media id from 'media generate --json') as
5676
- an image/document/audio inbound — any "message" rides as its caption; feeds a
5677
- running media-collect flow (e.g. activate-app).
5678
- --json dumps the raw SSE envelopes for the turn.
5679
-
5680
- Each reply line says who sent it. Plain \`← …\` is the AI Agent. \`← [notify] …\` is a
5681
- reminder or notification (a \`schedule_notify\`, a reaction) — NOT the turn's answer,
5682
- so the wait for the real reply goes on. \`← [operator: Name] …\` is a person in
5683
- Live chat; \`← [platform] …\` is a platform line (a handoff joining or returning).
5684
- After the last turn the stream stays open ${LATE_DRAIN_MS / 1000}s more, so a late message
5685
- still prints (marked "after the last turn") instead of reaching a closed connection.
5686
-
5687
- --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
5688
- process over one connection — waiting for each turn to settle before sending
5689
- the next. Use this for any multi-step flow: chaining shell invocations races
5690
- the agent loop, because a turn ends on a quiet gap that can arrive while the
5691
- server is still working (the symptom is placeholder-filled fields or a second
5692
- workflow run). Blank lines and # comments are skipped:
5693
-
5694
- # book an appointment end to end
5695
- احجز موعد
5696
- tap:t:invoke:book-appointment:doctor_id=D1
5697
- media:./licence.jpg | here is my licence
5698
- press:Confirm
5699
-
5700
- press:<label> taps the button of the reply just received whose label matches —
5701
- exactly, or the same words without emoji or case, or the one label containing
5702
- them. None, or several buttons, stops the script and lists the buttons. A form
5703
- step's tap id carries its run id (t:resume:…:run_id=…), which exists only once
6142
+ pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
6143
+ Write a DEPLOYED pack's source back to disk — the inverse of deploy.
6144
+ A pack pushed with 'octwin deploy' lives on the platform as an artifact the
6145
+ runtime serves but nothing hands back, so its only source copy is the machine
6146
+ that pushed it. Pull it, fix it, redeploy it.
6147
+ Defaults to the version installed on the target project; --version overrides.
6148
+ Writes into the folder you are in when it is this pack, else ./<packId>; --dir overrides.
6149
+ Replaces only files that still match your last pull or deploy (${BASE_FILE}); a file
6150
+ you changed since stops it — save your edit first, or --force overwrites it.
6151
+ The pulled dir redeploys where it came from — the target is your saved login.
6152
+ You may pull a pack your workspace OWNS, or any first-party (Octwin) pack — those are
6153
+ free to copy as a starting point; an operator token pulls any.`,
6154
+ chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--press <label>] [--media <file|id>] [--json]
6155
+ octwin chat --script <file> [--as <handle>] [--json]
6156
+ Drive ONE turn through the dev web channel and print every render with its
6157
+ tap ids. Same --as handle = same conversation (multi-turn works).
6158
+ --tap presses a rendered button/list row instead of sending text.
6159
+ --press does the same BY ITS LABEL, in the reply the conversation is waiting on
6160
+ — for a form or checkout step, whose tap id carries a run id that changes.
6161
+ --media uploads a local file (or a media id from 'media generate --json') as
6162
+ an image/document/audio inbound — any "message" rides as its caption; feeds a
6163
+ running media-collect flow (e.g. activate-app).
6164
+ --json dumps the raw SSE envelopes for the turn.
6165
+
6166
+ Each reply line says who sent it. Plain \`← …\` is the AI Agent. \`← [notify] …\` is a
6167
+ reminder or notification (a \`schedule_notify\`, a reaction) — NOT the turn's answer,
6168
+ so the wait for the real reply goes on. \`← [operator: Name] …\` is a person in
6169
+ Live chat; \`← [platform] …\` is a platform line (a handoff joining or returning).
6170
+ After the last turn the stream stays open ${LATE_DRAIN_MS / 1000}s more, so a late message
6171
+ still prints (marked "after the last turn") instead of reaching a closed connection.
6172
+
6173
+ --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
6174
+ process over one connection — waiting for each turn to settle before sending
6175
+ the next. Use this for any multi-step flow: chaining shell invocations races
6176
+ the agent loop, because a turn ends on a quiet gap that can arrive while the
6177
+ server is still working (the symptom is placeholder-filled fields or a second
6178
+ workflow run). Blank lines and # comments are skipped:
6179
+
6180
+ # book an appointment end to end
6181
+ احجز موعد
6182
+ tap:t:invoke:book-appointment:doctor_id=D1
6183
+ media:./licence.jpg | here is my licence
6184
+ press:Confirm
6185
+
6186
+ press:<label> taps the button of the reply just received whose label matches —
6187
+ exactly, or the same words without emoji or case, or the one label containing
6188
+ them. None, or several buttons, stops the script and lists the buttons. A form
6189
+ step's tap id carries its run id (t:resume:…:run_id=…), which exists only once
5704
6190
  the reply arrives, so a script reaches past the first form step only by press.`,
5705
- media: `octwin media generate "<prompt>" [--out <file.png>] [--json]
5706
- AI-generate an image (needs a media:generate-scoped token), store it as a
5707
- public asset, and print its MEDIA- handle + serve URL. --out downloads the
5708
- bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
6191
+ media: `octwin media generate "<prompt>" [--out <file.png>] [--json]
6192
+ AI-generate an image (needs a media:generate-scoped token), store it as a
6193
+ public asset, and print its MEDIA- handle + serve URL. --out downloads the
6194
+ bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
5709
6195
  bytes }. Pair with 'octwin chat --media' to drive media flows.`,
5710
- agents: `octwin agents [packId::agentId] [--prompt] [--json]
5711
- No args = the roster with each agent's EFFECTIVE model and which layer set it.
5712
- With an agent = every governed setting (model / memory.last_messages /
5713
- working_memory) plus the layer that won — an operator PLATFORM default can
5714
- override what your manifest declares, and this is where you see that.
5715
- --prompt = the exact system prompt the LLM sees for this project (pack
5716
- instructions + platform protocol + any project overlay). Needs agents:read.
5717
- The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
5718
-
5719
- WRITES (need \`agents:write\`):
5720
- octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
5721
- [--enable-tool <toolId>] [--disable-tool <toolId>]
5722
-
5723
- Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
5724
- so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
6196
+ agents: `octwin agents [packId::agentId] [--prompt] [--json]
6197
+ No args = the roster with each agent's EFFECTIVE model and which layer set it.
6198
+ With an agent = every governed setting (model / memory.last_messages /
6199
+ working_memory) plus the layer that won — an operator PLATFORM default can
6200
+ override what your manifest declares, and this is where you see that.
6201
+ --prompt = the exact system prompt the LLM sees for this project (pack
6202
+ instructions + platform protocol + any project overlay). Needs agents:read.
6203
+ The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
6204
+
6205
+ WRITES (need \`agents:write\`):
6206
+ octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
6207
+ [--enable-tool <toolId>] [--disable-tool <toolId>]
6208
+
6209
+ Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
6210
+ so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
5725
6211
  ids refuses --model with a 403 — the platform default governs there.`,
5726
- orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
5727
- No args = the order list (#number, status/payment, total, contact). With a
5728
- reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
5729
- payment_ref, and the allowed status transitions. Needs orders:read + the
5730
- \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
5731
- so \`pending\` on a gateway-less workspace is expected, not a bug.
5732
-
5733
- WRITES (need \`orders:write\`):
5734
- octwin orders transition <reference_id> --to <status>
5735
- octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
5736
-
5737
- Refund is irreversible and moves money, hence --force. The route answers 200 even
5738
- when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
5739
- on a refusal rather than reporting a refund that never happened. Only a payment in
6212
+ orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--offset n] [--json]
6213
+ No args = the order list (#number, status/payment, total, contact). With a
6214
+ reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
6215
+ payment_ref, and the allowed status transitions. Needs orders:read + the
6216
+ \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned —
6217
+ \`none\` = no payment opened (no gateway), \`pending\` = checkout not confirmed yet.
6218
+
6219
+ WRITES (need \`orders:write\`):
6220
+ octwin orders transition <reference_id> --to <status>
6221
+ octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
6222
+
6223
+ Refund is irreversible and moves money, hence --force. The route answers 200 even
6224
+ when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
6225
+ on a refusal rather than reporting a refund that never happened. Only a payment in
5740
6226
  \`captured\` state can be refunded; \`payment_status\` is never settable directly.`,
5741
- analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
5742
- No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
5743
- With an entity = stage-by-stage conversion (default --funnel) over the last 30
5744
- days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
6227
+ analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--limit n] [--offset n] [--json]
6228
+ No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
6229
+ With an entity = stage-by-stage conversion (default --funnel) over the last 30
6230
+ days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
5745
6231
  range-filtered). Needs records:read + a \`view\` grant on \`record.<entity>\`.`,
5746
- catalog: `octwin catalog [--readiness] [--json]
5747
- The commerce \`product\` records + price, availability, stock (null = not
5748
- inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
5749
- Graph checklist (LIVE Graph calls; needs a bound access token). Needs
5750
- catalog:read + the \`catalog\` plan feature.
5751
-
5752
- WRITES (need \`catalog:write\`):
5753
- octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
5754
- octwin catalog stock <retailerId> [--set-on-hand <n>]
5755
-
5756
- \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
5757
- inventory-tracked (always sellable), which is different from 0. Lowering on_hand
5758
- below the units already reserved for open carts is refused. Creating/deleting
6232
+ catalog: `octwin catalog [--readiness] [--limit n] [--offset n] [--json]
6233
+ The commerce \`product\` records + price, availability, stock (null = not
6234
+ inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
6235
+ Graph checklist (LIVE Graph calls; needs a bound access token). Needs
6236
+ catalog:read + the \`catalog\` plan feature.
6237
+
6238
+ WRITES (need \`catalog:write\`):
6239
+ octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
6240
+ octwin catalog stock <retailerId> [--set-on-hand <n>]
6241
+
6242
+ \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
6243
+ inventory-tracked (always sellable), which is different from 0. Lowering on_hand
6244
+ below the units already reserved for open carts is refused. Creating/deleting
5759
6245
  products and the Meta catalog binding/sync stay in the console.`,
5760
- scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
5761
- No args = the engine state (bookable resource types, upcoming slots, booked
5762
- seats). --slots <recordId> computes the slots for one bookable resource
5763
- (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
5764
- the availability rules a \`deploy --seed\` created. Needs scheduling:read.
5765
-
5766
- RULES (list needs scheduling:read; add/rm need scheduling:write):
5767
- octwin scheduling rules --resource <resourceRecordId>
5768
- octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
5769
- [--slot-minutes 30] [--capacity 1]
5770
- octwin scheduling rule rm <ruleId>
5771
- octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
5772
- [--start 09:00 --end 13:00]
5773
- octwin scheduling exception rm <exceptionId>
5774
-
5775
- --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
6246
+ scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
6247
+ No args = the engine state (bookable resource types, upcoming slots, booked
6248
+ seats). --slots <recordId> computes the slots for one bookable resource
6249
+ (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
6250
+ the availability rules a \`deploy --seed\` created. Needs scheduling:read.
6251
+
6252
+ RULES (list needs scheduling:read; add/rm need scheduling:write):
6253
+ octwin scheduling rules --resource <resourceRecordId>
6254
+ octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
6255
+ [--slot-minutes 30] [--capacity 1]
6256
+ octwin scheduling rule rm <ruleId>
6257
+ octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
6258
+ [--start 09:00 --end 13:00]
6259
+ octwin scheduling exception rm <exceptionId>
6260
+
6261
+ --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
5776
6262
  \`--slots\` is how you check what a rule actually produces.`,
5777
- automation: `octwin automation [campaigns] [--limit n] [--offset n] [--json]
5778
- No args = every job the pack's automation declaration produced, with its status,
5779
- interval and LAST RESULT (matched / acted / errors), under a health line whose
5780
- counts come from SQL rather than from filtering the page — the job list is capped
5781
- server-side, so a client-side count would depend on the cap. Needs automation:read.
5782
-
5783
- Jobs are DERIVED from declarations. There is no \`create\`: no automation block in
5784
- the pack means no jobs, and \`octwin deploy\` is what installs them.
5785
-
5786
- WRITES (automation:write):
5787
- octwin automation run <jobId> # run once, now — prints matched/acted/errors
5788
- octwin automation pause|resume <jobId>
5789
- octwin automation send <campaignId> # enqueue a campaign; enqueued != delivered
5790
-
5791
- <jobId> is the \`key\` the list shows (its uuid works too). The routes themselves
5792
- accept only a uuid — the CLI resolves the key for you, and names the keys that do
5793
- exist when it cannot. A 403 on a write can be an RBAC grant gap rather than a
6263
+ automation: `octwin automation [campaigns | --campaigns] [--limit n] [--offset n] [--json]
6264
+ No args = every job the pack's automation declaration produced, with its status,
6265
+ interval and LAST RESULT (matched / acted / errors), under a health line whose
6266
+ counts come from SQL rather than from filtering the page — the job list is capped
6267
+ server-side, so a client-side count would depend on the cap. Needs automation:read.
6268
+
6269
+ Jobs are DERIVED from declarations. There is no \`create\`: no automation block in
6270
+ the pack means no jobs, and \`octwin deploy\` is what installs them.
6271
+
6272
+ WRITES (automation:write):
6273
+ octwin automation run <jobId> # run once, now — prints matched/acted/errors
6274
+ octwin automation pause|resume <jobId>
6275
+ octwin automation send <campaignId> # enqueue a campaign; enqueued != delivered
6276
+
6277
+ <jobId> is the \`key\` the list shows (its uuid works too). The routes themselves
6278
+ accept only a uuid — the CLI resolves the key for you, and names the keys that do
6279
+ exist when it cannot. A 403 on a write can be an RBAC grant gap rather than a
5794
6280
  missing scope: the action is re-checked against the job.`,
5795
- integrations: `octwin integrations [--json]
5796
- What the pack DECLARES beside what is actually CONFIGURED, in one view — because a
5797
- connection that is declared and never configured is the commonest reason an
5798
- integration silently never fires, and neither list alone can show it. Flags the
5799
- gap explicitly. Needs integrations:read.
5800
-
5801
- DIAGNOSE ONE CONNECTION:
5802
- octwin integrations preflight <key> # every check, with a fix hint. Makes NO
5803
- # outbound call — needs only integrations:read
5804
- octwin integrations test <key> # a LIVE call to its health: operation
5805
- # (integrations:write). Exits 1 when it fails.
5806
-
5807
- THE DELIVERY LOG:
5808
- octwin integrations deliveries [--status s] [--operation id] [--limit n]
5809
- octwin integrations deliveries <id> # + the redacted request/response snapshots
5810
- octwin integrations retry|cancel|send-now <id> # integrations:write
5811
- octwin integrations events # INBOUND events (what arrived at your webhook)
5812
-
5813
- retry/cancel answer 409 when the delivery is in the wrong state; the message
6281
+ integrations: `octwin integrations [--json]
6282
+ What the pack DECLARES beside what is actually CONFIGURED, in one view — because a
6283
+ connection that is declared and never configured is the commonest reason an
6284
+ integration silently never fires, and neither list alone can show it. Flags the
6285
+ gap explicitly. Needs integrations:read.
6286
+
6287
+ DIAGNOSE ONE CONNECTION:
6288
+ octwin integrations preflight <key> # every check, with a fix hint. Makes NO
6289
+ # outbound call — needs only integrations:read
6290
+ octwin integrations test <key> # a LIVE call to its health: operation
6291
+ # (integrations:write). Exits 1 when it fails.
6292
+
6293
+ THE DELIVERY LOG:
6294
+ octwin integrations deliveries [--status s] [--operation id] [--limit n] [--offset n]
6295
+ octwin integrations deliveries <id> # + the redacted request/response snapshots
6296
+ octwin integrations retry|cancel|send-now <id> # integrations:write
6297
+ octwin integrations events # INBOUND events (what arrived at your webhook)
6298
+
6299
+ retry/cancel answer 409 when the delivery is in the wrong state; the message
5814
6300
  carries the rule.`,
5815
- journeys: `octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition]
5816
- [--stage <stageId>] [--limit n] [--json]
5817
- No args = the journeys the pack declares. With an id, one of six views —
5818
- --funnel (default) stage-by-stage reach and drop-off · --overview entered vs
5819
- converted plus the biggest drop-off · --goals completions, contacts, value and
5820
- p50 time · --trends per-bucket activity · --cost tokens and dollars per goal ·
5821
- --definition what was DECLARED, unmeasured (the one view that works with no
5822
- traffic). Needs journeys:read.
5823
-
5824
- --stage <stageId> lists the runs sitting at a stage right now (a live snapshot,
5825
- not the funnel's cumulative reached counts).
5826
-
5827
- Same flag grammar as \`octwin analytics\` on purpose: a journey funnel and an
5828
- entity funnel are the same question about different subjects. Journeys carry RBAC
5829
- on top of the scope, so an empty answer can be a missing \`view\` grant rather
6301
+ journeys: `octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition]
6302
+ [--stage <stageId>] [--limit n] [--offset n] [--json]
6303
+ No args = the journeys the pack declares. With an id, one of six views —
6304
+ --funnel (default) stage-by-stage reach and drop-off · --overview entered vs
6305
+ converted plus the biggest drop-off · --goals completions, contacts, value and
6306
+ p50 time · --trends per-bucket activity · --cost tokens and dollars per goal ·
6307
+ --definition what was DECLARED, unmeasured (the one view that works with no
6308
+ traffic). Needs journeys:read.
6309
+
6310
+ --stage <stageId> lists the runs sitting at a stage right now (a live snapshot,
6311
+ not the funnel's cumulative reached counts).
6312
+
6313
+ Same flag grammar as \`octwin analytics\` on purpose: a journey funnel and an
6314
+ entity funnel are the same question about different subjects. Journeys carry RBAC
6315
+ on top of the scope, so an empty answer can be a missing \`view\` grant rather
5830
6316
  than missing data — the output says which causes are possible.`,
5831
- performance: `octwin performance [--detail] [--json]
5832
- The project's business indicators — value produced, conversion, duration — each
5833
- with its delta against the previous window and a \`why\` naming the declaration it
5834
- came from. --detail adds the per-indicator breakdown.
5835
-
5836
- Needs records:read, NOT a performance scope (there is none), so a read-only token
5837
- already reaches it. Indicators are DERIVED: a pack that declares no journey goal
6317
+ performance: `octwin performance [--detail] [--range <days>] [--channel <ch>] [--json]
6318
+ The project's business indicators — value produced, conversion, duration — each
6319
+ with its delta against the previous window and a \`why\` naming the declaration it
6320
+ came from. --detail adds the per-indicator breakdown. --range <days> sets the window
6321
+ and --channel <whatsapp|web> narrows every figure to one channel — the console's two
6322
+ controls on the same page.
6323
+
6324
+ Needs records:read, NOT a performance scope (there is none), so a read-only token
6325
+ already reaches it. Indicators are DERIVED: a pack that declares no journey goal
5838
6326
  value and no pipelined entity produces none, which is a different thing from zero.`,
5839
- usage: `octwin usage [--json]
5840
- Model calls, tokens and cost for the resolved scope — project when one is pinned
5841
- or passed with --project, otherwise the whole workspace. Broken down by model,
5842
- kind, agent and channel.
5843
-
5844
- Needs no particular scope: any valid token reaches it.
5845
-
5846
- This is MODEL spend only. WhatsApp/Meta message billing is operator-only and
6327
+ charts: `octwin charts [<key>] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--json]
6328
+ The pack's own \`charts:\` (xrm.yaml), as the owner's dashboard draws them. No key = the
6329
+ list; with a key = that chart's figure, its parts (a \`by:\` chart) or its points (an
6330
+ \`over:\` chart) for the window. A token sees the charts whose entity it may view.
6331
+ Needs records:read.`,
6332
+ handoff: `octwin handoff [<id>] [--bucket waiting|mine|all|ended] [--limit n] [--offset n] [--json]
6333
+ The Live chat desk, read-only: the handoffs in a bucket (default all), or one with
6334
+ its transcript — who said what: customer, AI Agent, operator, platform. Needs work:read
6335
+ and the live_chat plan feature. Accepting, answering and returning one stay in the
6336
+ console: those need a signed-in PERSON to hold the conversation, and a token is not one.`,
6337
+ surveys: `octwin surveys [<key>] [--limit n] [--offset n] [--json]
6338
+ No key = the declared surveys with their responses and headline score (under the
6339
+ survey's own model — average, top box or NPS). With a key = its questions, the rollup,
6340
+ and the recent responses (--limit / --offset page them). Needs records:read.`,
6341
+ usage: `octwin usage [--json]
6342
+ Model calls, tokens and cost for the resolved scope — project when one is pinned
6343
+ or passed with --project, otherwise the whole workspace. Broken down by model,
6344
+ kind, agent and channel.
6345
+
6346
+ Needs no particular scope: any valid token reaches it.
6347
+
6348
+ This is MODEL spend only. WhatsApp/Meta message billing is operator-only and
5847
6349
  deliberately outside the token scope registry — no API token can read it.`,
5848
- 'platform-kb': `octwin platform-kb [pull] [--if-stale] [--check] [--dir .] [--url <url>] [--token <t>]
5849
- Pull the platform capability reference (markdown + JSON catalogs) into
5850
- .octwin/platform-kb/ for the octwin-pack authoring skill, plus three maps:
5851
- INDEX.md (the corpus) · SYMBOLS.md (every name -> its file; grep this) ·
5852
- OUTLINE.md (every heading with its line number).
5853
-
5854
- NO TOKEN NEEDED — the reference is platform stdlib and is served anonymously,
5855
- and this command never sends one. --token is accepted and ignored, so an older
5856
- script that passes it keeps working.
5857
-
5858
- --if-stale poll the platform's content_hash first and skip the download when
5859
- nothing changed. Cheap enough to run at the start of every session.
5860
- --check report only, write nothing. Exit 0 = current, 2 = stale or never
5861
- pulled, 1 = could not tell (offline / no reference served). For
6350
+ 'platform-kb': `octwin platform-kb [pull] [--if-stale] [--check] [--dir .] [--url <url>] [--token <t>]
6351
+ Pull the platform capability reference (markdown + JSON catalogs) into
6352
+ .octwin/platform-kb/ for the octwin-pack authoring skill, plus three maps:
6353
+ INDEX.md (the corpus) · SYMBOLS.md (every name -> its file; grep this) ·
6354
+ OUTLINE.md (every heading with its line number).
6355
+
6356
+ NO TOKEN NEEDED — the reference is platform stdlib and is served anonymously,
6357
+ and this command never sends one. --token is accepted and ignored, so an older
6358
+ script that passes it keeps working.
6359
+
6360
+ --if-stale poll the platform's content_hash first and skip the download when
6361
+ nothing changed. Cheap enough to run at the start of every session.
6362
+ --check report only, write nothing. Exit 0 = current, 2 = stale or never
6363
+ pulled, 1 = could not tell (offline / no reference served). For
5862
6364
  scripts and agent loops that want to branch without parsing prose.`,
5863
- test: `octwin test [--dir .]
5864
- Alias for \`octwin validate --remote\` — the full platform check.`,
5865
- memos: `octwin memos [--all] [--json]
5866
- Read what the platform has told you: a REPLY to a report you sent with
5867
- \`octwin feedback\`, or a NOTICE published to every author (a new capability,
5868
- a deprecation, a breaking change). Bodies are printed in full.
5869
- Reading marks them read, so the reminder stops. --all re-reads history and
5870
- acks nothing. --json to branch on \`severity\`
6365
+ test: `octwin test [--dir .] [--require-kb] [--strict-primitives]
6366
+ Alias for \`octwin validate --remote\` — the full platform check, REQUIRED (a skipped full
6367
+ check is an error). --offline contradicts it; the other validate flags mean what they mean there.`,
6368
+ memos: `octwin memos [--all] [--json]
6369
+ Read what the platform has told you: a REPLY to a report you sent with
6370
+ \`octwin feedback\`, or a NOTICE published to every author (a new capability,
6371
+ a deprecation, a breaking change). Bodies are printed in full.
6372
+ Reading marks them read, so the reminder stops. --all re-reads history and
6373
+ acks nothing. --json to branch on \`severity\`
5871
6374
  (info | action_required | breaking).`,
5872
- feedback: `octwin feedback [--dir .]
5873
- Submit this pack's FEEDBACK.md to the platform team.
5874
- The octwin-pack skill writes that file in its last step — findings grouped by
5875
- owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
5876
- you to paste it into a chat.
5877
- Attaches the pack id + version from manifest.yaml, this CLI's version, and the
5878
- content_hash of the capability reference in .octwin/platform-kb/ — triage needs
5879
- the last two to tell "the platform is wrong" from "that was already fixed" or
6375
+ feedback: `octwin feedback [--dir .]
6376
+ Submit this pack's FEEDBACK.md to the platform team.
6377
+ The octwin-pack skill writes that file in its last step — findings grouped by
6378
+ owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
6379
+ you to paste it into a chat.
6380
+ Attaches the pack id + version from manifest.yaml, this CLI's version, and the
6381
+ content_hash of the capability reference in .octwin/platform-kb/ — triage needs
6382
+ the last two to tell "the platform is wrong" from "that was already fixed" or
5880
6383
  "you were reading a stale reference". Needs the \`pack:deploy\` scope.`,
5881
6384
  };
5882
6385
  /**
@@ -5887,36 +6390,48 @@ octwin projects rm <slug> [--yes]
5887
6390
  * (`cli.json-contract.test.ts`).
5888
6391
  */
5889
6392
  export const COMMANDS = {
5890
- init: { run: cmdInit, json: false },
5891
- validate: { run: cmdValidate, json: false },
5892
- login: { run: cmdLogin, json: 'lines' }, // pending, then approved — two objects
5893
- whoami: { run: cmdWhoami, json: false },
5894
- deploy: { run: cmdDeploy, json: false },
5895
- pull: { run: cmdPull, json: false },
5896
- status: { run: cmdStatus, json: false },
5897
- records: { run: cmdRecords, json: 'document' },
5898
- work: { run: cmdWork, json: 'document' },
5899
- logs: { run: cmdLogs, json: 'document' },
5900
- chat: { run: cmdChat, json: 'lines' }, // one line per event the turn produced
5901
- media: { run: cmdMedia, json: 'document' },
5902
- seed: { run: cmdSeed, json: false },
5903
- projects: { run: cmdProjects, json: 'document' },
5904
- agents: { run: cmdAgents, json: 'document' },
5905
- orders: { run: cmdOrders, json: 'document' },
5906
- analytics: { run: cmdAnalytics, json: 'document' },
5907
- catalog: { run: cmdCatalog, json: 'document' },
5908
- scheduling: { run: cmdScheduling, json: 'document' },
5909
- automation: { run: cmdAutomation, json: 'document' },
5910
- integrations: { run: cmdIntegrations, json: 'document' },
5911
- journeys: { run: cmdJourneys, json: 'document' },
5912
- performance: { run: cmdPerformance, json: 'document' },
5913
- usage: { run: cmdUsage, json: 'document' },
5914
- 'platform-kb': { run: cmdPlatformKb, json: false },
5915
- feedback: { run: cmdFeedback, json: false },
5916
- memos: { run: cmdMemos, json: 'document' },
6393
+ init: { run: cmdInit, json: false, flags: ['id', 'description', 'display-name'] },
6394
+ recipe: { run: cmdRecipe, json: false, flags: ['dry-run'] },
6395
+ validate: { run: cmdValidate, json: false, flags: ['offline', 'remote', 'require-kb', 'strict-primitives'] },
6396
+ login: { run: cmdLogin, json: 'lines', flags: ['wait'] }, // pending, then approved — two objects
6397
+ whoami: { run: cmdWhoami, json: false, flags: [] },
6398
+ deploy: { run: cmdDeploy, json: false, flags: ['force', 'seed', 'request-listing', 'withdraw-listing'] },
6399
+ pull: { run: cmdPull, json: false, flags: ['force', 'version'] },
6400
+ status: { run: cmdStatus, json: false, flags: [] },
6401
+ records: { run: cmdRecords, json: 'document', flags: ['contact', 'entity', 'fields-json', 'limit', 'note', 'offset', 'outcome', 'search', 'set', 'stage', 'to'] },
6402
+ work: { run: cmdWork, json: 'document', flags: ['action', 'dry-run', 'limit', 'note', 'offset', 'param', 'queues', 'to', 'unrouted'] },
6403
+ logs: { run: cmdLogs, json: 'document', flags: ['as'] },
6404
+ chat: { run: cmdChat, json: 'lines', flags: ['as', 'media', 'press', 'script', 'tap'] }, // one line per event the turn produced
6405
+ media: { run: cmdMedia, json: 'document', flags: ['out'] },
6406
+ seed: { run: cmdSeed, json: false, flags: ['pack', 'reset'] },
6407
+ projects: { run: cmdProjects, json: 'document', flags: ['archived', 'pack', 'slug', 'yes'] },
6408
+ agents: { run: cmdAgents, json: 'document', flags: ['disable-tool', 'enable-tool', 'enabled', 'model', 'overlay', 'prompt'] },
6409
+ orders: { run: cmdOrders, json: 'document', flags: ['force', 'limit', 'mark-returned', 'offset', 'payment', 'reason', 'status', 'to'] },
6410
+ analytics: { run: cmdAnalytics, json: 'document', flags: ['funnel', 'overview', 'milestones', 'trends', 'cost', 'stage', 'limit', 'offset'] },
6411
+ catalog: { run: cmdCatalog, json: 'document', flags: ['limit', 'offset', 'readiness', 'set-on-hand', 'to'] },
6412
+ scheduling: { run: cmdScheduling, json: 'document', flags: ['capacity', 'date', 'days', 'dow', 'end', 'from', 'kind', 'resource', 'slot-minutes', 'slots', 'start'] },
6413
+ automation: { run: cmdAutomation, json: 'document', flags: ['campaigns', 'limit', 'offset'] },
6414
+ integrations: { run: cmdIntegrations, json: 'document', flags: ['limit', 'offset', 'operation', 'status'] },
6415
+ journeys: { run: cmdJourneys, json: 'document', flags: ['funnel', 'overview', 'goals', 'trends', 'cost', 'definition', 'stage', 'limit', 'offset'] },
6416
+ performance: { run: cmdPerformance, json: 'document', flags: ['detail', 'range', 'channel'] },
6417
+ usage: { run: cmdUsage, json: 'document', flags: [] },
6418
+ charts: { run: cmdCharts, json: 'document', flags: ['from', 'to'] },
6419
+ handoff: { run: cmdHandoff, json: 'document', flags: ['bucket', 'limit', 'offset'] },
6420
+ surveys: { run: cmdSurveys, json: 'document', flags: ['limit', 'offset'] },
6421
+ 'platform-kb': { run: cmdPlatformKb, json: false, flags: ['check', 'if-stale'] },
6422
+ feedback: { run: cmdFeedback, json: false, flags: [] },
6423
+ memos: { run: cmdMemos, json: 'document', flags: ['all'] },
5917
6424
  // A6: `test` = the full remote validate, not a validate-clone.
5918
- test: { run: flags => cmdValidate({ ...flags, remote: true }), json: false },
6425
+ test: { run: flags => cmdValidate({ ...flags, remote: true }), json: false, flags: ['offline', 'remote', 'require-kb', 'strict-primitives'] },
5919
6426
  };
6427
+ /**
6428
+ * The flags every command takes: the target (`--url` / `--tenant` / `--project` / `--token`, read by
6429
+ * `resolveTarget`), `--json` (held to `json:` above), `--help`, and `--dir` — the pack directory, which
6430
+ * `runCli` itself reads on every run to find the pulled KB (`readLocalKb`). Each command's OWN flags are its
6431
+ * `flags:` row — the one list `runCli` refuses against, and `cli.flags.test.ts` holds to the code that
6432
+ * reads them, to `help()` / `COMMAND_HELP`, and to the README.
6433
+ */
6434
+ export const GLOBAL_FLAGS = ['url', 'tenant', 'project', 'token', 'json', 'help', 'dir'];
5920
6435
  /** Exit code when a `--json` command ended without the result it promised — a bug in this CLI. */
5921
6436
  export const EXIT_NO_RESULT = 70;
5922
6437
  /**
@@ -5967,6 +6482,13 @@ export async function runCli(argv, io) {
5967
6482
  if (flags.json === true && spec.json === false) {
5968
6483
  die(`\`octwin ${command}\` has no --json output — the commands that do are: ${Object.entries(COMMANDS).filter(([, c]) => c.json !== false).map(([n]) => n).join(', ')}`);
5969
6484
  }
6485
+ // A flag the command does not read is REFUSED, before it runs. It used to be ignored, so a
6486
+ // typo — or a flag the command never learned, like `performance --channel` until 2026-10-11 —
6487
+ // ran the command with its default and printed an answer to a question nobody asked.
6488
+ const unknownFlags = Object.keys(flags).filter(k => k !== '_' && !GLOBAL_FLAGS.includes(k) && !spec.flags.includes(k));
6489
+ if (unknownFlags.length) {
6490
+ die(`\`octwin ${command}\` has no ${unknownFlags.map(f => `--${f}`).join(', ')} — its flags: ${spec.flags.length ? spec.flags.map(f => `--${f}`).join(' ') : '(none of its own)'}; on every command: ${GLOBAL_FLAGS.map(f => `--${f}`).join(' ')}. Usage: octwin ${command} --help`);
6491
+ }
5970
6492
  // Compute the trailing nudges BEFORE the command runs, so they survive a `die()` — see the
5971
6493
  // notice-channel block above. They are PRINTED after the command, by `flushNotices` here on the
5972
6494
  // success path and by `exitNow` on every failure path. Fail-silent by construction (each helper