octwin-cli 0.8.16 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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?
@@ -28,7 +28,7 @@
28
28
  * octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion, any pipelined entity
29
29
  * octwin catalog [--readiness] # commerce products + stock + the WhatsApp catalog binding (catalog:read)
30
30
  * octwin scheduling [--slots <resourceRecordId>] # engine state / computed slots (scheduling:read)
31
- * octwin automation [campaigns] # the jobs your declarations produced + health + last result (automation:read)
31
+ * octwin automation [coming|happened|why <id>|campaigns] # the project's rules in words, what is coming up, what happened (automation:read)
32
32
  * octwin integrations [deliveries|events|preflight|test …] # declared vs configured, and the delivery log (integrations:read)
33
33
  * octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition] # declared journeys, measured (journeys:read)
34
34
  * octwin performance [--detail] # the project's business indicators (records:read — there is no performance scope)
@@ -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,10 +78,14 @@ 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';
88
+ import { phraseText, resolveRuleKey, shortTime } from './lib/rules.js';
83
89
  import { chatLabel, answersTheTurn } from './lib/chat-line.js';
84
90
  import { kbOneLiner, buildKbIndexMarkdown, buildKbOutlineMarkdown, } from './lib/kb-index.js';
85
91
  import { buildSymbols, linkExplainers, renderSymbolsMarkdown } from './lib/kb-symbols.js';
