@lexq/cli 0.1.38 → 0.1.40

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/index.js CHANGED
@@ -933,9 +933,27 @@ ${data.length} total`);
933
933
  ]
934
934
  }'
935
935
 
936
- Condition Operators:
937
- EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL,
938
- LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
936
+ Condition Operators (by fact type):
937
+ STRING EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
938
+ NUMBER EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL,
939
+ LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
940
+ BOOLEAN EQUALS, NOT_EQUALS
941
+ LIST_STRING HAS_ANY, HAS_ALL, HAS_NONE
942
+ LIST_NUMBER HAS_ANY, HAS_ALL, HAS_NONE
943
+
944
+ Using an operator outside its fact type is rejected by the server.
945
+
946
+ List-typed facts (HAS_*) — the value is always an array:
947
+ HAS_ANY fact has at least one of the given values
948
+ HAS_ALL fact has all of the given values
949
+ HAS_NONE fact has none of the given values
950
+
951
+ Example:
952
+ { "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY",
953
+ "value": ["VIP", "GOLD"], "valueType": "LIST_STRING" }
954
+
955
+ CONTAINS is substring match on STRING facts, not list membership.
956
+ IN is the mirror of HAS_*: scalar fact, list value.
939
957
 
940
958
  Action Types:
941
959
  MUTATE_FACT, INCREMENT_FACT, EMIT_EVENT, BLOCK, EMIT_NOTIFICATION, EMIT_WEBHOOK, SET_FACT, ADD_TAG
@@ -1358,13 +1376,16 @@ function registerDeployCommands(program) {
1358
1376
  "after",
1359
1377
  dedent7`
1360
1378
 
1361
- Lifecycle: Publish (DRAFT→ACTIVE) → Deploy (ACTIVE→LIVE) → Rollback / Undeploy
1379
+ Lifecycle: Publish (DRAFT→ACTIVE) → Deploy or Schedule (ACTIVE→LIVE) → Rollback / Undeploy
1362
1380
 
1363
1381
  Commands:
1364
1382
  publish Lock a DRAFT version (DRAFT → ACTIVE)
1365
1383
  live Push an ACTIVE version to production traffic
1366
1384
  rollback Revert to the previous deployed version
1367
1385
  undeploy Remove the live version (stops all traffic)
1386
+ schedule Schedule an ACTIVE version to auto-deploy at its effective start
1387
+ unschedule Cancel the pending scheduled deployment
1388
+ schedules List scheduled deployments (all statuses)
1368
1389
  history List deployment history with filters
1369
1390
  detail Get deployment detail with integrity check
1370
1391
  overview Show all groups' deployment status at a glance
@@ -1502,10 +1523,7 @@ function registerDeployCommands(program) {
1502
1523
  process.exit(1);
1503
1524
  }
1504
1525
  });
1505
- deploy.command("history").description("List deployment history").option("--group-id <groupId>", "Filter by policy group").option(
1506
- "--types <types>",
1507
- "Filter by types (comma-separated: PUBLISH,DEPLOY,ROLLBACK,UNDEPLOY)"
1508
- ).option("--start-date <date>", "Start date (yyyy-MM-dd)").option("--end-date <date>", "End date (yyyy-MM-dd)").option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
1526
+ deploy.command("history").description("List deployment history").option("--group-id <groupId>", "Filter by policy group").option("--types <types>", "Filter by types (comma-separated: DEPLOY,ROLLBACK,UNDEPLOY)").option("--start-date <date>", "Start date (yyyy-MM-dd)").option("--end-date <date>", "End date (yyyy-MM-dd)").option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
1509
1527
  "after",
