conduyt 1.52.0 → 1.54.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";
@@ -2683,6 +2684,39 @@ leadPool
2683
2684
  const offers = Array.isArray(data.offers) ? data.offers : [];
2684
2685
  return { data: { offers, count: offers.length, poolFilter: pf, criteria: poolCriteriaPhrases(filter, pf?.tags ?? [], pf?.tagLookupFailed === true) } };
2685
2686
  }));
2687
+ // ─── pool filter sets (Paul's signed concept 2026-09-25 §2–§3, slice D parity with the gear and the MCP tools): the
2688
+ // command bodies live in ./pool-filter-sets.ts, tested against the API's exact wire envelope ───
2689
+ leadPool
2690
+ .command("filter-sets")
2691
+ .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.")
2692
+ .requiredOption("--page <page>", "up_for_grabs or lead_pool")
2693
+ .action(run(async (client, opts) => listPoolFilterSets(client, opts)));
2694
+ leadPool
2695
+ .command("add-filter-set")
2696
+ .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.")
2697
+ .requiredOption("--page <page>", "up_for_grabs or lead_pool")
2698
+ .requiredOption("--name <name>", "the set's name (1-80 characters, unique on its page)")
2699
+ .requiredOption("--filters <json>", "the Advanced Filters payload (a JSON array of ≤ 10 groups, each { conjunction: AND|OR, rules: [≤ 20 of { field, operator, value }] })")
2700
+ .requiredOption("--page-version <version>", "the pageVersion your `lead-pool filter-sets` read answered")
2701
+ .option("--enabled <on|off>", "enable at once (default: a draft)")
2702
+ .option("--acknowledge-opens-pool", "confirm a change that opens the whole page to every unassigned lead (for the page state --page-version names)")
2703
+ .action(run(async (client, opts) => addPoolFilterSet(client, opts)));
2704
+ leadPool
2705
+ .command("update-filter-set <id>")
2706
+ .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).")
2707
+ .requiredOption("--page-version <version>", "the pageVersion your `lead-pool filter-sets` read answered for the set's page")
2708
+ .option("--name <name>", "new name (1-80 characters)")
2709
+ .option("--filters <json>", "new Advanced Filters payload (a JSON array of groups)")
2710
+ .option("--enabled <on|off>", "switch the set on or off")
2711
+ .option("--sort-order <n>", "the set's position on its page (0-10000)")
2712
+ .option("--acknowledge-opens-pool", "confirm a change that opens the whole page")
2713
+ .action(run(async (client, id, opts) => updatePoolFilterSet(client, id, opts)));
2714
+ leadPool
2715
+ .command("remove-filter-set <id>")
2716
+ .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.")
2717
+ .requiredOption("--page-version <version>", "the pageVersion your `lead-pool filter-sets` read answered for the set's page")
2718
+ .option("--acknowledge-opens-pool", "confirm a removal that opens the whole page")
2719
+ .action(run(async (client, id, opts) => removePoolFilterSet(client, id, opts)));
2686
2720
  const lifecycle = program
2687
2721
  .command("lifecycle")
2688
2722
  .description("Lifecycle settings: lead master statuses, intake-deal creation, how the tenant works leads, and lost propagation");
@@ -3527,11 +3561,29 @@ reports
3527
3561
  const smartViews = program.command("smart-views").description("Smart Views and their Smart Dialing priorities");
3528
3562
  smartViews
3529
3563
  .command("list")
3530
- .description("List Smart Views in sidebar order (= Smart Dialing priority) with lead counts and dialing settings")
3564
+ .description("List Smart Views in sidebar order (= Smart Dialing priority) with lead counts, audience (groupId / members / isPersonal) and dialing settings")
3531
3565
  .option("--dialing", "only the dial-enabled views (the Smart Dialing priorities)")
3532
3566
  .action(run(async (client, opts) => {
3533
- const views = (await client.get("/api/v1/smart-views"));
3534
- const rows = Array.isArray(views) ? views : [];
3567
+ let rows = null;
3568
+ for (let attempt = 0; attempt < 3 && rows === null; attempt += 1) {
3569
+ const first = (await client.get("/api/v1/smart-views"));
3570
+ const walked = [...first.data.data];
3571
+ let meta = first.data.meta;
3572
+ let shifted = false;
3573
+ for (let page = 2; meta.has_more && page <= 50; page += 1) {
3574
+ const next = (await client.get(`/api/v1/smart-views?page=${page}&counts=false`));
3575
+ if (next.data.meta.order_stamp !== first.data.meta.order_stamp) {
3576
+ shifted = true;
3577
+ break;
3578
+ }
3579
+ walked.push(...next.data.data);
3580
+ meta = next.data.meta;
3581
+ }
3582
+ if (!shifted)
3583
+ rows = walked;
3584
+ }
3585
+ if (rows === null)
3586
+ throw new Error("Smart Views kept changing while they were listed; try again.");
3535
3587
  const sorted = [...rows].sort((a, b) => Number(a.sortPosition ?? 0) - Number(b.sortPosition ?? 0));
3536
3588
  return opts.dialing ? sorted.filter((v) => v.dialEnabled === true) : sorted;
3537
3589
  }));