@@ -235,9 +241,9 @@ const VERB_REQUIREMENTS = {
235
241
  // which does NOT confer this — that 403 is otherwise baffling.
236
242
  'projects create': { scope: 'projects:write' },
237
243
  'projects rm': { scope: 'projects:write' },
238
- // Jobs are declaration-derived, so there is no `create` — only acting on one.
239
- // `campaigns` is absent on purpose: it is a READ sharing the verb slot, and an
240
- // entry here would print "needs automation:write" on a read failure.
244
+ // Rules are declared, so there is no `create` — only acting on one (or a campaign).
245
+ // `coming` / `happened` / `why` / `campaigns` are absent on purpose: they are READS sharing
246
+ // the verb slot, and an entry here would print "needs automation:write" on a read failure.
241
247
  'automation run': { scope: 'automation:write' },
242
248
  'automation pause': { scope: 'automation:write' },
243
249
  'automation resume': { scope: 'automation:write' },
@@ -291,6 +297,9 @@ const COMMAND_REQUIREMENTS = {
291
297
  // derived from record + journey data, and the route is gated accordingly, so the
292
298
  // Read-only token preset already reaches this.
293
299
  performance: { scope: 'records:read' },
300
+ charts: { scope: 'records:read', feature: 'records' },
301
+ handoff: { scope: 'work:read', feature: 'live_chat' },
302
+ surveys: { scope: 'records:read', feature: 'records' },
294
303
  // `usage` is deliberately absent: its route is `requireTenantAccess` only, so any
295
304
  // valid token reaches it. Declaring a requirement would print "needs the X scope"
296
305
  // on a failure whose real cause is an unreachable instance — the same reasoning as
@@ -914,6 +923,9 @@ function commandTouchesPlatform(command, flags) {
914
923
  case 'journeys':
915
924
  case 'performance':
916
925
  case 'usage':
926
+ case 'charts':
927
+ case 'surveys':
928
+ case 'handoff':
917
929
  case 'projects':
918
930
  case 'seed': return true;
919
931
  default: return false;
@@ -927,7 +939,7 @@ function cmdInit(flags) {
927
939
  // which is why `init` can stay offline (it has no idea which workspace this will deploy
928
940
  // to, and no longer needs one) and why the same scaffold deploys into two workspaces as
929
941
  // two packs with nothing to edit in between.
930
- const id = flags.id ?? target.replace(/[/\\]/g, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
942
+ const id = flags.id ?? packIdFromDir(target);
931
943
  const idProblem = describePackNameProblem(id);
932
944
  if (idProblem)
933
945
  die(`${idProblem}\n → pass --id <pack-name>`);
@@ -948,17 +960,127 @@ function cmdInit(flags) {
948
960
  // the token carries its own tenant + optional project pin.
949
961
  writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.pack-bundles/\n.octwin/\n', 'utf8');
950
962
  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');
963
+ writeFileSync(join(dir, 'README.md'), [
964
+ `# ${id}`, '',
965
+ 'A pure-YAML pack for the Octwin platform, in Arabic and English.', '',
966
+ '- `flows/tools/home.flow.yaml` — the menu (your front door); add a row per journey',
967
+ '- `flows/tools/request.flow.yaml` + `xrm.yaml` — a visit request, saved as a record; replace it with your own',
968
+ '- `journeys/request.journey.yaml` — the funnel the owner reads on the Performance page',
969
+ '- `octwin platform-kb pull` — the authoring guides and recipes; start with `.octwin/platform-kb/INDEX.md`',
970
+ '- `octwin recipe` — list the recipes; `octwin recipe <module>` adds one to this pack',
971
+ '- `octwin validate` — the platform\'s full check, before every deploy',
972
+ '- `octwin deploy --seed` — deploy, install, and add the demo rows',
973
+ '',
974
+ ].join('\n'), 'utf8');
952
975
  }
953
976
  out.print(`✓ Scaffolded pure-YAML pack '${id}' at ${dir}`);
954
977
  out.print('\nNext:');
955
978
  out.print(` cd ${target}`);
956
979
  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');
980
+ out.print(' octwin platform-kb pull # the authoring guides + recipes — read .octwin/platform-kb/INDEX.md first');
981
+ out.print(' octwin recipe # the recipes you can add; `octwin recipe <module>` writes one in');
982
+ out.print(' octwin validate # the platform\'s FULL check — no login needed');
983
+ out.print(' # point the CLI at your platform (the token carries the tenant), then deploy with the demo rows:');
984
+ out.print(' octwin login --url <platformUrl>');
985
+ out.print(' octwin deploy --seed');
986
+ }
987
+ /**
988
+ * `octwin recipe [<module>] [--dir .] [--dry-run]` — a KB recipe's starter files into this pack.
989
+ *
990
+ * Read from the PULLED guide (`lib/recipe.ts`), so a recipe has one copy — the one the platform holds
991
+ * to its full deploy check together with every other recipe, on the `init` starter. Nothing is
992
+ * overwritten: a file the pack already has is named, and a declaration it already has (an `xrm.yaml`
993
+ * holding the author's own entities) gets the recipe's part printed to merge. With no module it lists
994
+ * the recipes the pulled KB carries.
995
+ */
996
+ function cmdRecipe(flags) {
997
+ const packDir = resolve(flags.dir ?? '.');
998
+ const kbDir = findPlatformKbDir(packDir)
999
+ ?? die('no pulled KB here or above — run `octwin platform-kb pull` first (a recipe is read from it)');
1000
+ let index = [];
1001
+ try {
1002
+ index = JSON.parse(readFileSync(join(kbDir, 'index.json'), 'utf8')).index ?? [];
1003
+ }
1004
+ catch {
1005
+ die(`${join(kbDir, 'index.json')} could not be read — re-run \`octwin platform-kb pull\``);
1006
+ }
1007
+ const modules = recipeModules(index);
1008
+ const module = flags._[0]?.toLowerCase();
1009
+ if (!module) {
1010
+ if (modules.size === 0)
1011
+ die('the pulled KB names no recipe — re-run `octwin platform-kb pull` (an older platform published none)');
1012
+ out.print('Recipes in the pulled KB — `octwin recipe <module>` writes one into this pack:');
1013
+ for (const [m, key] of modules)
1014
+ out.print(` ${m.padEnd(14)} guide:${key}#${m}-recipe`);
1015
+ return;
1016
+ }
1017
+ const key = modules.get(module) ?? die(`no recipe '${module}' in the pulled KB — known: ${[...modules.keys()].join(', ')}`);
1018
+ const starters = recipeStarters(readFileSync(join(kbDir, `${key}.md`), 'utf8'), module)
1019
+ ?? die(`${key}.md has no '## ${module} recipe' section — re-run \`octwin platform-kb pull\``);
1020
+ const manifestPath = join(packDir, 'manifest.yaml');
1021
+ if (!existsSync(manifestPath))
1022
+ die(`no manifest.yaml in ${packDir} — run this inside a pack, or pass --dir`);
1023
+ const manifest = parseYaml(readFileSync(manifestPath, 'utf8'));
1024
+ const { actions, flows } = planRecipe(starters, p => existsSync(join(packDir, p)), String(manifest.id ?? ''));
1025
+ const dry = flags['dry-run'] === true;
1026
+ out.print(`${dry ? 'Would apply' : 'Applying'} the ${module} recipe (guide:${key}#${module}-recipe) to ${packDir}`);
1027
+ for (const a of actions) {
1028
+ if (a.act === 'write') {
1029
+ if (!dry) {
1030
+ mkdirSync(dirname(join(packDir, a.path)), { recursive: true });
1031
+ writeFileSync(join(packDir, a.path), a.body, 'utf8');
1032
+ }
1033
+ out.print(` ✓ ${dry ? 'would write' : 'wrote'} ${a.path}`);
1034
+ }
1035
+ else if (a.act === 'exists') {
1036
+ out.print(` • ${a.path} already exists — left as it is (compare it with guide:${key}#${module}-starter)`);
1037
+ }
1038
+ }
1039
+ for (const a of actions) {
1040
+ if (a.act === 'merge') {
1041
+ out.print(`\n${a.path} already exists — merge this part into it${a.note ? ` (${a.note})` : ''}:`);
1042
+ out.print(a.body.trimEnd());
1043
+ }
1044
+ else if (a.act === 'manifest') {
1045
+ out.print('\nAdd to manifest.yaml:');
1046
+ out.print(a.body.trimEnd());
1047
+ }
1048
+ }
1049
+ const listed = (v) => (Array.isArray(v) ? v.map(String) : []);
1050
+ const newFlows = flows.filter(f => !listed(manifest.flows).includes(f));
1051
+ const newTools = flows.filter(f => !listed(manifest.agents?.[0]?.tools).includes(f));
1052
+ if (newFlows.length || newTools.length) {
1053
+ out.print('\nList the new flows in manifest.yaml:');
1054
+ if (newFlows.length)
1055
+ out.print(` flows: ${newFlows.map(f => `- ${f}`).join(' ')}`);
1056
+ if (newTools.length)
1057
+ out.print(` agents[0].tools: ${newTools.map(f => `- ${f}`).join(' ')}`);
1058
+ }
1059
+ // What this recipe's buttons and `track:` ids need that the pack does not ship yet — other recipes,
1060
+ // derived from their files (`recipeNeeds`). Said now, so the author does not meet the chain as one
1061
+ // validate error after another.
1062
+ const all = new Map();
1063
+ for (const [m, k] of modules) {
1064
+ const s = existsSync(join(kbDir, `${k}.md`)) ? recipeStarters(readFileSync(join(kbDir, `${k}.md`), 'utf8'), m) : null;
1065
+ if (s)
1066
+ all.set(m, s);
1067
+ }
1068
+ const journeyDir = join(packDir, 'journeys');
1069
+ const journeys = existsSync(journeyDir)
1070
+ ? readdirSync(journeyDir).filter(f => f.endsWith('.journey.yaml'))
1071
+ .map(f => [`journeys/${f}`, parseYaml(readFileSync(join(journeyDir, f), 'utf8'))])
1072
+ : [];
1073
+ const { needs, unresolved } = recipeNeeds(module, all, {
1074
+ flows: new Set([...listed(manifest.flows), ...flows]), journeys,
1075
+ });
1076
+ if (needs.length) {
1077
+ out.print(`\nThis recipe also needs ${needs.length === 1 ? 'another recipe' : `${needs.length} other recipes`} — its buttons and \`track:\` ids name what they ship:`);
1078
+ for (const n of needs)
1079
+ out.print(` octwin recipe ${n}`);
1080
+ }
1081
+ for (const u of unresolved)
1082
+ out.print(` ! ${u}`);
1083
+ out.print(`\nThen read guide:${key}#${module}-ingredients and #${module}-rules, add the home row it names, and run \`octwin validate\`.`);
962
1084
  }
963
1085
  function localValidate(packDir) {
964
1086
  const { files, blobs } = collectBundleFiles(packDir);
@@ -1194,6 +1316,25 @@ async function cmdValidate(flags) {
1194
1316
  : { kind: 'passed', line: 'declaration files carry no unknown or missing keys (unions are left to --remote)' };
1195
1317
  },
1196
1318
  },
1319
+ {
1320
+ // Journey ids named by `track:` / `do: track_event`, against the pack's OWN `journeys/`.
1321
+ // Needs no KB — the declared set is in the bundle — so it never skips. Last, because a
1322
+ // malformed journey file is the declaration check's finding, not a wall of these.
1323
+ label: 'journey ids',
1324
+ run: () => {
1325
+ const ids = collectJourneyIds(yamlDocs());
1326
+ const findings = withLines(yamlDocs()
1327
+ .filter(([p]) => isFlowFile(p))
1328
+ .flatMap(([p, doc]) => findJourneyViolations(doc, p, ids)));
1329
+ return findings.length
1330
+ ? {
1331
+ kind: 'findings', noun: 'undeclared journey id',
1332
+ lines: findings.map(describeJourneyFinding),
1333
+ 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',
1334
+ }
1335
+ : { kind: 'passed', line: 'every literal journey id in `track:` / `track_event` is declared in journeys/' };
1336
+ },
1337
+ },
1197
1338
  ];
1198
1339
  const skipped = reportChecks(checks, {
1199
1340
  log: (l) => out.print(l),
@@ -1639,16 +1780,20 @@ async function cmdWhoami(flags) {
1639
1780
  * You may pull a pack YOUR tenant owns (an operator token may pull any). A pack
1640
1781
  * you merely installed from the marketplace is not yours to read.
1641
1782
  *
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.
1783
+ * Writes into the pack folder you are standing in when it IS this pack (its
1784
+ * `.octwin-base.json` or its manifest says so), else into `./<packId>` — `--dir`
1785
+ * overrides. It replaces only files that still match the folder's base: a file you
1786
+ * changed since your last pull or deploy stops it (`planPull`), unless `--force`. It
1787
+ * then writes the new base, so the next `octwin deploy` is judged against what you
1788
+ * pulled. The pulled directory is immediately `octwin deploy`-able back to where it
1789
+ * came from — the target is the saved login, so nothing machine-specific needs to
1790
+ * land in the pack dir.
1646
1791
  */
1647
1792
  async function cmdPull(flags) {
1648
1793
  const packId = flags._[0] ?? '';
1649
1794
  if (!packId)
1650
1795
  die('usage: octwin pull <packId> [--dir <out>] [--version <v>] [--force]');
1651
- const outDir = resolve(flags.dir ?? packId);
1796
+ const outDir = pullTarget(flags, packId);
1652
1797
  const t = resolveTarget(flags);
1653
1798
  const { url } = t;
1654
1799
  const qs = typeof flags.version === 'string' ? `?version=${encodeURIComponent(flags.version)}` : '';
@@ -1676,8 +1821,23 @@ async function cmdPull(flags) {
1676
1821
  const total = Object.keys(files).length + Object.keys(blobs).length;
1677
1822
  if (total === 0)
1678
1823
  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`);
1824
+ // Replace only what still matches the base — never the author's own edits (`planPull`).
1825
+ const incoming = hashBundle(files, blobs);
1826
+ const prior = readBase(outDir);
1827
+ const base = prior?.pack_id === json.pack_id ? prior : null;
1828
+ const here = existsSync(outDir) ? collectBundleFiles(outDir) : { files: {}, blobs: {} };
1829
+ const { atRisk, remove } = planPull({ local: hashBundle(here.files, here.blobs), incoming, base });
1830
+ if (atRisk.length > 0 && flags.force !== true) {
1831
+ out.err(`✗ ${outDir} has changes the live ${json.version} does not — pulling would overwrite them:`);
1832
+ for (const rel of atRisk.slice(0, 15))
1833
+ out.err(` • ${rel}`);
1834
+ if (atRisk.length > 15)
1835
+ out.err(` … and ${atRisk.length - 15} more`);
1836
+ out.err(base
1837
+ ? ' Save them first (git commit, or copy the folder), then pull with --force and apply them again.'
1838
+ : ` This folder has no ${BASE_FILE} (the record of its last pull or deploy), so your edits cannot be told `
1839
+ + 'apart from an old version. Save them first (git commit, or copy the folder), then pull with --force.');
1840
+ exitNow(1);
1681
1841
  }
1682
1842
  for (const [rel, body] of Object.entries(files)) {
1683
1843
  const p = join(outDir, rel);
@@ -1689,9 +1849,35 @@ async function cmdPull(flags) {
1689
1849
  mkdirSync(dirname(p), { recursive: true });
1690
1850
  writeFileSync(p, Buffer.from(b64, 'base64'));
1691
1851
  }
1852
+ // The live version no longer has these — left behind, the next deploy would bring them back.
1853
+ for (const rel of remove)
1854
+ rmSync(join(outDir, rel), { force: true });
1855
+ writeBase(outDir, { pack_id: json.pack_id, version: json.version, content_sha: json.content_sha, files: incoming });
1692
1856
  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}`);
1857
+ out.print(` ${Object.keys(files).length} file(s)${Object.keys(blobs).length ? `, ${Object.keys(blobs).length} blob(s)` : ''}`
1858
+ + `${remove.length ? `, ${remove.length} removed` : ''} content_sha ${String(json.content_sha).slice(0, 12)}…`);
1859
+ out.print(`\nFix it, then: octwin deploy${outDir === resolve('.') ? '' : ` --dir ${outDir}`}`);
1860
+ }
1861
+ /**
1862
+ * Where `pull` writes: `--dir`, else the folder you are in when it is this pack (its base names it, or
1863
+ * its manifest declares the pack's name — the folder a refused deploy was run from), else `./<packId>`.
1864
+ */
1865
+ function pullTarget(flags, packId) {
1866
+ if (typeof flags.dir === 'string')
1867
+ return resolve(flags.dir);
1868
+ const here = resolve('.');
1869
+ if (readBase(here)?.pack_id === packId)
1870
+ return here;
1871
+ const manifest = join(here, 'manifest.yaml');
1872
+ if (existsSync(manifest)) {
1873
+ try {
1874
+ const id = parseYaml(readFileSync(manifest, 'utf8'))?.id;
1875
+ if (typeof id === 'string' && id === packId.slice(packId.indexOf('.') + 1))
1876
+ return here;
1877
+ }
1878
+ catch { /* not a manifest we can read — not this pack */ }
1879
+ }
1880
+ return resolve(packId);
1695
1881
  }
1696
1882
  /**
1697
1883
  * Read the deploy SSE stream, printing each progress frame's message live, and
@@ -1849,6 +2035,9 @@ function printDeploySuccess(id, version, t, r, listing, problems = 0) {
1849
2035
  out.print(problems > 0
1850
2036
  ? `⚠ Deployed ${id}@${version} onto ${targetLabel(t)} WITH ${problems} failure(s) — data may be incomplete`
1851
2037
  : `✓ Deployed ${id}@${version} and installed onto ${targetLabel(t)}`);
2038
+ if (r?.forced_over) {
2039
+ out.print(` ⚠ --force replaced ${r.forced_over === 'downgrade' ? 'a higher live version' : 'a version someone else published after your copy'}.`);
2040
+ }
1852
2041
  if (r?.warning)
1853
2042
  out.print(` ⚠ ${r.warning}`);
1854
2043
  // This read `r.summary.records` / `.rules` / `.failed` and had been DEAD: the deploy route's
@@ -1908,7 +2097,7 @@ async function cmdSeed(flags) {
1908
2097
  if (stepErrors.length) {
1909
2098
  // A kind failed but the rest ran — the reconcile softens each step. Say which,
1910
2099
  // and exit non-zero so a scripted `seed && chat` doesn't read as clean.
1911
- out.err(`
2100
+ out.err(`
1912
2101
  ⚠ ${stepErrors.length} step${stepErrors.length === 1 ? '' : 's'} failed — data may be incomplete:`);
1913
2102
  for (const e of stepErrors)
1914
2103
  out.err(` • ${e}`);
@@ -1954,7 +2143,12 @@ async function cmdDeploy(flags) {
1954
2143
  const endpoint = `${url}/api/self/p/packs/deploy`;
1955
2144
  const seed = flags.seed === true;
1956
2145
  const listing = resolveListingFlags(flags);
1957
- const extras = [seed ? 'with demo seed' : null, listing ? LISTING_LABEL[listing] : null].filter(Boolean);
2146
+ // What this copy was made from: the platform refuses when someone published since (`live_moved`).
2147
+ const base = deployBaseOf(readBase(packDir));
2148
+ const force = flags.force === true;
2149
+ const extras = [
2150
+ seed ? 'with demo seed' : null, listing ? LISTING_LABEL[listing] : null, force ? 'forced over any newer live version' : null,
2151
+ ].filter(Boolean);
1958
2152
  out.print(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${extras.length ? ` — ${extras.join(', ')}` : ''} …`);
1959
2153
  const res = await fetchOrDie(endpoint, {
1960
2154
  method: 'POST',
@@ -1963,7 +2157,9 @@ async function cmdDeploy(flags) {
1963
2157
  headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
1964
2158
  // `listing` is omitted entirely unless asked for: an ordinary deploy says NOTHING about the
1965
2159
  // marketplace, which is what keeps a test loop out of the operator review queue.
1966
- body: JSON.stringify({ files, blobs, seed, ...(listing ? { listing } : {}) }),
2160
+ body: JSON.stringify({
2161
+ files, blobs, seed, ...(listing ? { listing } : {}), ...(base ? { base } : {}), ...(force ? { force: true } : {}),
2162
+ }),
1967
2163
  }, 'deploy');
1968
2164
  // Streaming path — live install + seed progress (image generation can take a
1969
2165
  // while, so `--seed` prints per-record / per-image lines as they happen).
@@ -1971,6 +2167,7 @@ async function cmdDeploy(flags) {
1971
2167
  const { terminal: final, stepErrors } = await readDeployProgress(res.body);
1972
2168
  if (!final || final.stage === 'error')
1973
2169
  die(`deploy failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
2170
+ rememberDeploy(packDir, final, files, blobs);
1974
2171
  // The count goes IN, so the headline itself carries the verdict. It used to be
1975
2172
  // printed underneath a `✓ Deployed` line, which is what a reporting author read.
1976
2173
  printDeploySuccess(id, version, t, final, { intent: listing, packDir }, stepErrors.length);
@@ -1996,11 +2193,28 @@ async function cmdDeploy(flags) {
1996
2193
  json = text;
1997
2194
  }
1998
2195
  if (!res.ok) {
2196
+ // Someone published since this copy was made, or it is an older version: nothing changed on the
2197
+ // platform. The message says what happened and the way out (pull, or --force) — print it as words.
2198
+ if (res.status === 409 && (json?.code === 'live_moved' || json?.code === 'downgrade')) {
2199
+ out.err(`✗ ${json.error}`);
2200
+ if (json.code === 'live_moved' && typeof flags.dir === 'string') {
2201
+ out.err(` This copy is in ${flags.dir} — add \`--dir ${flags.dir}\` to the pull.`);
2202
+ }
2203
+ exitNow(1);
2204
+ }
2205
+ // The checks refused it: say what keeps running, then what to fix (`formatChecksRefusal`).
2206
+ const refusal = res.status === 400 ? formatChecksRefusal(json) : null;
2207
+ if (refusal) {
2208
+ for (const line of refusal)
2209
+ out.err(line);
2210
+ exitNow(1);
2211
+ }
1999
2212
  out.err(`✗ deploy failed (HTTP ${res.status})`);
2000
2213
  printAuthHint(res.status, url);
2001
2214
  out.err(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
2002
2215
  exitNow(1);
2003
2216
  }
2217
+ rememberDeploy(packDir, json, files, blobs);
2004
2218
  // This path had NO step-error check at all. With no SSE frames to read, the only
2005
2219
  // evidence a seed kind threw is `seed_failed` in the body — so a broken seed printed
2006
2220
  // a clean `✓ Deployed` here even after the streaming path was fixed in 0.1.14.
@@ -2013,6 +2227,15 @@ async function cmdDeploy(flags) {
2013
2227
  exitNow(1);
2014
2228
  }
2015
2229
  }
2230
+ /**
2231
+ * After a deploy: this folder's new base is what the project now runs. Written even when a seed step
2232
+ * failed — the pack itself IS installed. A platform too old to report `content_sha` leaves the base alone.
2233
+ */
2234
+ function rememberDeploy(packDir, r, files, blobs) {
2235
+ if (typeof r?.content_sha !== 'string' || typeof r?.pack_id !== 'string' || typeof r?.version !== 'string')
2236
+ return;
2237
+ writeBase(packDir, { pack_id: r.pack_id, version: r.version, content_sha: r.content_sha, files: hashBundle(files, blobs) });
2238
+ }
2016
2239
  /**
2017
2240
  * The QUALIFIED pack id (`<owner>.<name>`) for a manifest's bare name.
2018
2241
  *
@@ -2092,6 +2315,13 @@ async function cmdStatus(flags) {
2092
2315
  const shortSha = (s) => (typeof s === 'string' && s ? s.slice(0, 12) + '…' : '(none)');
2093
2316
  out.print(` live on instance : registered=${json.registered} loaded=${shortSha(json.loaded_content_sha)}`);
2094
2317
  out.print(` catalog artifact : ${shortSha(json.catalog_content_sha)}${json.origin ? ` origin=${json.origin}` : ''}`);
2318
+ // The copy on disk against what runs: the question a deploy is about to be judged on (`live_moved`).
2319
+ const base = readBase(packDir);
2320
+ out.print(` your copy : ${!base || base.pack_id !== id
2321
+ ? `no ${BASE_FILE} — run \`octwin pull ${id}\` once, so a deploy can tell when someone else published`
2322
+ : base.content_sha === json.catalog_content_sha
2323
+ ? `from ${base.version} (${shortSha(base.content_sha)}) — the live content`
2324
+ : `from ${base.version} (${shortSha(base.content_sha)}) — the live content MOVED since; run \`octwin pull ${id}\` before you deploy`}`);
2095
2325
  out.print(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
2096
2326
  // Every line above is process-global — `registered` is true for a pack ANY
2097
2327
  // project on this instance loaded. `dispatches` is the project-scoped answer to
@@ -2322,9 +2552,10 @@ async function cmdPlatformKb(flags) {
2322
2552
  writeFileSync(join(outDir, 'OUTLINE.md'), outline, 'utf8');
2323
2553
  // Persist `content_hash` too — the staleness observer (`notifyIfKbStale`) reads
2324
2554
  // 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');
2555
+ 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
2556
  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 ?? '?'}`);
2557
+ out.print(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`
2558
+ + `, published by platform ${bundle.platform_version ?? 'unknown'}`);
2328
2559
  out.print(` Three maps: INDEX.md (the corpus) · SYMBOLS.md (${symbols.length} names → their file, grep it)`
2329
2560
  + `${outline ? ' · OUTLINE.md (every heading, with line numbers)' : ''}`);
2330
2561
  if (!outline) {
@@ -2533,12 +2764,16 @@ async function cmdRecords(flags) {
2533
2764
  out.print('No AI Agent is loaded in this project.');
2534
2765
  return;
2535
2766
  }
2536
- if (json?.has_xrm === false) {
2537
- // No record TYPES of its own — but the platform's own records (an order, a case) still read by name.
2767
+ // The LISTED types, as the console's switcher shows them: the catalog carries every platform
2768
+ // entity for lookups by name. A server older than the `listed` field sent declared types only,
2769
+ // so a missing one reads as listed.
2770
+ const ents = (json?.entities ?? []).filter(e => e.listed !== false);
2771
+ if (json?.has_xrm === false && ents.length === 0) {
2772
+ // No record TYPES of its own and no platform records yet — the platform's (an order, a case)
2773
+ // still read by name.
2538
2774
  out.print('This pack declares no record types of its own. Platform records still read by name: octwin records order | product | case | …');
2539
2775
  return;
2540
2776
  }
2541
- const ents = (json?.entities ?? []);
2542
2777
  if (ents.length === 0) {
2543
2778
  out.print('No entities visible — mint a token with the `records:read` scope to inspect data.');
2544
2779
  return;
@@ -4092,12 +4327,15 @@ async function cmdAgents(flags) {
4092
4327
  * docs/BACKLOG.md → "Headless payment settle" (a `--settle` flag lands here once
4093
4328
  * the platform grows a manual-driver-only reconcile endpoint). */
4094
4329
  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.');
4330
+ if (paymentStatus === 'none') {
4331
+ out.print(" → payment_status 'none': no payment was opened. Without a hosted-checkout gateway");
4332
+ out.print(' `payment_request` takes its `empty` port and leaves the order alone — the flow should offer');
4333
+ out.print(' pay-on-delivery. Refund is the one operator-driven move.');
4334
+ }
4335
+ else if (paymentStatus === 'pending') {
4336
+ out.print(" → payment_status 'pending': a checkout was opened and the gateway's webhook has not");
4337
+ out.print(' confirmed it yet. The forward payment lifecycle is WEBHOOK-owned (not patchable).');
4338
+ }
4101
4339
  }
4102
4340
  /** `octwin orders [referenceId] [--status s] [--payment p] [--limit n] [--json]` —
4103
4341
  * the orders a conversation created: money breakdown, payment state, allowed
@@ -4108,8 +4346,8 @@ const ORDER_VERBS = new Set(['transition', 'refund']);
4108
4346
  * The write half of `octwin orders` — fulfilment transitions and refunds.
4109
4347
  *
4110
4348
  * `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`).
4349
+ * webhook-owned: `none` is an order no payment was opened for (no gateway), `pending` one whose
4350
+ * checkout the gateway has not confirmed yet (`printPaymentNote`).
4113
4351
  */
4114
4352
  async function cmdOrdersWrite(flags) {
4115
4353
  const t = resolveTarget(flags);
@@ -4694,49 +4932,51 @@ async function cmdScheduling(flags) {
4694
4932
  out.print(` upcoming slots: ${json?.upcoming_slots ?? 0} booked seats: ${json?.booked_seats ?? 0}`);
4695
4933
  out.print('\nSlots for one resource: octwin scheduling --slots <resourceRecordId> (ids: octwin records <entity>)');
4696
4934
  }
4697
- // ── automation: declaration-derived jobs + campaigns ─────────────────────────
4935
+ // ── automation: the project's rules + campaigns ──────────────────────────────
4698
4936
  /**
4699
- * Verbs that mean "act on a job", not "an id".
4937
+ * Verbs of `octwin automation` — a view (`coming`, `happened`, `why`, `campaigns`) or an act (`run`, `pause`,
4938
+ * `resume`, `send`) — not a rule key.
4700
4939
  *
4701
- * `pause` / `resume` rather than a literal `status active|paused`: the route body
4702
- * takes the status, but the RBAC layer it calls checks the ACTION
4703
- * (`assertCan(…, action: 'pause' | 'resume')`), and `VERB_REQUIREMENTS` is keyed
4704
- * per verb — so one verb per intent makes both the permission hint and the 403
4705
- * say the right thing.
4940
+ * Until 0.9.0 the command read JOB ROWS (`…/automation/jobs`). Since the platform's migration 126 those are only its
4941
+ * own two sweeps: a pack's `rules:` — and its older `segment` / `stale_records` / `sla_sweep` jobs and `on:` hooks,
4942
+ * which run as compiled rules — were invisible in the terminal. It reads the rules routes the console's Outbound →
4943
+ * Automatic tab reads now, so the words are the server's.
4706
4944
  */
4707
- const AUTOMATION_VERBS = new Set(['run', 'pause', 'resume', 'campaigns', 'send']);
4945
+ const AUTOMATION_VERBS = new Set(['coming', 'happened', 'why', 'run', 'pause', 'resume', 'campaigns', 'send']);
4946
+ const AUTOMATION_WRITES = new Set(['run', 'pause', 'resume', 'send']);
4708
4947
  /**
4709
- * Turn whatever the author typed into the UUID the route demands.
4710
- *
4711
- * (`UUID_RE` is the one already declared for `--media`, deliberately reused rather
4712
- * than a second copy of the same pattern.)
4713
- *
4714
- * The write routes take a UUID path param and reject anything else with a bare
4715
- * *"Malformed identifier in the URL — expected a UUID"* 400. But the thing an author
4716
- * has in front of them is the declaration KEY (`cart_recovery_nudge`) — that is what
4717
- * the list prints, and it is the name in their own YAML. Measured: passing the key
4718
- * 400s on all three write verbs.
4719
- *
4720
- * So the key is resolved here, against the list route, rather than documented as a
4721
- * gotcha. A UUID passes straight through, and an unknown key fails naming the keys
4722
- * that DO exist — which is the answer to the question the author is actually asking.
4948
+ * Turn the campaign an author typed into the UUID the send route demands — the id `octwin automation campaigns`
4949
+ * prints, or the campaign's key. An unknown one fails naming those that exist.
4723
4950
  */
4724
- async function resolveAutomationId(kind, typed, base, t) {
4951
+ async function resolveCampaignId(typed, t) {
4725
4952
  if (UUID_RE.test(typed))
4726
4953
  return typed;
4727
- const { status, json } = await apiGet(`${base}/${kind}`, t);
4728
- if (status !== 200) {
4729
- die(`could not resolve '${typed}' — reading ${kind} failed (HTTP ${status})${errDetail(json)}`);
4730
- }
4954
+ const { url } = t;
4955
+ const base = `${url}/api/self/p/automation`;
4956
+ const { status, json } = await apiGet(`${base}/campaigns`, t);
4957
+ if (status !== 200)
4958
+ die(`could not resolve '${typed}' — reading campaigns failed (HTTP ${status})${errDetail(json)}`);
4731
4959
  const rows = readPage(json).rows;
4732
4960
  const hit = rows.find(r => r.key === typed || r.id === typed);
4733
4961
  if (hit?.id)
4734
4962
  return hit.id;
4735
4963
  const keys = rows.map(r => r.key ?? r.id).filter(Boolean);
4736
- die(`no ${kind === 'jobs' ? 'job' : 'campaign'} '${typed}' in this project.`
4737
- + (keys.length ? ` Available: ${keys.join(', ')}` : ` This project declares none.`));
4964
+ die(`no campaign '${typed}' in this project.` + (keys.length ? ` Available: ${keys.join(', ')}` : ' This project has none.'));
4738
4965
  }
4739
- /** `pause`/`resume`/`run`/`send` — the writes behind `octwin automation`. */
4966
+ /** The rule an author typed, against the project's rules (`resolveRuleKey`) — or the command dies naming them. */
4967
+ async function resolveRule(typed, t) {
4968
+ const { url } = t;
4969
+ const base = `${url}/api/self/p/automation`;
4970
+ const { status, json } = await apiGet(`${base}/rules`, t);
4971
+ if (status !== 200)
4972
+ die(`could not resolve '${typed}' — reading the rules failed (HTTP ${status})${errDetail(json)}`);
4973
+ const keys = (json?.rules ?? []).map(r => r.key).filter((k) => typeof k === 'string');
4974
+ const r = resolveRuleKey(typed, keys);
4975
+ if ('error' in r)
4976
+ die(r.error);
4977
+ return r.key;
4978
+ }
4979
+ /** `pause` / `resume` / `run` a rule, `send` a campaign — the writes behind `octwin automation`. */
4740
4980
  async function cmdAutomationWrite(flags) {
4741
4981
  const t = resolveTarget(flags);
4742
4982
  const { url } = t;
@@ -4744,88 +4984,212 @@ async function cmdAutomationWrite(flags) {
4744
4984
  const verb = flags._[0];
4745
4985
  const typed = flags._[1];
4746
4986
  const asJson = flags.json === true;
4747
- if (!typed)
4748
- die(`usage: octwin automation ${verb} <${verb === 'send' ? 'campaignId' : 'jobId'}> (ids: octwin automation${verb === 'send' ? ' campaigns' : ''})`);
4749
- const id = await resolveAutomationId(verb === 'send' ? 'campaigns' : 'jobs', typed, base, t);
4750
- if (verb === 'pause' || verb === 'resume') {
4751
- const { status, json } = await apiSend('PATCH', `${base}/jobs/${encodeURIComponent(id)}/status`, { status: verb === 'pause' ? 'paused' : 'active' }, t);
4987
+ if (verb === 'send') {
4988
+ if (!typed)
4989
+ die('usage: octwin automation send <campaignId> (ids: octwin automation campaigns)');
4990
+ const id = await resolveCampaignId(typed, t);
4991
+ if (!asJson)
4992
+ out.print(`→ Sending campaign '${typed}' in ${targetLabel(t)} …`);
4993
+ const { status, json } = await apiSend('POST', `${base}/campaigns/${encodeURIComponent(id)}/send`, undefined, t);
4752
4994
  if (status === 404)
4753
- die(`job '${typed}' not found in ${targetLabel(t)} (ids: octwin automation)`);
4754
- // RBAC re-checks the ACTION on the job, so a 403 here can be a grant gap
4755
- // rather than a missing scope — same caveat as a record write.
4995
+ die(`campaign '${typed}' not found in ${targetLabel(t)} (ids: octwin automation campaigns)`);
4756
4996
  if (status !== 200)
4757
- writeFail(`${verb} job '${typed}'`, status, json, url, true);
4997
+ writeFail(`send campaign '${typed}'`, status, json, url, true);
4758
4998
  if (asJson) {
4759
4999
  out.result(json);
4760
5000
  return;
4761
5001
  }
4762
- const j = json?.job ?? {};
4763
- out.print(`✓ job '${j.key ?? typed}' is now ${j.status}`);
4764
- if (j.next_run_at)
4765
- out.print(` next run: ${j.next_run_at}`);
5002
+ const r = json?.result ?? {};
5003
+ const already = Number(r.already_queued ?? 0);
5004
+ out.print(`✓ campaign '${typed}': matched ${r.matched ?? 0}, enqueued ${r.enqueued ?? 0}${already ? `, ${already} already had it` : ''} [${r.stage ?? '?'}]`);
5005
+ // A campaign that stopped half-way is FINISHED by the same command: it queues only the contacts
5006
+ // with no delivery yet, so `already` is the earlier send's work, not a skip.
5007
+ if (already)
5008
+ out.print(' finished an earlier send — each contact gets this campaign once');
5009
+ if ((r.enqueued ?? 0) + already < (r.matched ?? 0))
5010
+ out.print(' partial: some matched records were not enqueued (records sharing a contact, or a record with none)');
5011
+ out.print(' enqueued ≠ delivered — watch the sends land with `octwin automation happened`');
5012
+ return;
5013
+ }
5014
+ if (!typed)
5015
+ die(`usage: octwin automation ${verb} <ruleKey> (keys: octwin automation)`);
5016
+ const key = await resolveRule(typed, t);
5017
+ const path = `${base}/rules/${encodeURIComponent(key)}/${verb}`;
5018
+ if (!asJson && verb === 'run')
5019
+ out.print(`→ Running rule '${key}' in ${targetLabel(t)} …`);
5020
+ const { status, json } = await apiSend('POST', path, undefined, t);
5021
+ if (status === 404)
5022
+ die(`rule '${key}' not found in ${targetLabel(t)} (keys: octwin automation)`);
5023
+ // RBAC re-checks the ACTION on `automation.rule`, so a 403 can be a grant gap rather than a missing scope; a 409
5024
+ // is the rule saying why not (only a rule on a timetable can be run now) — the server's words.
5025
+ if (status !== 200)
5026
+ writeFail(`${verb} rule '${key}'`, status, json, url, true);
5027
+ if (asJson) {
5028
+ out.result(json);
4766
5029
  return;
4767
5030
  }
4768
5031
  if (verb === 'run') {
4769
- if (!asJson)
4770
- out.print(`→ Running job '${typed}' in ${targetLabel(t)} …`);
4771
- const { status, json } = await apiSend('POST', `${base}/jobs/${encodeURIComponent(id)}/run`, undefined, t);
4772
- if (status === 404)
4773
- die(`job '${typed}' not found in ${targetLabel(t)} (ids: octwin automation)`);
4774
- if (status !== 200)
4775
- writeFail(`run job '${typed}'`, status, json, url, true);
4776
- if (asJson) {
4777
- out.result(json);
4778
- return;
4779
- }
4780
- const r = json?.result ?? {};
4781
- out.print(`✓ ran '${typed}': matched ${r.matched ?? 0}, acted ${r.acted ?? 0}, errors ${r.errors ?? 0}`);
4782
- // `acted < matched` is the route's own definition of a PARTIAL run, so say so
4783
- // rather than leaving three numbers for the author to compare.
4784
- if ((r.errors ?? 0) > 0)
4785
- out.print(' ⚠ some rows errored — see the job\'s last_result in `octwin automation`');
4786
- else if ((r.acted ?? 0) < (r.matched ?? 0))
4787
- out.print(' partial: matched rows were skipped (cooldown, or already acted on)');
5032
+ out.print(`✓ '${key}' runs now — it reaches ${json?.reaches ?? 0} record(s); next time after this run: ${shortTime(json?.next)}`);
5033
+ out.print(' see what it did: octwin automation happened');
5034
+ }
5035
+ else if (verb === 'pause') {
5036
+ out.print(`✓ '${key}' is paused — its times still arm, and nothing runs (nothing is caught up when it resumes)`);
5037
+ }
5038
+ else {
5039
+ out.print(json?.resumed === false ? ` '${key}' was not paused` : `✓ '${key}' runs again`);
5040
+ }
5041
+ }
5042
+ /**
5043
+ * A campaign's delivery progress — the per-status counts of its deliveries. Not sent yet (a draft) is all zeros, and
5044
+ * reads as a dash rather than "sent 0".
5045
+ */
5046
+ function campaignProgressLine(p) {
5047
+ const n = (k) => Number(p?.[k] ?? 0);
5048
+ if (n('sent') + n('failed') + n('pending') + n('cancelled') === 0)
5049
+ return '—'.padEnd(28);
5050
+ return `sent ${n('sent')} · failed ${n('failed')} · pending ${n('pending')}${n('cancelled') ? ` · cancelled ${n('cancelled')}` : ''}`.padEnd(28);
5051
+ }
5052
+ /** A line's record and customer, as one tail — `· Booking #7 · Sara`. */
5053
+ function lineTail(l) {
5054
+ const rec = l?.record ? ` · ${l.record.noun ?? l.record.entity ?? 'record'} #${l.record.number ?? '?'}` : '';
5055
+ const who = l?.contact ? ` · ${l.contact.name ?? 'customer'}` : '';
5056
+ return `${rec}${who}`;
5057
+ }
5058
+ /** A query string of the values that are set — `range=week&q=cairo`. Each caller names the flags it passes. */
5059
+ function setQs(values) {
5060
+ const p = new URLSearchParams();
5061
+ for (const [k, v] of Object.entries(values))
5062
+ if (typeof v === 'string')
5063
+ p.set(k, v);
5064
+ return p.toString();
5065
+ }
5066
+ /** `octwin automation coming` — every time still to come: a rule's, or a flow's own message (a send). */
5067
+ async function automationComing(flags, t) {
5068
+ const { url } = t;
5069
+ const base = `${url}/api/self/p/automation`;
5070
+ const asJson = flags.json === true;
5071
+ if (!asJson)
5072
+ out.print(`→ Reading what is coming up in ${targetLabel(t)} …`);
5073
+ const qs = setQs({ range: flags.range, entity: flags.entity, q: flags.q, cursor: flags.cursor });
5074
+ const { status, json } = await apiGet(`${base}/upcoming?${qs || 'range=week'}`, t);
5075
+ if (status !== 200)
5076
+ die(`could not read what is coming up (HTTP ${status})${errDetail(json)}${authFailureDetail(status, t.url)}`);
5077
+ if (asJson) {
5078
+ out.result(json);
4788
5079
  return;
4789
5080
  }
4790
- // send — one campaign
5081
+ const lines = (json?.lines ?? []);
5082
+ out.print(`Coming up in ${targetLabel(t)} (${typeof flags.range === 'string' ? flags.range : 'week'}): ${json?.total ?? lines.length}`);
5083
+ if (lines.length === 0)
5084
+ out.print(' (nothing — a rule\'s time arms when its record is made or changed)');
5085
+ for (const l of lines) {
5086
+ const mark = l.skipped ? ' [skipped]' : l.paused ? ' [paused]' : '';
5087
+ out.print(` ${shortTime(l.at)} ${phraseText(l.headline)}${lineTail(l)}${mark} ${l.kind === 'queued' ? 'message' : 'timer'} ${l.id}`);
5088
+ }
5089
+ if (json?.next)
5090
+ out.print(`\nMore: octwin automation coming --cursor ${json.next}`);
5091
+ if (lines.length)
5092
+ out.print('Why one goes, and what it will say: octwin automation why <id>');
5093
+ }
5094
+ /** `octwin automation happened` — every fire of the period: its final word, and why a message did not go. */
5095
+ async function automationHappened(flags, t) {
5096
+ const { url } = t;
5097
+ const base = `${url}/api/self/p/automation`;
5098
+ const asJson = flags.json === true;
4791
5099
  if (!asJson)
4792
- out.print(`→ Sending campaign '${typed}' in ${targetLabel(t)} …`);
4793
- const { status, json } = await apiSend('POST', `${base}/campaigns/${encodeURIComponent(id)}/send`, undefined, t);
4794
- if (status === 404)
4795
- die(`campaign '${typed}' not found in ${targetLabel(t)} (ids: octwin automation campaigns)`);
5100
+ out.print(`→ Reading what happened in ${targetLabel(t)} …`);
5101
+ const qs = setQs({ chip: flags.chip, period: flags.period, q: flags.q, cursor: flags.cursor });
5102
+ const { status, json } = await apiGet(`${base}/history?${qs || 'period=7d'}`, t);
4796
5103
  if (status !== 200)
4797
- writeFail(`send campaign '${typed}'`, status, json, url, true);
5104
+ die(`could not read what happened (HTTP ${status})${errDetail(json)}${authFailureDetail(status, t.url)}`);
4798
5105
  if (asJson) {
4799
5106
  out.result(json);
4800
5107
  return;
4801
5108
  }
4802
- 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)');
4806
- out.print(' enqueued ≠ delivered — watch the sends land with `octwin logs`');
5109
+ const lines = (json?.lines ?? []);
5110
+ const c = json?.counts ?? {};
5111
+ out.print(`Happened in ${targetLabel(t)} (${typeof flags.period === 'string' ? flags.period : '7d'}): ${json?.total ?? lines.length}`
5112
+ + ` — ${c.not_delivered ?? 0} not delivered, ${c.failed ?? 0} failed, ${c.skipped ?? 0} skipped`);
5113
+ if (lines.length === 0)
5114
+ out.print(' (nothing yet)');
5115
+ for (const l of lines) {
5116
+ const why = l.code || l.reason ? ` (${l.code ?? l.reason})` : '';
5117
+ out.print(` ${shortTime(l.at)} ${String(l.outcome ?? '—').padEnd(13)} ${phraseText(l.headline)}${lineTail(l)}${why} ${l.id}`);
5118
+ }
5119
+ if (json?.next)
5120
+ out.print(`\nMore: octwin automation happened --cursor ${json.next}`);
5121
+ if (lines.length)
5122
+ out.print('Each step and the words sent: octwin automation why <id>');
5123
+ }
5124
+ /**
5125
+ * `octwin automation why <id>` — one line's *Why?*, as the console's drawer: the rule in words, each step with its
5126
+ * outcome, the words the customer reads. The line's kind picks the route (`--kind timer|fire|queued`); without it
5127
+ * each is asked in turn — a coming time, a fire, a send.
5128
+ */
5129
+ async function automationWhy(flags, t) {
5130
+ const { url } = t;
5131
+ const base = `${url}/api/self/p/automation`;
5132
+ const asJson = flags.json === true;
5133
+ const id = flags._[1];
5134
+ if (!id)
5135
+ die('usage: octwin automation why <id> [--kind timer|fire|queued] (ids: octwin automation coming | happened)');
5136
+ const kinds = typeof flags.kind === 'string' ? [flags.kind] : ['timer', 'fire', 'queued'];
5137
+ for (const kind of kinds) {
5138
+ if (!['timer', 'fire', 'queued'].includes(kind))
5139
+ die(`--kind is timer, fire or queued (got '${kind}')`);
5140
+ const { status, json } = await apiGet(`${base}/why/${kind}/${encodeURIComponent(id)}`, t);
5141
+ if (status === 404)
5142
+ continue;
5143
+ if (status !== 200)
5144
+ die(`could not read why (HTTP ${status})${errDetail(json)}${authFailureDetail(status, t.url)}`);
5145
+ if (asJson) {
5146
+ out.result(json);
5147
+ return;
5148
+ }
5149
+ const v = json ?? {};
5150
+ out.print(`${phraseText(v.line?.headline)}${lineTail(v.line)} — ${shortTime(v.line?.at)}`);
5151
+ if (v.rule)
5152
+ out.print(` rule ${v.rule.key}: ${phraseText(v.rule.sentence)}`);
5153
+ if (v.line?.outcome)
5154
+ out.print(` outcome: ${v.line.outcome}${v.line.code ? ` (${v.line.code})` : ''}${v.line.reason ? ` — ${v.line.reason}` : ''}`);
5155
+ for (const check of (v.checks ?? []))
5156
+ out.print(` checked again before it acts: ${phraseText(check)}`);
5157
+ if (v.words?.text)
5158
+ out.print(` the customer reads${v.words.tag ? ` (${v.words.tag})` : ''}: ${v.words.text}`);
5159
+ for (const s of (v.steps ?? [])) {
5160
+ const send = s.send ? ` · ${s.send.status}${s.send.reason ? ` (${s.send.reason})` : ''}` : '';
5161
+ out.print(` step ${s.verb}: ${s.skipped ? `held back (${s.skipped})` : s.state}${send}${s.detail ? ` — ${s.detail}` : ''}`);
5162
+ }
5163
+ if (v.queued)
5164
+ out.print(` a flow's own message: ${v.queued.status}${v.queued.error ? ` — ${v.queued.error}` : ''}`);
5165
+ return;
5166
+ }
5167
+ die(`nothing under '${id}' in ${targetLabel(t)} — a coming time may have just happened, and a fire is kept 30 days`);
4807
5168
  }
4808
5169
  /**
4809
- * `octwin automation [--campaigns] [--json]` — the jobs a pack's declarations
4810
- * produced, with their last result, plus the health counts.
5170
+ * `octwin automation [--json]` — the project's rules, each in the server's words, under the week's numbers.
4811
5171
  *
4812
- * Needs `automation:read`. The job list is CAPPED server-side and the counts are
4813
- * computed in SQL, so the header numbers come from `/health` rather than from
4814
- * filtering the page — past the cap a client-side count would depend on the cap
4815
- * instead of the data.
5172
+ * Needs `automation:read`. The words are the console's (`rules/describe.ts` writes each rule as a sentence), so the
5173
+ * terminal says what the Automatic tab says.
4816
5174
  */
4817
5175
  async function cmdAutomation(flags) {
4818
- if (typeof flags._[0] === 'string' && AUTOMATION_VERBS.has(flags._[0])) {
4819
- // `campaigns` is a READ that shares the verb slot with the writes.
4820
- if (flags._[0] !== 'campaigns')
4821
- return cmdAutomationWrite(flags);
5176
+ const verb = typeof flags._[0] === 'string' ? flags._[0] : null;
5177
+ if (verb && AUTOMATION_WRITES.has(verb))
5178
+ return cmdAutomationWrite(flags);
5179
+ if (verb && !AUTOMATION_VERBS.has(verb)) {
5180
+ die(`unknown automation verb '${verb}' — octwin automation [coming | happened | why <id> | campaigns | run|pause|resume <ruleKey> | send <campaignId>]`);
4822
5181
  }
4823
5182
  const t = resolveTarget(flags);
4824
5183
  const { url } = t;
4825
5184
  const base = `${url}/api/self/p/automation`;
4826
5185
  const asJson = flags.json === true;
4827
- const campaigns = flags._[0] === 'campaigns' || flags.campaigns === true;
4828
- if (campaigns) {
5186
+ if (verb === 'coming')
5187
+ return automationComing(flags, t);
5188
+ if (verb === 'happened')
5189
+ return automationHappened(flags, t);
5190
+ if (verb === 'why')
5191
+ return automationWhy(flags, t);
5192
+ if (verb === 'campaigns' || flags.campaigns === true) {
4829
5193
  if (!asJson)
4830
5194
  out.print(`→ Reading campaigns from ${targetLabel(t)} …`);
4831
5195
  const { status, json } = await apiGet(`${base}/campaigns?${pagingQs(flags)}`, t);
@@ -4838,50 +5202,51 @@ async function cmdAutomation(flags) {
4838
5202
  const page = readPage(json);
4839
5203
  out.print(`Campaigns in ${targetLabel(t)}: ${page.total ?? page.rows.length}`);
4840
5204
  if (page.rows.length === 0)
4841
- out.print(' (none — a campaign comes from a `campaigns:` block in the pack\'s automation declaration)');
5205
+ out.print(' (none — a flow drafts one with the `campaign_create` step)');
5206
+ // What the route returns, field for field: `{ id, record_number, name, entity, stage, progress }`.
5207
+ // It read `label` / `key` / `audience_size` / `matched` until 2026-10-11 — none of which a campaign
5208
+ // row carries — so every row printed its uuid twice and "—".
4842
5209
  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}`);
5210
+ 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
5211
  }
4846
5212
  const more = morePageHint(page, 'octwin automation campaigns');
4847
5213
  if (more)
4848
5214
  out.print(more);
4849
- out.print('\nSend one: octwin automation send <campaignId> (needs automation:write)');
5215
+ out.print('\nSend a draft, or finish one stuck at `sending`: octwin automation send <campaignId> (needs automation:write; each contact gets it once)');
4850
5216
  return;
4851
5217
  }
4852
5218
  if (!asJson)
4853
- out.print(`→ Reading automation jobs from ${targetLabel(t)} …`);
4854
- const [jobs, health] = await Promise.all([
4855
- apiGet(`${base}/jobs`, t),
4856
- apiGet(`${base}/health`, t),
4857
- ]);
4858
- if (jobs.status !== 200)
4859
- die(`could not read automation jobs (HTTP ${jobs.status})${errDetail(jobs.json)}${authFailureDetail(jobs.status, url)}`);
5219
+ out.print(`→ Reading the rules of ${targetLabel(t)} …`);
5220
+ const [rules, summary] = await Promise.all([apiGet(`${base}/rules`, t), apiGet(`${base}/summary`, t)]);
5221
+ if (rules.status !== 200)
5222
+ die(`could not read the rules (HTTP ${rules.status})${errDetail(rules.json)}${authFailureDetail(rules.status, url)}`);
5223
+ const s = summary.status === 200 ? summary.json : null;
4860
5224
  if (asJson) {
4861
- out.result({ jobs: jobs.json, health: health.json });
5225
+ out.result({ summary: s, rules: rules.json?.rules ?? [] });
4862
5226
  return;
4863
5227
  }
4864
- const page = readPage(jobs.json);
4865
- const h = health.status === 200 ? (health.json ?? {}) : {};
4866
- 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`);
4868
- if (page.rows.length === 0) {
4869
- out.print(' (none — jobs are DERIVED from the pack\'s automation declaration, not created here.');
4870
- out.print(' No `automation.yaml` block → no jobs. `octwin deploy` installs them.)');
4871
- return;
5228
+ const list = (rules.json?.rules ?? []);
5229
+ const w = s?.week ?? {};
5230
+ out.print(`Automation in ${targetLabel(t)}: ${list.length} rule(s)`
5231
+ + (s ? ` — this week ${w.sent ?? 0} sent, ${w.notDelivered ?? 0} not delivered, ${w.failed ?? 0} failed, ${s.counts?.upcoming ?? 0} coming up` : ''));
5232
+ for (const n of (s?.needsLook ?? [])) {
5233
+ out.print(` ⚠ ${n.rule?.key ?? '?'}: ${n.kind === 'paused' ? 'paused' : n.kind === 'not_delivered' ? `${n.n} not delivered${n.codes?.length ? ` (${n.codes.join(', ')})` : ''}` : `${n.n} failed`}`);
4872
5234
  }
4873
- for (const j of page.rows) {
4874
- const r = j.last_result ?? {};
4875
- const ran = j.last_run_at ? `last ${j.last_run_at}` : 'never ran';
4876
- const result = j.last_result
4877
- ? ` matched ${r.matched ?? 0}/acted ${r.acted ?? 0}${(r.errors ?? 0) > 0 ? `/ERRORS ${r.errors}` : ''}`
4878
- : '';
4879
- out.print(` ${String(j.key ?? j.id).padEnd(26)} ${String(j.status).padEnd(7)} ${j.kind}/${j.entity ?? '—'}`
4880
- + ` every ${j.interval_seconds}s ${ran}${result}`);
5235
+ if (list.length === 0) {
5236
+ out.print(' (none — a pack writes rules in automation.yaml `rules:`; `octwin deploy` installs them)');
5237
+ return;
4881
5238
  }
4882
- out.print('\nRun one now: octwin automation run <jobId> (jobId = the `key` above, or its uuid)');
4883
- out.print('Pause/resume: octwin automation pause|resume <jobId>');
4884
- out.print('Campaigns: octwin automation campaigns');
5239
+ for (const r of list) {
5240
+ const wk = r.week ?? {};
5241
+ out.print(` ${String(r.key)} [${r.paused ? 'paused' : 'on'}] next ${shortTime(r.next)}`
5242
+ + ` · week ${wk.total ?? 0}: ${wk.sent ?? 0} sent, ${wk.notDelivered ?? 0} not delivered, ${wk.failed ?? 0} failed, ${wk.skipped ?? 0} skipped`);
5243
+ out.print(` ${phraseText(r.sentence)}`);
5244
+ }
5245
+ out.print('\nComing up: octwin automation coming [--range today|tomorrow|week|all]');
5246
+ out.print('Happened: octwin automation happened [--chip not_delivered|failed|skipped|moves|tasks] [--period 7d|30d]');
5247
+ out.print('Why one line: octwin automation why <id>');
5248
+ out.print('Act: octwin automation pause|resume|run <ruleKey> (needs automation:write)');
5249
+ out.print('Campaigns: octwin automation campaigns');
4885
5250
  }
4886
5251
  // ── integrations: declared connections, their credentials, and the delivery log ──
4887
5252
  /** Verbs that act on a connection or a delivery, rather than naming one. */
@@ -5334,8 +5699,214 @@ async function cmdJourneys(flags) {
5334
5699
  }
5335
5700
  // ── performance: the project's business indicators ──────────────────────────
5336
5701
  /**
5337
- * `octwin performance [--detail] [--json]` — the indicators the project's own
5338
- * declarations produce: value, conversion, duration, per journey.
5702
+ * `octwin charts [<key>] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--json]` — the pack's own `charts:`
5703
+ * (xrm.yaml), as the owner's dashboard draws them: the list, or one chart's figure, parts or points.
5704
+ * Needs `records:read`; a `view` grant on each chart's entity decides which ones the token sees.
5705
+ */
5706
+ async function cmdCharts(flags) {
5707
+ const t = resolveTarget(flags);
5708
+ const { url } = t;
5709
+ const base = `${url}/api/self/p/xrm/charts`;
5710
+ const key = flags._[0];
5711
+ const asJson = flags.json === true;
5712
+ if (!key) {
5713
+ if (!asJson)
5714
+ out.print(`→ Reading the pack's charts from ${targetLabel(t)} …`);
5715
+ const { status, json } = await apiGet(`${base}`, t);
5716
+ if (status !== 200)
5717
+ die(`could not read charts (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5718
+ if (asJson) {
5719
+ out.result(json);
5720
+ return;
5721
+ }
5722
+ const charts = (json?.charts ?? []);
5723
+ if (charts.length === 0) {
5724
+ 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.');
5725
+ return;
5726
+ }
5727
+ for (const c of charts)
5728
+ out.print(` ${c.key.padEnd(24)} ${(pickLabel(c.label) ?? c.key).padEnd(32)} ${c.show} · ${c.entity}`);
5729
+ out.print('\n`octwin charts <key>` draws one.');
5730
+ return;
5731
+ }
5732
+ const q = new URLSearchParams();
5733
+ if (typeof flags.from === 'string')
5734
+ q.set('from', flags.from);
5735
+ if (typeof flags.to === 'string')
5736
+ q.set('to', flags.to);
5737
+ const qs = q.toString() ? `?${q}` : '';
5738
+ if (!asJson)
5739
+ out.print(`→ Reading chart '${key}' from ${targetLabel(t)} …`);
5740
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(key)}${qs}`, t);
5741
+ if (status !== 200)
5742
+ die(`could not read chart '${key}' (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5743
+ if (asJson) {
5744
+ out.result(json);
5745
+ return;
5746
+ }
5747
+ if (json?.has_data !== true) {
5748
+ out.print(`No figure for '${key}' — its entity has no records in this window, or this token may not view it.`);
5749
+ return;
5750
+ }
5751
+ const c = json.chart;
5752
+ out.print(`${pickLabel(c.label) ?? c.key} ${json.range?.from ?? '?'} → ${json.range?.to ?? '?'}`);
5753
+ if (c.value !== undefined)
5754
+ out.print(` ${c.value ?? '—'}${c.ratio ? ` (${c.ratio.of ?? '—'} ÷ ${c.ratio.per ?? '—'})` : ''}`);
5755
+ for (const p of c.parts ?? [])
5756
+ out.print(` ${String(pickLabel(p.label) ?? p.key).padEnd(28)} ${String(p.value ?? '—').padStart(10)} n=${p.count}`);
5757
+ if (c.cut)
5758
+ out.print(` … top ${c.cut.kept} of ${c.cut.groups}${c.cut.other ? `; the rest: ${c.cut.other.value ?? '—'} (n=${c.cut.other.count})` : ''}`);
5759
+ for (const p of c.points ?? [])
5760
+ out.print(` ${String(p.bucket).padEnd(12)} ${String(p.value ?? '—').padStart(10)} n=${p.count}`);
5761
+ }
5762
+ /**
5763
+ * `octwin handoff [<id>] [--bucket waiting|mine|all|ended] [--limit n] [--offset n] [--json]` — the
5764
+ * Live chat desk, READ-ONLY: the handoffs in a bucket, or one with its transcript (who said what —
5765
+ * customer, AI Agent, operator, platform). Needs `work:read` and the `live_chat` plan feature.
5766
+ *
5767
+ * Accepting, answering and returning a handoff stay in the console: those routes need a signed-in
5768
+ * PERSON to hold the conversation (`accepting needs a signed-in person`), and a token is not one.
5769
+ */
5770
+ async function cmdHandoff(flags) {
5771
+ const t = resolveTarget(flags);
5772
+ const { url } = t;
5773
+ const base = `${url}/api/self/p/handoff`;
5774
+ const id = flags._[0];
5775
+ const asJson = flags.json === true;
5776
+ if (!id) {
5777
+ const q = new URLSearchParams();
5778
+ if (typeof flags.bucket === 'string')
5779
+ q.set('bucket', flags.bucket);
5780
+ if (flags.limit !== undefined)
5781
+ q.set('limit', String(flags.limit));
5782
+ if (flags.offset !== undefined)
5783
+ q.set('offset', String(flags.offset));
5784
+ const qs = q.toString() ? `?${q}` : '';
5785
+ if (!asJson)
5786
+ out.print(`→ Reading the Live chat desk from ${targetLabel(t)} …`);
5787
+ const { status, json } = await apiGet(`${base}${qs}`, t);
5788
+ if (status !== 200)
5789
+ die(`could not read the Live chat desk (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5790
+ if (asJson) {
5791
+ out.result(json);
5792
+ return;
5793
+ }
5794
+ if (json?.has_data === false) {
5795
+ out.print(`No Live chat on this project — ${json?.reason ?? 'the pack works no handoff'} (craft-escalation, the Handoff recipe).`);
5796
+ return;
5797
+ }
5798
+ const rows = (json?.rows ?? []);
5799
+ if (rows.length === 0) {
5800
+ out.print('No handoffs in this view (--bucket waiting|mine|all|ended).');
5801
+ return;
5802
+ }
5803
+ for (const r of rows) {
5804
+ const who = r.contact?.display_name ?? r.contact?.channel_contact_handle ?? '—';
5805
+ 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}`);
5806
+ }
5807
+ out.print('\n`octwin handoff <id>` shows one with its transcript.');
5808
+ return;
5809
+ }
5810
+ if (!asJson)
5811
+ out.print(`→ Reading handoff ${id} from ${targetLabel(t)} …`);
5812
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(id)}`, t);
5813
+ if (status !== 200)
5814
+ die(`could not read handoff ${id} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5815
+ if (asJson) {
5816
+ out.result(json);
5817
+ return;
5818
+ }
5819
+ const h = json?.handoff ?? {};
5820
+ out.print(`Handoff #${h.handoff_number ?? '?'} — ${h.stage ?? '?'}${h.queue_key ? ` · queue ${h.queue_key}` : ''}${h.reason ? ` · ${h.reason}` : ''}`);
5821
+ if (h.summary)
5822
+ out.print(` ${h.summary}`);
5823
+ for (const line of (json?.transcript ?? [])) {
5824
+ out.print(` ${String(line.ts).slice(11, 16)} ${line.role === 'operator' && line.author ? `operator: ${line.author}` : line.role} ${line.body}`);
5825
+ }
5826
+ if (json?.has_more)
5827
+ out.print(' … earlier lines in the console (Live chat).');
5828
+ }
5829
+ /**
5830
+ * `octwin surveys [<key>] [--limit n] [--offset n] [--json]` — the declared surveys with their
5831
+ * standing (responses, the headline score under its model), or one survey: its questions, the
5832
+ * rollup and the recent responses. The same reads as the console's Surveys page. Needs `records:read`.
5833
+ */
5834
+ async function cmdSurveys(flags) {
5835
+ const t = resolveTarget(flags);
5836
+ const { url } = t;
5837
+ const base = `${url}/api/self/p/surveys`;
5838
+ const key = flags._[0];
5839
+ const asJson = flags.json === true;
5840
+ const score = (s) => s?.value == null ? '—' : `${s.value}${s.unit === 'pct' ? '%' : ''} (${s.model})`;
5841
+ if (!key) {
5842
+ if (!asJson)
5843
+ out.print(`→ Reading surveys from ${targetLabel(t)} …`);
5844
+ const { status, json } = await apiGet(`${base}`, t);
5845
+ if (status !== 200)
5846
+ die(`could not read surveys (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5847
+ if (asJson) {
5848
+ out.result(json);
5849
+ return;
5850
+ }
5851
+ const rows = (json?.rows ?? []);
5852
+ if (rows.length === 0) {
5853
+ out.print('No surveys — a pack declares them in surveys.yaml (craft-surveys).');
5854
+ return;
5855
+ }
5856
+ for (const r of rows)
5857
+ out.print(` ${r.key.padEnd(22)} ${(pickLabel(r.label) ?? r.key).padEnd(30)} responses ${String(r.responses ?? '—').padStart(5)} score ${score(r.score)}`);
5858
+ out.print('\n`octwin surveys <key>` shows one — its questions, rollup and recent answers.');
5859
+ return;
5860
+ }
5861
+ const q = new URLSearchParams();
5862
+ if (flags.limit !== undefined)
5863
+ q.set('limit', String(flags.limit));
5864
+ if (flags.offset !== undefined)
5865
+ q.set('offset', String(flags.offset));
5866
+ const qs = q.toString() ? `?${q}` : '';
5867
+ if (!asJson)
5868
+ out.print(`→ Reading survey '${key}' from ${targetLabel(t)} …`);
5869
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(key)}${qs}`, t);
5870
+ if (status !== 200)
5871
+ die(`could not read survey '${key}' (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5872
+ if (asJson) {
5873
+ out.result(json);
5874
+ return;
5875
+ }
5876
+ const sv = json?.survey ?? {};
5877
+ const a = json?.analytics;
5878
+ out.print(`${pickLabel(sv.label) ?? key}${sv.subject ? ` about: ${sv.subject}` : ''}`);
5879
+ for (const qq of sv.questions ?? [])
5880
+ out.print(` • ${qq.key} (${qq.type}${qq.required ? ', required' : ''})`);
5881
+ out.print(a ? ` responses ${a.responses} · scored ${a.scored} · score ${score(a.score)}` : ' rollup unavailable — the pack has not loaded on this project');
5882
+ const rows = (json?.responses?.rows ?? []);
5883
+ for (const r of rows)
5884
+ out.print(` ${String(r.created_at).slice(0, 16)} score ${r.score ?? '—'} ${JSON.stringify(r.answers)}`);
5885
+ }
5886
+ /**
5887
+ * `--range <days>` and `--channel <whatsapp|web>` as the route's own `?range=` / `?channel=` — the
5888
+ * console's two controls. Until 2026-10-11 the CLI read only `--detail`, so `--channel web` printed
5889
+ * the all-channel band as if it were the web's (an unknown flag was ignored then, not refused).
5890
+ */
5891
+ function performanceQuery(flags) {
5892
+ const p = new URLSearchParams();
5893
+ if (flags.range !== undefined) {
5894
+ const days = Number(flags.range);
5895
+ if (!Number.isInteger(days) || days <= 0)
5896
+ die('--range takes a whole number of days, e.g. --range 30');
5897
+ p.set('range', String(days));
5898
+ }
5899
+ if (flags.channel !== undefined) {
5900
+ if (typeof flags.channel !== 'string')
5901
+ die('--channel takes a channel name, e.g. --channel whatsapp');
5902
+ p.set('channel', flags.channel);
5903
+ }
5904
+ const s = p.toString();
5905
+ return s ? `?${s}` : '';
5906
+ }
5907
+ /**
5908
+ * `octwin performance [--detail] [--range <days>] [--channel <ch>] [--json]` — the indicators the
5909
+ * project's own declarations produce: value, conversion, duration, per journey.
5339
5910
  *
5340
5911
  * Needs `records:read` — **not** a `performance:*` scope, which does not exist.
5341
5912
  * That means the Read-only token preset already reaches this.
@@ -5351,9 +5922,10 @@ async function cmdPerformance(flags) {
5351
5922
  // Two explicit calls rather than `apiGet(detail ? … : base)`: the route guard's
5352
5923
  // extractor only reads a template literal in the FIRST argument position, so a
5353
5924
  // ternary hides both URLs from it.
5925
+ const qs = performanceQuery(flags);
5354
5926
  const { status, json } = detail
5355
- ? await apiGet(`${base}/detail`, t)
5356
- : await apiGet(`${base}`, t);
5927
+ ? await apiGet(`${base}/detail${qs}`, t)
5928
+ : await apiGet(`${base}${qs}`, t);
5357
5929
  if (status !== 200)
5358
5930
  die(`could not read performance (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
5359
5931
  if (asJson) {
@@ -5466,427 +6038,472 @@ async function cmdUsage(flags) {
5466
6038
  out.print('\nThis is MODEL spend. WhatsApp/Meta message billing is operator-only — not reachable by an API token.');
5467
6039
  }
5468
6040
  function help() {
5469
- out.print(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
5470
-
5471
- octwin --version # print the CLI version (+ any upgrade notice)
5472
- octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
5473
- octwin validate [--dir .] [--remote | --offline] [--require-kb] # the platform's FULL check by default — no login needed
5474
- octwin login --url <platformUrl> # approve in a browser; the CLI collects the token
5475
- octwin login --url <platformUrl> --token oct_… # or paste a deploy token from the console
5476
- octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
5477
- octwin projects [--archived] [--json] # the --project slugs this token can name
5478
- octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
5479
- [--request-listing | --withdraw-listing] # public marketplace — opt-in, see: octwin help deploy
5480
- octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
5481
- octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
5482
- octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
5483
- octwin work [recordId] [--queues] [--unrouted] [--json] # inspect the work inbox (worked records) + timelines
5484
- # --queues: per-queue open counts + an UNROUTED warning · --unrouted: only the items in no queue
5485
- octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
5486
- octwin chat "message" [--as <handle>] [--tap <tap-id>] [--press <label>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
5487
- octwin media generate "<prompt>" [--out <file.png>] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
5488
- octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
5489
- octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
5490
- octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
5491
- octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
5492
- octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
5493
- octwin automation [campaigns] [--json] # the jobs your declarations produced + health, last result each
5494
- octwin integrations [--json] # declared connections BESIDE what is configured (the silent-never-fires check)
5495
- octwin integrations deliveries [<id>] | events # the outbound delivery log / inbound events
5496
- octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition] [--stage <id>]
5497
- octwin performance [--detail] [--json] # the project's business indicators (value/conversion/duration)
5498
- octwin usage [--json] # model calls, tokens and cost (project if pinned, else workspace)
5499
- octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
5500
- octwin test [--dir .] # = validate --remote (the full platform check)
5501
- octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
5502
- octwin memos [--all] [--json] # read the platform's replies + notices (a reply to your feedback lands here)
5503
-
5504
- Writes — exercise the state your pack creates (each needs the matching :write scope):
5505
- octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…", archive|restore <id>
5506
- octwin records tasks | task complete <taskId> [--outcome done|cancelled]
5507
- octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
5508
- octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
5509
- octwin orders transition <ref> --to <status> | refund <ref> --force
5510
- octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
5511
- octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
5512
- octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
5513
- octwin automation run <jobId> | pause <jobId> | resume <jobId> | send <campaignId>
5514
- octwin integrations test <key> # a LIVE call to the connection's health: operation
5515
- octwin integrations retry|cancel|send-now <deliveryId>
5516
- (octwin integrations preflight <key> needs only integrations:read — it makes no call)
5517
-
5518
- Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
5519
- \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
5520
- button/row with \`--tap "<tap-id>"\` (chat prints every tap id), or by its label with
5521
- \`--press "<label>"\` — the way through a form or checkout, whose ids change.
5522
- Signing in: \`octwin login --url <u>\` prints a link + code to approve in a browser. To pick your own
5523
- scopes instead, mint a token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
5524
- octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
5525
- Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
6041
+ out.print(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
6042
+
6043
+ octwin --version # print the CLI version (+ any upgrade notice)
6044
+ octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
6045
+ octwin recipe [<module>] [--dir .] [--dry-run] # a KB recipe's starter files into this pack (no module = the list)
6046
+ octwin validate [--dir .] [--remote | --offline] [--require-kb] [--strict-primitives] # the platform's FULL check by default — no login needed
6047
+ octwin login --url <platformUrl> # approve in a browser; the CLI collects the token
6048
+ octwin login --url <platformUrl> --token oct_… # or paste a deploy token from the console
6049
+ octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
6050
+ octwin projects [--archived] [--json] # the --project slugs this token can name
6051
+ octwin projects create "<name>" [--slug s] [--pack p] | rm <slug> [--yes] # a disposable project (needs projects:write)
6052
+ octwin seed [--reset] [--pack <packId>] # apply the pack's demo data without redeploying
6053
+ octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed] [--force]
6054
+ [--request-listing | --withdraw-listing] # public marketplace — opt-in, see: octwin help deploy
6055
+ octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
6056
+ octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
6057
+ octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
6058
+ octwin work [recordId] [--queues] [--unrouted] [--json] # inspect the work inbox (worked records) + timelines
6059
+ # --queues: per-queue open counts + an UNROUTED warning · --unrouted: only the items in no queue
6060
+ octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
6061
+ octwin chat "message" [--as <handle>] [--tap <tap-id>] [--press <label>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
6062
+ octwin media generate "<prompt>" [--out <file.png>] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
6063
+ octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
6064
+ octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
6065
+ octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
6066
+ octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
6067
+ octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
6068
+ octwin automation [coming|happened|why <id>|campaigns] [--json] # the rules in words; coming up; happened, and why
6069
+ octwin integrations [--json] # declared connections BESIDE what is configured (the silent-never-fires check)
6070
+ octwin integrations deliveries [<id>] | events # the outbound delivery log / inbound events
6071
+ octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition] [--stage <id>]
6072
+ octwin performance [--detail] [--range <days>] [--channel <ch>] [--json] # the project's business indicators (value/conversion/duration)
6073
+ octwin usage [--json] # model calls, tokens and cost (project if pinned, else workspace)
6074
+ octwin charts [<key>] [--from d] [--to d] [--json] # the pack's own charts: — the list, or one chart's figure
6075
+ octwin handoff [<id>] [--bucket waiting|mine|all|ended] [--limit n] [--offset n] [--json] # the Live chat desk, read-only
6076
+ octwin surveys [<key>] [--limit n] [--offset n] [--json] # surveys with their score; one survey's questions, rollup, answers
6077
+ octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
6078
+ octwin test [--dir .] [--require-kb] [--strict-primitives] # = validate --remote (the full platform check)
6079
+ octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
6080
+ octwin memos [--all] [--json] # read the platform's replies + notices (a reply to your feedback lands here)
6081
+
6082
+ Writes — exercise the state your pack creates (each needs the matching :write scope):
6083
+ octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…", archive|restore <id>
6084
+ octwin records tasks | task complete <taskId> [--outcome done|cancelled]
6085
+ octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
6086
+ octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
6087
+ octwin orders transition <ref> --to <status> | refund <ref> --force
6088
+ octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
6089
+ octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
6090
+ octwin agents set <packId::agentId> [--model m] [--enable-tool t] [--disable-tool t]
6091
+ octwin automation pause|resume|run <ruleKey> | send <campaignId>
6092
+ octwin integrations test <key> # a LIVE call to the connection's health: operation
6093
+ octwin integrations retry|cancel|send-now <deliveryId>
6094
+ (octwin integrations preflight <key> needs only integrations:read — it makes no call)
6095
+
6096
+ Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
6097
+ \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
6098
+ button/row with \`--tap "<tap-id>"\` (chat prints every tap id), or by its label with
6099
+ \`--press "<label>"\` — the way through a form or checkout, whose ids change.
6100
+ Signing in: \`octwin login --url <u>\` prints a link + code to approve in a browser. To pick your own
6101
+ scopes instead, mint a token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
6102
+ octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
6103
+ Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
5526
6104
  Per-command usage: octwin <command> --help`);
5527
6105
  }
5528
6106
  /** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
5529
6107
  * network/auth work (a --help that 401s is worse than no help at all). */
5530
6108
  const COMMAND_HELP = {
5531
- init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
6109
+ init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
5532
6110
  Scaffold a pure-YAML starter pack into <dir>.`,
5533
- validate: `octwin validate [--dir .] [--remote | --offline] [--require-kb] [--strict-primitives]
5534
- First the fast OFFLINE checks (structure, YAML, and the checks driven by the
5535
- pulled capability reference — those SKIP when the reference is missing, and
5536
- --require-kb turns the skip into a failure for CI). Then, BY DEFAULT, the
5537
- platform's FULL validation — the same check deploy runs, all errors at once.
5538
-
5539
- The full check needs NO login: without a token + --project it goes to the
5540
- platform's public validate route. With a token AND --project (or PACK_PROJECT)
5541
- it goes through your workspace, which also confirms that project exists.
5542
-
5543
- When the platform cannot be reached (no url, offline, rate-limited) the run
5544
- ends with "only the OFFLINE check ran — a PARTIAL check" and exits 0.
5545
- --remote REQUIRES the full check: not reaching it is an error. --offline runs
5546
- only the partial subset. --strict-primitives additionally type-checks LITERAL
5547
- args: values against each primitive's declared input schema; expression
6111
+ validate: `octwin validate [--dir .] [--remote | --offline] [--require-kb] [--strict-primitives]
6112
+ First the fast OFFLINE checks (structure, YAML, and the checks driven by the
6113
+ pulled capability reference — those SKIP when the reference is missing, and
6114
+ --require-kb turns the skip into a failure for CI). Then, BY DEFAULT, the
6115
+ platform's FULL validation — the same check deploy runs, all errors at once.
6116
+
6117
+ The full check needs NO login: without a token + --project it goes to the
6118
+ platform's public validate route. With a token AND --project (or PACK_PROJECT)
6119
+ it goes through your workspace, which also confirms that project exists.
6120
+
6121
+ When the platform cannot be reached (no url, offline, rate-limited) the run
6122
+ ends with "only the OFFLINE check ran — a PARTIAL check" and exits 0.
6123
+ --remote REQUIRES the full check: not reaching it is an error. --offline runs
6124
+ only the partial subset. --strict-primitives additionally type-checks LITERAL
6125
+ args: values against each primitive's declared input schema; expression
5548
6126
  strings ('$found.id', '{$t(…)}') are always exempt.`,
5549
- login: `octwin login --url <platformUrl> [--token oct_…] [--wait <seconds>] [--json]
5550
- Sign in to a platform and make that url the DEFAULT deploy target for every
5551
- later command.
5552
-
5553
- WITH --token: saves a token you minted yourself (console → Settings → API
5554
- tokens). Unchanged, and still the right answer for CI — there, skip login and
5555
- set PACK_PLATFORM_URL + PACK_TOKEN instead.
5556
-
5557
- WITHOUT --token: prints a link to approve in a browser. A workspace admin opens
5558
- it, checks which machine is asking, and clicks Approve; the CLI then collects a
5559
- 30-day token by itself, so no token is ever pasted between people. The code is
5560
- printed too — it is what the page shows, so you can tell it is your own request.
5561
- Approve only a link you opened because YOU ran this command: a link someone
5562
- sends you would connect THEIR machine to your workspace.
5563
-
5564
- The wait is bounded (90s by default) and RESUMABLE: if nobody has approved yet
5565
- it exits 75 and keeps the request, so running \`octwin login\` again picks up the
6127
+ login: `octwin login --url <platformUrl> [--token oct_…] [--wait <seconds>] [--json]
6128
+ Sign in to a platform and make that url the DEFAULT deploy target for every
6129
+ later command.
6130
+
6131
+ WITH --token: saves a token you minted yourself (console → Settings → API
6132
+ tokens). Unchanged, and still the right answer for CI — there, skip login and
6133
+ set PACK_PLATFORM_URL + PACK_TOKEN instead.
6134
+
6135
+ WITHOUT --token: prints a link to approve in a browser. A workspace admin opens
6136
+ it, checks which machine is asking, and clicks Approve; the CLI then collects a
6137
+ 30-day token by itself, so no token is ever pasted between people. The code is
6138
+ printed too — it is what the page shows, so you can tell it is your own request.
6139
+ Approve only a link you opened because YOU ran this command: a link someone
6140
+ sends you would connect THEIR machine to your workspace.
6141
+
6142
+ The wait is bounded (90s by default) and RESUMABLE: if nobody has approved yet
6143
+ it exits 75 and keeps the request, so running \`octwin login\` again picks up the
5566
6144
  same code. --wait 0 prints the code and returns at once.`,
5567
- whoami: `octwin whoami [--url <url>] [--tenant <slug>]
6145
+ whoami: `octwin whoami [--url <url>] [--tenant <slug>]
5568
6146
  Verify the resolved token authenticates against the tenant.`,
5569
- projects: `octwin projects [--archived] [--json]
5570
- List the workspace's projects — the slugs every --project flag takes, with the
5571
- plan's project cap. --archived includes archived ones. A pack:deploy token
5572
- reaches this (it names a project in every other command).
5573
-
5574
- octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
5575
- Create a project. The URL slug is derived from the name unless --slug pins one.
5576
- --pack installs an ALREADY-published pack; the usual next step is instead
5577
- \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
5578
-
5579
- octwin projects rm <slug> [--yes]
5580
- HARD delete — the project and everything cascading from it (conversations,
5581
- contacts, records, installs). No undo, and not the same as archiving.
5582
- WITHOUT --yes it only previews what would be destroyed, so the dry run is the
5583
- default. Together these make a disposable end-to-end environment:
5584
- octwin projects create "Scratch" && octwin deploy --project scratch --seed
5585
- octwin chat "hi" --project scratch
5586
- octwin projects rm scratch --yes
6147
+ projects: `octwin projects [--archived] [--json]
6148
+ List the workspace's projects — the slugs every --project flag takes, with the
6149
+ plan's project cap. --archived includes archived ones. A pack:deploy token
6150
+ reaches this (it names a project in every other command).
6151
+
6152
+ octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
6153
+ Create a project. The URL slug is derived from the name unless --slug pins one.
6154
+ --pack installs an ALREADY-published pack; the usual next step is instead
6155
+ \`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
6156
+
6157
+ octwin projects rm <slug> [--yes]
6158
+ HARD delete — the project and everything cascading from it (conversations,
6159
+ contacts, records, installs). No undo, and not the same as archiving.
6160
+ WITHOUT --yes it only previews what would be destroyed, so the dry run is the
6161
+ default. Together these make a disposable end-to-end environment:
6162
+ octwin projects create "Scratch" && octwin deploy --project scratch --seed
6163
+ octwin chat "hi" --project scratch
6164
+ octwin projects rm scratch --yes
5587
6165
  Both verbs need the \`projects:write\` scope — a pack:deploy token does NOT confer it.`,
5588
- deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
5589
- [--request-listing | --withdraw-listing]
5590
- Upload the pack bundle, validate server-side, install onto the project.
5591
- --seed additionally applies the pack's demo seed (streams progress).
5592
-
5593
- A plain deploy says NOTHING about the public marketplace — it is a test loop, so it
5594
- neither asks for a listing nor gives one up. The marketplace flags are opt-in:
5595
-
5596
- --request-listing ask an operator to review this pack for the public marketplace
5597
- (the pre-signup storefront at /packs). Requires 'public: true'
5598
- under 'listing:' in manifest.yaml — the manifest states that the
5599
- pack is a product, the flag is you choosing to ask.
5600
- --withdraw-listing retract the request, including an approved listing.
5601
-
5602
- An approval covers the CONTENT it was made against, so a later deploy that changes the
6166
+ deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed] [--force]
6167
+ [--request-listing | --withdraw-listing]
6168
+ Upload the pack bundle, validate server-side, install onto the project.
6169
+ --seed additionally applies the pack's demo seed (streams progress).
6170
+
6171
+ A deploy sends what this copy was made from (${BASE_FILE}, written by every pull and
6172
+ deploy). When someone published since — Tavi in the console, another deploy, a repo
6173
+ import — it is refused and NOTHING changes: run 'octwin pull <packId>', which updates
6174
+ this folder (it stops if you changed a file since, so you can save your edit first),
6175
+ then deploy again. A version lower than the live one is refused the same way.
6176
+ --force deploy anyway, replacing the newer or higher live version.
6177
+
6178
+ A plain deploy says NOTHING about the public marketplace — it is a test loop, so it
6179
+ neither asks for a listing nor gives one up. The marketplace flags are opt-in:
6180
+
6181
+ --request-listing ask an operator to review this pack for the public marketplace
6182
+ (the pre-signup storefront at /packs). Requires 'public: true'
6183
+ under 'listing:' in manifest.yaml — the manifest states that the
6184
+ pack is a product, the flag is you choosing to ask.
6185
+ --withdraw-listing retract the request, including an approved listing.
6186
+
6187
+ An approval covers the CONTENT it was made against, so a later deploy that changes the
5603
6188
  pack returns it to the review queue on its own — no flag needed, and the CLI says so.`,
5604
- seed: `octwin seed [--pack <packId>] [--reset]
5605
- Apply the pack's demo/reference data to the project it is installed on, without
5606
- redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
5607
- and the demo operator topology. Reports what each kind produced.
5608
- Idempotent and safe to re-run — each demo row is found again by its handle (its
5609
- \`as:\`, natural key or title) and updated, and existing media is REUSED rather
5610
- than regenerated, so a second pass costs nothing. Demo rows are DATA: no \`on:\`
5611
- reaction fires, and a row takes its \`stage:\` as written.
5612
- --reset archive every row the pack's demo loaded, then load it again. Needed once
5613
- for demo rows seeded before 2026-10-05 (the seed says so), and a clean
5614
- slate whenever you want one. Records your customers made are not touched.
6189
+ seed: `octwin seed [--pack <packId>] [--reset]
6190
+ Apply the pack's demo/reference data to the project it is installed on, without
6191
+ redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
6192
+ and the demo operator topology. Reports what each kind produced.
6193
+ Idempotent and safe to re-run — each demo row is found again by its handle (its
6194
+ \`as:\`, natural key or title) and updated, and existing media is REUSED rather
6195
+ than regenerated, so a second pass costs nothing. Demo rows are DATA: no \`on:\`
6196
+ reaction fires, and a row takes its \`stage:\` as written.
6197
+ --reset archive every row the pack's demo loaded, then load it again. Needed once
6198
+ for demo rows seeded before 2026-10-05 (the seed says so), and a clean
6199
+ slate whenever you want one. Records your customers made are not touched.
5615
6200
  --pack is only needed when a project somehow runs more than one.`,
5616
- status: `octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
5617
- Show installed vs live version + the flow list for this pack.
5618
- The pack id is read from manifest.yaml and QUALIFIED with your workspace slug
5619
- (a manifest declares a bare name; the owner is attached when you publish). Pass
5620
- <packId> explicitly to skip that lookup — \`octwin agents\` and \`octwin projects\`
6201
+ status: `octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
6202
+ Show installed vs live version + the flow list for this pack, and whether your copy
6203
+ (${BASE_FILE}) is still the live content — if not, pull before you deploy.
6204
+ The pack id is read from manifest.yaml and QUALIFIED with your workspace slug
6205
+ (a manifest declares a bare name; the owner is attached when you publish). Pass
6206
+ <packId> explicitly to skip that lookup — \`octwin agents\` and \`octwin projects\`
5621
6207
  both print the qualified form.`,
5622
- records: `octwin records [entity] [id] [--limit 50] [--offset n] [--json]
5623
- Inspect the pack's XRM data. No args = list entities. Worked records (cases,
5624
- tickets, anything routed to a queue) read best through \`octwin work\`.
5625
-
5626
- SEARCH:
5627
- octwin records <entity> --search "<words>" # ranked, best match first, each row with its score (0–1)
5628
- Works for an entity that declares \`search:\` in xrm.yaml (fuzzy, plus semantic when declared) —
5629
- the same search the console's search box runs. Any other entity answers "not searchable".
5630
-
5631
- WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
5632
- octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
5633
- octwin records patch <recordId> --entity <entity> --set field=value
5634
- octwin records stage <recordId> --to <stage> [--note "..."]
5635
- octwin records note <recordId> "the note text"
5636
- octwin records archive <recordId> # out of every list and count (test data too)
5637
- octwin records restore <recordId> # undo an archive
5638
- octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
5639
- octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
5640
-
5641
- There is NO delete — on the platform or here. \`archive\` is how a record leaves use:
5642
- it fires the entity's \`on.archived\` reactions, exactly like the console's Archive,
5643
- and \`restore\` brings it back.
5644
-
5645
- --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
5646
- sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
5647
- \`patch\` needs --entity even though it has an id: the route resolves the field
5648
- validator from it. A leading \`create/patch/stage/note/archive/restore/tasks/task\` is
6208
+ records: `octwin records [entity] [id] [--limit 50] [--offset n] [--json]
6209
+ Inspect the pack's XRM data. No args = list entities. Worked records (cases,
6210
+ tickets, anything routed to a queue) read best through \`octwin work\`.
6211
+
6212
+ SEARCH:
6213
+ octwin records <entity> --search "<words>" # ranked, best match first, each row with its score (0–1)
6214
+ Works for an entity that declares \`search:\` in xrm.yaml (fuzzy, plus semantic when declared) —
6215
+ the same search the console's search box runs. Any other entity answers "not searchable".
6216
+
6217
+ WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
6218
+ octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
6219
+ octwin records patch <recordId> --entity <entity> --set field=value
6220
+ octwin records stage <recordId> --to <stage> [--note "..."]
6221
+ octwin records note <recordId> "the note text"
6222
+ octwin records archive <recordId> # out of every list and count (test data too)
6223
+ octwin records restore <recordId> # undo an archive
6224
+ octwin records tasks # open follow-up tasks (\`tasks\` plan feature)
6225
+ octwin records task complete <taskId> [--outcome done|cancelled] [--note "..."]
6226
+
6227
+ There is NO delete — on the platform or here. \`archive\` is how a record leaves use:
6228
+ it fires the entity's \`on.archived\` reactions, exactly like the console's Archive,
6229
+ and \`restore\` brings it back.
6230
+
6231
+ --set coerces JSON scalars: \`--set rating=4.5\` sends a number, \`--set x=null\`
6232
+ sends null. Use --fields-json '{"a":{"b":1}}' for anything nested.
6233
+ \`patch\` needs --entity even though it has an id: the route resolves the field
6234
+ validator from it. A leading \`create/patch/stage/note/archive/restore/tasks/task\` is
5649
6235
  read as a VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
5650
- work: `octwin work [recordId] [--queues] [--unrouted] [--limit 50] [--offset n] [--json]
5651
- Inspect the work inbox — every entity the pack declares worked (cases, orders
5652
- needing review, applications, …): the inbox, one item + its timeline
5653
- (+ applicable actions), or --queues for queue keys + open counts.
5654
-
5655
- WRITES (need \`work:write\`; \`stage\` needs \`records:write\`):
5656
- octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none
5657
- octwin work note <recordId> "the note text"
5658
- octwin work stage <recordId> --to <stage> [--note "..."]
5659
- octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]
5660
-
5661
- \`decide\` applies one of the entity's declared operator actions — \`octwin work <id>\`
5662
- lists them with their params. --dry-run previews the customer-facing copy and the
5663
- resulting stage WITHOUT committing (that route needs only \`work:read\`).
6236
+ work: `octwin work [recordId] [--queues] [--unrouted] [--limit 50] [--offset n] [--json]
6237
+ Inspect the work inbox — every entity the pack declares worked (cases, orders
6238
+ needing review, applications, …): the inbox, one item + its timeline
6239
+ (+ applicable actions), or --queues for queue keys + open counts.
6240
+
6241
+ WRITES (need \`work:write\`; \`stage\` needs \`records:write\`):
6242
+ octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none
6243
+ octwin work note <recordId> "the note text"
6244
+ octwin work stage <recordId> --to <stage> [--note "..."]
6245
+ octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]
6246
+
6247
+ \`decide\` applies one of the entity's declared operator actions — \`octwin work <id>\`
6248
+ lists them with their params. --dry-run previews the customer-facing copy and the
6249
+ resulting stage WITHOUT committing (that route needs only \`work:read\`).
5664
6250
  \`stage\` is the XRM records verb (one transition spelling platform-wide).`,
5665
- logs: `octwin logs [conversationId] [--as <handle>] [--json]
5666
- No id = recent conversations (handle, status, last activity; --as filters).
5667
- With id = the full event timeline including what each turn rendered.
6251
+ logs: `octwin logs [conversationId] [--as <handle>] [--json]
6252
+ No id = recent conversations (handle, status, last activity; --as filters).
6253
+ With id = the full event timeline including what each turn rendered.
5668
6254
  --json = raw events (verbatim payloads).`,
5669
- pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
5670
- Write a DEPLOYED pack's source back to disk — the inverse of deploy.
5671
- A pack pushed with 'octwin deploy' lives on the platform as an artifact the
5672
- runtime serves but nothing hands back, so its only source copy is the machine
5673
- that pushed it. Pull it, fix it, redeploy it.
5674
- Defaults to the version installed on the target project; --version overrides.
5675
- --dir defaults to ./<packId>; a non-empty dir needs --force.
5676
- The pulled dir redeploys where it came from — the target is your saved login.
5677
- You may pull a pack your tenant OWNS (deployed); an operator token pulls any.`,
5678
- chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--press <label>] [--media <file|id>] [--json]
5679
- octwin chat --script <file> [--as <handle>] [--json]
5680
- Drive ONE turn through the dev web channel and print every render with its
5681
- tap ids. Same --as handle = same conversation (multi-turn works).
5682
- --tap presses a rendered button/list row instead of sending text.
5683
- --press does the same BY ITS LABEL, in the reply the conversation is waiting on
5684
- — for a form or checkout step, whose tap id carries a run id that changes.
5685
- --media uploads a local file (or a media id from 'media generate --json') as
5686
- an image/document/audio inbound — any "message" rides as its caption; feeds a
5687
- running media-collect flow (e.g. activate-app).
5688
- --json dumps the raw SSE envelopes for the turn.
5689
-
5690
- Each reply line says who sent it. Plain \`← …\` is the AI Agent. \`← [notify] …\` is a
5691
- reminder or notification (a \`schedule_notify\`, a reaction) — NOT the turn's answer,
5692
- so the wait for the real reply goes on. \`← [operator: Name] …\` is a person in
5693
- Live chat; \`← [platform] …\` is a platform line (a handoff joining or returning).
5694
- After the last turn the stream stays open ${LATE_DRAIN_MS / 1000}s more, so a late message
5695
- still prints (marked "after the last turn") instead of reaching a closed connection.
5696
-
5697
- --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
5698
- process over one connection — waiting for each turn to settle before sending
5699
- the next. Use this for any multi-step flow: chaining shell invocations races
5700
- the agent loop, because a turn ends on a quiet gap that can arrive while the
5701
- server is still working (the symptom is placeholder-filled fields or a second
5702
- workflow run). Blank lines and # comments are skipped:
5703
-
5704
- # book an appointment end to end
5705
- احجز موعد
5706
- tap:t:invoke:book-appointment:doctor_id=D1
5707
- media:./licence.jpg | here is my licence
5708
- press:Confirm
5709
-
5710
- press:<label> taps the button of the reply just received whose label matches —
5711
- exactly, or the same words without emoji or case, or the one label containing
5712
- them. None, or several buttons, stops the script and lists the buttons. A form
5713
- step's tap id carries its run id (t:resume:…:run_id=…), which exists only once
6255
+ pull: `octwin pull <packId> [--dir <out>] [--version <v>] [--force]
6256
+ Write a DEPLOYED pack's source back to disk — the inverse of deploy.
6257
+ A pack pushed with 'octwin deploy' lives on the platform as an artifact the
6258
+ runtime serves but nothing hands back, so its only source copy is the machine
6259
+ that pushed it. Pull it, fix it, redeploy it.
6260
+ Defaults to the version installed on the target project; --version overrides.
6261
+ Writes into the folder you are in when it is this pack, else ./<packId>; --dir overrides.
6262
+ Replaces only files that still match your last pull or deploy (${BASE_FILE}); a file
6263
+ you changed since stops it — save your edit first, or --force overwrites it.
6264
+ The pulled dir redeploys where it came from — the target is your saved login.
6265
+ You may pull a pack your workspace OWNS, or any first-party (Octwin) pack — those are
6266
+ free to copy as a starting point; an operator token pulls any.`,
6267
+ chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--press <label>] [--media <file|id>] [--json]
6268
+ octwin chat --script <file> [--as <handle>] [--json]
6269
+ Drive ONE turn through the dev web channel and print every render with its
6270
+ tap ids. Same --as handle = same conversation (multi-turn works).
6271
+ --tap presses a rendered button/list row instead of sending text.
6272
+ --press does the same BY ITS LABEL, in the reply the conversation is waiting on
6273
+ — for a form or checkout step, whose tap id carries a run id that changes.
6274
+ --media uploads a local file (or a media id from 'media generate --json') as
6275
+ an image/document/audio inbound — any "message" rides as its caption; feeds a
6276
+ running media-collect flow (e.g. activate-app).
6277
+ --json dumps the raw SSE envelopes for the turn.
6278
+
6279
+ Each reply line says who sent it. Plain \`← …\` is the AI Agent. \`← [notify] …\` is a
6280
+ reminder or notification (a \`schedule_notify\`, a reaction) — NOT the turn's answer,
6281
+ so the wait for the real reply goes on. \`← [operator: Name] …\` is a person in
6282
+ Live chat; \`← [platform] …\` is a platform line (a handoff joining or returning).
6283
+ After the last turn the stream stays open ${LATE_DRAIN_MS / 1000}s more, so a late message
6284
+ still prints (marked "after the last turn") instead of reaching a closed connection.
6285
+
6286
+ --script drives a WHOLE conversation from a file, ONE TURN PER LINE, in one
6287
+ process over one connection — waiting for each turn to settle before sending
6288
+ the next. Use this for any multi-step flow: chaining shell invocations races
6289
+ the agent loop, because a turn ends on a quiet gap that can arrive while the
6290
+ server is still working (the symptom is placeholder-filled fields or a second
6291
+ workflow run). Blank lines and # comments are skipped:
6292
+
6293
+ # book an appointment end to end
6294
+ احجز موعد
6295
+ tap:t:invoke:book-appointment:doctor_id=D1
6296
+ media:./licence.jpg | here is my licence
6297
+ press:Confirm
6298
+
6299
+ press:<label> taps the button of the reply just received whose label matches —
6300
+ exactly, or the same words without emoji or case, or the one label containing
6301
+ them. None, or several buttons, stops the script and lists the buttons. A form
6302
+ step's tap id carries its run id (t:resume:…:run_id=…), which exists only once
5714
6303
  the reply arrives, so a script reaches past the first form step only by press.`,
5715
- media: `octwin media generate "<prompt>" [--out <file.png>] [--json]
5716
- AI-generate an image (needs a media:generate-scoped token), store it as a
5717
- public asset, and print its MEDIA- handle + serve URL. --out downloads the
5718
- bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
6304
+ media: `octwin media generate "<prompt>" [--out <file.png>] [--json]
6305
+ AI-generate an image (needs a media:generate-scoped token), store it as a
6306
+ public asset, and print its MEDIA- handle + serve URL. --out downloads the
6307
+ bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
5719
6308
  bytes }. Pair with 'octwin chat --media' to drive media flows.`,
5720
- agents: `octwin agents [packId::agentId] [--prompt] [--json]
5721
- No args = the roster with each agent's EFFECTIVE model and which layer set it.
5722
- With an agent = every governed setting (model / memory.last_messages /
5723
- working_memory) plus the layer that won — an operator PLATFORM default can
5724
- override what your manifest declares, and this is where you see that.
5725
- --prompt = the exact system prompt the LLM sees for this project (pack
5726
- instructions + platform protocol + any project overlay). Needs agents:read.
5727
- The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
5728
-
5729
- WRITES (need \`agents:write\`):
5730
- octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
5731
- [--enable-tool <toolId>] [--disable-tool <toolId>]
5732
-
5733
- Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
5734
- so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
6309
+ agents: `octwin agents [packId::agentId] [--prompt] [--json]
6310
+ No args = the roster with each agent's EFFECTIVE model and which layer set it.
6311
+ With an agent = every governed setting (model / memory.last_messages /
6312
+ working_memory) plus the layer that won — an operator PLATFORM default can
6313
+ override what your manifest declares, and this is where you see that.
6314
+ --prompt = the exact system prompt the LLM sees for this project (pack
6315
+ instructions + platform protocol + any project overlay). Needs agents:read.
6316
+ The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.
6317
+
6318
+ WRITES (need \`agents:write\`):
6319
+ octwin agents set <ref> [--model <m>] [--enabled true|false] [--overlay "..."|none]
6320
+ [--enable-tool <toolId>] [--disable-tool <toolId>]
6321
+
6322
+ Only what you pass is changed. Tool flags read-modify-write \`config_json.tools\`
6323
+ so a sibling decision isn't dropped; absent = enabled. A workspace that hides model
5735
6324
  ids refuses --model with a 403 — the platform default governs there.`,
5736
- orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
5737
- No args = the order list (#number, status/payment, total, contact). With a
5738
- reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
5739
- payment_ref, and the allowed status transitions. Needs orders:read + the
5740
- \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
5741
- so \`pending\` on a gateway-less workspace is expected, not a bug.
5742
-
5743
- WRITES (need \`orders:write\`):
5744
- octwin orders transition <reference_id> --to <status>
5745
- octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
5746
-
5747
- Refund is irreversible and moves money, hence --force. The route answers 200 even
5748
- when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
5749
- on a refusal rather than reporting a refund that never happened. Only a payment in
6325
+ orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--offset n] [--json]
6326
+ No args = the order list (#number, status/payment, total, contact). With a
6327
+ reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
6328
+ payment_ref, and the allowed status transitions. Needs orders:read + the
6329
+ \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned —
6330
+ \`none\` = no payment opened (no gateway), \`pending\` = checkout not confirmed yet.
6331
+
6332
+ WRITES (need \`orders:write\`):
6333
+ octwin orders transition <reference_id> --to <status>
6334
+ octwin orders refund <reference_id> [--reason "..."] [--mark-returned] --force
6335
+
6336
+ Refund is irreversible and moves money, hence --force. The route answers 200 even
6337
+ when the GATEWAY refuses, so the CLI reads the gateway verdict and exits non-zero
6338
+ on a refusal rather than reporting a refund that never happened. Only a payment in
5750
6339
  \`captured\` state can be refunded; \`payment_status\` is never settable directly.`,
5751
- analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
5752
- No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
5753
- With an entity = stage-by-stage conversion (default --funnel) over the last 30
5754
- days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
6340
+ analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--limit n] [--offset n] [--json]
6341
+ No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
6342
+ With an entity = stage-by-stage conversion (default --funnel) over the last 30
6343
+ days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
5755
6344
  range-filtered). Needs records:read + a \`view\` grant on \`record.<entity>\`.`,
5756
- catalog: `octwin catalog [--readiness] [--json]
5757
- The commerce \`product\` records + price, availability, stock (null = not
5758
- inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
5759
- Graph checklist (LIVE Graph calls; needs a bound access token). Needs
5760
- catalog:read + the \`catalog\` plan feature.
5761
-
5762
- WRITES (need \`catalog:write\`):
5763
- octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
5764
- octwin catalog stock <retailerId> [--set-on-hand <n>]
5765
-
5766
- \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
5767
- inventory-tracked (always sellable), which is different from 0. Lowering on_hand
5768
- below the units already reserved for open carts is refused. Creating/deleting
6345
+ catalog: `octwin catalog [--readiness] [--limit n] [--offset n] [--json]
6346
+ The commerce \`product\` records + price, availability, stock (null = not
6347
+ inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
6348
+ Graph checklist (LIVE Graph calls; needs a bound access token). Needs
6349
+ catalog:read + the \`catalog\` plan feature.
6350
+
6351
+ WRITES (need \`catalog:write\`):
6352
+ octwin catalog availability <retailerId> --to "in stock"|"out of stock"|…
6353
+ octwin catalog stock <retailerId> [--set-on-hand <n>]
6354
+
6355
+ \`stock\` with no --set-on-hand READS it; \`null\` means the SKU is not
6356
+ inventory-tracked (always sellable), which is different from 0. Lowering on_hand
6357
+ below the units already reserved for open carts is refused. Creating/deleting
5769
6358
  products and the Meta catalog binding/sync stay in the console.`,
5770
- scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
5771
- No args = the engine state (bookable resource types, upcoming slots, booked
5772
- seats). --slots <recordId> computes the slots for one bookable resource
5773
- (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
5774
- the availability rules a \`deploy --seed\` created. Needs scheduling:read.
5775
-
5776
- RULES (list needs scheduling:read; add/rm need scheduling:write):
5777
- octwin scheduling rules --resource <resourceRecordId>
5778
- octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
5779
- [--slot-minutes 30] [--capacity 1]
5780
- octwin scheduling rule rm <ruleId>
5781
- octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
5782
- [--start 09:00 --end 13:00]
5783
- octwin scheduling exception rm <exceptionId>
5784
-
5785
- --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
6359
+ scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
6360
+ No args = the engine state (bookable resource types, upcoming slots, booked
6361
+ seats). --slots <recordId> computes the slots for one bookable resource
6362
+ (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
6363
+ the availability rules a \`deploy --seed\` created. Needs scheduling:read.
6364
+
6365
+ RULES (list needs scheduling:read; add/rm need scheduling:write):
6366
+ octwin scheduling rules --resource <resourceRecordId>
6367
+ octwin scheduling rule add --resource <id> --dow 1 --start 09:00 --end 17:00
6368
+ [--slot-minutes 30] [--capacity 1]
6369
+ octwin scheduling rule rm <ruleId>
6370
+ octwin scheduling exception add --resource <id> --date YYYY-MM-DD --kind closed|extra
6371
+ [--start 09:00 --end 13:00]
6372
+ octwin scheduling exception rm <exceptionId>
6373
+
6374
+ --dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
5786
6375
  \`--slots\` is how you check what a rule actually produces.`,
5787
- automation: `octwin automation [campaigns] [--limit n] [--offset n] [--json]
5788
- No args = every job the pack's automation declaration produced, with its status,
5789
- interval and LAST RESULT (matched / acted / errors), under a health line whose
5790
- counts come from SQL rather than from filtering the page — the job list is capped
5791
- server-side, so a client-side count would depend on the cap. Needs automation:read.
5792
-
5793
- Jobs are DERIVED from declarations. There is no \`create\`: no automation block in
5794
- the pack means no jobs, and \`octwin deploy\` is what installs them.
5795
-
5796
- WRITES (automation:write):
5797
- octwin automation run <jobId> # run once, now — prints matched/acted/errors
5798
- octwin automation pause|resume <jobId>
5799
- octwin automation send <campaignId> # enqueue a campaign; enqueued != delivered
6376
+ automation: `octwin automation [coming | happened | why <id> | campaigns | --campaigns] [--json]
6377
+ No args = every RULE of the project — each in the platform's own words (the
6378
+ sentence the console's Outbound → Automatic tab shows), on or paused, its week
6379
+ (sent / not delivered / failed / skipped) and its next time — under the week's
6380
+ numbers and what needs a look. Needs automation:read.
5800
6381
 
5801
- <jobId> is the \`key\` the list shows (its uuid works too). The routes themselves
5802
- accept only a uuid — the CLI resolves the key for you, and names the keys that do
5803
- exist when it cannot. A 403 on a write can be an RBAC grant gap rather than a
5804
- missing scope: the action is re-checked against the job.`,
5805
- integrations: `octwin integrations [--json]
5806
- What the pack DECLARES beside what is actually CONFIGURED, in one view — because a
5807
- connection that is declared and never configured is the commonest reason an
5808
- integration silently never fires, and neither list alone can show it. Flags the
5809
- gap explicitly. Needs integrations:read.
6382
+ A pack writes rules in automation.yaml \`rules:\`; its older \`segment\` /
6383
+ \`stale_records\` / \`sla_sweep\` jobs and xrm \`on:\` hooks run as rules too,
6384
+ listed under the key they compile to (\`segment:<job key>\`).
5810
6385
 
5811
- DIAGNOSE ONE CONNECTION:
5812
- octwin integrations preflight <key> # every check, with a fix hint. Makes NO
5813
- # outbound call — needs only integrations:read
5814
- octwin integrations test <key> # a LIVE call to its health: operation
5815
- # (integrations:write). Exits 1 when it fails.
6386
+ READ:
6387
+ octwin automation coming [--range today|tomorrow|week|all] [--entity e] [--q text] [--cursor c]
6388
+ # every time still to come — a rule's, or a flow's own message
6389
+ octwin automation happened [--chip all|not_delivered|failed|skipped|moves|tasks] [--period 7d|30d] [--cursor c]
6390
+ # every fire: its final word, and why a message did not go
6391
+ octwin automation why <id> [--kind timer|fire|queued]
6392
+ # one line: the rule in words, each step, the words sent
6393
+ octwin automation campaigns [--limit n] [--offset n]
5816
6394
 
5817
- THE DELIVERY LOG:
5818
- octwin integrations deliveries [--status s] [--operation id] [--limit n]
5819
- octwin integrations deliveries <id> # + the redacted request/response snapshots
5820
- octwin integrations retry|cancel|send-now <id> # integrations:write
5821
- octwin integrations events # INBOUND events (what arrived at your webhook)
6395
+ WRITES (automation:write):
6396
+ octwin automation pause|resume <ruleKey> # paused: its times arm, nothing runs, nothing caught up
6397
+ octwin automation run <ruleKey> # a rule on a timetable, now
6398
+ octwin automation send <campaignId> # enqueue a campaign; enqueued != delivered
5822
6399
 
5823
- retry/cancel answer 409 when the delivery is in the wrong state; the message
6400
+ <ruleKey> is the key the list prints; the key of the older job in your own
6401
+ automation.yaml finds the rule it runs as. A 403 on a write can be an RBAC grant
6402
+ gap rather than a missing scope: the action is re-checked on automation.rule.
6403
+ (Until 0.9.0 this command listed job rows — since the platform ran every pack's
6404
+ automation as rules, those were only its own two sweeps.)`,
6405
+ integrations: `octwin integrations [--json]
6406
+ What the pack DECLARES beside what is actually CONFIGURED, in one view — because a
6407
+ connection that is declared and never configured is the commonest reason an
6408
+ integration silently never fires, and neither list alone can show it. Flags the
6409
+ gap explicitly. Needs integrations:read.
6410
+
6411
+ DIAGNOSE ONE CONNECTION:
6412
+ octwin integrations preflight <key> # every check, with a fix hint. Makes NO
6413
+ # outbound call — needs only integrations:read
6414
+ octwin integrations test <key> # a LIVE call to its health: operation
6415
+ # (integrations:write). Exits 1 when it fails.
6416
+
6417
+ THE DELIVERY LOG:
6418
+ octwin integrations deliveries [--status s] [--operation id] [--limit n] [--offset n]
6419
+ octwin integrations deliveries <id> # + the redacted request/response snapshots
6420
+ octwin integrations retry|cancel|send-now <id> # integrations:write
6421
+ octwin integrations events # INBOUND events (what arrived at your webhook)
6422
+
6423
+ retry/cancel answer 409 when the delivery is in the wrong state; the message
5824
6424
  carries the rule.`,
5825
- journeys: `octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition]
5826
- [--stage <stageId>] [--limit n] [--json]
5827
- No args = the journeys the pack declares. With an id, one of six views —
5828
- --funnel (default) stage-by-stage reach and drop-off · --overview entered vs
5829
- converted plus the biggest drop-off · --goals completions, contacts, value and
5830
- p50 time · --trends per-bucket activity · --cost tokens and dollars per goal ·
5831
- --definition what was DECLARED, unmeasured (the one view that works with no
5832
- traffic). Needs journeys:read.
5833
-
5834
- --stage <stageId> lists the runs sitting at a stage right now (a live snapshot,
5835
- not the funnel's cumulative reached counts).
5836
-
5837
- Same flag grammar as \`octwin analytics\` on purpose: a journey funnel and an
5838
- entity funnel are the same question about different subjects. Journeys carry RBAC
5839
- on top of the scope, so an empty answer can be a missing \`view\` grant rather
6425
+ journeys: `octwin journeys [journeyId] [--funnel|--overview|--goals|--trends|--cost|--definition]
6426
+ [--stage <stageId>] [--limit n] [--offset n] [--json]
6427
+ No args = the journeys the pack declares. With an id, one of six views —
6428
+ --funnel (default) stage-by-stage reach and drop-off · --overview entered vs
6429
+ converted plus the biggest drop-off · --goals completions, contacts, value and
6430
+ p50 time · --trends per-bucket activity · --cost tokens and dollars per goal ·
6431
+ --definition what was DECLARED, unmeasured (the one view that works with no
6432
+ traffic). Needs journeys:read.
6433
+
6434
+ --stage <stageId> lists the runs sitting at a stage right now (a live snapshot,
6435
+ not the funnel's cumulative reached counts).
6436
+
6437
+ Same flag grammar as \`octwin analytics\` on purpose: a journey funnel and an
6438
+ entity funnel are the same question about different subjects. Journeys carry RBAC
6439
+ on top of the scope, so an empty answer can be a missing \`view\` grant rather
5840
6440
  than missing data — the output says which causes are possible.`,
5841
- performance: `octwin performance [--detail] [--json]
5842
- The project's business indicators — value produced, conversion, duration — each
5843
- with its delta against the previous window and a \`why\` naming the declaration it
5844
- came from. --detail adds the per-indicator breakdown.
5845
-
5846
- Needs records:read, NOT a performance scope (there is none), so a read-only token
5847
- already reaches it. Indicators are DERIVED: a pack that declares no journey goal
6441
+ performance: `octwin performance [--detail] [--range <days>] [--channel <ch>] [--json]
6442
+ The project's business indicators — value produced, conversion, duration — each
6443
+ with its delta against the previous window and a \`why\` naming the declaration it
6444
+ came from. --detail adds the per-indicator breakdown. --range <days> sets the window
6445
+ and --channel <whatsapp|web> narrows every figure to one channel — the console's two
6446
+ controls on the same page.
6447
+
6448
+ Needs records:read, NOT a performance scope (there is none), so a read-only token
6449
+ already reaches it. Indicators are DERIVED: a pack that declares no journey goal
5848
6450
  value and no pipelined entity produces none, which is a different thing from zero.`,
5849
- usage: `octwin usage [--json]
5850
- Model calls, tokens and cost for the resolved scope — project when one is pinned
5851
- or passed with --project, otherwise the whole workspace. Broken down by model,
5852
- kind, agent and channel.
5853
-
5854
- Needs no particular scope: any valid token reaches it.
5855
-
5856
- This is MODEL spend only. WhatsApp/Meta message billing is operator-only and
6451
+ charts: `octwin charts [<key>] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--json]
6452
+ The pack's own \`charts:\` (xrm.yaml), as the owner's dashboard draws them. No key = the
6453
+ list; with a key = that chart's figure, its parts (a \`by:\` chart) or its points (an
6454
+ \`over:\` chart) for the window. A token sees the charts whose entity it may view.
6455
+ Needs records:read.`,
6456
+ handoff: `octwin handoff [<id>] [--bucket waiting|mine|all|ended] [--limit n] [--offset n] [--json]
6457
+ The Live chat desk, read-only: the handoffs in a bucket (default all), or one with
6458
+ its transcript — who said what: customer, AI Agent, operator, platform. Needs work:read
6459
+ and the live_chat plan feature. Accepting, answering and returning one stay in the
6460
+ console: those need a signed-in PERSON to hold the conversation, and a token is not one.`,
6461
+ surveys: `octwin surveys [<key>] [--limit n] [--offset n] [--json]
6462
+ No key = the declared surveys with their responses and headline score (under the
6463
+ survey's own model — average, top box or NPS). With a key = its questions, the rollup,
6464
+ and the recent responses (--limit / --offset page them). Needs records:read.`,
6465
+ usage: `octwin usage [--json]
6466
+ Model calls, tokens and cost for the resolved scope — project when one is pinned
6467
+ or passed with --project, otherwise the whole workspace. Broken down by model,
6468
+ kind, agent and channel.
6469
+
6470
+ Needs no particular scope: any valid token reaches it.
6471
+
6472
+ This is MODEL spend only. WhatsApp/Meta message billing is operator-only and
5857
6473
  deliberately outside the token scope registry — no API token can read it.`,
5858
- 'platform-kb': `octwin platform-kb [pull] [--if-stale] [--check] [--dir .] [--url <url>] [--token <t>]
5859
- Pull the platform capability reference (markdown + JSON catalogs) into
5860
- .octwin/platform-kb/ for the octwin-pack authoring skill, plus three maps:
5861
- INDEX.md (the corpus) · SYMBOLS.md (every name -> its file; grep this) ·
5862
- OUTLINE.md (every heading with its line number).
5863
-
5864
- NO TOKEN NEEDED — the reference is platform stdlib and is served anonymously,
5865
- and this command never sends one. --token is accepted and ignored, so an older
5866
- script that passes it keeps working.
5867
-
5868
- --if-stale poll the platform's content_hash first and skip the download when
5869
- nothing changed. Cheap enough to run at the start of every session.
5870
- --check report only, write nothing. Exit 0 = current, 2 = stale or never
5871
- pulled, 1 = could not tell (offline / no reference served). For
6474
+ 'platform-kb': `octwin platform-kb [pull] [--if-stale] [--check] [--dir .] [--url <url>] [--token <t>]
6475
+ Pull the platform capability reference (markdown + JSON catalogs) into
6476
+ .octwin/platform-kb/ for the octwin-pack authoring skill, plus three maps:
6477
+ INDEX.md (the corpus) · SYMBOLS.md (every name -> its file; grep this) ·
6478
+ OUTLINE.md (every heading with its line number).
6479
+
6480
+ NO TOKEN NEEDED — the reference is platform stdlib and is served anonymously,
6481
+ and this command never sends one. --token is accepted and ignored, so an older
6482
+ script that passes it keeps working.
6483
+
6484
+ --if-stale poll the platform's content_hash first and skip the download when
6485
+ nothing changed. Cheap enough to run at the start of every session.
6486
+ --check report only, write nothing. Exit 0 = current, 2 = stale or never
6487
+ pulled, 1 = could not tell (offline / no reference served). For
5872
6488
  scripts and agent loops that want to branch without parsing prose.`,
5873
- test: `octwin test [--dir .]
5874
- Alias for \`octwin validate --remote\` — the full platform check.`,
5875
- memos: `octwin memos [--all] [--json]
5876
- Read what the platform has told you: a REPLY to a report you sent with
5877
- \`octwin feedback\`, or a NOTICE published to every author (a new capability,
5878
- a deprecation, a breaking change). Bodies are printed in full.
5879
- Reading marks them read, so the reminder stops. --all re-reads history and
5880
- acks nothing. --json to branch on \`severity\`
6489
+ test: `octwin test [--dir .] [--require-kb] [--strict-primitives]
6490
+ Alias for \`octwin validate --remote\` — the full platform check, REQUIRED (a skipped full
6491
+ check is an error). --offline contradicts it; the other validate flags mean what they mean there.`,
6492
+ memos: `octwin memos [--all] [--json]
6493
+ Read what the platform has told you: a REPLY to a report you sent with
6494
+ \`octwin feedback\`, or a NOTICE published to every author (a new capability,
6495
+ a deprecation, a breaking change). Bodies are printed in full.
6496
+ Reading marks them read, so the reminder stops. --all re-reads history and
6497
+ acks nothing. --json to branch on \`severity\`
5881
6498
  (info | action_required | breaking).`,
5882
- feedback: `octwin feedback [--dir .]
5883
- Submit this pack's FEEDBACK.md to the platform team.
5884
- The octwin-pack skill writes that file in its last step — findings grouped by
5885
- owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
5886
- you to paste it into a chat.
5887
- Attaches the pack id + version from manifest.yaml, this CLI's version, and the
5888
- content_hash of the capability reference in .octwin/platform-kb/ — triage needs
5889
- the last two to tell "the platform is wrong" from "that was already fixed" or
6499
+ feedback: `octwin feedback [--dir .]
6500
+ Submit this pack's FEEDBACK.md to the platform team.
6501
+ The octwin-pack skill writes that file in its last step — findings grouped by
6502
+ owner (A · CLI, B · Platform, C · Skill/KB). This delivers it instead of asking
6503
+ you to paste it into a chat.
6504
+ Attaches the pack id + version from manifest.yaml, this CLI's version, and the
6505
+ content_hash of the capability reference in .octwin/platform-kb/ — triage needs
6506
+ the last two to tell "the platform is wrong" from "that was already fixed" or
5890
6507
  "you were reading a stale reference". Needs the \`pack:deploy\` scope.`,
5891
6508
  };
5892
6509
  /**
@@ -5897,36 +6514,48 @@ octwin projects rm <slug> [--yes]
5897
6514
  * (`cli.json-contract.test.ts`).
5898
6515
  */
5899
6516
  export const COMMANDS = {
5900
- init: { run: cmdInit, json: false },
5901
- validate: { run: cmdValidate, json: false },
5902
- login: { run: cmdLogin, json: 'lines' }, // pending, then approved — two objects
5903
- whoami: { run: cmdWhoami, json: false },
5904
- deploy: { run: cmdDeploy, json: false },
5905
- pull: { run: cmdPull, json: false },
5906
- status: { run: cmdStatus, json: false },
5907
- records: { run: cmdRecords, json: 'document' },
5908
- work: { run: cmdWork, json: 'document' },
5909
- logs: { run: cmdLogs, json: 'document' },
5910
- chat: { run: cmdChat, json: 'lines' }, // one line per event the turn produced
5911
- media: { run: cmdMedia, json: 'document' },
5912
- seed: { run: cmdSeed, json: false },
5913
- projects: { run: cmdProjects, json: 'document' },
5914
- agents: { run: cmdAgents, json: 'document' },
5915
- orders: { run: cmdOrders, json: 'document' },
5916
- analytics: { run: cmdAnalytics, json: 'document' },
5917
- catalog: { run: cmdCatalog, json: 'document' },
5918
- scheduling: { run: cmdScheduling, json: 'document' },
5919
- automation: { run: cmdAutomation, json: 'document' },
5920
- integrations: { run: cmdIntegrations, json: 'document' },
5921
- journeys: { run: cmdJourneys, json: 'document' },
5922
- performance: { run: cmdPerformance, json: 'document' },
5923
- usage: { run: cmdUsage, json: 'document' },
5924
- 'platform-kb': { run: cmdPlatformKb, json: false },
5925
- feedback: { run: cmdFeedback, json: false },
5926
- memos: { run: cmdMemos, json: 'document' },
6517
+ init: { run: cmdInit, json: false, flags: ['id', 'description', 'display-name'] },
6518
+ recipe: { run: cmdRecipe, json: false, flags: ['dry-run'] },
6519
+ validate: { run: cmdValidate, json: false, flags: ['offline', 'remote', 'require-kb', 'strict-primitives'] },
6520
+ login: { run: cmdLogin, json: 'lines', flags: ['wait'] }, // pending, then approved — two objects
6521
+ whoami: { run: cmdWhoami, json: false, flags: [] },
6522
+ deploy: { run: cmdDeploy, json: false, flags: ['force', 'seed', 'request-listing', 'withdraw-listing'] },
6523
+ pull: { run: cmdPull, json: false, flags: ['force', 'version'] },
6524
+ status: { run: cmdStatus, json: false, flags: [] },
6525
+ records: { run: cmdRecords, json: 'document', flags: ['contact', 'entity', 'fields-json', 'limit', 'note', 'offset', 'outcome', 'search', 'set', 'stage', 'to'] },
6526
+ work: { run: cmdWork, json: 'document', flags: ['action', 'dry-run', 'limit', 'note', 'offset', 'param', 'queues', 'to', 'unrouted'] },
6527
+ logs: { run: cmdLogs, json: 'document', flags: ['as'] },
6528
+ chat: { run: cmdChat, json: 'lines', flags: ['as', 'media', 'press', 'script', 'tap'] }, // one line per event the turn produced
6529
+ media: { run: cmdMedia, json: 'document', flags: ['out'] },
6530
+ seed: { run: cmdSeed, json: false, flags: ['pack', 'reset'] },
6531
+ projects: { run: cmdProjects, json: 'document', flags: ['archived', 'pack', 'slug', 'yes'] },
6532
+ agents: { run: cmdAgents, json: 'document', flags: ['disable-tool', 'enable-tool', 'enabled', 'model', 'overlay', 'prompt'] },
6533
+ orders: { run: cmdOrders, json: 'document', flags: ['force', 'limit', 'mark-returned', 'offset', 'payment', 'reason', 'status', 'to'] },
6534
+ analytics: { run: cmdAnalytics, json: 'document', flags: ['funnel', 'overview', 'milestones', 'trends', 'cost', 'stage', 'limit', 'offset'] },
6535
+ catalog: { run: cmdCatalog, json: 'document', flags: ['limit', 'offset', 'readiness', 'set-on-hand', 'to'] },
6536
+ scheduling: { run: cmdScheduling, json: 'document', flags: ['capacity', 'date', 'days', 'dow', 'end', 'from', 'kind', 'resource', 'slot-minutes', 'slots', 'start'] },
6537
+ automation: { run: cmdAutomation, json: 'document', flags: ['campaigns', 'chip', 'cursor', 'entity', 'kind', 'limit', 'offset', 'period', 'q', 'range'] },
6538
+ integrations: { run: cmdIntegrations, json: 'document', flags: ['limit', 'offset', 'operation', 'status'] },
6539
+ journeys: { run: cmdJourneys, json: 'document', flags: ['funnel', 'overview', 'goals', 'trends', 'cost', 'definition', 'stage', 'limit', 'offset'] },
6540
+ performance: { run: cmdPerformance, json: 'document', flags: ['detail', 'range', 'channel'] },
6541
+ usage: { run: cmdUsage, json: 'document', flags: [] },
6542
+ charts: { run: cmdCharts, json: 'document', flags: ['from', 'to'] },
6543
+ handoff: { run: cmdHandoff, json: 'document', flags: ['bucket', 'limit', 'offset'] },
6544
+ surveys: { run: cmdSurveys, json: 'document', flags: ['limit', 'offset'] },
6545
+ 'platform-kb': { run: cmdPlatformKb, json: false, flags: ['check', 'if-stale'] },
6546
+ feedback: { run: cmdFeedback, json: false, flags: [] },
6547
+ memos: { run: cmdMemos, json: 'document', flags: ['all'] },
5927
6548
  // A6: `test` = the full remote validate, not a validate-clone.
5928
- test: { run: flags => cmdValidate({ ...flags, remote: true }), json: false },
6549
+ test: { run: flags => cmdValidate({ ...flags, remote: true }), json: false, flags: ['offline', 'remote', 'require-kb', 'strict-primitives'] },
5929
6550
  };
6551
+ /**
6552
+ * The flags every command takes: the target (`--url` / `--tenant` / `--project` / `--token`, read by
6553
+ * `resolveTarget`), `--json` (held to `json:` above), `--help`, and `--dir` — the pack directory, which
6554
+ * `runCli` itself reads on every run to find the pulled KB (`readLocalKb`). Each command's OWN flags are its
6555
+ * `flags:` row — the one list `runCli` refuses against, and `cli.flags.test.ts` holds to the code that
6556
+ * reads them, to `help()` / `COMMAND_HELP`, and to the README.
6557
+ */
6558
+ export const GLOBAL_FLAGS = ['url', 'tenant', 'project', 'token', 'json', 'help', 'dir'];
5930
6559
  /** Exit code when a `--json` command ended without the result it promised — a bug in this CLI. */
5931
6560
  export const EXIT_NO_RESULT = 70;
5932
6561
  /**
@@ -5977,6 +6606,13 @@ export async function runCli(argv, io) {
5977
6606
  if (flags.json === true && spec.json === false) {
5978
6607
  die(`\`octwin ${command}\` has no --json output — the commands that do are: ${Object.entries(COMMANDS).filter(([, c]) => c.json !== false).map(([n]) => n).join(', ')}`);
5979
6608
  }
6609
+ // A flag the command does not read is REFUSED, before it runs. It used to be ignored, so a
6610
+ // typo — or a flag the command never learned, like `performance --channel` until 2026-10-11 —
6611
+ // ran the command with its default and printed an answer to a question nobody asked.
6612
+ const unknownFlags = Object.keys(flags).filter(k => k !== '_' && !GLOBAL_FLAGS.includes(k) && !spec.flags.includes(k));
6613
+ if (unknownFlags.length) {
6614
+ 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`);
6615
+ }
5980
6616
  // Compute the trailing nudges BEFORE the command runs, so they survive a `die()` — see the
5981
6617
  // notice-channel block above. They are PRINTED after the command, by `flushNotices` here on the
5982
6618
  // success path and by `exitNow` on every failure path. Fail-silent by construction (each helper