conduyt 1.53.0 → 1.55.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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * (Codex mcp/cli r6 h1) DOES THIS RULE COMPILE? — the null-returning paths of advancedRuleToWhere / ruleToWhere /
3
+ * customFieldRuleToWhere / masterStatusRuleToWhere in lib/smart-view-filters.ts, as ONE pure predicate with no Prisma and no
4
+ * imports, so the MCP server and the CLI can carry it VERBATIM and refuse exactly what the app's validator refuses (the app's
5
+ * validator runs the real compiler; lib/advanced-filter-compilability.test.ts proves this predicate agrees with it over an
6
+ * adversarial value matrix for every field, operator and custom-field operator). Keep in sync with the compiler.
7
+ */
8
+ const CF_FIELD_PREFIX = "cf:";
9
+ const CF_OPERATORS = new Set(["equals", "not_equals", "contains", "is_any_of", "is", "is_not", "num_equals", "num_not_equals", "gt", "gte", "lt", "lte", "is_true", "is_false", "has_any_of", "has_all_of", "has_none_of"]);
10
+ const SCALAR_DATE_FIELDS = new Set(["lastContactedAt", "lastActionAt", "lastContactAttemptAt", "createdAt", "updatedAt"]);
11
+ const LIST_FIELDS = new Set(["tags", "pipeline", "pipelineStage", "dealStatus", "email", "phone", "source", "assignedTo"]);
12
+ const STRING_FIELDS = new Set(["city", "state", "jobTitle"]);
13
+ function cfKeyOf(field) {
14
+ if (!field.startsWith(CF_FIELD_PREFIX))
15
+ return null;
16
+ const key = field.slice(CF_FIELD_PREFIX.length).trim();
17
+ return key.length > 0 ? key : null;
18
+ }
19
+ function isValidIsoDate(value) {
20
+ const ts = Date.parse(value);
21
+ return !isNaN(ts) && new Date(ts).toISOString() !== "Invalid Date";
22
+ }
23
+ const has = (set, op) => set.includes(op);
24
+ /** the exact compile verdict of the app for one rule: true when advancedRuleToWhere would return a condition */
25
+ export function advancedRuleCompiles(rule) {
26
+ const { field, operator: rawOp, value } = rule;
27
+ const operator = rawOp === "is" ? "equals" : rawOp === "is_not" ? "not_equals" : rawOp;
28
+ const strValue = Array.isArray(value) ? value.join(",") : value;
29
+ // String(undefined) is "undefined" in the compiler's own coercions: an absent value there is a one-element list, verbatim
30
+ const listOf = (v) => (Array.isArray(v) ? v : String(v).split(",")).map((x) => String(x).trim()).filter(Boolean);
31
+ const cfKey = cfKeyOf(field);
32
+ if (cfKey) {
33
+ const op = CF_OPERATORS.has(rawOp) ? rawOp : operator;
34
+ switch (op) {
35
+ case "equals":
36
+ case "is":
37
+ case "not_equals":
38
+ case "is_not":
39
+ case "contains":
40
+ case "is_true":
41
+ case "is_false": return true;
42
+ case "is_any_of": return (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim()).filter(Boolean)).length > 0;
43
+ case "num_equals":
44
+ case "num_not_equals":
45
+ case "gt":
46
+ case "gte":
47
+ case "lt":
48
+ case "lte": {
49
+ const s = strValue ?? "";
50
+ return s.trim() !== "" && Number.isFinite(Number(s));
51
+ }
52
+ case "has_any_of":
53
+ case "has_all_of":
54
+ case "has_none_of": return listOf(value).length > 0;
55
+ default: return false;
56
+ }
57
+ }
58
+ if (field === "masterStatus") {
59
+ const list = (Array.isArray(value) ? value : String(value ?? "").split(",")).map((v) => String(v).trim().toLowerCase()).filter(Boolean);
60
+ if (list.length === 0)
61
+ return false;
62
+ if (rawOp === "is_any_of" || rawOp === "is_none_of")
63
+ return true;
64
+ return operator === "equals" || operator === "not_equals";
65
+ }
66
+ if (rawOp === "is_any_of") {
67
+ const values = Array.isArray(value) ? value : String(value ?? "").split(",").map((v) => v.trim()).filter(Boolean);
68
+ return values.length > 0 && LIST_FIELDS.has(field);
69
+ }
70
+ if (field === "company")
71
+ return has(["is_empty", "is_not_empty", "equals", "not_equals", "contains"], operator);
72
+ // (Codex pool r28 h1) a name-bearing tag rule needs at least one name (lib/tag-rule-names.ts: an array is one name per
73
+ // element, a scalar splits on commas; blanks dropped)
74
+ if (field === "tags" && has(["contains", "not_contains", "equals", "not_equals"], operator)) {
75
+ const names = Array.isArray(value) ? value.filter((x) => typeof x === "string").map((x) => x.trim()).filter(Boolean) : typeof value === "string" ? value.split(",").map((x) => x.trim()).filter(Boolean) : [];
76
+ return names.length > 0;
77
+ }
78
+ // ruleToWhere on the flattened value
79
+ const v = strValue ?? "";
80
+ // a duration is parsed with String.prototype.match: an ABSENT value throws inside the compiler (dropped), any string parses
81
+ const hasString = typeof strValue === "string";
82
+ if (SCALAR_DATE_FIELDS.has(field)) {
83
+ if (operator === "is_empty" || operator === "is_not_empty")
84
+ return true;
85
+ if (operator === "within" || operator === "not_within")
86
+ return hasString;
87
+ if (operator === "before" || operator === "after")
88
+ return isValidIsoDate(v);
89
+ return false;
90
+ }
91
+ if (field === "lastEmailAt" || field === "lastSmsAt") {
92
+ if (operator === "is_empty" || operator === "is_not_empty")
93
+ return true;
94
+ if (operator === "within" || operator === "not_within")
95
+ return hasString;
96
+ if (operator === "before" || operator === "after")
97
+ return isValidIsoDate(v);
98
+ return false;
99
+ }
100
+ if (field === "lastCallAt")
101
+ return operator === "is_empty" || operator === "is_not_empty" || (hasString && (operator === "within" || operator === "not_within"));
102
+ if (field === "callAttempts")
103
+ return true; // an unsupported boundary compiles to the impossible filter (the validator gates the value)
104
+ if (field === "nextAppointmentAt")
105
+ return operator === "is_empty" || operator === "is_not_empty" || (hasString && (operator === "within" || operator === "not_within"));
106
+ if (field === "appointmentStatus")
107
+ return has(["equals", "not_equals"], operator);
108
+ if (STRING_FIELDS.has(field))
109
+ return has(["is_empty", "is_not_empty", "equals", "not_equals", "contains"], operator);
110
+ if (field === "country")
111
+ return has(["equals", "not_equals"], operator);
112
+ if (field === "optedOutSms" || field === "optedOutEmail")
113
+ return operator === "equals";
114
+ if (field === "leadScore")
115
+ return v.trim() !== "" && Number.isFinite(Number(v.trim())) && has(["gt", "lt", "gte", "lte", "equals"], operator); // (Codex pool r28 m1) the whole value
116
+ if (field === "email" || field === "phone")
117
+ return has(["is_empty", "is_not_empty", "contains", "equals", "not_equals"], operator);
118
+ if (field === "source")
119
+ return has(["equals", "not_equals", "contains", "is_empty", "is_not_empty"], operator);
120
+ if (field === "assignedTo")
121
+ return has(["equals", "not_equals", "is_empty", "is_not_empty"], operator);
122
+ if (field === "tags")
123
+ return has(["is_empty", "is_not_empty"], operator);
124
+ if (field === "pipeline" || field === "pipelineStage" || field === "dealStatus")
125
+ return has(["equals", "not_equals", "is_empty", "is_not_empty"], operator);
126
+ return false;
127
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * PORTED VERBATIM from conduyt-app lib/smart-view-filters.ts @ slice B r28 (bfba9b0ef + the non-string array element refusal; the app's validator additionally compiles every rule — see validateAdvancedFiltersEffective below) — validateAdvancedFilters, its tables and its
3
+ * value rules — so a call this surface accepts is never a predictable 400 from the API (Codex mcp r2 m1 / cli r2 m1).
4
+ * Keep in sync with the app: when validateAdvancedFilters, VALID_FIELDS, VALID_OPERATORS, ADVANCED_OPERATORS,
5
+ * CF_OPERATORS, VALUELESS_OPERATORS, DATE_FILTER_FIELDS or isSupportedCallAttemptsFilter change there, re-port here.
6
+ */
7
+ export const CF_FIELD_PREFIX = "cf:";
8
+ export const MAX_FILTER_GROUPS = 10;
9
+ export const MAX_RULES_PER_GROUP = 20;
10
+ export const VALID_FIELDS = new Set([
11
+ "lastContactedAt", "lastActionAt", "lastContactAttemptAt", "createdAt", "updatedAt", "lastEmailAt", "lastSmsAt",
12
+ "lastCallAt", "callAttempts", "nextAppointmentAt", "appointmentStatus",
13
+ "city", "state", "country", "jobTitle", "optedOutSms", "optedOutEmail",
14
+ "leadScore", "email", "phone", "source", "assignedTo", "tags",
15
+ "pipeline", "pipelineStage", "dealStatus", "company",
16
+ "masterStatus",
17
+ ]);
18
+ const VALID_OPERATORS = {
19
+ lastContactedAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
20
+ lastActionAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
21
+ lastContactAttemptAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
22
+ createdAt: new Set(["within", "not_within", "before", "after"]),
23
+ updatedAt: new Set(["within", "not_within", "before", "after"]),
24
+ lastEmailAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
25
+ lastSmsAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
26
+ lastCallAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
27
+ callAttempts: new Set(["gt", "lt", "gte", "lte", "equals"]),
28
+ nextAppointmentAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
29
+ appointmentStatus: new Set(["equals", "not_equals"]),
30
+ leadScore: new Set(["gt", "lt", "gte", "lte", "equals"]),
31
+ email: new Set(["is_empty", "is_not_empty", "contains"]),
32
+ phone: new Set(["is_empty", "is_not_empty", "contains"]),
33
+ source: new Set(["equals", "not_equals", "contains"]),
34
+ assignedTo: new Set(["equals", "not_equals", "is_empty"]),
35
+ tags: new Set(["contains", "not_contains", "is_empty", "is_not_empty"]),
36
+ pipeline: new Set(["equals", "not_equals", "is_empty", "is_not_empty"]),
37
+ pipelineStage: new Set(["equals", "not_equals", "is_empty", "is_not_empty"]),
38
+ dealStatus: new Set(["equals", "not_equals", "is_empty", "is_not_empty"]),
39
+ masterStatus: new Set(["equals", "not_equals", "is_any_of", "is_none_of"]),
40
+ city: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
41
+ state: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
42
+ country: new Set(["equals", "not_equals"]),
43
+ jobTitle: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
44
+ optedOutSms: new Set(["equals"]),
45
+ optedOutEmail: new Set(["equals"]),
46
+ company: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
47
+ };
48
+ export const ADVANCED_OPERATORS = {
49
+ lastContactedAt: new Set([...VALID_OPERATORS.lastContactedAt, "before", "after"]),
50
+ lastActionAt: new Set([...VALID_OPERATORS.lastActionAt, "before", "after"]),
51
+ lastContactAttemptAt: new Set([...VALID_OPERATORS.lastContactAttemptAt, "before", "after"]),
52
+ createdAt: new Set([...VALID_OPERATORS.createdAt, "is_empty", "is_not_empty"]),
53
+ updatedAt: new Set([...VALID_OPERATORS.updatedAt, "is_empty", "is_not_empty"]),
54
+ lastEmailAt: new Set([...VALID_OPERATORS.lastEmailAt, "is_not_empty", "before", "after"]),
55
+ lastSmsAt: new Set([...VALID_OPERATORS.lastSmsAt, "is_not_empty", "before", "after"]),
56
+ lastCallAt: new Set([...VALID_OPERATORS.lastCallAt, "before", "after"]),
57
+ callAttempts: new Set([...VALID_OPERATORS.callAttempts]),
58
+ nextAppointmentAt: new Set([...VALID_OPERATORS.nextAppointmentAt]),
59
+ appointmentStatus: new Set([...VALID_OPERATORS.appointmentStatus, "is", "is_not", "is_any_of"]),
60
+ leadScore: new Set([...VALID_OPERATORS.leadScore]),
61
+ email: new Set([...VALID_OPERATORS.email, "is", "is_not", "is_any_of"]),
62
+ phone: new Set([...VALID_OPERATORS.phone, "is", "is_not", "is_any_of"]),
63
+ source: new Set([...VALID_OPERATORS.source, "is", "is_not", "is_empty", "is_not_empty", "is_any_of"]),
64
+ assignedTo: new Set([...VALID_OPERATORS.assignedTo, "is", "is_not", "is_not_empty"]),
65
+ tags: new Set([...VALID_OPERATORS.tags, "is", "is_not", "is_any_of"]),
66
+ pipeline: new Set([...VALID_OPERATORS.pipeline, "is", "is_not", "is_any_of"]),
67
+ pipelineStage: new Set([...VALID_OPERATORS.pipelineStage, "is", "is_not", "is_empty", "is_not_empty", "is_any_of"]),
68
+ dealStatus: new Set([...VALID_OPERATORS.dealStatus, "is", "is_not", "is_empty", "is_not_empty", "is_any_of"]),
69
+ masterStatus: new Set([...VALID_OPERATORS.masterStatus, "is", "is_not"]),
70
+ city: new Set([...VALID_OPERATORS.city, "is", "is_not", "is_any_of"]),
71
+ state: new Set([...VALID_OPERATORS.state, "is", "is_not", "is_any_of"]),
72
+ country: new Set([...VALID_OPERATORS.country, "is", "is_not", "is_any_of"]),
73
+ jobTitle: new Set([...VALID_OPERATORS.jobTitle, "is", "is_not", "is_any_of"]),
74
+ optedOutSms: new Set([...VALID_OPERATORS.optedOutSms]),
75
+ optedOutEmail: new Set([...VALID_OPERATORS.optedOutEmail]),
76
+ company: new Set(["is", "is_not", "contains", "is_empty", "is_not_empty"]),
77
+ };
78
+ export const CF_OPERATORS = new Set([
79
+ "equals", "not_equals", "contains", "is_any_of", "is", "is_not",
80
+ "num_equals", "num_not_equals", "gt", "gte", "lt", "lte", "is_true", "is_false",
81
+ "has_any_of", "has_all_of", "has_none_of",
82
+ ]);
83
+ const VALUELESS_OPERATORS = new Set(["is_empty", "is_not_empty", "is_true", "is_false"]);
84
+ const DATE_FILTER_FIELDS = new Set([
85
+ "createdAt", "updatedAt", "lastContactedAt", "lastActionAt", "lastContactAttemptAt", "lastEmailAt", "lastSmsAt", "lastCallAt", "nextAppointmentAt",
86
+ ]);
87
+ export function customFieldKey(field) {
88
+ if (!field.startsWith(CF_FIELD_PREFIX))
89
+ return null;
90
+ const key = field.slice(CF_FIELD_PREFIX.length).trim();
91
+ return key.length > 0 ? key : null;
92
+ }
93
+ function normalizeOperator(_field, operator) {
94
+ if (operator === "is")
95
+ return "equals";
96
+ if (operator === "is_not")
97
+ return "not_equals";
98
+ return operator;
99
+ }
100
+ function isSupportedCallAttemptsFilter(operator, value) {
101
+ const num = Number(value);
102
+ if (!Number.isInteger(num) || num < 0)
103
+ return false;
104
+ return ((operator === "equals" && num === 0) ||
105
+ (operator === "gt" && num === 0) ||
106
+ (operator === "gte" && num === 1) ||
107
+ (operator === "lt" && num === 1) ||
108
+ (operator === "lte" && num === 0));
109
+ }
110
+ function validateCommonRuleValue(field, operator, value, label) {
111
+ if (!VALUELESS_OPERATORS.has(operator)) {
112
+ if (typeof value !== "string" || value.trim() === "")
113
+ return `${label}: value is required for ${field} ${operator}`;
114
+ }
115
+ if (field === "callAttempts" && typeof value === "string") {
116
+ if (!isSupportedCallAttemptsFilter(operator, value))
117
+ return `${label}: callAttempts only supports exact zero-call or has-call boundary filters`;
118
+ }
119
+ if (DATE_FILTER_FIELDS.has(field) && typeof value === "string" && value.trim() !== "") {
120
+ if (operator === "before" || operator === "after") {
121
+ if (isNaN(Date.parse(value)))
122
+ return `${label}: invalid date value "${value}" for ${operator}`;
123
+ }
124
+ else if (operator === "within" || operator === "not_within") {
125
+ if (!/^\d+(h|d|m)$/.test(value))
126
+ return `${label}: invalid duration value "${value}" — expected format like "7d", "24h", or "3m"`;
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ /** the API's validateAdvancedFilters, verbatim: the same verdict and the same message for the same payload */
132
+ export function validateAdvancedFilters(payload) {
133
+ if (!Array.isArray(payload))
134
+ return { valid: false, error: "Filter payload must be an array of groups" };
135
+ if (payload.length > MAX_FILTER_GROUPS)
136
+ return { valid: false, error: `Too many filter groups (max ${MAX_FILTER_GROUPS})` };
137
+ for (let gi = 0; gi < payload.length; gi++) {
138
+ const group = payload[gi];
139
+ if (!group || typeof group !== "object")
140
+ return { valid: false, error: `Group ${gi}: invalid object` };
141
+ const { conjunction, rules } = group;
142
+ if (conjunction !== "AND" && conjunction !== "OR")
143
+ return { valid: false, error: `Group ${gi}: conjunction must be "AND" or "OR"` };
144
+ if (!Array.isArray(rules))
145
+ return { valid: false, error: `Group ${gi}: rules must be an array` };
146
+ if (rules.length > MAX_RULES_PER_GROUP)
147
+ return { valid: false, error: `Group ${gi}: too many rules (max ${MAX_RULES_PER_GROUP})` };
148
+ for (let ri = 0; ri < rules.length; ri++) {
149
+ const rule = rules[ri];
150
+ if (!rule || typeof rule !== "object")
151
+ return { valid: false, error: `Group ${gi} rule ${ri}: invalid object` };
152
+ const { field, operator, value } = rule;
153
+ if (typeof field !== "string")
154
+ return { valid: false, error: `Group ${gi} rule ${ri}: field must be a string` };
155
+ const cfKey = customFieldKey(field);
156
+ if (cfKey) {
157
+ if (typeof operator !== "string")
158
+ return { valid: false, error: `Group ${gi} rule ${ri}: operator must be a string` };
159
+ if (!CF_OPERATORS.has(operator))
160
+ return { valid: false, error: `Group ${gi} rule ${ri}: invalid operator "${operator}" for custom field "${field}"` };
161
+ }
162
+ else {
163
+ if (!VALID_FIELDS.has(field))
164
+ return { valid: false, error: `Group ${gi} rule ${ri}: unknown field "${field}"` };
165
+ if (typeof operator !== "string")
166
+ return { valid: false, error: `Group ${gi} rule ${ri}: operator must be a string` };
167
+ const validOps = ADVANCED_OPERATORS[field];
168
+ if (!validOps?.has(operator))
169
+ return { valid: false, error: `Group ${gi} rule ${ri}: invalid operator "${operator}" for field "${field}"` };
170
+ }
171
+ // (Codex cli r3 m1) an array element that is not a string is refused here, as the app now does (the is_any_of branch trims each element)
172
+ if (value !== undefined && typeof value !== "string" && !(Array.isArray(value) && value.every((v) => typeof v === "string")))
173
+ return { valid: false, error: `Group ${gi} rule ${ri}: value must be a string or string[]` };
174
+ if (operator === "is_any_of") {
175
+ const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
176
+ if (values.map((v) => v.trim()).filter(Boolean).length === 0)
177
+ return { valid: false, error: `Group ${gi} rule ${ri}: at least one value is required` };
178
+ }
179
+ else {
180
+ const scalarValue = Array.isArray(value) ? value.join(",") : value;
181
+ const valueError = validateCommonRuleValue(field, normalizeOperator(field, operator), scalarValue, `Group ${gi} rule ${ri}`);
182
+ if (valueError)
183
+ return { valid: false, error: valueError };
184
+ }
185
+ }
186
+ }
187
+ return { valid: true, groups: payload };
188
+ }
189
+ // ─── (Codex mcp/cli r5 h1 → r6 h1) VALID MEANS COMPILES ────────────────────────────────────────────────────────────────────────
190
+ // The app's validateAdvancedFilters runs every rule through its strict compiler and refuses what the compiler would drop. This
191
+ // surface carries the compiler's EXACT null-returning paths as one pure predicate — ./advanced-filter-compilability.ts, ported
192
+ // verbatim from conduyt-app lib/advanced-filter-compilability.ts (slice B, on 0dab73baf), which the app proves equal to its real
193
+ // compiler over an adversarial value matrix for every field, operator and custom-field operator
194
+ // (lib/advanced-filter-compilability.test.ts) — so a rule accepted here is one the API accepts and the compiler keeps.
195
+ import { advancedRuleCompiles } from "./advanced-filter-compilability.js";
196
+ /** validateAdvancedFilters (the port) PLUS the compile verdict per rule: the same answer the API gives a write */
197
+ export function validateAdvancedFiltersEffective(payload) {
198
+ const v = validateAdvancedFilters(payload);
199
+ if (!v.valid)
200
+ return v;
201
+ for (const [gi, group] of v.groups.entries())
202
+ for (const [ri, rule] of group.rules.entries()) {
203
+ if (!advancedRuleCompiles(rule))
204
+ return { valid: false, error: `Group ${gi} rule ${ri}: ${rule.field} ${rule.operator} cannot be applied to this value` };
205
+ }
206
+ return v;
207
+ }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
+ import { addPoolFilterSet, listPoolFilterSets, removePoolFilterSet, updatePoolFilterSet } from "./pool-filter-sets.js";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { dirname, join } from "node:path";
5
6
  import { Command } from "commander";
@@ -2096,6 +2097,45 @@ webhooks
2096
2097
  Object.assign(body, jsonArg(opts.json, "--json"));
2097
2098
  return client.post("/api/v1/webhooks/manage", body);
2098
2099
  }));
2100
+ // ---- drip send barrier recovery (Smart Views slice A, Codex r70 m5) ----
2101
+ // A drip step whose provider outcome was unconfirmed is PARKED behind a barrier (a parked webhook log and/or the
2102
+ // processor's marker). An operator (admin/owner) cancels it here: cancel-only leaves the barrier standing (nothing is
2103
+ // sent); --skip assumes the step was delivered (the processor advances without sending); --resend sends the step AGAIN
2104
+ // (a possible duplicate SMS) and is refused without --acknowledge-duplicate-risk.
2105
+ const uuidArg = (raw, name) => {
2106
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(raw))
2107
+ throw new Error(`${name} must be a UUID`);
2108
+ return raw;
2109
+ };
2110
+ const barrierBody = (opts) => {
2111
+ if (opts.skip && opts.resend)
2112
+ throw new Error("pass --skip or --resend, not both");
2113
+ if (opts.resend && !opts.acknowledgeDuplicateRisk)
2114
+ throw new Error("--resend may deliver the step TWICE (its outcome was unconfirmed): pass --acknowledge-duplicate-risk, or --skip to advance without sending");
2115
+ const body = {};
2116
+ if (opts.skip)
2117
+ body.resolution = "skip";
2118
+ if (opts.resend) {
2119
+ body.resolution = "resend";
2120
+ body.confirm = "RESEND_UNCONFIRMED_DRIP";
2121
+ }
2122
+ return body;
2123
+ };
2124
+ webhooks
2125
+ .command("cancel-log <id>")
2126
+ .description("Cancel ONE parked / queued / failed webhook log of an unconfirmed drip send (POST /webhook-logs/{id}/cancel; admin/owner, webhooks scope). Cancel-only by default: the barrier stands")
2127
+ .option("--skip", "assume the step was delivered: the processor advances without sending")
2128
+ .option("--resend", "send the step again (duplicate risk: needs --acknowledge-duplicate-risk; refused 409 step_already_sent when the step already carries sent evidence: use --skip)")
2129
+ .option("--acknowledge-duplicate-risk", "you accept the step may be delivered twice")
2130
+ .action(run(async (client, id, opts) => client.post(`/api/v1/webhook-logs/${uuidArg(id, "id")}/cancel`, barrierBody(opts))));
2131
+ const dripEnrollments = program.command("drip-enrollments").description("Drip enrollment recovery");
2132
+ dripEnrollments
2133
+ .command("cancel-barrier <id>")
2134
+ .description("Cancel a drip enrollment's whole send barrier (POST /drip-enrollments/{id}/cancel-barrier; admin/owner, drip-enrollments scope) — every parked / queued / failed log, including a marker-only barrier with no log at all. Cancel-only by default: the barrier stands")
2135
+ .option("--skip", "assume the current step was delivered: the processor advances without sending")
2136
+ .option("--resend", "send the step again (duplicate risk: needs --acknowledge-duplicate-risk; refused 409 step_already_sent when the step already carries sent evidence: use --skip)")
2137
+ .option("--acknowledge-duplicate-risk", "you accept the step may be delivered twice")
2138
+ .action(run(async (client, id, opts) => client.post(`/api/v1/drip-enrollments/${uuidArg(id, "id")}/cancel-barrier`, barrierBody(opts))));
2099
2139
  // ---- dnc (Do Not Call) ----
