@lexq/cli 0.1.31 → 0.1.33

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/CONTEXT.md CHANGED
@@ -112,7 +112,7 @@ live)
112
112
 
113
113
  ### Simulation Statuses
114
114
 
115
- `PENDING`, `RUNNING`, `COMPLETED`, `FAILED`, `CANCELLED`
115
+ `PENDING`, `RUNNING`, `COMPLETED`, `FAILED`, `CANCELED`
116
116
 
117
117
  ### Decision Statuses
118
118
 
package/dist/index.js CHANGED
@@ -59,7 +59,7 @@ var ApiError = class extends Error {
59
59
  statusCode;
60
60
  errorCode;
61
61
  };
62
- async function apiRequest(method, path, options = {}) {
62
+ async function doFetch(method, path, options) {
63
63
  const config = loadConfig();
64
64
  const baseUrl = options.baseUrl ?? config.baseUrl;
65
65
  const apiKey = options.apiKey ?? config.apiKey;
@@ -69,18 +69,11 @@ async function apiRequest(method, path, options = {}) {
69
69
  const url = new URL(path, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/");
70
70
  if (options.params) {
71
71
  for (const [key, value] of Object.entries(options.params)) {
72
- if (value !== void 0 && value !== "") {
73
- url.searchParams.set(key, value);
74
- }
72
+ if (value !== void 0 && value !== "") url.searchParams.set(key, value);
75
73
  }
76
74
  }
77
- const headers = {
78
- "X-API-KEY": apiKey,
79
- Accept: "application/json"
80
- };
81
- if (options.body) {
82
- headers["Content-Type"] = "application/json";
83
- }
75
+ const headers = { "X-API-KEY": apiKey, Accept: "application/json" };
76
+ if (options.body) headers["Content-Type"] = "application/json";
84
77
  if (options.dryRun) {
85
78
  const masked = apiKey.length > 8 ? apiKey.substring(0, 4) + "****" + apiKey.substring(apiKey.length - 4) : "****";
86
79
  console.log(`${method} ${url.toString()}`);
@@ -94,9 +87,7 @@ async function apiRequest(method, path, options = {}) {
94
87
  console.log("\n(Use without --dry-run to execute)");
95
88
  process.exit(0);
96
89
  }
97
- if (options.verbose) {
98
- console.error(`\u2192 ${method} ${url.toString()}`);
99
- }
90
+ if (options.verbose) console.error(`\u2192 ${method} ${url.toString()}`);
100
91
  const startTime = Date.now();
101
92
  const response = await fetch(url.toString(), {
102
93
  method,
@@ -106,14 +97,9 @@ async function apiRequest(method, path, options = {}) {
106
97
  if (options.verbose) {
107
98
  console.error(`\u2190 ${response.status} ${response.statusText} (${Date.now() - startTime}ms)`);
108
99
  }
109
- const contentType = response.headers.get("content-type") ?? "";
110
- if (contentType.includes("text/csv") || contentType.includes("application/octet-stream")) {
111
- return response;
112
- }
113
- if (response.status === 204 || contentType === "") {
114
- return void 0;
115
- }
116
- const json = await response.json();
100
+ return response;
101
+ }
102
+ function assertOk(response, json) {
117
103
  if (!response.ok || json.result !== "SUCCESS") {
118
104
  throw new ApiError(
119
105
  response.status,
@@ -121,7 +107,23 @@ async function apiRequest(method, path, options = {}) {
121
107
  json.message ?? `Request failed with status ${response.status}`
122
108
  );
123
109
  }
124
- return json.data;
110
+ }
111
+ async function apiRequest(method, path, options = {}) {
112
+ const { data } = await apiRequestWithMeta(method, path, options);
113
+ return data;
114
+ }
115
+ async function apiRequestWithMeta(method, path, options = {}) {
116
+ const response = await doFetch(method, path, options);
117
+ const contentType = response.headers.get("content-type") ?? "";
118
+ if (contentType.includes("text/csv") || contentType.includes("application/octet-stream")) {
119
+ return { data: response, meta: null };
120
+ }
121
+ if (response.status === 204 || contentType === "") {
122
+ return { data: void 0, meta: null };
123
+ }
124
+ const json = await response.json();
125
+ assertOk(response, json);
126
+ return { data: json.data, meta: json.meta ?? null };
125
127
  }
126
128
 
127
129
  // src/lib/output.ts
@@ -163,6 +165,23 @@ function printError(error) {
163
165
  console.error(JSON.stringify({ error: "UNKNOWN", message: String(error) }, null, 2));
164
166
  }
165
167
  }
168
+ function printUnregisteredFactsWarning(facts) {
169
+ if (facts.length === 0) return;
170
+ const s = facts.length === 1 ? "" : "s";
171
+ console.error(
172
+ `
173
+ \u26A0 ${facts.length} undefined fact${s} referenced \u2014 register to enable validation:`
174
+ );
175
+ for (const f of facts) {
176
+ const type = f.inferredType ?? f.candidateTypes?.[0] ?? "STRING";
177
+ const note = f.conflict ? ` (ambiguous: ${f.candidateTypes?.join(" | ") ?? "?"})` : "";
178
+ console.error(` \u2022 ${f.key} \u2192 ${type}${note}`);
179
+ console.error(
180
+ ` lexq facts create --key ${f.key} --name "${f.suggestedName}" --type ${type}`
181
+ );
182
+ }
183
+ console.error("");
184
+ }
166
185
 
167
186
  // src/commands/auth.ts
168
187
  function registerAuthCommands(program) {
@@ -435,7 +454,7 @@ ${data.length} total`);
435
454
  const answer = await rl.question(`Delete group ${opts.id}? [y/N] `);
436
455
  rl.close();
437
456
  if (answer.toLowerCase() !== "y") {
438
- console.log("Cancelled.");
457
+ console.log("Canceled.");
439
458
  return;
440
459
  }
441
460
  }
@@ -543,7 +562,7 @@ ${data.length} total`);
543
562
  const answer = await rl.question(`Stop A/B test for group ${opts.groupId}? [y/N] `);
544
563
  rl.close();
545
564
  if (answer.toLowerCase() !== "y") {
546
- console.log("Cancelled.");
565
+ console.log("Canceled.");
547
566
  return;
548
567
  }
549
568
  }
@@ -755,7 +774,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
755
774
  const answer = await rl.question(`Delete version ${opts.id}? [y/N] `);
756
775
  rl.close();
757
776
  if (answer.toLowerCase() !== "y") {
758
- console.log("Cancelled.");
777
+ console.log("Canceled.");
759
778
  return;
760
779
  }
761
780
  }
@@ -935,7 +954,7 @@ ${data.length} total`);
935
954
  try {
936
955
  const globalOpts = program.opts();
937
956
  const body = JSON.parse(opts.json);
938
- const data = await apiRequest(
957
+ const { data, meta } = await apiRequestWithMeta(
939
958
  "POST",
940
959
  `policy-groups/${opts.groupId}/versions/${opts.versionId}/rules`,
941
960
  {
@@ -947,6 +966,7 @@ ${data.length} total`);
947
966
  }
948
967
  );
949
968
  printJson(data);
969
+ printUnregisteredFactsWarning(meta?.unregisteredFacts ?? []);
950
970
  } catch (error) {
951
971
  printError(error);
952
972
  process.exit(1);
@@ -970,7 +990,7 @@ ${data.length} total`);
970
990
  try {
971
991
  const globalOpts = program.opts();
972
992
  const body = JSON.parse(opts.json);
973
- const data = await apiRequest(
993
+ const { data, meta } = await apiRequestWithMeta(
974
994
  "PUT",
975
995
  `policy-groups/${opts.groupId}/versions/${opts.versionId}/rules/${opts.id}`,
976
996
  {
@@ -982,6 +1002,7 @@ ${data.length} total`);
982
1002
  }