1510
1528
  dedent7`
1511
1529
 
@@ -1660,6 +1678,119 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1660
1678
  process.exit(1);
1661
1679
  }
1662
1680
  });
1681
+ deploy.command("schedule").description("Schedule an ACTIVE version to auto-deploy at its effective start").requiredOption("--group-id <groupId>", "Policy group ID").requiredOption("--version-id <versionId>", "Version ID to schedule").requiredOption("--memo <memo>", "Schedule memo").addHelpText(
1682
+ "after",
1683
+ dedent7`
1684
+
1685
+ The version must be ACTIVE with a future effective start date; the system
1686
+ deploys it automatically at that time (within one scheduler tick, ≤60s).
1687
+ One pending schedule per group. Manual deploy/rollback/undeploy, starting
1688
+ an A/B test, or archiving the group cancels the pending schedule.
1689
+
1690
+ Example:
1691
+ $ lexq deploy schedule --group-id <gid> --version-id <vid> --memo "Q4 pricing"
1692
+ `
1693
+ ).action(async (opts) => {
1694
+ try {
1695
+ const globalOpts = program.opts();
1696
+ const data = await apiRequest(
1697
+ "POST",
1698
+ `policy-groups/${opts.groupId}/schedule`,
1699
+ {
1700
+ apiKey: globalOpts.apiKey,
1701
+ baseUrl: globalOpts.baseUrl,
1702
+ dryRun: globalOpts.dryRun,
1703
+ verbose: globalOpts.verbose,
1704
+ body: { versionId: opts.versionId, memo: opts.memo }
1705
+ }
1706
+ );
1707
+ console.log(`\u2713 Scheduled v${data.versionNo ?? "?"} for ${data.scheduledFor}.`);
1708
+ } catch (error) {
1709
+ printError(error);
1710
+ process.exit(1);
1711
+ }
1712
+ });
1713
+ deploy.command("unschedule").description("Cancel the pending scheduled deployment for a group").requiredOption("--group-id <groupId>", "Policy group ID").option("--force", "Skip confirmation prompt").addHelpText(
1714
+ "after",
1715
+ dedent7`
1716
+
1717
+ Cancels the PENDING schedule only — the version itself is not affected.
1718
+
1719
+ Example:
1720
+ $ lexq deploy unschedule --group-id <gid> --force
1721
+ `
1722
+ ).action(async (opts) => {
1723
+ try {
1724
+ const globalOpts = program.opts();
1725
+ if (!opts.force) {
1726
+ const { createInterface: createInterface2 } = await import("readline/promises");
1727
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
1728
+ const answer = await rl.question(
1729
+ `Cancel the pending scheduled deployment for group ${opts.groupId}? [y/N] `
1730
+ );
1731
+ rl.close();
1732
+ if (answer.toLowerCase() !== "y") {
1733
+ console.log("Canceled.");
1734
+ return;
1735
+ }
1736
+ }
1737
+ await apiRequest("DELETE", `policy-groups/${opts.groupId}/schedule`, {
1738
+ apiKey: globalOpts.apiKey,
1739
+ baseUrl: globalOpts.baseUrl,
1740
+ dryRun: globalOpts.dryRun,
1741
+ verbose: globalOpts.verbose
1742
+ });
1743
+ console.log(`\u2713 Scheduled deployment canceled for group ${opts.groupId}.`);
1744
+ } catch (error) {
1745
+ printError(error);
1746
+ process.exit(1);
1747
+ }
1748
+ });
1749
+ deploy.command("schedules").description("List scheduled deployments (all statuses, newest first)").option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
1750
+ "after",
1751
+ dedent7`
1752
+
1753
+ Example:
1754
+ $ lexq deploy schedules --format table
1755
+ `
1756
+ ).action(async (opts) => {
1757
+ try {
1758
+ const globalOpts = program.opts();
1759
+ const format = globalOpts.format ?? "json";
1760
+ const data = await apiRequest(
1761
+ "GET",
1762
+ "policy-groups/schedules",
1763
+ {
1764
+ apiKey: globalOpts.apiKey,
1765
+ baseUrl: globalOpts.baseUrl,
1766
+ dryRun: globalOpts.dryRun,
1767
+ verbose: globalOpts.verbose,
1768
+ params: { page: opts.page, size: opts.size }
1769
+ }
1770
+ );
1771
+ if (format === "table") {
1772
+ printTable(
1773
+ ["Status", "Group", "Version", "Scheduled For", "By", "Result"],
1774
+ data.content.map((s) => [
1775
+ s.status,
1776
+ s.policyGroupName ?? s.policyGroupId.substring(0, 8),
1777
+ s.versionNo != null ? `v${s.versionNo}` : "\u2013",
1778
+ s.scheduledFor.substring(0, 16),
1779
+ s.scheduledByName,
1780
+ s.status === "EXECUTED" ? s.executedAt?.substring(0, 16) ?? "\u2013" : s.status === "CANCELED" ? s.canceledReason ?? "\u2013" : s.status === "FAILED" ? s.failedReason ?? "\u2013" : "\u2013"
1781
+ ]),
1782
+ { truncate: 20 }
1783
+ );
1784
+ console.log(`
1785
+ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1786
+ } else {
1787
+ printJson(data);
1788
+ }
1789
+ } catch (error) {
1790
+ printError(error);
1791
+ process.exit(1);
1792
+ }
1793
+ });
1663
1794
  }