@@ -3606,6 +3658,31 @@ smartViews
3606
3658
  throw new Error("Nothing to change. Pass --enable/--disable, --max-attempts, --per-day, --cooldown or --schedule.");
3607
3659
  return client.patch(`/api/v1/smart-views/${encodeURIComponent(id)}`, body);
3608
3660
  }));
3661
+ smartViews
3662
+ .command("audience <id>")
3663
+ .description("Who a shared Smart View is for (PATCH): everyone, one Team group (its current members, resolved when the view is read), or picked reps. A group replaces picked reps and vice versa.")
3664
+ .option("--everyone", "every rep on the account")
3665
+ .option("--group <groupId>", "one Team group (see: conduyt groups list)")
3666
+ .option("--reps <userIds>", "comma-separated user ids of the reps")
3667
+ .action(run(async (client, id, opts) => {
3668
+ assertUuid(id, "smart view id");
3669
+ const picked = [opts.everyone ? "everyone" : null, opts.group ? "group" : null, opts.reps ? "reps" : null].filter(Boolean);
3670
+ if (picked.length !== 1)
3671
+ throw new Error("Pass exactly one of --everyone, --group <groupId> or --reps <userIds>.");
3672
+ if (opts.group) {
3673
+ assertUuid(opts.group, "group id");
3674
+ return client.patch(`/api/v1/smart-views/${encodeURIComponent(id)}`, { groupId: opts.group, members: [] });
3675
+ }
3676
+ if (opts.reps) {
3677
+ const ids = opts.reps.split(",").map((x) => x.trim()).filter(Boolean);
3678
+ if (ids.length === 0)
3679
+ throw new Error("--reps needs at least one user id.");
3680
+ for (const u of ids)
3681
+ assertUuid(u, "user id");
3682
+ return client.patch(`/api/v1/smart-views/${encodeURIComponent(id)}`, { groupId: null, members: ids.map((userId) => ({ userId })) });
3683
+ }
3684
+ return client.patch(`/api/v1/smart-views/${encodeURIComponent(id)}`, { groupId: null, members: [] });
3685
+ }));
3609
3686
  smartViews
3610
3687
  .command("dial-order [ids...]")
3611
3688
  .description("No ids: show the Smart Dialing priorities, uncapped (every dial-enabled view in dial order + candidates). With ids: set the priority order — every dial-enabled view id, first = dialed first (the server keeps non-priority views in their slots)")
@@ -3620,6 +3697,21 @@ smartViews
3620
3697
  assertUuid(id, "smart view id");
3621
3698
  return client.patch("/api/v1/smart-views/dial-order", { order: ids });
3622
3699
  }));
3700
+ smartViews
3701
+ .command("move <id>")
3702
+ .description("Move ONE Smart View next to another in the sidebar order (= Smart Dialing priority). No size limit: use this on accounts with more than 200 views")
3703
+ .option("--before <viewId>", "land directly above this view")
3704
+ .option("--after <viewId>", "land directly below this view")
3705
+ .action(run(async (client, id, opts) => {
3706
+ assertUuid(id, "smart view id");
3707
+ const anchors = [opts.before, opts.after].filter(Boolean);
3708
+ if (anchors.length !== 1)
3709
+ throw new Error("Pass exactly one of --before <viewId> or --after <viewId>.");
3710
+ assertUuid(anchors[0], "anchor view id");
3711
+ if (anchors[0] === id)
3712
+ throw new Error("A view cannot be anchored to itself.");
3713
+ return client.patch("/api/v1/smart-views/reorder", { move: opts.before ? { id, beforeId: opts.before } : { id, afterId: opts.after } });
3714
+ }));
3623
3715
  smartViews
3624
3716
  .command("reorder <ids...>")
3625
3717
  .description("Set the SIDEBAR order of Smart Views. Pass EVERY view id; views left out keep a stale position. For the dialing priority use dial-order")
@@ -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,7 +1,7 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.52.0",
4
- "description": "Command-line interface for Conduyt CRM — manage contacts, deals, pipelines, and run insight queries from your terminal.",
3
+ "version": "1.54.0",
4
+ "description": "Command-line interface for Conduyt CRM \u2014 manage contacts, deals, pipelines, and run insight queries from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -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",