983
1003
  );
984
1004
  printJson(data);
1005
+ printUnregisteredFactsWarning(meta?.unregisteredFacts ?? []);
985
1006
  } catch (error) {
986
1007
  printError(error);
987
1008
  process.exit(1);
@@ -996,7 +1017,7 @@ ${data.length} total`);
996
1017
  const answer = await rl.question(`Delete rule ${opts.id}? [y/N] `);
997
1018
  rl.close();
998
1019
  if (answer.toLowerCase() !== "y") {
999
- console.log("Cancelled.");
1020
+ console.log("Canceled.");
1000
1021
  return;
1001
1022
  }
1002
1023
  }
@@ -1099,6 +1120,7 @@ function registerFactCommands(program) {
1099
1120
 
1100
1121
  Commands:
1101
1122
  list List all fact definitions
1123
+ unregistered List facts referenced by a version but not yet defined
1102
1124
  create Register a new fact
1103
1125
  update Update fact metadata
1104
1126
  delete Remove a fact definition
@@ -1143,6 +1165,52 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1143
1165
  process.exit(1);
1144
1166
  }
1145
1167
  });
1168
+ facts.command("unregistered").description("List facts referenced by a version but not yet defined").requiredOption("--group-id <groupId>", "Policy group ID").requiredOption("--version-id <versionId>", "Policy version ID").addHelpText(
1169
+ "after",
1170
+ dedent6`
1171
+
1172
+ Facts used in a version's rules that have no definition yet (read-only).
1173
+ Does not block publish/deploy — register them to enable validation.
1174
+
1175
+ Example:
1176
+ $ lexq facts unregistered --group-id <gid> --version-id <vid> --format table
1177
+ `
1178
+ ).action(async (opts) => {
1179
+ try {
1180
+ const globalOpts = program.opts();
1181
+ const format = globalOpts.format ?? "json";
1182
+ const data = await apiRequest(
1183
+ "GET",
1184
+ `policy-groups/${opts.groupId}/versions/${opts.versionId}/unregistered-facts`,
1185
+ {
1186
+ apiKey: globalOpts.apiKey,
1187
+ baseUrl: globalOpts.baseUrl,
1188
+ dryRun: globalOpts.dryRun,
1189
+ verbose: globalOpts.verbose
1190
+ }
1191
+ );
1192
+ if (format === "table") {
1193
+ printTable(
1194
+ ["Key", "Type", "Suggested Name", "Conflict", "Sources"],
1195
+ data.map((f) => [
1196
+ f.key,
1197
+ f.inferredType ?? f.candidateTypes?.join("|") ?? "?",
1198
+ f.suggestedName,
1199
+ f.conflict ? "\u2713" : "\u2013",
1200
+ f.sources.map((s) => `${s.kind}:${s.field}`).join(", ")
1201
+ ]),
1202
+ { truncate: 28 }
1203
+ );
1204
+ console.log(`
1205
+ ${data.length} unregistered`);
1206
+ } else {
1207
+ printJson(data);
1208
+ }
1209
+ } catch (error) {
1210
+ printError(error);
1211
+ process.exit(1);
1212
+ }
1213
+ });
1146
1214
  facts.command("create").description("Create a new fact definition").option("--key <key>", "Fact key (lowercase, underscores)").option("--name <n>", "Display name").option("--type <type>", "Value type: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER").option("--description <desc>", "Description").option("--required", "Mark as required", false).option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
1147
1215
  "after",
1148
1216
  dedent6`
@@ -1220,7 +1288,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1220
1288
  const answer = await rl.question(`Delete fact ${opts.id}? [y/N] `);
1221
1289
  rl.close();
1222
1290
  if (answer.toLowerCase() !== "y") {
1223
- console.log("Cancelled.");
1291
+ console.log("Canceled.");
1224
1292
  return;
1225
1293
  }
1226
1294
  }