1664
1795
  async function warnUnregisteredFacts(globalOpts, groupId, versionId) {
1665
1796
  if (globalOpts.dryRun) return;
@@ -2206,13 +2337,16 @@ var TaskType = [
2206
2337
  // Internal
2207
2338
  "IMAGE_PROCESSING",
2208
2339
  "DAILY_SETTLEMENT",
2209
- "PLATFORM_WEBHOOK"
2340
+ "PLATFORM_WEBHOOK",
2341
+ "SCHEDULED_DEPLOYMENT"
2210
2342
  ];
2211
2343
  var PlatformEventType = [
2212
2344
  "VERSION_PUBLISHED",
2213
2345
  "DEPLOYED",
2214
2346
  "ROLLED_BACK",
2215
- "UNDEPLOYED"
2347
+ "UNDEPLOYED",
2348
+ "DEPLOY_SCHEDULED",
2349
+ "DEPLOY_SCHEDULE_CANCELED"
2216
2350
  ];
2217
2351
  var WebhookPayloadFormat = ["GENERIC", "SLACK"];
2218
2352
 
@@ -3596,8 +3730,23 @@ function registerRuleTools(server, callApi) {
3596
3730
  defined (non-blocking, version-wide) — use it to decide what to register.
3597
3731
 
3598
3732
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
3599
- Operators: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
3600
3733
  Value types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
3734
+
3735
+ Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
3736
+ its type is rejected by the server — check the fact type before choosing an operator.
3737
+ - STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
3738
+ - NUMBER fact: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
3739
+ - BOOLEAN fact: EQUALS, NOT_EQUALS
3740
+ - LIST_* fact: HAS_ANY, HAS_ALL, HAS_NONE (only these)
3741
+
3742
+ HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
3743
+ - HAS_ANY: fact has at least one of the given values
3744
+ - HAS_ALL: fact has all of the given values
3745
+ - HAS_NONE: fact has none of the given values
3746
+ Example: { "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
3747
+
3748
+ Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
3749
+ IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
3601
3750
 
3602
3751
  Actions: [{ type, parameters }]
3603
3752
 
@@ -3606,14 +3755,14 @@ function registerRuleTools(server, callApi) {
3606
3755
  - INCREMENT_FACT: { targetVar: string, method: "PERCENTAGE"|"AMOUNT", refVar?: string (required when PERCENTAGE), rate?: number (when PERCENTAGE), value?: number (when AMOUNT), rounding?: RoundingOption } targetVar (accumulation target) must exist at execution; refVar (PERCENTAGE source) must exist when method is PERCENTAGE. Each is supplied as an input fact or written by a prior action in this rule — a missing required fact throws (no 0 default). Note: external system call (e.g. point system sync) is NOT a primitive responsibility. Compose [INCREMENT_FACT, EMIT_EVENT] chain instead.
3607
3756
  - EMIT_EVENT: { integrationId: uuid, eventPayload: object (Map<string,unknown>, ≥1 entry) } eventPayload is passed through to the integration provider as-is. Domain-specific keys (couponId, ticketId, etc.) are routed by the provider, not validated by the engine.
3608
3757
  - BLOCK: { reason: string }
3609
- - EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token). notificationPayload (channel, templateId, body, variables, etc.) is passed through to the provider.
3758
+ - EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token) and is REQUIRED — the named fact must be present in the request or the action throws. (Contrast with ADD_TAG, where targetVar is an optional write target that is created if absent.)
3610
3759
  - EMIT_WEBHOOK: { url: string, method: "POST", payloadTemplate?: object } payloadTemplate is optional. Without it, all facts are sent as-is. With it, the object is sent as the HTTP body with {{variables}} replaced at execution time. Variables: {{fact.xxx}}, {{output.xxx}}, {{timestamp}}, {{ruleName}}, {{groupName}}, {{versionNo}}, {{xxx}} (shorthand).
3611
3760
  Platform examples:
3612
3761
  Slack: { "text": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
3613
3762
  Discord: { "content": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
3614
3763
  Generic: { "event": "rule_matched", "rule": "{{ruleName}}", "amount": "{{output.payment_amount}}" }
3615
3764
  - SET_FACT: { key: string, value: string|number|boolean }
3616
- - ADD_TAG: { tag: string, targetVar: string }
3765
+ - ADD_TAG: { tag: string, targetVar?: string (defaults to "user_tags") } Appends tag to a LIST_STRING fact, creating it if absent. Adding an existing tag is a no-op (idempotent). Read tags back with HAS_ANY / HAS_ALL / HAS_NONE.
3617
3766
 
3618
3767
  RoundingOption (optional, MUTATE_FACT / INCREMENT_FACT only): { scale: integer (0..16), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
3619
3768
  `,
@@ -3817,7 +3966,7 @@ function registerDeployTools(server, callApi) {
3817
3966
  "lexq_deploy_live",
3818
3967
  {
3819
3968
  title: "Deploy to Live",
3820
- 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.",
3969
+ description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Versions whose effective start date has not arrived are rejected (P-037) \u2014 use lexq_deploy_schedule for those. Undefined facts do not block deployment (INV-4); use lexq_facts_unregistered to review what the version references but has not defined.",
3821
3970
  inputSchema: {
3822
3971
  groupId: z5.string().uuid().describe("Policy group ID"),
3823
3972
  versionId: z5.string().uuid().describe("Version ID to deploy"),
@@ -3856,6 +4005,44 @@ function registerDeployTools(server, callApi) {
3856
4005
  body: { memo }
3857
4006
  })
3858
4007
  );
4008
+ server.registerTool(
4009
+ "lexq_deploy_schedule",
4010
+ {
4011
+ title: "Schedule Deployment",
4012
+ description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. The snapshot hash is sealed at scheduling and re-verified at execution (fail-closed).",
4013
+ inputSchema: {
4014
+ groupId: z5.string().uuid().describe("Policy group ID"),
4015
+ versionId: z5.string().uuid().describe("ACTIVE version ID with a future effective start date"),
4016
+ memo: z5.string().min(1).describe("Schedule memo (required)")
4017
+ }
4018
+ },
4019
+ async ({ groupId, versionId, memo }) => callApi("POST", `policy-groups/${groupId}/schedule`, {
4020
+ body: { versionId, memo }
4021
+ })
4022
+ );
4023
+ server.registerTool(
4024
+ "lexq_deploy_unschedule",
4025
+ {
4026
+ title: "Cancel Scheduled Deployment",
4027
+ description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
4028
+ inputSchema: {
4029
+ groupId: z5.string().uuid().describe("Policy group ID")
4030
+ }
4031
+ },
4032
+ async ({ groupId }) => callApi("DELETE", `policy-groups/${groupId}/schedule`)
4033
+ );
4034
+ server.registerTool(
4035
+ "lexq_deploy_schedules",
4036
+ {
4037
+ title: "List Scheduled Deployments",
4038
+ description: "List scheduled deployments across all groups (all statuses: PENDING, EXECUTED, CANCELED, FAILED), newest first.",
4039
+ inputSchema: {
4040
+ page: z5.number().int().min(0).default(0).describe("Page number"),
4041
+ size: z5.number().int().min(1).max(100).default(20).describe("Page size")
4042
+ }
4043
+ },
4044
+ async ({ page, size }) => callApi("GET", "policy-groups/schedules", { params: paginationParams(page, size) })
4045
+ );
3859
4046
  server.registerTool(
3860
4047
  "lexq_deploy_history",
3861
4048
  {
@@ -3865,9 +4052,7 @@ function registerDeployTools(server, callApi) {
3865
4052
  page: z5.number().int().min(0).default(0).describe("Page number"),
3866
4053
  size: z5.number().int().min(1).max(100).default(20).describe("Page size"),
3867
4054
  groupId: z5.string().uuid().optional().describe("Filter by group ID"),
3868
- types: z5.string().optional().describe(
3869
- "Filter by deployment types (comma-separated: PUBLISH,DEPLOY,ROLLBACK,UNDEPLOY)"
3870
- ),
4055
+ types: z5.string().optional().describe("Filter by deployment types (comma-separated: DEPLOY,ROLLBACK,UNDEPLOY)"),
3871
4056
  startDate: z5.string().optional().describe("Start date (yyyy-MM-dd)"),
3872
4057
  endDate: z5.string().optional().describe("End date (yyyy-MM-dd)")
3873
4058
  }
@@ -35,6 +35,12 @@ declare function paginationParams(page?: number, size?: number): Record<string,
35
35
  */
36
36
  declare function formatUnregisteredFactWarning(meta: unknown): string | null;
37
37
 
38
+ declare class ApiError extends Error {
39
+ readonly statusCode: number;
40
+ readonly errorCode: string | null;
41
+ constructor(statusCode: number, errorCode: string | null, message: string);
42
+ }
43
+
38
44
  /**
39
45
  * Registers all MCP tools on the given server.
40
46
  *
@@ -43,4 +49,4 @@ declare function formatUnregisteredFactWarning(meta: unknown): string | null;
43
49
  */
44
50
  declare function registerAllTools(server: McpServer, callApi: CallApi): void;
45
51
 
46
- export { type CallApi, type McpToolResult, formatUnregisteredFactWarning, paginationParams, registerAllTools };
52
+ export { ApiError, type CallApi, type McpToolResult, formatUnregisteredFactWarning, paginationParams, registerAllTools };
@@ -154,6 +154,18 @@ import { join } from "path";
154
154
  var CONFIG_DIR = join(homedir(), ".lexq");
155
155
  var CONFIG_FILE = join(CONFIG_DIR, "config.json");
156
156
 
157
+ // src/lib/api-client.ts
158
+ var ApiError = class extends Error {
159
+ constructor(statusCode, errorCode, message) {
160
+ super(message);
161
+ this.statusCode = statusCode;
162
+ this.errorCode = errorCode;
163
+ this.name = "ApiError";
164
+ }
165
+ statusCode;
166
+ errorCode;
167
+ };
168
+
157
169
  // src/mcp/tools/_shared.ts
158
170
  function paginationParams(page, size) {
159
171
  const params = {};
@@ -311,8 +323,23 @@ function registerRuleTools(server, callApi) {
311
323
  defined (non-blocking, version-wide) — use it to decide what to register.
312
324
 
313
325
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
314
- Operators: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
315
326
  Value types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
327
+
328
+ Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
329
+ its type is rejected by the server — check the fact type before choosing an operator.
330
+ - STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
331
+ - NUMBER fact: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
332
+ - BOOLEAN fact: EQUALS, NOT_EQUALS
333
+ - LIST_* fact: HAS_ANY, HAS_ALL, HAS_NONE (only these)
334
+
335
+ HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
336
+ - HAS_ANY: fact has at least one of the given values
337
+ - HAS_ALL: fact has all of the given values
338
+ - HAS_NONE: fact has none of the given values
339
+ Example: { "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
340
+
341
+ Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
342
+ IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
316
343
 
317
344
  Actions: [{ type, parameters }]
318
345
 
@@ -321,14 +348,14 @@ function registerRuleTools(server, callApi) {
321
348
  - INCREMENT_FACT: { targetVar: string, method: "PERCENTAGE"|"AMOUNT", refVar?: string (required when PERCENTAGE), rate?: number (when PERCENTAGE), value?: number (when AMOUNT), rounding?: RoundingOption } targetVar (accumulation target) must exist at execution; refVar (PERCENTAGE source) must exist when method is PERCENTAGE. Each is supplied as an input fact or written by a prior action in this rule — a missing required fact throws (no 0 default). Note: external system call (e.g. point system sync) is NOT a primitive responsibility. Compose [INCREMENT_FACT, EMIT_EVENT] chain instead.
322
349
  - EMIT_EVENT: { integrationId: uuid, eventPayload: object (Map<string,unknown>, ≥1 entry) } eventPayload is passed through to the integration provider as-is. Domain-specific keys (couponId, ticketId, etc.) are routed by the provider, not validated by the engine.
323
350
  - BLOCK: { reason: string }
324
- - EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token). notificationPayload (channel, templateId, body, variables, etc.) is passed through to the provider.
351
+ - EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token) and is REQUIRED — the named fact must be present in the request or the action throws. (Contrast with ADD_TAG, where targetVar is an optional write target that is created if absent.)
325
352
  - EMIT_WEBHOOK: { url: string, method: "POST", payloadTemplate?: object } payloadTemplate is optional. Without it, all facts are sent as-is. With it, the object is sent as the HTTP body with {{variables}} replaced at execution time. Variables: {{fact.xxx}}, {{output.xxx}}, {{timestamp}}, {{ruleName}}, {{groupName}}, {{versionNo}}, {{xxx}} (shorthand).
326
353
  Platform examples:
327
354
  Slack: { "text": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
328
355
  Discord: { "content": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
329
356
  Generic: { "event": "rule_matched", "rule": "{{ruleName}}", "amount": "{{output.payment_amount}}" }
330
357
  - SET_FACT: { key: string, value: string|number|boolean }
331
- - ADD_TAG: { tag: string, targetVar: string }
358
+ - ADD_TAG: { tag: string, targetVar?: string (defaults to "user_tags") } Appends tag to a LIST_STRING fact, creating it if absent. Adding an existing tag is a no-op (idempotent). Read tags back with HAS_ANY / HAS_ALL / HAS_NONE.
332
359
 
333
360
  RoundingOption (optional, MUTATE_FACT / INCREMENT_FACT only): { scale: integer (0..16), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
334
361
  `,
@@ -532,7 +559,7 @@ function registerDeployTools(server, callApi) {
532
559
  "lexq_deploy_live",
533
560
  {
534
561
  title: "Deploy to Live",
535
- 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.",
562
+ description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Versions whose effective start date has not arrived are rejected (P-037) \u2014 use lexq_deploy_schedule for those. Undefined facts do not block deployment (INV-4); use lexq_facts_unregistered to review what the version references but has not defined.",
536
563
  inputSchema: {
537
564
  groupId: z5.string().uuid().describe("Policy group ID"),
538
565
  versionId: z5.string().uuid().describe("Version ID to deploy"),
@@ -571,6 +598,44 @@ function registerDeployTools(server, callApi) {
571
598
  body: { memo }
572
599
  })
573
600
  );
601
+ server.registerTool(
602
+ "lexq_deploy_schedule",
603
+ {
604
+ title: "Schedule Deployment",
605
+ description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. The snapshot hash is sealed at scheduling and re-verified at execution (fail-closed).",
606
+ inputSchema: {
607
+ groupId: z5.string().uuid().describe("Policy group ID"),
608
+ versionId: z5.string().uuid().describe("ACTIVE version ID with a future effective start date"),
609
+ memo: z5.string().min(1).describe("Schedule memo (required)")
610
+ }
611
+ },
612
+ async ({ groupId, versionId, memo }) => callApi("POST", `policy-groups/${groupId}/schedule`, {
613
+ body: { versionId, memo }
614
+ })
615
+ );
616
+ server.registerTool(
617
+ "lexq_deploy_unschedule",
618
+ {
619
+ title: "Cancel Scheduled Deployment",
620
+ description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
621
+ inputSchema: {
622
+ groupId: z5.string().uuid().describe("Policy group ID")
623
+ }
624
+ },
625
+ async ({ groupId }) => callApi("DELETE", `policy-groups/${groupId}/schedule`)
626
+ );
627
+ server.registerTool(
628
+ "lexq_deploy_schedules",
629
+ {
630
+ title: "List Scheduled Deployments",
631
+ description: "List scheduled deployments across all groups (all statuses: PENDING, EXECUTED, CANCELED, FAILED), newest first.",
632
+ inputSchema: {
633
+ page: z5.number().int().min(0).default(0).describe("Page number"),
634
+ size: z5.number().int().min(1).max(100).default(20).describe("Page size")
635
+ }
636
+ },
637
+ async ({ page, size }) => callApi("GET", "policy-groups/schedules", { params: paginationParams(page, size) })
638
+ );
574
639
  server.registerTool(
575
640
  "lexq_deploy_history",
576
641
  {
@@ -580,9 +645,7 @@ function registerDeployTools(server, callApi) {
580
645
  page: z5.number().int().min(0).default(0).describe("Page number"),
581
646
  size: z5.number().int().min(1).max(100).default(20).describe("Page size"),
582
647
  groupId: z5.string().uuid().optional().describe("Filter by group ID"),
583
- types: z5.string().optional().describe(
584
- "Filter by deployment types (comma-separated: PUBLISH,DEPLOY,ROLLBACK,UNDEPLOY)"
585
- ),
648
+ types: z5.string().optional().describe("Filter by deployment types (comma-separated: DEPLOY,ROLLBACK,UNDEPLOY)"),
586
649
  startDate: z5.string().optional().describe("Start date (yyyy-MM-dd)"),
587
650
  endDate: z5.string().optional().describe("End date (yyyy-MM-dd)")
588
651
  }
@@ -1182,13 +1245,16 @@ var TaskType = [
1182
1245
  // Internal
1183
1246
  "IMAGE_PROCESSING",
1184
1247
  "DAILY_SETTLEMENT",
1185
- "PLATFORM_WEBHOOK"
1248
+ "PLATFORM_WEBHOOK",
1249
+ "SCHEDULED_DEPLOYMENT"
1186
1250
  ];
1187
1251
  var PlatformEventType = [
1188
1252
  "VERSION_PUBLISHED",
1189
1253
  "DEPLOYED",
1190
1254
  "ROLLED_BACK",
1191
- "UNDEPLOYED"
1255
+ "UNDEPLOYED",
1256
+ "DEPLOY_SCHEDULED",
1257
+ "DEPLOY_SCHEDULE_CANCELED"
1192
1258
  ];
1193
1259
  var WebhookPayloadFormat = ["GENERIC", "SLACK"];
1194
1260
 
@@ -1396,6 +1462,7 @@ function registerAllTools(server, callApi) {
1396
1462
  registerWebhookSubscriptionTools(server, callApi);
1397
1463
  }
1398
1464
  export {
1465
+ ApiError,
1399
1466
  formatUnregisteredFactWarning,
1400
1467
  paginationParams,
1401
1468
  registerAllTools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
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": {
@@ -29,7 +29,10 @@
29
29
  "lint": "eslint src/",
30
30
  "typecheck": "tsc --noEmit",
31
31
  "start": "node dist/index.js",
32
- "prepublishOnly": "pnpm build"
32
+ "prepublishOnly": "pnpm build",
33
+ "knip": "knip",
34
+ "format": "prettier --write \"src/**/*.ts\"",
35
+ "format:check": "prettier --check \"src/**/*.ts\""
33
36
  },
34
37
  "keywords": [
35
38
  "lexq",
@@ -57,15 +60,14 @@
57
60
  "cli-table3": "^0.6.5",
58
61
  "commander": "^13.1.0",
59
62
  "dedent": "^1.7.2",
60
- "ora": "^8.2.0",
61
- "prettier": "^3.8.1",
62
63
  "zod": "^3.25.76"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@eslint/js": "^9.20.0",
66
- "@types/dedent": "^0.7.2",
67
67
  "@types/node": "^22.12.0",
68
68
  "eslint": "^10.2.0",
69
+ "knip": "^6.29.0",
70
+ "prettier": "^3.9.6",
69
71
  "tsup": "^8.5.1",
70
72
  "typescript": "^5.7.0",
71
73
  "typescript-eslint": "^8.24.0"
@@ -256,7 +256,7 @@ lexq rules create --group-id <gid> --version-id <vid> --json '{
256
256
  "type": "EMIT_NOTIFICATION",
257
257
  "parameters": {
258
258
  "integrationId": "<notification-integration-uuid>",
259
- "target": "user_id",
259
+ "targetVar": "user_id",
260
260
  "notificationPayload": {
261
261
  "channel": "PUSH",
262
262
  "templateId": "welcome_points"
@@ -502,4 +502,73 @@ lexq analytics simulation start --json '{
502
502
 
503
503
  # 6. If simulation passes, deploy
504
504
  lexq deploy live --group-id <gid> --version-id <newVid> --memo "Migration complete"
505
- ```
505
+ ```
506
+
507
+ ---
508
+
509
+ ## Recipe 11: Tag-Based Segmentation
510
+
511
+ **Goal:** Write tags in one rule, branch on them in another.
512
+
513
+ `user_tags` is a `LIST_STRING` fact seeded automatically for every tenant. `ADD_TAG` appends to
514
+ it; `HAS_ANY` / `HAS_ALL` / `HAS_NONE` read it.
515
+
516
+ ```bash
517
+ # Tags are written by earlier rules (or supplied as input facts).
518
+ lexq rules create --group-id <gid> --version-id <vid> --json '{
519
+ "name": "Tag High-Value Customers",
520
+ "condition": {
521
+ "type": "SINGLE", "field": "lifetime_value", "operator": "GREATER_THAN_OR_EQUAL",
522
+ "value": 1000000, "valueType": "NUMBER"
523
+ },
524
+ "actions": [
525
+ { "type": "ADD_TAG", "parameters": { "tag": "high_value" } }
526
+ ]
527
+ }'
528
+
529
+ # Branch on any one of several tags
530
+ lexq rules create --group-id <gid> --version-id <vid> --json '{
531
+ "name": "Priority Support for VIP or High Value",
532
+ "condition": {
533
+ "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY",
534
+ "value": ["VIP", "high_value"], "valueType": "LIST_STRING"
535
+ },
536
+ "actions": [{ "type": "SET_FACT", "parameters": { "key": "support_tier", "value": "PRIORITY" } }]
537
+ }'
538
+
539
+ # Require every tag
540
+ lexq rules create --group-id <gid> --version-id <vid> --json '{
541
+ "name": "Beta Feature for Verified VIP",
542
+ "condition": {
543
+ "type": "SINGLE", "field": "user_tags", "operator": "HAS_ALL",
544
+ "value": ["VIP", "verified"], "valueType": "LIST_STRING"
545
+ },
546
+ "actions": [{ "type": "SET_FACT", "parameters": { "key": "beta_enabled", "value": true } }]
547
+ }'
548
+
549
+ # Exclude tagged users
550
+ lexq rules create --group-id <gid> --version-id <vid> --json '{
551
+ "name": "Promo Excludes Fraud Review",
552
+ "condition": {
553
+ "type": "SINGLE", "field": "user_tags", "operator": "HAS_NONE",
554
+ "value": ["fraud_review", "suspended"], "valueType": "LIST_STRING"
555
+ },
556
+ "actions": [{ "type": "MUTATE_FACT", "parameters": {
557
+ "refVar": "payment_amount", "method": "PERCENTAGE", "operator": "SUB", "rate": 5,
558
+ "rounding": { "mode": "HALF_UP", "scale": 0 }
559
+ }}]
560
+ }'
561
+
562
+ # Verify
563
+ lexq analytics dry-run --version-id <vid> --debug --mock --json '{
564
+ "facts": { "user_tags": ["VIP", "verified"], "payment_amount": 100000, "lifetime_value": 500000 }
565
+ }'
566
+ ```
567
+
568
+ **Notes**
569
+
570
+ - The `value` is always an array, even for a single tag: `"value": ["VIP"]`.
571
+ - `ADD_TAG` writes to `user_tags` by default. Pass `targetVar` to append to a different `LIST_STRING` fact. The list is
572
+ created if absent, and re-adding an existing tag is a no-op.
573
+ - An empty array makes `HAS_ALL` and `HAS_NONE` always true — they never look at the fact.
574
+ - Rules fire in priority order, so a tag written by rule 0 is visible to rule 1.
@@ -70,17 +70,53 @@ Conditions use a tree structure with two node types: `SINGLE` and `GROUP`.
70
70
 
71
71
  ### Operators
72
72
 
73
- | Operator | Types | Description |
74
- |-------------------------|----------------|-----------------------------------|
75
- | `EQUALS` | all | Exact match |
76
- | `NOT_EQUALS` | all | Negation |
77
- | `GREATER_THAN` | NUMBER | `>` |
78
- | `GREATER_THAN_OR_EQUAL` | NUMBER | `>=` |
79
- | `LESS_THAN` | NUMBER | `<` |
80
- | `LESS_THAN_OR_EQUAL` | NUMBER | `<=` |
81
- | `CONTAINS` | STRING | Substring match |
82
- | `IN` | STRING, NUMBER | Value is in the provided list |
83
- | `NOT_IN` | STRING, NUMBER | Value is not in the provided list |
73
+ Operators are constrained by the **left fact's type**. Using one outside its type is rejected
74
+ by the server — check `lexq facts list` before choosing.
75
+
76
+ | Fact type | Allowed operators |
77
+ |-------------------------------|--------------------------------------------------------------------------------------------------------------------|
78
+ | `STRING` | `EQUALS`, `NOT_EQUALS`, `CONTAINS`, `IN`, `NOT_IN` |
79
+ | `NUMBER` | `EQUALS`, `NOT_EQUALS`, `GREATER_THAN`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN`, `LESS_THAN_OR_EQUAL`, `IN`, `NOT_IN` |
80
+ | `BOOLEAN` | `EQUALS`, `NOT_EQUALS` |
81
+ | `LIST_STRING` / `LIST_NUMBER` | `HAS_ANY`, `HAS_ALL`, `HAS_NONE` |
82
+
83
+ | Operator | Description | `value` |
84
+ |-------------------------------------------------------------------------------|------------------------------------------------------------|---------|
85
+ | `EQUALS` / `NOT_EQUALS` | Exact match / negation | scalar |
86
+ | `GREATER_THAN` / `GREATER_THAN_OR_EQUAL` / `LESS_THAN` / `LESS_THAN_OR_EQUAL` | Numeric comparison | scalar |
87
+ | `CONTAINS` | **Substring** match on a STRING fact — not list membership | scalar |
88
+ | `IN` / `NOT_IN` | Scalar fact is (not) in the given list | array |
89
+ | `HAS_ANY` | List fact has **at least one** of the given values | array |
90
+ | `HAS_ALL` | List fact has **all** of the given values | array |
91
+ | `HAS_NONE` | List fact has **none** of the given values | array |
92
+
93
+ **`IN` vs `HAS_*` — mirrors of each other.** This is the most common mistake here:
94
+
95
+ ```json
96
+ // scalar fact, list value
97
+ {
98
+ "field": "region",
99
+ "operator": "IN",
100
+ "value": [
101
+ "KR",
102
+ "JP"
103
+ ],
104
+ "valueType": "LIST_STRING"
105
+ }
106
+
107
+ // list fact, list value
108
+ {
109
+ "field": "user_tags",
110
+ "operator": "HAS_ANY",
111
+ "value": [
112
+ "VIP",
113
+ "GOLD"
114
+ ],
115
+ "valueType": "LIST_STRING"
116
+ }
117
+ ```
118
+
119
+ Do **not** use `CONTAINS` on a list fact — that idiom works in some rule engines but is rejected here.
84
120
 
85
121
  ### Value Types
86
122
 
@@ -145,10 +181,15 @@ Each rule can have multiple actions. Actions fire sequentially.
145
181
  | `INCREMENT_FACT` | Increment a fact (cumulative add) | `targetVar`, `refVar`, `method`, `value` or `rate`, `rounding` |
146
182
  | `EMIT_EVENT` | Emit an event to an external integration (coupons, etc.) | `integrationId`, `eventPayload` (Map) |
147
183
  | `BLOCK` | Block the transaction | `reason`, `code` |
148
- | `EMIT_NOTIFICATION` | Send a notification | `integrationId`, `target`, `notificationPayload` (Map) |
184
+ | `EMIT_NOTIFICATION` | Send a notification | `integrationId`, `targetVar`, `notificationPayload` (Map) |
149
185
  | `EMIT_WEBHOOK` | Call an external URL | `url`, `payloadTemplate` |
150
186
  | `SET_FACT` | Set a fact value (literal assignment) | `key`, `value` |
151
- | `ADD_TAG` | Add a tag to the result | `tag` |
187
+ | `ADD_TAG` | Append a tag to a list fact | `tag`, `targetVar` (optional, default `user_tags`) |
188
+
189
+ **Two different `targetVar` meanings.** `EMIT_NOTIFICATION.targetVar` is a **read** — it names the
190
+ fact holding the recipient (`phone_number`, `email`, `device_token`), and the action throws if that
191
+ fact is absent from the request. `ADD_TAG.targetVar` is a **write** — the list is created if absent,
192
+ and adding a tag that is already present is a no-op.
152
193
 
153
194
  ### Action Example: 10% Discount via MUTATE_FACT
154
195
 
@@ -305,4 +346,5 @@ Before creating rules, always:
305
346
  1. **Check available facts:** `lexq facts list`
306
347
  2. **Confirm the version is DRAFT:** `lexq versions get --group-id <gid> --id <vid>` → status must be `DRAFT`
307
348
  3. **Use exact fact keys** from the fact definitions (snake_case, case-sensitive)
308
- 4. **Match value types** — a fact defined as `NUMBER` must receive numeric values, not strings
349
+ 4. **Match value types** — a fact defined as `NUMBER` must receive numeric values, not strings
350
+ 5. **Match the operator to the fact type** — list-typed facts accept only `HAS_ANY` / `HAS_ALL` / `HAS_NONE`
@@ -111,7 +111,7 @@ lexq analytics dry-run --version-id <vid> --debug --mock --json '{
111
111
  "ruleId": "...",
112
112
  "ruleName": "VIP 10% Discount",
113
113
  "matched": true,
114
- "matchExpression": "(customer_tier == VIP AND payment_amount >= 100000)",
114
+ "matchExpression": "(customer_tier == 'VIP') && (payment_amount >= 100000)",
115
115
  "generatedActions": [
116
116
  {
117
117
  "type": "MUTATE_FACT",
@@ -143,26 +143,46 @@ lexq analytics dry-run --version-id <vid> --debug --mock --json '{
143
143
 
144
144
  ### Reading Decision Traces
145
145
 
146
- | Status | Meaning |
147
- |-----------------|---------------------------------------------|
148
- | `SELECTED` | Rule matched and its actions fired |
149
- | `NO_MATCH` | Condition did not match the input |
150
- | `NOT_SELECTED` | Matched but excluded by conflict resolution |
151
- | `BLOCKED_MUTEX` | Blocked by mutex group constraint |
152
- | `LOST_PRIORITY` | Lost to a higher-priority rule |
153
- | `DROPPED_LIMIT` | Execution limit reached |
154
- | `ERROR` | Rule evaluation failed |
146
+ Each trace carries a `status` (what happened) and a `reasonCode` (why).
147
+
148
+ | Status | Meaning |
149
+ |----------------|----------------------------------------------------|
150
+ | `SELECTED` | Rule matched and its actions fired |
151
+ | `NO_MATCH` | Condition did not match, or could not be evaluated |
152
+ | `NOT_SELECTED` | Matched but excluded by conflict resolution |
153
+ | `BLOCKED` | Blocked by a mutex group or group activation limit |
154
+ | `ERROR` | Action execution failed |
155
155
 
156
156
  ### Reading Reason Codes
157
157
 
158
- | Code | Meaning |
159
- |-----------------------|----------------------------------------------------------|
160
- | `FINAL_WINNER` | Successfully executed |
161
- | `CONDITION_MISMATCH` | Input facts didn't satisfy the condition |
162
- | `MUTEX_PRIORITY_LOST` | Another rule in the same mutex group had higher priority |
163
- | `MUTEX_LIMIT_REACHED` | Mutex group's max rules already fired |
164
- | `GROUP_LIMIT_REACHED` | Group's `executionLimit` reached |
165
- | `ACTION_ERROR` | Action execution failed (e.g., webhook timeout) |
158
+ | Code | Meaning |
159
+ |--------------------------|----------------------------------------------------------|
160
+ | `FINAL_WINNER` | Successfully executed |
161
+ | `CONDITION_MISMATCH` | Condition not satisfied, or could not be evaluated |
162
+ | `EFFECTIVE_DATE_INVALID` | Outside the version's effective date range |
163
+ | `MUTEX_PRIORITY_LOST` | Another rule in the same mutex group had higher priority |
164
+ | `MUTEX_LIMIT_REACHED` | Mutex group's max rules already fired |
165
+ | `GROUP_PRIORITY_LOST` | Another group in the same activation group won |
166
+ | `GROUP_LIMIT_REACHED` | Group's `executionLimit` reached |
167
+ | `ACTION_ERROR` | Action execution failed (e.g., webhook timeout) |
168
+ | `ENGINE_ERROR` | Internal engine failure |
169
+
170
+ #### `reasonDetail` on unevaluable conditions
171
+
172
+ `CONDITION_MISMATCH` covers two different things, distinguished by `reasonDetail`:
173
+
174
+ - **empty** — the condition was evaluated and did not match
175
+ - **`Evaluation error: <code>`** — the condition could not be evaluated at all
176
+
177
+ | Code | Meaning |
178
+ |-------------------------|--------------------------------------------------------------------|
179
+ | `FACT_NOT_PROVIDED` | The rule references a fact absent from the request |
180
+ | `FACT_TYPE_MISMATCH` | The fact's runtime type does not match the condition |
181
+ | `UNSUPPORTED_FACT_TYPE` | Operator not valid for the fact's type (e.g. `CONTAINS` on a list) |
182
+ | `MALFORMED_RULE` | The stored rule is structurally invalid |
183
+
184
+ A rule from another group referencing facts you did not send yields `FACT_NOT_PROVIDED` — this
185
+ is normal, not an error.
166
186
 
167
187
  ## 3. Dry Run Compare
168
188