2100
2140
  // NOTE: an AI agent's API key must be granted the `dnc` scope to manage the DNC
2101
2141
  // list. Reading also requires dialer:view; adding a litigator entry and removals
@@ -2683,6 +2723,39 @@ leadPool
2683
2723
  const offers = Array.isArray(data.offers) ? data.offers : [];
2684
2724
  return { data: { offers, count: offers.length, poolFilter: pf, criteria: poolCriteriaPhrases(filter, pf?.tags ?? [], pf?.tagLookupFailed === true) } };
2685
2725
  }));
2726
+ // ─── pool filter sets (Paul's signed concept 2026-09-25 §2–§3, slice D parity with the gear and the MCP tools): the
2727
+ // command bodies live in ./pool-filter-sets.ts, tested against the API's exact wire envelope ───
2728
+ leadPool
2729
+ .command("filter-sets")
2730
+ .description("The named filter sets behind the gear on a pool page (admin only; GET /api/v1/pool-filter-sets): name, Advanced Filters payload, enabled flag, order — and the page's pageVersion every write must name (LIST FIRST). The page shows the UNION of the enabled sets.")
2731
+ .requiredOption("--page <page>", "up_for_grabs or lead_pool")
2732
+ .action(run(async (client, opts) => listPoolFilterSets(client, opts)));
2733
+ leadPool
2734
+ .command("add-filter-set")
2735
+ .description("Add a named filter set to a pool page (admin only; POST /api/v1/pool-filter-sets). A new set is a DRAFT unless --enabled on. A change that OPENS the whole page (an enabled rule-less set) needs --acknowledge-opens-pool (else 409 pool_opens). Tag rules must name live tags (422 tag_missing). --page-version is the pageVersion your filter-sets read answered (409 page_version_stale when the page moved since: re-list and retry). --filters is validated exactly like the API validates it.")
2736
+ .requiredOption("--page <page>", "up_for_grabs or lead_pool")
2737
+ .requiredOption("--name <name>", "the set's name (1-80 characters, unique on its page)")
2738
+ .requiredOption("--filters <json>", "the Advanced Filters payload (a JSON array of ≤ 10 groups, each { conjunction: AND|OR, rules: [≤ 20 of { field, operator, value }] })")
2739
+ .requiredOption("--page-version <version>", "the pageVersion your `lead-pool filter-sets` read answered")
2740
+ .option("--enabled <on|off>", "enable at once (default: a draft)")
2741
+ .option("--acknowledge-opens-pool", "confirm a change that opens the whole page to every unassigned lead (for the page state --page-version names)")
2742
+ .action(run(async (client, opts) => addPoolFilterSet(client, opts)));
2743
+ leadPool
2744
+ .command("update-filter-set <id>")
2745
+ .description("Rename, refilter, enable / disable or reorder a pool filter set (admin only; PATCH /api/v1/pool-filter-sets/:id). Pass only what changes. A change that OPENS the whole page needs --acknowledge-opens-pool. --page-version is the pageVersion your filter-sets read answered for the set's page (409 page_version_stale when the page moved since: re-list and retry).")
2746
+ .requiredOption("--page-version <version>", "the pageVersion your `lead-pool filter-sets` read answered for the set's page")
2747
+ .option("--name <name>", "new name (1-80 characters)")
2748
+ .option("--filters <json>", "new Advanced Filters payload (a JSON array of groups)")
2749
+ .option("--enabled <on|off>", "switch the set on or off")
2750
+ .option("--sort-order <n>", "the set's position on its page (0-10000)")
2751
+ .option("--acknowledge-opens-pool", "confirm a change that opens the whole page")
2752
+ .action(run(async (client, id, opts) => updatePoolFilterSet(client, id, opts)));
2753
+ leadPool
2754
+ .command("remove-filter-set <id>")
2755
+ .description("Remove a pool filter set (admin only; DELETE /api/v1/pool-filter-sets/:id). Removing the last enabled set once the legacy filter is retired OPENS the page: refused with 409 pool_opens unless --acknowledge-opens-pool. --page-version is the pageVersion your filter-sets read answered for the set's page.")
2756
+ .requiredOption("--page-version <version>", "the pageVersion your `lead-pool filter-sets` read answered for the set's page")
2757
+ .option("--acknowledge-opens-pool", "confirm a removal that opens the whole page")
2758
+ .action(run(async (client, id, opts) => removePoolFilterSet(client, id, opts)));
2686
2759
  const lifecycle = program