@@ -1316,6 +1384,7 @@ function registerDeployCommands(program) {
1316
1384
  ).action(async (opts) => {
1317
1385
  try {
1318
1386
  const globalOpts = program.opts();
1387
+ await warnUnregisteredFacts(globalOpts, opts.groupId, opts.versionId);
1319
1388
  await apiRequest(
1320
1389
  "POST",
1321
1390
  `policy-groups/${opts.groupId}/versions/${opts.versionId}/publish`,
@@ -1345,6 +1414,7 @@ function registerDeployCommands(program) {
1345
1414
  ).action(async (opts) => {
1346
1415
  try {
1347
1416
  const globalOpts = program.opts();
1417
+ await warnUnregisteredFacts(globalOpts, opts.groupId, opts.versionId);
1348
1418
  await apiRequest("POST", `policy-groups/${opts.groupId}/deploy`, {
1349
1419
  apiKey: globalOpts.apiKey,
1350
1420
  baseUrl: globalOpts.baseUrl,
@@ -1377,7 +1447,7 @@ function registerDeployCommands(program) {
1377
1447
  const answer = await rl.question(`Rollback group ${opts.groupId}? [y/N] `);
1378
1448
  rl.close();
1379
1449
  if (answer.toLowerCase() !== "y") {
1380
- console.log("Cancelled.");
1450
+ console.log("Canceled.");
1381
1451
  return;
1382
1452
  }
1383
1453
  }
@@ -1412,7 +1482,7 @@ function registerDeployCommands(program) {
1412
1482
  const answer = await rl.question(`Undeploy group ${opts.groupId}? [y/N] `);
1413
1483
  rl.close();
1414
1484
  if (answer.toLowerCase() !== "y") {
1415
- console.log("Cancelled.");
1485
+ console.log("Canceled.");
1416
1486
  return;
1417
1487
  }
1418
1488
  }
@@ -1588,6 +1658,18 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1588
1658
  }
1589
1659
  });
1590
1660
  }
1661
+ async function warnUnregisteredFacts(globalOpts, groupId, versionId) {
1662
+ if (globalOpts.dryRun) return;
1663
+ try {
1664
+ const facts = await apiRequest(
1665
+ "GET",
1666
+ `policy-groups/${groupId}/versions/${versionId}/unregistered-facts`,
1667
+ { apiKey: globalOpts.apiKey, baseUrl: globalOpts.baseUrl, verbose: globalOpts.verbose }
1668
+ );
1669
+ printUnregisteredFactsWarning(facts);
1670
+ } catch {
1671
+ }
1672
+ }
1591
1673
 
1592
1674
  // src/commands/analytics.ts
1593
1675
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
@@ -1867,10 +1949,7 @@ function registerAnalyticsCommands(program) {
1867
1949
  process.exit(1);
1868
1950
  }
1869
1951
  });
1870
- sim.command("list").description("List simulation history").option(
1871
- "--status <status>",
1872
- "Filter by status (PENDING, RUNNING, COMPLETED, FAILED, CANCELLED)"
1873
- ).option("--from <date>", "Start date (yyyy-MM-dd)").option("--to <date>", "End date (yyyy-MM-dd)").option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").action(async (opts) => {
1952
+ sim.command("list").description("List simulation history").option("--status <status>", "Filter by status (PENDING, RUNNING, COMPLETED, FAILED, CANCELED)").option("--from <date>", "Start date (yyyy-MM-dd)").option("--to <date>", "End date (yyyy-MM-dd)").option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").action(async (opts) => {
1874
1953
  try {
1875
1954
  const globalOpts = program.opts();
1876
1955
  const format = globalOpts.format ?? "json";
@@ -1917,7 +1996,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1917
1996
  "after",
1918
1997
  dedent8`
1919
1998
 
1920
- Only PENDING or RUNNING simulations can be cancelled.
1999
+ Only PENDING or RUNNING simulations can be canceled.
1921
2000
  `
1922
2001
  ).action(async (opts) => {
1923
2002
  try {
@@ -1928,7 +2007,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1928
2007
  const answer = await rl.question(`Cancel simulation ${opts.id}? [y/N] `);
1929
2008
  rl.close();
1930
2009
  if (answer.toLowerCase() !== "y") {
1931
- console.log("Cancelled.");
2010
+ console.log("Canceled.");
1932
2011
  return;
1933
2012
  }
1934
2013
  }
@@ -1938,7 +2017,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1938
2017
  dryRun: globalOpts.dryRun,
1939
2018
  verbose: globalOpts.verbose
1940
2019
  });
1941
- console.log(`\u2713 Simulation ${opts.id} cancelled.`);
2020
+ console.log(`\u2713 Simulation ${opts.id} canceled.`);
1942
2021
  } catch (error) {
1943
2022
  printError(error);
1944
2023
  process.exit(1);
@@ -2388,7 +2467,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2388
2467
  const answer = await rl.question(`Delete integration ${opts.id}? [y/N] `);
2389
2468
  rl.close();
2390
2469
  if (answer.toLowerCase() !== "y") {
2391
- console.log("Cancelled.");
2470
+ console.log("Canceled.");
2392
2471
  return;
2393
2472
  }
2394
2473
  }
@@ -2765,7 +2844,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2765
2844
  const answer = await rl.question(`Delete webhook subscription ${opts.id}? [y/N] `);
2766
2845
  rl.close();
2767
2846
  if (answer.toLowerCase() !== "y") {
2768
- console.log("Cancelled.");
2847
+ console.log("Canceled.");
2769
2848
  return;
2770
2849
  }
2771
2850
  }
@@ -2861,14 +2940,17 @@ function createCallApiFromConfig() {
2861
2940
  apiKey: config.apiKey,
2862
2941
  baseUrl: config.baseUrl
2863
2942
  };
2864
- const data = await apiRequest(method, path, {
2943
+ const { data, meta } = await apiRequestWithMeta(method, path, {
2865
2944
  ...clientOpts,
2866
2945
  body: opts?.body,
2867
2946
  params: opts?.params
2868
2947
  });
2869
- return {
2870
- content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
2871
- };
2948
+ const content = [
2949
+ { type: "text", text: JSON.stringify(data, null, 2) }
2950
+ ];
2951
+ const warning = formatUnregisteredFactWarning(meta);
2952
+ if (warning) content.push({ type: "text", text: warning });
2953
+ return { content };
2872
2954
  } catch (error) {
2873
2955
  const message = error instanceof Error ? error.message : String(error);
2874
2956
  return {
@@ -2884,6 +2966,22 @@ function paginationParams(page, size) {
2884
2966
  if (size !== void 0) params.size = String(size);
2885
2967
  return params;
2886
2968
  }
2969
+ function formatUnregisteredFactWarning(meta) {
2970
+ const facts = meta?.unregisteredFacts ?? [];
2971
+ if (facts.length === 0) return null;
2972
+ const lines = facts.map((f) => {
2973
+ if (f.inferredType) {
2974
+ return ` \u2022 ${f.key} (${f.inferredType}) \u2014 register: lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: "${f.inferredType}" })`;
2975
+ }
2976
+ const choices = f.candidateTypes?.join(" | ") ?? "STRING | NUMBER | BOOLEAN";
2977
+ return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
2978
+ });
2979
+ return [
2980
+ `\u26A0 The saved rule references ${facts.length} fact(s) not defined in the schema. The rule was saved \u2014 this does not block \u2014 but undefined facts skip type validation and the dry-run requirements analyzer.`,
2981
+ ...lines,
2982
+ `Register them now so the rule validates as intended, or call lexq_facts_unregistered to review the version's full list.`
2983
+ ].join("\n");
2984
+ }
2887
2985
 
2888
2986
  // src/mcp/tools/status.ts
2889
2987
  function registerStatusTools(server, callApi) {
@@ -3160,6 +3258,9 @@ function registerRuleTools(server, callApi) {
3160
3258
  If a required key is missing, ask the user to confirm the type, isRequired, and description
3161
3259
  before calling lexq_facts_create — registering facts enables type validation, Console UI
3162
3260
  autocomplete, and the dry-run requirements analyzer.
3261
+
3262
+ After saving, lexq_facts_unregistered lists any keys this version references but has not
3263
+ defined (non-blocking, version-wide) — use it to decide what to register.
3163
3264
 
3164
3265
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
3165
3266
  Operators: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
@@ -3345,6 +3446,18 @@ function registerFactTools(server, callApi) {
3345
3446
  },
3346
3447
  async () => callApi("GET", "schema/action-metadata")
3347
3448
  );
3449
+ server.registerTool(
3450
+ "lexq_facts_unregistered",
3451
+ {
3452
+ title: "List Unregistered Facts",
3453
+ description: "List facts referenced by a version's rules but not yet defined (read-only \u2014 does not block publish/deploy, INV-4). Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Register them with lexq_facts_create to enable type validation and the dry-run requirements analyzer.",
3454
+ inputSchema: {
3455
+ groupId: z4.string().uuid().describe("Policy group ID"),
3456
+ versionId: z4.string().uuid().describe("Version ID")
3457
+ }
3458
+ },
3459
+ async ({ groupId, versionId }) => callApi("GET", `policy-groups/${groupId}/versions/${versionId}/unregistered-facts`)
3460
+ );
3348
3461
  }
3349
3462
 
3350
3463
  // src/mcp/tools/deploy.ts
@@ -3354,7 +3467,7 @@ function registerDeployTools(server, callApi) {
3354
3467
  "lexq_deploy_publish",
3355
3468
  {
3356
3469
  title: "Publish Version",
3357
- description: "Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule.",
3470
+ description: "Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts referenced by rules do not block publishing (INV-4); call lexq_facts_unregistered first to review them.",
3358
3471
  inputSchema: {
3359
3472
  groupId: z5.string().uuid().describe("Policy group ID"),
3360
3473
  versionId: z5.string().uuid().describe("Version ID to publish"),
@@ -3367,7 +3480,7 @@ function registerDeployTools(server, callApi) {
3367
3480
  "lexq_deploy_live",
3368
3481
  {
3369
3482
  title: "Deploy to Live",
3370
- description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately.",
3483
+ description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Undefined facts do not block deployment (INV-4); use lexq_facts_unregistered to review what the version references but has not defined.",
3371
3484
  inputSchema: {
3372
3485
  groupId: z5.string().uuid().describe("Policy group ID"),
3373
3486
  versionId: z5.string().uuid().describe("Version ID to deploy"),
@@ -3597,7 +3710,7 @@ function registerAnalyticsTools(server, callApi) {
3597
3710
  inputSchema: {
3598
3711
  page: z6.number().int().min(0).default(0).describe("Page number"),
3599
3712
  size: z6.number().int().min(1).max(100).default(20).describe("Page size"),
3600
- status: z6.enum(["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELLED"]).optional().describe("Filter by status"),
3713
+ status: z6.enum(["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELED"]).optional().describe("Filter by status"),
3601
3714
  from: z6.string().optional().describe("Start date (yyyy-MM-dd)"),
3602
3715
  to: z6.string().optional().describe("End date (yyyy-MM-dd)")
3603
3716
  }
@@ -4175,7 +4288,7 @@ function registerDomainTemplateCommands(program) {
4175
4288
  );
4176
4289
  rl.close();
4177
4290
  if (answer.toLowerCase() !== "y") {
4178
- console.log("Cancelled.");
4291
+ console.log("Canceled.");
4179
4292
  return;
4180
4293
  }
4181
4294
  }
@@ -1,5 +1,30 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
 
3
+ declare const ValueType: readonly ["STRING", "NUMBER", "BOOLEAN", "LIST_STRING", "LIST_NUMBER"];
4
+ type ValueType = (typeof ValueType)[number];
5
+ declare const Confidence: readonly ["EXACT", "AMBIGUOUS"];
6
+ type Confidence = (typeof Confidence)[number];
7
+ declare const SourceKind: readonly ["CONDITION", "ACTION"];
8
+ type SourceKind = (typeof SourceKind)[number];
9
+
10
+ interface ResponseMeta {
11
+ unregisteredFacts?: UnregisteredFact[];
12
+ }
13
+ interface UnregisteredFact {
14
+ key: string;
15
+ inferredType: ValueType | null;
16
+ confidence: Confidence;
17
+ conflict: boolean;
18
+ candidateTypes: ValueType[] | null;
19
+ suggestedName: string;
20
+ sources: UnregisteredFactSource[];
21
+ }
22
+ interface UnregisteredFactSource {
23
+ kind: SourceKind;
24
+ field: string;
25
+ operator?: string;
26
+ }
27
+
3
28
  interface McpToolResult {
4
29
  [key: string]: unknown;
5
30
  content: Array<{
@@ -25,13 +50,22 @@ type CallApi = (method: string, path: string, opts?: {
25
50
  };
26
51
  }) => Promise<McpToolResult>;
27
52
  declare function paginationParams(page?: number, size?: number): Record<string, string>;
53
+ /**
54
+ * Builds a salient, actionable warning from a response's meta when it reports facts that
55
+ * rules reference but the schema does not define. Returns null when there is nothing to warn.
56
+ *
57
+ * Shared by both CallApi implementations (CLI stdio here, lexq-mcp HTTP via @lexq/cli/mcp)
58
+ * so the agent-facing text is identical on every surface. The backend only populates
59
+ * meta.unregisteredFacts on rule create/update, so this only ever fires there.
60
+ */
61
+ declare function formatUnregisteredFactWarning(meta: ResponseMeta | null | undefined): string | null;
28
62
 
29
63
  /**
30
- * Registers all 66 MCP tools on the given server.
64
+ * Registers all MCP tools on the given server.
31
65
  *
32
66
  * @param server - McpServer instance
33
67
  * @param callApi - API caller function (config-based for CLI, Bearer-based for HTTP)
34
68
  */
35
69
  declare function registerAllTools(server: McpServer, callApi: CallApi): void;
36
70
 
37
- export { type CallApi, type McpToolResult, paginationParams, registerAllTools };
71
+ export { type CallApi, type McpToolResult, formatUnregisteredFactWarning, paginationParams, registerAllTools };
@@ -161,6 +161,22 @@ function paginationParams(page, size) {
161
161
  if (size !== void 0) params.size = String(size);
162
162
  return params;
163
163
  }
164
+ function formatUnregisteredFactWarning(meta) {
165
+ const facts = meta?.unregisteredFacts ?? [];
166
+ if (facts.length === 0) return null;
167
+ const lines = facts.map((f) => {
168
+ if (f.inferredType) {
169
+ return ` \u2022 ${f.key} (${f.inferredType}) \u2014 register: lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: "${f.inferredType}" })`;
170
+ }
171
+ const choices = f.candidateTypes?.join(" | ") ?? "STRING | NUMBER | BOOLEAN";
172
+ return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
173
+ });
174
+ return [
175
+ `\u26A0 The saved rule references ${facts.length} fact(s) not defined in the schema. The rule was saved \u2014 this does not block \u2014 but undefined facts skip type validation and the dry-run requirements analyzer.`,
176
+ ...lines,
177
+ `Register them now so the rule validates as intended, or call lexq_facts_unregistered to review the version's full list.`
178
+ ].join("\n");
179
+ }
164
180
 
165
181
  // src/mcp/tools/versions.ts
166
182
  function registerVersionTools(server, callApi) {
@@ -290,6 +306,9 @@ function registerRuleTools(server, callApi) {
290
306
  If a required key is missing, ask the user to confirm the type, isRequired, and description
291
307
  before calling lexq_facts_create — registering facts enables type validation, Console UI
292
308
  autocomplete, and the dry-run requirements analyzer.
309
+
310
+ After saving, lexq_facts_unregistered lists any keys this version references but has not
311
+ defined (non-blocking, version-wide) — use it to decide what to register.
293
312
 
294
313
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
295
314
  Operators: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
@@ -475,6 +494,18 @@ function registerFactTools(server, callApi) {
475
494
  },
476
495
  async () => callApi("GET", "schema/action-metadata")
477
496
  );
497
+ server.registerTool(
498
+ "lexq_facts_unregistered",
499
+ {
500
+ title: "List Unregistered Facts",
501
+ description: "List facts referenced by a version's rules but not yet defined (read-only \u2014 does not block publish/deploy, INV-4). Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Register them with lexq_facts_create to enable type validation and the dry-run requirements analyzer.",
502
+ inputSchema: {
503
+ groupId: z4.string().uuid().describe("Policy group ID"),
504
+ versionId: z4.string().uuid().describe("Version ID")
505
+ }
506
+ },
507
+ async ({ groupId, versionId }) => callApi("GET", `policy-groups/${groupId}/versions/${versionId}/unregistered-facts`)
508
+ );
478
509
  }
479
510
 
480
511
  // src/mcp/tools/deploy.ts
@@ -484,7 +515,7 @@ function registerDeployTools(server, callApi) {
484
515
  "lexq_deploy_publish",
485
516
  {
486
517
  title: "Publish Version",
487
- description: "Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule.",
518
+ description: "Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts referenced by rules do not block publishing (INV-4); call lexq_facts_unregistered first to review them.",
488
519
  inputSchema: {
489
520
  groupId: z5.string().uuid().describe("Policy group ID"),
490
521
  versionId: z5.string().uuid().describe("Version ID to publish"),
@@ -497,7 +528,7 @@ function registerDeployTools(server, callApi) {
497
528
  "lexq_deploy_live",
498
529
  {
499
530
  title: "Deploy to Live",
500
- description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately.",
531
+ description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Undefined facts do not block deployment (INV-4); use lexq_facts_unregistered to review what the version references but has not defined.",
501
532
  inputSchema: {
502
533
  groupId: z5.string().uuid().describe("Policy group ID"),
503
534
  versionId: z5.string().uuid().describe("Version ID to deploy"),
@@ -727,7 +758,7 @@ function registerAnalyticsTools(server, callApi) {
727
758
  inputSchema: {
728
759
  page: z6.number().int().min(0).default(0).describe("Page number"),
729
760
  size: z6.number().int().min(1).max(100).default(20).describe("Page size"),
730
- status: z6.enum(["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELLED"]).optional().describe("Filter by status"),
761
+ status: z6.enum(["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELED"]).optional().describe("Filter by status"),
731
762
  from: z6.string().optional().describe("Start date (yyyy-MM-dd)"),
732
763
  to: z6.string().optional().describe("End date (yyyy-MM-dd)")
733
764
  }
@@ -1175,6 +1206,7 @@ function registerAllTools(server, callApi) {
1175
1206
  registerWebhookSubscriptionTools(server, callApi);
1176
1207
  }
1177
1208
  export {
1209
+ formatUnregisteredFactWarning,
1178
1210
  paginationParams,
1179
1211
  registerAllTools
1180
1212
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "LexQ CLI — manage policies, simulate rules, and deploy from the terminal. Built for humans and AI agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -260,7 +260,7 @@ Simulation is async. Poll until `status` is `COMPLETED` or `FAILED`.
260
260
  | `RUNNING` | In progress (`progress` field shows 0–100) |
261
261
  | `COMPLETED` | Done — results available |
262
262
  | `FAILED` | Error occurred |
263
- | `CANCELLED` | Manually cancelled |
263
+ | `CANCELED` | Manually canceled |
264
264
 
265
265
  ### List Simulations
266
266