@lotics/cli 0.183.0 → 0.184.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/AGENTS.md CHANGED
@@ -5,7 +5,7 @@ conventions are, and where the traps are.
5
5
 
6
6
  | Read | For |
7
7
  |---|---|
8
- | `lotics --help` | The verb inventory (§ COMMANDS) and global flags. Authoritative; never stale. |
8
+ | `lotics --help` | The verb inventory (§ COMMANDS) and global flags. The verb LIST is generated and never stale; the prose beside each verb is hand-written, so where it disagrees with `docs/cli_reference.md`, the reference wins. |
9
9
  | `lotics tools` · `lotics tools <name>` | The agent tool registry and one tool's full JSON Schema. |
10
10
  | `lotics docs` · `lotics docs <area>` | Every reference the packages installed beside the project actually ship — `@lotics/app-sdk`, `@lotics/ui` and the document engines each carry their own, and this index only covers THIS package. Discovered by looking, not by a list, so it reports the installed VERSION of each: a doc always describes the code that is really there. |
11
11
  | [docs/building_an_app.md](./docs/building_an_app.md) | The SEQUENCE — scaffold, model, types, queries, workflows, screens, ship — and the deploy-free inner loop. The other references describe contracts; this one is the order they go in and why. Read it once before starting an app. |
@@ -29,7 +29,8 @@ Several capabilities exist **only** as commands and appear nowhere in `lotics to
29
29
  file and running a bound app agent are the two that most often get mistaken for missing. Concluding
30
30
  "the platform can't do X" from the tool list alone is a mistake; check both.
31
31
 
32
- ⚠️ **`lotics <subcommand> --help` prints the generic top-level help.** It does not describe the
32
+ ⚠️ **`lotics <subcommand> --help` prints the generic top-level help**, except `lotics report --help`
33
+ and a bare `lotics auth` / `xlsx` / `docx`, which print their own. It does not describe the
33
34
  subcommand, so an unhelpful response there is *not* evidence the subcommand is absent. To find out
34
35
  whether something exists, read `lotics --help` § COMMANDS — the whole section, not a narrow grep.
35
36
 
@@ -37,8 +38,9 @@ whether something exists, read `lotics --help` § COMMANDS — the whole section
37
38
 
38
39
  - **Scope is resolved per invocation.** `LOTICS_ORG` / `LOTICS_WORKSPACE` (or `LOTICS_API_KEY`) scope a
39
40
  single call without changing the active org or a directory pin — the safe way to touch one tenant
40
- from a shell serving many. Every command echoes its resolved target to **stderr** (`lotics → <org> /
41
- <workspace>`); read it back before trusting a write. Resolution precedence is in README § Organizations.
41
+ from a shell serving many. Every command that resolves a workspace echoes its target to **stderr**
42
+ (`lotics → <org> / <workspace>`); read it back before trusting a write. `file preview <fil_…>` is
43
+ the one credentialed command with no echo. Resolution precedence is in README § Organizations.
42
44
  - **Large payloads bypass `ARG_MAX`** — `lotics run <tool> @args.json` or piped stdin. A leading `@` is
43
45
  unambiguously a file path (JSON args start with `{`).
44
46
  - **stdout is the payload, stderr is the narration.** Progress, status lines, and the target echo go to
@@ -48,7 +50,8 @@ whether something exists, read `lotics --help` § COMMANDS — the whole section
48
50
  (`run_app_workflow`, `run_app_agent`, `run_app_query`). An `app` command exists only for work no
49
51
  tool call can do: scaffold, build, typecheck, serve, or read and push a local file.
50
52
  - **Exit codes are assertable, and they report the WORK rather than the call.** `lotics run` exits
51
- non-zero when the result's own envelope carries a failed `status` (`error`/`failed`/`cancelled`),
53
+ non-zero when a `run_app_workflow` or `run_app_agent` result's own envelope carries a failed
54
+ `status` (`error`/`failed`/`cancelled`) — any other tool's top-level status is data and exits 0 —
52
55
  so `lotics run … && next-step` cannot walk past a refused run; `workspace doctor` exits non-zero on
53
56
  findings. An unrecognized status exits 0 — the list is an allowlist of failure, so a status added
54
57
  later never turns a working script red — and a parked run (`awaiting_input`) is not a failure.
@@ -88,11 +91,11 @@ whether something exists, read `lotics --help` § COMMANDS — the whole section
88
91
  | `queries`, `capabilities` | the manifest | `app deploy` — re-synced on every one (an absent `capabilities` block turns them all OFF) |
89
92
  | `knowledge` | the manifest | nothing, until the app is published as a starter — it declares which docs ship with it |
90
93
  | `workflows` | the workflow row's verified contract | `app workflow set`, which type-checks the BODY against your declaration and refuses a schema the body cannot satisfy |
91
- | `agents` | the app row | **nothing.** It is a mirror: `app codegen` re-silvers it from the app, and an agent is changed with `set_app_agent` |
94
+ | `agents` | `inputs`/`outputs`: the manifest. Everything else: the app row | `app agent set`, and `app deploy`, which pushes a diverged `inputs`/`outputs` before it ships. The rest is a mirror `app codegen` re-silvers, changed with `set_app_agent` |
92
95
 
93
- `agents` is the one that bites, because editing a mirror still retypes `useAgentRun` — green
94
- locally, unchanged in production. `app codegen` reverts such an edit and `app deploy` refuses while
95
- the two disagree. An agent's PROSE is not in the manifest at all: it lives in
96
- `src/agents/<alias>.md` and is pushed by `app agent set`.
96
+ `agents` is the one that bites, because the two halves of the same block behave differently:
97
+ editing `tool_names` or `knowledge_doc_ids` still retypes `useAgentRun` — green locally, unchanged
98
+ in production, and reverted by the next `app codegen`. An agent's PROSE is not in the manifest at
99
+ all: it lives in `src/agents/<alias>.md` and is pushed by `app agent set`.
97
100
  - **OAuth connections.** Attaching a connected account is web-only; the CLI can list them.
98
101
  - **Anything needing a browser.** `app dev` and `file preview` shell out to a local Chrome.
package/README.md CHANGED
@@ -70,7 +70,7 @@ irm https://lotics.ai/install.ps1 | iex # Windows PowerShell — no No
70
70
  Re-run whichever installer you used; `npm install -g @lotics/cli@latest` updates an npm install.
71
71
  The CLI names the right one for the copy you are running when it sees a newer version.
72
72
 
73
- The CLI checks for updates once per day and prompts when a new version is available.
73
+ The CLI checks for updates once per day and prints a note on stderr, naming the right installer, when a new version is available.
74
74
 
75
75
  ## Authentication
76
76
 
@@ -94,7 +94,7 @@ lotics auth api-key # interactive prompt
94
94
  lotics auth api-key ltk_... # registers the key's org as a profile (now active)
95
95
  ```
96
96
 
97
- Run `lotics auth logout [<name|id>]` to remove a profile (default: the active org), or `lotics auth logout --all` to wipe the store.
97
+ Run `lotics auth logout [<name|id>]` to remove a profile (default: the active org), or `lotics auth logout --all` to wipe the store. Inside a pinned directory the bare form removes the **pin**, not a profile — name the org to remove its credential.
98
98
 
99
99
  ## Organizations
100
100
 
@@ -120,7 +120,7 @@ lotics workspace select wks_... # records the workspace in the local pin
120
120
 
121
121
  Each worktree resolves independently; switching the global default in another shell leaves pinned worktrees untouched. `.lotics/` should be gitignored.
122
122
 
123
- Every command names its target before it acts — `lotics → <org> / <workspace>` on **stderr**, so stdout stays clean for piping. When nothing in the directory or environment chose the org and it came from the machine-wide default, the line says so and prints the `--local` command to pin — that default is the one another shell can move between two of your commands. `lotics auth whoami` reports the same resolution on demand, including which source won.
123
+ Every command that resolves a workspace names its target before it acts — `lotics → <org> / <workspace>` on **stderr**, so stdout stays clean for piping. When nothing in the directory or environment chose the org and it came from the machine-wide default, the line says so and prints the `--local` command to pin — that default is the one another shell can move between two of your commands. `lotics auth whoami` reports the same resolution on demand, including which source won.
124
124
 
125
125
  ### Resolution precedence
126
126
 
package/dist/src/cli.js CHANGED
@@ -44602,6 +44602,19 @@ var LoticsClient = class {
44602
44602
  async listStarterVersions(starter_id) {
44603
44603
  return this.request("GET", `/v1/starters/${encodeURIComponent(starter_id)}/versions`);
44604
44604
  }
44605
+ /**
44606
+ * One version's contract — what a copy of it is supposed to produce. The
44607
+ * history above omits it (heavy per row); this is the read that carries it,
44608
+ * so a check compares a copy against the declaration itself rather than
44609
+ * against a description of it. Admin-only; cross-tenant by id like
44610
+ * `getStarter`.
44611
+ */
44612
+ async getStarterVersion(starter_id, version2) {
44613
+ return this.request(
44614
+ "GET",
44615
+ `/v1/starters/${encodeURIComponent(starter_id)}/versions/${encodeURIComponent(String(version2))}`
44616
+ );
44617
+ }
44605
44618
  /**
44606
44619
  * Workspace-wide dangling-reference sweep — active app/workflow artifacts
44607
44620
  * whose prefixed schema ids no longer resolve. Backs
@@ -44669,13 +44682,13 @@ var LoticsClient = class {
44669
44682
  const fields = table.fields.flatMap((field) => {
44670
44683
  if (field === null || typeof field !== "object") return [];
44671
44684
  const f = field;
44672
- if (typeof f.key !== "string" || typeof f.name !== "string") return [];
44685
+ if (typeof f.key !== "string" || typeof f.name !== "string" || typeof f.type !== "string") return [];
44673
44686
  const options = Array.isArray(f.options) ? f.options.flatMap((opt) => {
44674
44687
  if (opt === null || typeof opt !== "object") return [];
44675
44688
  const o = opt;
44676
44689
  return typeof o.key === "string" && typeof o.name === "string" ? [{ id: o.key, label: o.name }] : [];
44677
44690
  }) : void 0;
44678
- return [{ id: f.key, name: f.name, ...options && options.length > 0 ? { options } : {} }];
44691
+ return [{ id: f.key, name: f.name, type: f.type, ...options && options.length > 0 ? { options } : {} }];
44679
44692
  });
44680
44693
  return { id: table.id, name: table.name, fields };
44681
44694
  })
@@ -45429,6 +45442,25 @@ function resolveContext(flags, appWorkspaceId) {
45429
45442
  const envOrg = process.env.LOTICS_ORG;
45430
45443
  const envWorkspace = process.env.LOTICS_WORKSPACE;
45431
45444
  const wsOverride = flags.workspace ?? envWorkspace;
45445
+ if (flags.org) {
45446
+ if (flags.apiKey) {
45447
+ throw new Error("Pass --org or --api-key, not both \u2014 they name two different credentials.");
45448
+ }
45449
+ const resolved = resolveProfileByNameOrId(loadGlobalConfig()?.profiles ?? {}, flags.org);
45450
+ if (!resolved) {
45451
+ throw new Error(
45452
+ `--org "${flags.org}" matches no saved credential. Run "lotics org" to list, or "lotics auth api-key <key>" to add one.`
45453
+ );
45454
+ }
45455
+ const [orgId, profile] = resolved;
45456
+ return {
45457
+ apiKey: profile.api_key,
45458
+ orgId,
45459
+ orgName: profile.org_name,
45460
+ workspaceId: wsOverride ?? profile.workspace_id,
45461
+ source: "flag_org"
45462
+ };
45463
+ }
45432
45464
  if (flags.apiKey) {
45433
45465
  return { apiKey: flags.apiKey, workspaceId: wsOverride, source: "flag" };
45434
45466
  }
@@ -45679,7 +45711,7 @@ function resultSideEffects(result) {
45679
45711
  }
45680
45712
 
45681
45713
  // src/version.ts
45682
- var VERSION = "0.183.0";
45714
+ var VERSION = "0.184.0";
45683
45715
 
45684
45716
  // src/timezone.ts
45685
45717
  function machineTimezone() {
@@ -45966,8 +45998,10 @@ var COMMANDS = [
45966
45998
  " shipping: agent schemas vs the live app, workflow",
45967
45999
  " declarations and BODIES vs what is live, aliases",
45968
46000
  " the code calls but nothing bound, undeclared",
45969
- " capabilities, query drift. Exits 1 on what a",
45970
- " deploy would refuse, so CI can gate on it",
46001
+ " capabilities, query drift. Plus the one rule no",
46002
+ " deploy runs: a concrete id or an id-shaped",
46003
+ " placeholder in source a starter would ship.",
46004
+ " Exits 1 on any of it, so CI can gate on it",
45971
46005
  " lotics app workflow set <alias> Push the edited src/workflows/<alias>.ts body",
45972
46006
  " through set_app_workflow (server verifies)",
45973
46007
  " lotics app workflow pull Rewrite src/workflows/*.ts from the server",
@@ -71253,6 +71287,203 @@ function checkWorkflowBodies(tsApi, aliases) {
71253
71287
  }));
71254
71288
  }
71255
71289
 
71290
+ // ../../node_modules/nanoid/index.js
71291
+ import { webcrypto as crypto3 } from "node:crypto";
71292
+ var POOL_SIZE_MULTIPLIER = 128;
71293
+ var pool;
71294
+ var poolOffset;
71295
+ function fillPool(bytes) {
71296
+ if (bytes < 0) throw new RangeError("Wrong ID size");
71297
+ try {
71298
+ if (!pool || pool.length < bytes) {
71299
+ pool = Buffer.allocUnsafe(bytes * POOL_SIZE_MULTIPLIER);
71300
+ crypto3.getRandomValues(pool);
71301
+ poolOffset = 0;
71302
+ } else if (poolOffset + bytes > pool.length) {
71303
+ crypto3.getRandomValues(pool);
71304
+ poolOffset = 0;
71305
+ }
71306
+ } catch (e) {
71307
+ pool = void 0;
71308
+ throw e;
71309
+ }
71310
+ poolOffset += bytes;
71311
+ }
71312
+ function random(bytes) {
71313
+ fillPool(bytes |= 0);
71314
+ return pool.subarray(poolOffset - bytes, poolOffset);
71315
+ }
71316
+ function customRandom(alphabet, defaultSize, getRandom) {
71317
+ let safeByteCutoff = 256 - 256 % alphabet.length;
71318
+ if (safeByteCutoff === 256) {
71319
+ let mask = alphabet.length - 1;
71320
+ return (size2 = defaultSize) => {
71321
+ if (!size2) return "";
71322
+ let id = "";
71323
+ while (true) {
71324
+ let bytes = getRandom(size2);
71325
+ let i2 = size2;
71326
+ while (i2--) {
71327
+ id += alphabet[bytes[i2] & mask];
71328
+ if (id.length >= size2) return id;
71329
+ }
71330
+ }
71331
+ };
71332
+ }
71333
+ let step = Math.ceil(1.6 * 256 * defaultSize / safeByteCutoff);
71334
+ return (size2 = defaultSize) => {
71335
+ if (!size2) return "";
71336
+ let id = "";
71337
+ while (true) {
71338
+ let bytes = getRandom(step);
71339
+ let i2 = step;
71340
+ while (i2--) {
71341
+ if (bytes[i2] < safeByteCutoff) {
71342
+ id += alphabet[bytes[i2] % alphabet.length];
71343
+ if (id.length >= size2) return id;
71344
+ }
71345
+ }
71346
+ }
71347
+ };
71348
+ }
71349
+ function customAlphabet(alphabet, size2 = 21) {
71350
+ return customRandom(alphabet, size2, random);
71351
+ }
71352
+
71353
+ // ../shared/src/id.ts
71354
+ var ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
71355
+ var ID_LENGTH = 12;
71356
+ var nanoid3 = customAlphabet(ID_ALPHABET, ID_LENGTH);
71357
+ function generatePrefixedId(prefix) {
71358
+ return `${prefix}_${nanoid3()}`;
71359
+ }
71360
+ var RECORD_ID_PATTERN = new RegExp(`^rec_[${ID_ALPHABET}]{${ID_LENGTH}}$`);
71361
+ function generateWorkspaceId() {
71362
+ return generatePrefixedId("wsp");
71363
+ }
71364
+ function generateTableId() {
71365
+ return generatePrefixedId("tbl");
71366
+ }
71367
+ function generateGroupId() {
71368
+ return generatePrefixedId("grp");
71369
+ }
71370
+ function generateAppId() {
71371
+ return generatePrefixedId("app");
71372
+ }
71373
+ function generateDocumentTemplateId() {
71374
+ return generatePrefixedId("dtl");
71375
+ }
71376
+
71377
+ // ../shared/src/table_field_keys.ts
71378
+ var nanoid4 = customAlphabet(ID_ALPHABET, 6);
71379
+ function generateFieldKey() {
71380
+ return `fld_${nanoid4()}`;
71381
+ }
71382
+ function generateOptionKey() {
71383
+ return `opt_${nanoid4()}`;
71384
+ }
71385
+
71386
+ // ../shared/src/concrete_ids.ts
71387
+ function bodyWidth(id) {
71388
+ return id.length - id.indexOf("_") - 1;
71389
+ }
71390
+ var GENERATED_BODY_WIDTH = /* @__PURE__ */ new Map([
71391
+ ["tbl", bodyWidth(generateTableId())],
71392
+ ["fld", bodyWidth(generateFieldKey())],
71393
+ ["opt", bodyWidth(generateOptionKey())],
71394
+ ["dtl", bodyWidth(generateDocumentTemplateId())],
71395
+ ["grp", bodyWidth(generateGroupId())],
71396
+ ["wsp", bodyWidth(generateWorkspaceId())],
71397
+ ["app", bodyWidth(generateAppId())]
71398
+ ]);
71399
+ function widthOf(prefix) {
71400
+ const width = GENERATED_BODY_WIDTH.get(prefix);
71401
+ if (width === void 0) throw new Error(`concrete_ids: no generator registered for ${prefix}_`);
71402
+ return width;
71403
+ }
71404
+ var SCHEMA_PREFIXES = ["tbl", "fld", "opt", "dtl", "grp"];
71405
+ var SHORTEST_SCHEMA_BODY = Math.min(...SCHEMA_PREFIXES.map(widthOf));
71406
+ var CONCRETE_ID_SCAN = new RegExp(
71407
+ `(?<![0-9A-Za-z])(?:${SCHEMA_PREFIXES.join("|")})_[0-9A-Za-z]{${SHORTEST_SCHEMA_BODY},}`,
71408
+ "g"
71409
+ );
71410
+ var WORKSPACE_ID_SCAN = new RegExp(
71411
+ `(?<![0-9A-Za-z])(?:organization-(?:[0-9a-z]+-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|wsp_[0-9A-Za-z]{${widthOf("wsp")}}(?![0-9A-Za-z])|app_[0-9A-Za-z]{${widthOf("app")}}(?![0-9A-Za-z])|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`,
71412
+ "g"
71413
+ );
71414
+ var ID_PLACEHOLDER_SCAN = new RegExp(
71415
+ `(["'\`])((?:${SCHEMA_PREFIXES.join("|")})_[0-9A-Za-z]+)\\1`,
71416
+ "g"
71417
+ );
71418
+ function isGeneratedIdBody(id) {
71419
+ const underscore = id.indexOf("_");
71420
+ if (underscore === -1) return true;
71421
+ return /[0-9A-Z]/.test(id.slice(underscore + 1));
71422
+ }
71423
+ function isBelowGeneratedWidth(token) {
71424
+ const underscore = token.indexOf("_");
71425
+ const width = GENERATED_BODY_WIDTH.get(token.slice(0, underscore));
71426
+ if (width === void 0) return false;
71427
+ return token.length - underscore - 1 < width;
71428
+ }
71429
+ var SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".html", ".css", ".json"];
71430
+ var SCAN_FILE_MAX_BYTES = 1024 * 1024;
71431
+ function normalizeScanPath(path13) {
71432
+ return path13.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
71433
+ }
71434
+ function hasScannableExtension(path13) {
71435
+ return SCAN_EXTENSIONS.some((ext) => path13.endsWith(ext));
71436
+ }
71437
+ function isConcreteIdScanPath(path13) {
71438
+ const name2 = normalizeScanPath(path13);
71439
+ if (name2.endsWith(".md")) return true;
71440
+ if (!name2.startsWith("src/")) return false;
71441
+ if (name2.startsWith("src/workflows/")) return false;
71442
+ return hasScannableExtension(name2);
71443
+ }
71444
+ function isTestFile(path13) {
71445
+ return /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path13);
71446
+ }
71447
+ function isIdPlaceholderScanPath(path13) {
71448
+ const name2 = normalizeScanPath(path13);
71449
+ if (isTestFile(name2)) return false;
71450
+ if (name2.endsWith(".md")) return true;
71451
+ if (name2 === "package.json") return true;
71452
+ if (!name2.startsWith("src/")) return false;
71453
+ return hasScannableExtension(name2);
71454
+ }
71455
+ function firstLines(text, pattern, keep, into) {
71456
+ text.split("\n").forEach((line, index) => {
71457
+ for (const match2 of line.matchAll(pattern)) {
71458
+ const id = match2[2] ?? match2[0];
71459
+ if (!keep(id) || into.has(id)) continue;
71460
+ into.set(id, index + 1);
71461
+ }
71462
+ });
71463
+ return into;
71464
+ }
71465
+ function toFindings(path13, rule, lines) {
71466
+ return [...lines].map(([id, line]) => ({ path: path13, line, id, rule }));
71467
+ }
71468
+ function scanFileForConcreteIds(path13, text) {
71469
+ if (!isConcreteIdScanPath(path13)) return [];
71470
+ const lines = firstLines(text, CONCRETE_ID_SCAN, () => true, /* @__PURE__ */ new Map());
71471
+ firstLines(text, WORKSPACE_ID_SCAN, isGeneratedIdBody, lines);
71472
+ return toFindings(path13, "concrete", lines);
71473
+ }
71474
+ function scanFileForIdPlaceholders(path13, text) {
71475
+ if (!isIdPlaceholderScanPath(path13)) return [];
71476
+ const lines = firstLines(text, ID_PLACEHOLDER_SCAN, isBelowGeneratedWidth, /* @__PURE__ */ new Map());
71477
+ return toFindings(path13, "placeholder", lines);
71478
+ }
71479
+ function scanFileForIds(path13, text) {
71480
+ const concrete = scanFileForConcreteIds(path13, text);
71481
+ const seen = new Set(concrete.map((finding) => finding.id));
71482
+ return [...concrete, ...scanFileForIdPlaceholders(path13, text).filter((f) => !seen.has(f.id))].sort(
71483
+ (a, b) => a.line - b.line || (a.id < b.id ? -1 : 1)
71484
+ );
71485
+ }
71486
+
71256
71487
  // ../shared/src/agent_instructions.ts