2687
2760
  .command("lifecycle")
2688
2761
  .description("Lifecycle settings: lead master statuses, intake-deal creation, how the tenant works leads, and lost propagation");
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Pool filter sets (Paul's signed Smart Views concept 2026-09-25 §2–§3, slice D parity with the gear and the MCP tools):
3
+ * the command bodies, as pure functions over the API client so they are tested against the API's EXACT wire envelope
4
+ * (jsonResponse({ data: sets, pageVersion }) is { data: { data, pageVersion } }) and never make a request they should not.
5
+ */
6
+ import { validateAdvancedFiltersEffective } from "./advanced-filter-rules.js";
7
+ export const POOL_PAGES = ["up_for_grabs", "lead_pool"];
8
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ export function isPoolFilterSetRow(r) {
10
+ if (!r || typeof r !== "object" || Array.isArray(r))
11
+ return false;
12
+ const x = r;
13
+ if (typeof x.id !== "string" || !UUID_RE.test(x.id))
14
+ return false;
15
+ if (typeof x.name !== "string" || typeof x.isEnabled !== "boolean" || typeof x.valid !== "boolean")
16
+ return false;
17
+ if (typeof x.sortOrder !== "number" || !Number.isInteger(x.sortOrder) || x.sortOrder < 0 || x.sortOrder > 10_000)
18
+ return false;
19
+ if (!Array.isArray(x.filters))
20
+ return false;
21
+ // (Codex cli r7 m1) a row flagged valid must COMPILE by the same verdict the writes use: a version-skewed or malformed answer is never surfaced as a valid enabled set
22
+ return x.valid ? validateAdvancedFiltersEffective(x.filters).valid : x.filters.length === 0;
23
+ }
24
+ export function poolPageArg(raw) {
25
+ if (raw === "up_for_grabs" || raw === "lead_pool")
26
+ return raw;
27
+ throw new Error(`--page takes up_for_grabs or lead_pool (got '${raw ?? ""}')`);
28
+ }
29
+ /** (Codex mcp r1 h1) the page version is the CALLER's — the one its `lead-pool filter-sets` read answered — so the API's
30
+ * 409 page_version_stale protects the state the caller decided on; the CLI never reads a version on the caller's behalf */
31
+ export function pageVersionFlag(raw) {
32
+ if (typeof raw !== "string" || raw.trim().length === 0)
33
+ throw new Error("--page-version is required: the pageVersion your `conduyt lead-pool filter-sets --page …` read answered (list first; a stale version is 409 page_version_stale: re-list, re-read the sets, retry)");
34
+ return raw.trim();
35
+ }
36
+ /** (Codex cli r2 h2) a set id is a UUID and nothing else: an id like ../contacts/<uuid> would normalise into another route */
37
+ export function poolSetIdArg(raw) {
38
+ const id = raw.trim();
39
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))
40
+ throw new Error("pool filter set id must be a UUID.");
41
+ return encodeURIComponent(id);
42
+ }
43
+ /** the API's validateAdvancedFilters, ported verbatim (./advanced-filter-rules.ts): a flag-valid payload is never a predictable 400 */
44
+ export function filtersFlag(raw) {
45
+ if (raw === undefined)
46
+ return undefined;
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(raw);
50
+ }
51
+ catch {
52
+ throw new Error("--filters must be JSON: the Contacts Advanced Filters payload, an array of groups");
53
+ }
54
+ if (!Array.isArray(parsed))
55
+ throw new Error("--filters must be a JSON ARRAY of groups, e.g. '[{\"conjunction\":\"AND\",\"rules\":[{\"field\":\"source\",\"operator\":\"equals\",\"value\":\"Kloudi\"}]}]' (tag rules take an array of names)");
56
+ const verdict = validateAdvancedFiltersEffective(parsed); // (Codex cli r5 h1) valid means COMPILES, as the API now judges
57
+ if (!verdict.valid)
58
+ throw new Error(`--filters: ${verdict.error}`);
59
+ return parsed;
60
+ }
61
+ export function poolSetNameFlag(raw) {
62
+ const name = raw.trim();
63
+ if (name.length === 0 || name.length > 80)
64
+ throw new Error("--name must be 1-80 characters");
65
+ return name;
66
+ }
67
+ export function onOffFlag(raw, flag) {
68
+ const v = raw.trim().toLowerCase();
69
+ if (v === "on" || v === "true" || v === "yes")
70
+ return true;
71
+ if (v === "off" || v === "false" || v === "no")
72
+ return false;
73
+ throw new Error(`${flag} takes on or off (got '${raw}').`);
74
+ }
75
+ function sortOrderFlag(raw) {
76
+ if (raw === undefined)
77
+ return undefined;
78
+ const n = Number(raw);
79
+ if (!Number.isInteger(n) || n < 0 || n > 10_000)
80
+ throw new Error("--sort-order takes a position 0-10000");
81
+ return n;
82
+ }
83
+ export async function listPoolFilterSets(client, opts) {
84
+ const page = poolPageArg(opts.page);
85
+ const result = (await client.get(`/api/v1/pool-filter-sets?page=${page}`));
86
+ const pageVersion = typeof result?.data?.pageVersion === "string" && result.data.pageVersion.trim() ? result.data.pageVersion : "";
87
+ if (!pageVersion)
88
+ throw new Error("GET /api/v1/pool-filter-sets answered no pageVersion — the API is older than this CLI");
89
+ // (Codex cli r3 m2) FAIL CLOSED on a malformed answer: a missing or non-array set collection, or a set without its essential
90
+ // fields, is never presented as an empty page (the list is the state snapshot every write is decided on)
91
+ const rows = result?.data?.data;
92
+ // (Codex cli r4 m1) the WHOLE row contract: a row missing or mistyping any field (filters, sortOrder, the valid flag too) fails the call
93
+ if (!Array.isArray(rows) || !rows.every(isPoolFilterSetRow)) {
94
+ throw new Error("GET /api/v1/pool-filter-sets answered a malformed set collection — refusing to report an empty page (the API and this CLI disagree on the response shape)");
95
+ }
96
+ return { data: { sets: rows, pageVersion } };
97
+ }
98
+ export async function addPoolFilterSet(client, opts) {
99
+ const page = poolPageArg(opts.page);
100
+ const body = { page, name: poolSetNameFlag(opts.name), filters: filtersFlag(opts.filters) ?? [], pageVersion: pageVersionFlag(opts.pageVersion) };
101
+ if (opts.enabled !== undefined)
102
+ body.isEnabled = onOffFlag(opts.enabled, "--enabled");
103
+ if (opts.acknowledgeOpensPool)
104
+ body.acknowledgeOpensPool = true;
105
+ return client.post("/api/v1/pool-filter-sets", body);
106
+ }
107
+ export async function updatePoolFilterSet(client, rawId, opts) {
108
+ const id = poolSetIdArg(rawId);
109
+ const body = {};
110
+ if (opts.name !== undefined)
111
+ body.name = poolSetNameFlag(opts.name);
112
+ const filters = filtersFlag(opts.filters);
113
+ if (filters !== undefined)
114
+ body.filters = filters;
115
+ if (opts.enabled !== undefined)
116
+ body.isEnabled = onOffFlag(opts.enabled, "--enabled");
117
+ const sortOrder = sortOrderFlag(opts.sortOrder);
118
+ if (sortOrder !== undefined)
119
+ body.sortOrder = sortOrder;
120
+ if (Object.keys(body).length === 0)
121
+ throw new Error("nothing to change — pass at least one of --name, --filters, --enabled, --sort-order");
122
+ if (opts.acknowledgeOpensPool)
123
+ body.acknowledgeOpensPool = true;
124
+ body.pageVersion = pageVersionFlag(opts.pageVersion);
125
+ return client.patch(`/api/v1/pool-filter-sets/${id}`, body);
126
+ }
127
+ export async function removePoolFilterSet(client, rawId, opts) {
128
+ const id = poolSetIdArg(rawId);
129
+ const params = new URLSearchParams({ pageVersion: pageVersionFlag(opts.pageVersion) });
130
+ if (opts.acknowledgeOpensPool)
131
+ params.set("acknowledgeOpensPool", "true");
132
+ return client.del(`/api/v1/pool-filter-sets/${id}?${params}`);
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.53.0",
3
+ "version": "1.55.0",
4
4
  "description": "Command-line interface for Conduyt CRM — manage contacts, deals, pipelines, and run insight queries from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,9 +18,11 @@
18
18
  "build": "tsc",
19
19
  "typecheck": "tsc --noEmit",
20
20
  "start": "tsx src/index.ts",
21
- "prepublishOnly": "npm run check:version && npm run build && node --import tsx --experimental-test-module-mocks --test src/*.test.ts",
21
+ "prepublishOnly": "npm run check:version && npm run build && node --import tsx --experimental-test-module-mocks --test src/*.test.ts && ENFORCE=1 node scripts/verify-compilability.mjs",
22
22
  "test": "node --import tsx --test src/*.test.ts",
23
- "check:version": "node -e \"const p=require('./package.json').version,l=require('./package-lock.json');if(l.version!==p||l.packages[''].version!==p){console.error('lockfile version != '+p);process.exit(1)}console.log('Version sync OK: '+p)\""
23
+ "check:version": "node -e \"const p=require('./package.json').version,l=require('./package-lock.json');if(l.version!==p||l.packages[''].version!==p){console.error('lockfile version != '+p);process.exit(1)}console.log('Version sync OK: '+p)\"",
24
+ "compilability:refresh": "node scripts/refresh-compilability.mjs",
25
+ "verify:compilability": "node scripts/verify-compilability.mjs"
24
26
  },
25
27
  "keywords": [
26
28
  "conduyt",