71257
71488
  function agentFilePath(alias) {
71258
71489
  return `src/agents/${alias}.md`;
@@ -72585,6 +72816,44 @@ function readAppSourceText(projectDir) {
72585
72816
  walk2(srcDir);
72586
72817
  return parts.join("\n");
72587
72818
  }
72819
+ var ARCHIVE_EXCLUDES = ["node_modules", "dist", ".lotics", "*.tsbuildinfo", ".git"];
72820
+ function isArchiveExcluded(name2) {
72821
+ return ARCHIVE_EXCLUDES.some(
72822
+ (pattern) => pattern.startsWith("*") ? name2.endsWith(pattern.slice(1)) : name2 === pattern
72823
+ );
72824
+ }
72825
+ function scanProjectForIds(projectDir) {
72826
+ const findings = [];
72827
+ const walk2 = (dir) => {
72828
+ for (const entry of fs8.readdirSync(dir, { withFileTypes: true })) {
72829
+ if (isArchiveExcluded(entry.name)) continue;
72830
+ const full = path9.join(dir, entry.name);
72831
+ if (entry.isDirectory()) {
72832
+ walk2(full);
72833
+ continue;
72834
+ }
72835
+ if (!entry.isFile()) continue;
72836
+ const rel = path9.relative(projectDir, full).split(path9.sep).join("/");
72837
+ if (!isConcreteIdScanPath(rel) && !isIdPlaceholderScanPath(rel)) continue;
72838
+ if (fs8.statSync(full).size > SCAN_FILE_MAX_BYTES) continue;
72839
+ findings.push(...scanFileForIds(rel, fs8.readFileSync(full, "utf8")));
72840
+ }
72841
+ };
72842
+ walk2(projectDir);
72843
+ return findings.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line);
72844
+ }
72845
+ function reportPortabilityIds(findings) {
72846
+ if (findings.length === 0) return;
72847
+ console.error(
72848
+ `
72849
+ \u2717 ${findings.length} id${findings.length === 1 ? "" : "s"} here cannot survive a copy into another workspace:`
72850
+ );
72851
+ for (const finding of findings) {
72852
+ console.error(
72853
+ finding.rule === "concrete" ? ` ${finding.path}:${finding.line} \u2014 ${finding.id} is this workspace's own id; address it through the generated .lotics/app_fields aliases (F/OPT), or in prose name the thing by alias.` : ` ${finding.path}:${finding.line} \u2014 ${finding.id} is id-shaped but no generator could have minted it; publish resolves every id against the app's footprint and refuses this one, so write a stand-in that is not id-shaped.`
72854
+ );
72855
+ }
72856
+ }
72588
72857
  async function appDeploy(client, args) {
72589
72858
  const projectDir = path9.resolve(args.projectDir ?? process.cwd());
72590
72859
  const meta3 = readAppMeta(projectDir);
@@ -72633,16 +72902,7 @@ async function appDeploy(client, args) {
72633
72902
  try {
72634
72903
  note("Packaging source...");
72635
72904
  await runTar(
72636
- [
72637
- "-czf",
72638
- tmpSource,
72639
- "--exclude=node_modules",
72640
- "--exclude=dist",
72641
- "--exclude=.lotics",
72642
- "--exclude=*.tsbuildinfo",
72643
- "--exclude=.git",
72644
- "."
72645
- ],
72905
+ ["-czf", tmpSource, ...ARCHIVE_EXCLUDES.map((pattern) => `--exclude=${pattern}`), "."],
72646
72906
  projectDir
72647
72907
  );
72648
72908
  note("Packaging dist...");
@@ -72817,10 +73077,14 @@ async function appCheck(client, args = {}) {
72817
73077
  warnIfProjectFootguns(projectDir, sourceText);
72818
73078
  warnIfUnbranded(app);
72819
73079
  warnIfUndescribed(app);
72820
- if (!nothingPending(pending)) {
73080
+ const unportable = scanProjectForIds(projectDir);
73081
+ reportPortabilityIds(unportable);
73082
+ if (!nothingPending(pending) || unportable.length > 0) {
72821
73083
  process.exit(1);
72822
73084
  }
72823
- console.error("Checked the app's bindings, capabilities and agent schemas \u2014 nothing blocking.");
73085
+ console.error(
73086
+ "Checked the app's bindings, capabilities, agent schemas and id portability \u2014 nothing blocking."
73087
+ );
72824
73088
  }
72825
73089
  async function pushPendingBindings(client, projectDir, pending) {
72826
73090
  const plan = [
@@ -103441,6 +103705,7 @@ function requireClient(flags, appWorkspaceId) {
103441
103705
  };
103442
103706
  }
103443
103707
  var SOURCE_LABELS = {
103708
+ flag_org: "--org flag",
103444
103709
  flag: "--api-key flag",
103445
103710
  env_key: "LOTICS_API_KEY env",
103446
103711
  env_org: "LOTICS_ORG env",
@@ -182,6 +182,54 @@ export interface StarterPublish {
182
182
  started_at: string | null;
183
183
  finished_at: string | null;
184
184
  }
185
+ /**
186
+ * A published version's contract — the artifact set a copy of it creates.
187
+ *
188
+ * Narrowed to what a caller CHECKING a copy reads back. The authoritative shape
189
+ * is `packageContractSchema`, which lives in `@lotics/shared` — a specifier a
190
+ * published `.d.ts` cannot resolve, so it is declared here rather than imported,
191
+ * the same trade the publish shapes above make.
192
+ */
193
+ export interface StarterVersionContract {
194
+ /**
195
+ * One table per entity, scaffolded under `label`, with the columns it
196
+ * declares — a copy whose table landed with the right NAME and the wrong
197
+ * columns is the failure a table-name check cannot see.
198
+ */
199
+ entities: Array<{
200
+ alias: string;
201
+ label: string;
202
+ fields: Array<{
203
+ alias: string;
204
+ label: string;
205
+ type: string;
206
+ }>;
207
+ }>;
208
+ templates: Array<{
209
+ alias: string;
210
+ label: string;
211
+ }>;
212
+ apps: Array<{
213
+ alias: string;
214
+ name: string;
215
+ /** The named queries the app's shipped source invokes. */
216
+ queries: Array<{
217
+ alias: string;
218
+ /** Param name → declaration. `required` defaults to TRUE when absent. */
219
+ params?: Record<string, {
220
+ required?: boolean;
221
+ }>;
222
+ }>;
223
+ }>;
224
+ /** The knowledge docs the starter ships, keyed by alias. */
225
+ knowledge: Record<string, {
226
+ name: string;
227
+ }>;
228
+ /** Sample rows per entity alias — `row_count` is the contract's own count of them. */
229
+ fixtures: Record<string, {
230
+ row_count: number;
231
+ }>;
232
+ }
185
233
  /** Advisory knowledge warnings surfaced by a copy (never block). */
186
234
  export interface KnowledgeWarnings {
187
235
  /** `knowledge_expects` doc names with no matching workspace doc. */
@@ -547,6 +595,17 @@ export declare class LoticsClient {
547
595
  created_at: string;
548
596
  }>;
549
597
  }>;
598
+ /**
599
+ * One version's contract — what a copy of it is supposed to produce. The
600
+ * history above omits it (heavy per row); this is the read that carries it,
601
+ * so a check compares a copy against the declaration itself rather than
602
+ * against a description of it. Admin-only; cross-tenant by id like
603
+ * `getStarter`.
604
+ */
605
+ getStarterVersion(starter_id: string, version: number): Promise<{
606
+ version: number;
607
+ contract: StarterVersionContract;
608
+ }>;
550
609
  /**
551
610
  * Workspace-wide dangling-reference sweep — active app/workflow artifacts
552
611
  * whose prefixed schema ids no longer resolve. Backs
@@ -597,6 +656,7 @@ export declare class LoticsClient {
597
656
  fields: Array<{
598
657
  id: string;
599
658
  name: string;
659
+ type: string;
600
660
  options?: Array<{
601
661
  id: string;
602
662
  label: string;
@@ -404,6 +404,16 @@ export class LoticsClient {
404
404
  async listStarterVersions(starter_id) {
405
405
  return this.request("GET", `/v1/starters/${encodeURIComponent(starter_id)}/versions`);
406
406
  }
407
+ /**
408
+ * One version's contract — what a copy of it is supposed to produce. The
409
+ * history above omits it (heavy per row); this is the read that carries it,
410
+ * so a check compares a copy against the declaration itself rather than
411
+ * against a description of it. Admin-only; cross-tenant by id like
412
+ * `getStarter`.
413
+ */
414
+ async getStarterVersion(starter_id, version) {
415
+ return this.request("GET", `/v1/starters/${encodeURIComponent(starter_id)}/versions/${encodeURIComponent(String(version))}`);
416
+ }
407
417
  /**
408
418
  * Workspace-wide dangling-reference sweep — active app/workflow artifacts
409
419
  * whose prefixed schema ids no longer resolve. Backs
@@ -482,7 +492,7 @@ export class LoticsClient {
482
492
  if (field === null || typeof field !== "object")
483
493
  return [];
484
494
  const f = field;
485
- if (typeof f.key !== "string" || typeof f.name !== "string")
495
+ if (typeof f.key !== "string" || typeof f.name !== "string" || typeof f.type !== "string")
486
496
  return [];
487
497
  const options = Array.isArray(f.options)
488
498
  ? f.options.flatMap((opt) => {
@@ -494,7 +504,7 @@ export class LoticsClient {
494
504
  : [];
495
505
  })
496
506
  : undefined;
497
- return [{ id: f.key, name: f.name, ...(options && options.length > 0 ? { options } : {}) }];
507
+ return [{ id: f.key, name: f.name, type: f.type, ...(options && options.length > 0 ? { options } : {}) }];
498
508
  });
499
509
  return { id: table.id, name: table.name, fields };
500
510
  }));
@@ -236,7 +236,8 @@ actually carries, not what the source says it should.
236
236
 
237
237
  ```
238
238
  npm run typecheck && npm run lint && npm test
239
- lotics app check # every pre-flight a deploy runs, without building or shipping
239
+ lotics app check # every pre-flight a deploy runs, without building or shipping,
240
+ # plus the portability gate a starter publish applies
240
241
  lotics app deploy -m "<what changed + why>"
241
242
  ```
242
243
 
@@ -24,7 +24,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
24
24
  | `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |
25
25
  | `lotics run <tool>` — **file cells** | A file in a tool's result carries its `fil_…` id and metadata and **no `url`**, on every tool and in both output modes. That is not a broken file — this surface resolves no URL for a cell. Reach the bytes with `lotics file download <file_id>`, which takes the id straight from the cell; the text output says so whenever a result carries one. |
26
26
  | — | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_agent`, `run_app_query`). What stays an `app` command is work no tool call can do — scaffold, build, typecheck, serve, or read and push a local file. |
27
- | — | **The exit code reports the WORK, not just the call.** A result whose envelope carries a failed `status` (`error`/`failed`/`cancelled`) exits non-zero and prints `<tool> → <status>: <message>` to stderr, so `lotics run … && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE — an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. |
27
+ | — | **The exit code reports the WORK, not just the call — for the two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`) exit non-zero and print `<tool> → <status>: <message>` to stderr, so `lotics run … && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE — an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. Any OTHER tool's `status` — `press_button`'s included — is data, and exits 0. |
28
28
  | `lotics run <tool> --print-created` | Report the records the call created, grouped by table, with a paste-ready `delete_records` per table and the mandatory caveat naming what cannot be auto-undone (external integrations, notifications, possible sub-workflows). Works for any tool that returns a `side_effects` block, not workflows alone. |
29
29
  | `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes — harvested records **only**, never files / external calls / notifications. **Not a rollback**; a rollback is structurally impossible here. A partial cleanup exits non-zero so a script cannot read it as success. |
30
30
  | `lotics upload <file\|dir...>` · `--stdin` · `--base64` · `--url <url>` | Upload files/directories via multipart POST to /v1/files. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** — an attachment decoded in memory, a generated document, a signed download link — each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY — `Buffer.from(s, "base64")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |
@@ -45,11 +45,11 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
45
45
  | `lotics starter list` | **Works with no account**, and that is the point: whether to copy a starter or build from scratch is decided before one exists, so requiring a key would mean signing up to learn the answer was no. Unauthenticated it lists what Lotics publishes (`GET /v1/starters/official`, public). Authenticated it is the org shelf — The starters this organization can copy — Lotics-reviewed ones plus its own, each with at least one released version. Deliberately NOT a catalogue of everything published: the server returns exactly what a copy would be allowed to take, so the list can never offer something that then refuses. Admin-only. |
46
46
  | `lotics starter show <starter_id>` | One starter's name, description, current version, shelf tile and trust standing (`official` — reviewed by Lotics; `your organization's own`; otherwise `not copyable from this organization`), plus the date it was unpublished once it has been. Read it before copying a starter you did not publish. Admin-only, and readable by id from any org — but an unpublished starter 404s for every org except the one that published it. |
47
47
  | `lotics starter init <starter_id>` | **Copy a starter into this workspace.** Server-side it scaffolds the tables and fields, creates the document templates and knowledge docs, inserts the sample records, creates every app the starter carries and materializes each one's queries, workflows and agents onto it — then deploys each app from the dist the starter was published with, rewriting the publisher's sentinel field keys to this workspace's. No build runs anywhere, nothing is written to this machine, and nothing here needs node: the apps are live when the command returns. **What you get is yours outright**: ordinary apps plus ordinary tables, with no link back to the starter, nothing pinned, and nothing to upgrade. Edit any of it — `lotics app pull <app_id>` is how an app's code is edited afterwards. **The publisher's code runs in your workspace as you** — its apps, workflows and agents — which is why provenance is the gate: **copyable only if the starter is Lotics-reviewed or your own organization published it**, enforced server-side; the disclosure is printed (and carried in `--json`'s `warnings`) whenever the starter is not your own. **Refuses a workspace that already has tables** unless `--adopt`: scaffold matches an entity by DISPLAY NAME, so a starter declaring `Contacts` would bind to yours. An app whose deploy failed is reported by name with its reason and the exit is non-zero, but the copy is complete around it — the tables, the records and the app row exist — so it must not be run again; the publisher fixes the starter and it is copied into a fresh workspace. The sign-in link lands on the app when there is one, else on the workspace's app list. `--json` prints one object on stdout instead of progress (the shape is under `lotics setup`). `--no-sample-data` skips the sample records, and a copy that ADOPTS an existing table writes none either — that table already holds real rows, and the fixture set links to itself, so it is all-or-nothing; with them, how many landed is reported. They are ordinary records, delete them whenever. Resolves and ANNOUNCES its workspace first (`lotics → <org> / <workspace>` on stderr). Admin-only. Authoring the registry (`opctl starter publish/unpublish`) stays operator-only. |
48
- | `lotics starter fixtures capture [--entity <alias> ...] [--limit <n>]` | **(authoring)** Write this app's live records into the project as `fixtures/<entity-alias>.json` — the sample data a starter carries, so a copy lands with something in it. Run from an app project; the app id comes from its manifest. The alias-keyed shape is produced server-side, because the aliases are minted when the starter is extracted and exist nowhere a project can read them. `--entity` is repeatable and comma-separated; omitted, every table the app declares is captured. **Capture a linked set in ONE call** — a link between two rows only resolves within a single capture, so taking companies and contacts separately drops the edge between them (it says so when it happens). `--limit` bounds rows per table (default 10, max 200). **READ WHAT IT WROTE before committing**: these rows are created verbatim in every workspace that copies the starter, so a real customer name, price or address captured here is published. Files, formulas, rollups, lookups and autonumbers are never captured — the platform writes those. Admin-only; writes nothing to the workspace. |
48
+ | `lotics starter fixtures capture [--entity <alias> ...] [--limit <n>]` | **(authoring)** Write this app's live records into the project as `fixtures/<entity-alias>.json` — the sample data a starter carries, so a copy lands with something in it. Run from an app project; the app id comes from its manifest. The alias-keyed shape is produced server-side, because the aliases are minted when the starter is extracted and exist nowhere a project can read them. `--entity` is repeatable and comma-separated; omitted, every table the app declares is captured. **Capture a linked set in ONE call** — a link between two rows only resolves within a single capture, so taking companies and contacts separately drops the edge between them (it says so when it happens). `--limit` bounds rows per table (default 10, max 200 — a higher one is clamped, not refused). **A captured row is written into a copy exactly as it reads here**: a cell the origin left empty stays empty, because the copy's inserts do not apply field defaults. **READ WHAT IT WROTE before committing**: these rows are created verbatim in every workspace that copies the starter, so a real customer name, price or address captured here is published. Files, formulas, rollups, lookups and autonumbers are never captured — the platform writes those. Admin-only; writes nothing to the workspace. |
49
49
  | `lotics upgrade` | Update this CLI in place. Runs the same installer a person would, chosen by how THIS copy arrived: an npm install upgrades through npm, a script install re-runs the script — the runtime knows which (the executable is compiled, the npm bin runs under node), so nobody has to. It downloads nothing itself; resolving a version, verifying the checksum and replacing a running executable already exist in the installers, and a second copy of that inside the binary would be a second thing to get right. Replacing the binary while it runs is safe — a rename leaves the running image mapped on unix, and on Windows the installer moves the old aside precisely because the file is in use. Already current is a no-op that says so. Needs no auth. |
50
50
  | `lotics docs` \| `lotics docs <area>` | The index of the reference docs, **resolved out of the packages installed beside this project** — never carried by this CLI. **Both levels are discovered by looking**: every `@lotics/*` package carrying an `AGENTS.md` or a `docs/` in any `node_modules/@lotics` from the current directory UPWARD (nearest wins, so a hoisted root copy never shadows the one a project's own imports resolve to), and within each, every area it actually ships. Titles come from each file's own `# heading` and the version from the installed `package.json`, so a doc OR a whole package added upstream appears with no change to this CLI, and a skewed install is visible rather than reassuring. A package's index is named after the package (`lotics docs ui`), never `index`. `@lotics/app-sdk`, `@lotics/ui` and `@lotics/cli` sort first as a reading ORDER, not a filter. Both the index and `<area>` print to **stdout** — the index is the payload of a bare `lotics docs`, so `lotics docs | grep -i excel` works — with only the provenance line on stderr, so `lotics docs ai > ai.md` is the doc alone; a name two packages share is refused with both qualified forms (`lotics docs ui/templates`) rather than resolved silently. Needs no auth. Outside a project only `@lotics/cli`'s own resolve, and it says so. |
51
51
  | `lotics report '<json>'` \| `lotics report @report.json` | File a report with the Lotics team about what got in your way. **Covers the classes telemetry structurally cannot see**: a capability that does not exist (no command ran, so nothing was recorded), a command that exited 0 having done the wrong thing, an error whose message did not name the remedy, and anything that made authoring slower than it should be. **A frame, not a paragraph** — `{goal, actual, expected?, tried?, wanted?}`, `goal` and `actual` required, unknown keys dropped rather than refused. **No severity or category.** Ingest is inline JSON, `@file`, or `-` for stdin. A bare sentence is refused with the frame printed beside it, so the fix is one step; a bare invocation prints the frame BEFORE asking for a credential, since someone whose key will not resolve is exactly who has something to report. **Not spooled**: unlike telemetry it posts inline, prints whether it landed, and exits non-zero if it did not, echoing the report back so a failed send never loses it. Runs regardless of `LOTICS_TELEMETRY` — invoking it IS the consent that passive collection needs an opt-in for — but with telemetry off there are no recorded commands to attach, and it says so rather than implying context it does not have. Requires auth. Never paste records, file contents, or credentials. |
52
- | `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping. **First, whether this project is even based on the served version** — the one thing a deploy REFUSES outright rather than pushing (the server 409s a stale `prev_version_id`), and the one finding that invalidates every other: a stale tree and the live app are two different apps, so comparing them reports nothing trustworthy. Stale exits 1 naming both versions and stops before the rest; a project with no stamp at all — or an app with no version yet — is a first deploy, not a conflict. `deploy` runs the SAME assertion off the app row it already fetched, so a stale tree fails before it pushes a binding or builds, instead of after the upload arrives and the server 409s. Then: the manifest's agent schemas against the live app row, every binding a deploy would push, aliases the source calls that nothing bound (queries, workflows AND agents), bindings this bundle stopped calling (the same transition — and the same baseline — `deploy` reports, so the two cannot disagree), capability-gated SDK calls the manifest doesn't declare, a missing icon/theme, a missing app `description` (it heads the capability catalog the chat agent reads every turn, and its absence has no other symptom), a `vite.config.ts` that never defines `global`/`__DEV__`, a `window.open` in the app's own source, and an INSTALLED `@lotics/app-sdk` below the version that understands the host's realtime push — read from `node_modules`, not the dependency range, because a caret is minor-locked below 1.0 so `^0.79.x` can never resolve `0.80` and `npm update` does nothing (all three fail ONLY in the deployed app — dev bundles with esbuild and production with rollup, so typecheck, lint, build and `app dev` are all green while react-native-web reads `global.cancelAnimationFrame` as a free variable and the sandboxed iframe drops a popup silently), an agent holding `run_app_query`/`run_app_workflow` with an EMPTY `query_aliases`/`workflow_aliases` (the tool is the capability, the alias list is the reach — empty means every call it makes is refused while the run still COMPLETES, so it surfaces as a model ignoring its prompt; read off the live row, never the manifest, which mirrors those fields but is pushed by no verb), and a notice for any alias the source computes at runtime (invisible to every check here and to `--prune`'s unbind guard). **And whether the kit this app builds against has fallen behind what is published** — `@lotics/ui` and `@lotics/app-sdk`, read from `node_modules` for the same reason as the floor check above: a range keeps accepting, so an app pinned `^44.x` reads healthy for a year, and even an in-range one sits on the lockfile's older patch until `npm update` (never `npm install`, which honours the lock). A MAJOR behind is loud and names the packages actually behind — plus `@lotics/ui`'s `MIGRATION.md`, when ui is one of them, since it is the only half that keeps one; anything smaller is one quiet line, because a warning that fires on every deploy is one the reader stops seeing. The registry lookup is bounded and every failure — offline, slow, private — is silence: a version check must never become a new way for a deploy to fail. Adds no rule of its own — each finding is the same helper `deploy` calls, so a green check means a deploy will not complain. **Exits 1 on what a `deploy` would REFUSE or PUSH** — an agent schema that disagrees with the live app, and any binding the project has ahead of the app (an edited workflow body or declaration, edited agent prose, a changed query). Both are things a deploy would act on, so CI gating on a green check means a deploy has nothing left to do; genuine advisories (capabilities, branding, a runtime-computed alias, orphaned bindings) stay advisory and never fail it. |
52
+ | `lotics app check` | Every pre-flight `deploy` runs, WITHOUT building or shipping. **First, whether this project is even based on the served version** — the one thing a deploy REFUSES outright rather than pushing (the server 409s a stale `prev_version_id`), and the one finding that invalidates every other: a stale tree and the live app are two different apps, so comparing them reports nothing trustworthy. Stale exits 1 naming both versions and stops before the rest; a project with no stamp at all — or an app with no version yet — is a first deploy, not a conflict. `deploy` runs the SAME assertion off the app row it already fetched, so a stale tree fails before it pushes a binding or builds, instead of after the upload arrives and the server 409s. Then: the manifest's agent schemas against the live app row, every binding a deploy would push, aliases the source calls that nothing bound (queries, workflows AND agents), bindings this bundle stopped calling (the same transition — and the same baseline — `deploy` reports, so the two cannot disagree), capability-gated SDK calls the manifest doesn't declare, a missing icon/theme, a missing app `description` (it heads the capability catalog the chat agent reads every turn, and its absence has no other symptom), a `vite.config.ts` that never defines `global`/`__DEV__`, a `window.open` in the app's own source, and an INSTALLED `@lotics/app-sdk` below the version that understands the host's realtime push — read from `node_modules`, not the dependency range, because a caret is minor-locked below 1.0 so `^0.79.x` can never resolve `0.80` and `npm update` does nothing (all three fail ONLY in the deployed app — dev bundles with esbuild and production with rollup, so typecheck, lint, build and `app dev` are all green while react-native-web reads `global.cancelAnimationFrame` as a free variable and the sandboxed iframe drops a popup silently), an agent whose capability and its reach disagree, in EITHER direction, over any of the five declaration-bound tools (`run_app_query`/`run_app_workflow` against `query_aliases`/`workflow_aliases`; `grep_knowledge`/`read_knowledge`/`list_knowledge` against `knowledge_doc_ids`) — the tool is the capability, the list is the reach, and a tool with no reach means every call it makes is refused while the run still COMPLETES, so it surfaces as a model ignoring its prompt; read off the live row, never the manifest, which mirrors those fields but is pushed by no verb, and a notice for any alias the source computes at runtime (invisible to every check here and to `--prune`'s unbind guard). **And whether the kit this app builds against has fallen behind what is published** — `@lotics/ui` and `@lotics/app-sdk`, read from `node_modules` for the same reason as the floor check above: a range keeps accepting, so an app pinned `^44.x` reads healthy for a year, and even an in-range one sits on the lockfile's older patch until `npm update` (never `npm install`, which honours the lock). A MAJOR behind is loud and names the packages actually behind — plus `@lotics/ui`'s `MIGRATION.md`, when ui is one of them, since it is the only half that keeps one; anything smaller is one quiet line, because a warning that fires on every deploy is one the reader stops seeing. The registry lookup is bounded and every failure — offline, slow, private — is silence: a version check must never become a new way for a deploy to fail. Every deploy finding is the same helper `deploy` calls, so a green check means a deploy will not complain. **And the PORTABILITY gate, which is the one rule `check` runs that a deploy does not** — the ids an app cannot carry into another workspace, over the working tree, with the same exclusions the deploy tar applies. Two rules. **An id this workspace MINTED**, written into `src/`, a `.md` or the app's own docs — it resolves to nothing in a copy, and in prose it is an instruction the copier's agent follows; this is the one a `starter publish` also refuses, on the uploaded archive. **And an id-shaped STAND-IN** too short for the generator that mints its prefix (`"opt_X"`, `"fld_a"` — quoted or in a code span, so a bare `opt_in` stays legal, and never in a test file), which only `check` runs, and which additionally reads a workflow body and the manifest: those two are exempt from the first rule because a publish INVERTS a real id there, and it cannot invert a fake — so without this a stand-in survives until the publish resolves it against the app's footprint, on somebody else's machine. Each is reported as `<file>:<line> — <id>` with the one edit that fixes it. This gate reads only the files, but the command around it still needs a resolvable credential and the live app row, so it is not an offline check. **Exits 1 on that and on what a `deploy` would REFUSE or PUSH** — an agent schema that disagrees with the live app, and any binding the project has ahead of the app (an edited workflow body or declaration, edited agent prose, a changed query). Both are things a deploy would act on, so CI gating on a green check means a deploy has nothing left to do; genuine advisories (capabilities, branding, a runtime-computed alias, orphaned bindings) stay advisory and never fail it. |
53
53
  | `lotics app workflow set <alias>` | Push the edited `src/workflows/<alias>.ts` body through `set_app_workflow` (the single author of `apps.workflows`). Reads the body from disk (header + `/// <reference>` + `export {};` marker + the `__workflow` wrapper all stripped) + the typed `inputs`/`outputs` **and the `description`** from `package.json#lotics.workflows.<alias>`; the **server** re-verifies the body and echoes the bound `outputs` (declared, else DERIVED from `return({ data })`). The `description` is the one line an agent reads when choosing between the app's aliases (the workflow counterpart to a query's) — authored in the manifest so it lives beside the body in version control and rides every push; omit it and the workflow keeps whatever description it already has, so a push can never blank one set elsewhere. When the manifest declared NO `outputs`, the DERIVED echo is written back into `package.json#lotics.workflows.<alias>.outputs` (a SURGICAL write — preserves `knowledge`/`config` and every other manifest field) and that alias's types are refreshed in place, so `useWorkflow("<alias>")`'s `result.data` is typed immediately with no hand-copy and no second `lotics app codegen`; an explicitly-declared `outputs` is authoritative and never overwritten. Deploy still never authors workflows — this is a CLI convenience over the existing tool. Clear error + non-zero exit on a missing file, an alias absent from the manifest, or a verify failure. |
54
54
  | `lotics app agent set <alias>` | Push `src/agents/<alias>.md` — plus `inputs`/`outputs` when `package.json#lotics.agents.<alias>` declares them — through `set_app_agent`. The agent mirror of `app workflow set`, and the deploy-free authoring path for an agent's prose and its typed edges. **It sends only those fields.** Everything else is absent, and absent means unchanged, so a declaration this CLI does not model cannot be reverted by a push from a checkout that predates it — the chat authoring agent's `knowledge_doc_ids`, another operator's `query_aliases` grant. To change one of those, call `set_app_agent` with just that field (`lotics run set_app_agent '{"app_id":…,"alias":…,"tool_names":[…]}'` — it merges), then `app pull` to bring the manifest back in step. **CREATES the alias when the app has not bound one yet**, so a new agent is authored the same way a new workflow is: write the prose, declare the typed half, push. A create needs the prose file (an agent without instructions is not an agent); it is gated on nothing else, because what keeps a binding alive is a `useAppAgentRun("<alias>")` call site in the shipped bundle — a deploy prunes an agent the bundle never names, manifest entry or not. The prose push is a conditional write against the fingerprint this project last saw, so it is refused rather than allowed to overwrite prose someone else changed. Clear error + non-zero exit when there is no prose file and nothing declared to push instead, when a create has no prose to create from, or when the file is empty once the header is stripped. |
55
55
  | `lotics app query set <alias>` \| `--all` | Push `package.json#lotics.queries` (`{ ast, params? }` per alias) to `apps.queries` through `set_app_query` — **the only author of a query binding**, the mirror of `app workflow set`. A deploy pushes a DRIFTED declaration through this same verb before it ships (see `app deploy`), so this is the explicit single-alias path, not the only way a query reaches the app. The **server** validates each one exactly as it always did (alias identifier, workspace-only tables, resolvable fields, declared params). `--all` pushes every declared alias, alias-sorted, stopping at the first failure and naming what already landed. Clear error + non-zero exit on an alias absent from the manifest or a validation failure. **The declaration's fields MERGE**, so the manifest is not a snapshot: deleting `params` from an alias and pushing leaves the live params exactly where they were, because an absent key means "unchanged". Clear one with `params: null`, or replace the map with the set you want. |
@@ -86,8 +86,10 @@ format handles display.
86
86
  At generate time, `data` must provide a key for **every** marker — pass `""` for fields
87
87
  that should render blank; a missing key fails with the full list of missing markers.
88
88
 
89
- To add markers/loops/conditionals to the uploaded file from the CLI: `word_replace_text`,
90
- `word_insert_loop`, `word_insert_conditional` (and their `remove_*` counterparts).
89
+ To read the uploaded file before marking it: `word_get_content`, `word_find_text`,
90
+ `word_get_table_data`. To add markers/loops/conditionals: `word_replace_text`,
91
+ `word_insert_loop`, `word_insert_conditional` — unmarking is `word_replace_text` putting the
92
+ plain text back.
91
93
 
92
94
  ### Email (`create_email_template`)
93
95
 
@@ -70,10 +70,10 @@ that member's agent.
70
70
  Docs are not injected wholesale. The agent works the corpus like a filesystem, and all three
71
71
  verbs respect access + activation, so only docs the caller may use ever surface.
72
72
 
73
- 1. **`list_knowledge`** — `ls`. The corpus as a folder tree: name, id, folder, and a truncated
74
- description. No bodies.
73
+ 1. **`list_knowledge`** — `ls`. The corpus as a flat list: id, name, tags, and a truncated
74
+ description; `tag` narrows it. No bodies.
75
75
  2. **`grep_knowledge`** — `grep -rn`. Match across every readable doc (or one doc, or one
76
- folder), returning **doc, line number, and the matching line**. It runs inside Postgres,
76
+ tag), returning **doc, line number, and the matching line**. It runs inside Postgres,
77
77
  so only matching lines cross the wire.
78
78
  3. **`read_knowledge`** — `cat` / `sed -n 'X,Yp'`. Read a doc whole or by line range, to see
79
79
  the context around a hit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.183.0",
3
+ "version": "0.184.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {