@opsrev/acculynx-cli 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/index.js +155 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,3 +159,36 @@ npm run dev -- jobs list --limit 5
|
|
|
159
159
|
## License
|
|
160
160
|
|
|
161
161
|
MIT
|
|
162
|
+
|
|
163
|
+
### Private marketing evidence from a native scan
|
|
164
|
+
|
|
165
|
+
Operators preparing approval-date reconciliation can preserve exact source
|
|
166
|
+
fields without parsing the rounded human digest:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
acculynx jobs scan --start-date 2026-07-01 --end-date 2026-09-04 --date-filter-type ModifiedDate --enrich dates,financials --format marketing-evidence
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
This emits one `acculynx-marketing-evidence/v1` JSON object. It is **private
|
|
173
|
+
operator evidence** containing full job IDs and individual values; do not post
|
|
174
|
+
it in a vendor/customer channel. It excludes contacts, addresses, names,
|
|
175
|
+
notes, documents, unrelated financials and raw provider error bodies.
|
|
176
|
+
`--out` and other enrichers are rejected before reading. Redirect stdout only
|
|
177
|
+
into an appropriately protected operator workspace if retaining the artifact.
|
|
178
|
+
This format does not alter downstream agents' command allowlists or authorize
|
|
179
|
+
them to export records.
|
|
180
|
+
|
|
181
|
+
The receipt distinguishes query completeness from enrichment completeness.
|
|
182
|
+
Strict pagination requires a numeric stable total, advancing offsets and unique
|
|
183
|
+
job IDs. Exit 3 means partial evidence; never publish it as a complete result.
|
|
184
|
+
Full source dates are preserved without timezone conversion, repeated approval
|
|
185
|
+
milestones remain separate, and current approved value is represented in exact
|
|
186
|
+
cents. Missing/invalid values and dates remain null with issue codes.
|
|
187
|
+
|
|
188
|
+
This is not an approval cohort, an original-acquisition attribution, a complete
|
|
189
|
+
lead-delivery inventory or historical revenue. ModifiedDate is a candidate
|
|
190
|
+
filter; confirm historical and unassigned/dead-lead coverage separately. Review
|
|
191
|
+
source date semantics, repeated approvals, transfers and current-versus-original
|
|
192
|
+
contract value before producing normalized results. A large historical scan
|
|
193
|
+
reads history and financials for each candidate; bootstrap it separately from
|
|
194
|
+
short daily marketing jobs, then validate an incremental refresh strategy.
|
package/dist/index.js
CHANGED
|
@@ -59,7 +59,7 @@ import { writeFileSync } from "fs";
|
|
|
59
59
|
|
|
60
60
|
// src/ops/scan.ts
|
|
61
61
|
var PAGE_SIZE = 25;
|
|
62
|
-
async function scanJobs(client, filters) {
|
|
62
|
+
async function scanJobs(client, filters, options = {}) {
|
|
63
63
|
const params = { includes: "contact", sortBy: "CreatedDate" };
|
|
64
64
|
if (filters.startDate) params.startDate = filters.startDate;
|
|
65
65
|
if (filters.endDate) params.endDate = filters.endDate;
|
|
@@ -70,6 +70,7 @@ async function scanJobs(client, filters) {
|
|
|
70
70
|
let serverCount;
|
|
71
71
|
let pageError;
|
|
72
72
|
let pageStartIndex = 0;
|
|
73
|
+
const seen = /* @__PURE__ */ new Set();
|
|
73
74
|
while (true) {
|
|
74
75
|
let data;
|
|
75
76
|
try {
|
|
@@ -82,6 +83,42 @@ async function scanJobs(client, filters) {
|
|
|
82
83
|
pageError = error instanceof Error ? error.message : String(error);
|
|
83
84
|
break;
|
|
84
85
|
}
|
|
86
|
+
if (options.strict) {
|
|
87
|
+
const page = data;
|
|
88
|
+
if (page === null || typeof page !== "object") {
|
|
89
|
+
pageError = "invalid_page";
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
if (!Number.isSafeInteger(page.count) || page.count < 0 || !Array.isArray(page.items) || page.items.length > PAGE_SIZE || page.pageStartIndex !== pageStartIndex) {
|
|
93
|
+
pageError = "invalid_page";
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (serverCount !== void 0 && serverCount !== page.count) {
|
|
97
|
+
pageError = "changing_inventory";
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
let invalidId = false;
|
|
101
|
+
for (const item of page.items) {
|
|
102
|
+
const id = item !== null && typeof item === "object" ? item.id : void 0;
|
|
103
|
+
if (typeof id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(id) || seen.has(id)) {
|
|
104
|
+
invalidId = true;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
seen.add(id);
|
|
108
|
+
}
|
|
109
|
+
if (invalidId) {
|
|
110
|
+
pageError = "duplicate_or_missing_job_id";
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
if (fetched.length + page.items.length > page.count) {
|
|
114
|
+
pageError = "count_mismatch";
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
if (pageStartIndex >= 5e4) {
|
|
118
|
+
pageError = "page_limit";
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
85
122
|
if (serverCount === void 0 && typeof data.count === "number") serverCount = data.count;
|
|
86
123
|
const items = Array.isArray(data.items) ? data.items : [];
|
|
87
124
|
fetched.push(...items);
|
|
@@ -102,9 +139,16 @@ async function scanJobs(client, filters) {
|
|
|
102
139
|
});
|
|
103
140
|
return { jobs, scanned: fetched.length, serverCount, complete, ...pageError ? { pageError } : {} };
|
|
104
141
|
}
|
|
105
|
-
var ENRICHERS = ["financials", "reps", "dates"];
|
|
142
|
+
var ENRICHERS = ["financials", "reps", "dates", "messages"];
|
|
106
143
|
var asRecord = (v) => v !== null && typeof v === "object" ? v : {};
|
|
107
|
-
async function
|
|
144
|
+
async function defaultLoadMessagesTool() {
|
|
145
|
+
const { createRequire: createRequire2 } = await import("module");
|
|
146
|
+
const require3 = createRequire2(import.meta.url);
|
|
147
|
+
const resolved = require3.resolve("@opsrev/acculynx-cli-unofficial");
|
|
148
|
+
const mod = await import(resolved);
|
|
149
|
+
return mod.handleExtendedToolCall;
|
|
150
|
+
}
|
|
151
|
+
async function enrichJobs(client, jobs, enrich, concurrency = 5, deps = {}) {
|
|
108
152
|
let userNames = /* @__PURE__ */ new Map();
|
|
109
153
|
if (enrich.includes("reps")) {
|
|
110
154
|
try {
|
|
@@ -114,6 +158,8 @@ async function enrichJobs(client, jobs, enrich, concurrency = 5) {
|
|
|
114
158
|
}
|
|
115
159
|
}
|
|
116
160
|
const nameOf = (id) => userNames.get(String(id)) || String(id ?? "").slice(0, 8);
|
|
161
|
+
let messagesToolPromise = null;
|
|
162
|
+
const getMessagesTool = () => messagesToolPromise ??= (deps.loadMessagesTool ?? defaultLoadMessagesTool)();
|
|
117
163
|
const out = jobs.map((job) => ({ job, errors: [] }));
|
|
118
164
|
let next = 0;
|
|
119
165
|
async function enrichOne(entry) {
|
|
@@ -139,10 +185,22 @@ async function enrichJobs(client, jobs, enrich, concurrency = 5) {
|
|
|
139
185
|
entry.reps = reps;
|
|
140
186
|
} else if (source === "dates") {
|
|
141
187
|
const m = asRecord(await jobMilestones(client, jobId));
|
|
142
|
-
|
|
188
|
+
if (!Array.isArray(m.items) || m.items.some((i) => i === null || typeof i !== "object")) throw new Error("invalid_milestone_history");
|
|
189
|
+
entry.dates = m.items.map((i) => ({
|
|
143
190
|
name: String(i.name ?? ""),
|
|
144
191
|
date: String(i.date ?? "")
|
|
145
192
|
}));
|
|
193
|
+
} else if (source === "messages") {
|
|
194
|
+
const tool = await getMessagesTool();
|
|
195
|
+
const res = await tool({ name: "acculynx_jobs_messages", args: { jobId } });
|
|
196
|
+
const text = String(res.content?.[0]?.text ?? "");
|
|
197
|
+
if (res.isError) throw new Error(text.slice(0, 200) || "messages tool error");
|
|
198
|
+
const data = asRecord(JSON.parse(text || "{}"));
|
|
199
|
+
entry.messages = (data.messages ?? []).slice(0, 3).map((msg) => ({
|
|
200
|
+
date: String(msg.createdDate ?? ""),
|
|
201
|
+
by: String(msg.createdBy ?? ""),
|
|
202
|
+
text: String(msg.message ?? "").replace(/\s+/g, " ").trim().slice(0, 200)
|
|
203
|
+
}));
|
|
146
204
|
}
|
|
147
205
|
} catch (error) {
|
|
148
206
|
entry.errors.push({ jobId, source, message: error instanceof Error ? error.message : String(error) });
|
|
@@ -211,6 +269,14 @@ function headerLine2(report) {
|
|
|
211
269
|
}
|
|
212
270
|
return line;
|
|
213
271
|
}
|
|
272
|
+
function messagesLine(entry, enrich) {
|
|
273
|
+
if (!enrich.includes("messages")) return null;
|
|
274
|
+
if (entry.errors.some((e) => e.source === "messages")) return " note ERR:messages";
|
|
275
|
+
const m = entry.messages?.[0];
|
|
276
|
+
if (!m) return " note: -";
|
|
277
|
+
const text = m.text.slice(0, 110) || "-";
|
|
278
|
+
return ` note ${mmdd(m.date)} ${m.by || "-"}: ${text}`;
|
|
279
|
+
}
|
|
214
280
|
function jobLine(entry, enrich) {
|
|
215
281
|
const job = entry.job;
|
|
216
282
|
const id8 = String(job.id ?? "").slice(0, 8);
|
|
@@ -245,11 +311,23 @@ function jobLine(entry, enrich) {
|
|
|
245
311
|
}
|
|
246
312
|
function formatScanDigest(report) {
|
|
247
313
|
const lines = [headerLine1(report), headerLine2(report)];
|
|
248
|
-
for (const entry of report.jobs)
|
|
314
|
+
for (const entry of report.jobs) {
|
|
315
|
+
lines.push(jobLine(entry, report.enrich));
|
|
316
|
+
const note = messagesLine(entry, report.enrich);
|
|
317
|
+
if (note !== null) lines.push(note);
|
|
318
|
+
}
|
|
249
319
|
const allErrors = report.jobs.flatMap((j) => j.errors);
|
|
250
320
|
if (allErrors.length > 0) {
|
|
251
321
|
lines.push("errors:");
|
|
252
|
-
|
|
322
|
+
const groups = /* @__PURE__ */ new Map();
|
|
323
|
+
for (const e of allErrors) {
|
|
324
|
+
const key = `${e.source}: ${e.message}`;
|
|
325
|
+
const ids = groups.get(key) ?? [];
|
|
326
|
+
ids.push(e.jobId.slice(0, 8));
|
|
327
|
+
groups.set(key, ids);
|
|
328
|
+
}
|
|
329
|
+
for (const [key, ids] of groups)
|
|
330
|
+
lines.push(ids.length > 3 ? ` ${key} (${ids.length} jobs)` : ` ${ids.join(",")} ${key}`);
|
|
253
331
|
}
|
|
254
332
|
return lines.join("\n");
|
|
255
333
|
}
|
|
@@ -257,6 +335,65 @@ function formatScanJsonl(report) {
|
|
|
257
335
|
return report.jobs.map((j) => JSON.stringify(j)).join("\n");
|
|
258
336
|
}
|
|
259
337
|
|
|
338
|
+
// src/ops/scan-marketing.ts
|
|
339
|
+
function marketingEvidence(report, capturedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
340
|
+
const record = (v) => v !== null && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
341
|
+
const ids = /* @__PURE__ */ new Set();
|
|
342
|
+
let uniqueIds = true;
|
|
343
|
+
const jobs = report.jobs.map((entry) => {
|
|
344
|
+
const issues = [];
|
|
345
|
+
const id = typeof entry.job.id === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(entry.job.id) ? entry.job.id : null;
|
|
346
|
+
if (!id) issues.push("invalid_job_id");
|
|
347
|
+
else if (ids.has(id)) {
|
|
348
|
+
uniqueIds = false;
|
|
349
|
+
issues.push("duplicate_job_id");
|
|
350
|
+
} else ids.add(id);
|
|
351
|
+
const date = (value2, field) => {
|
|
352
|
+
if (typeof value2 === "string" && /^\d{4}-\d{2}-\d{2}(?:T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:0\d|1[0-4]):[0-5]\d))?$/.test(value2) && Number.isFinite(Date.parse(value2)) && (/* @__PURE__ */ new Date(value2.slice(0, 10) + "T00:00:00Z")).toISOString().slice(0, 10) === value2.slice(0, 10)) return value2;
|
|
353
|
+
issues.push("invalid_" + field);
|
|
354
|
+
return null;
|
|
355
|
+
};
|
|
356
|
+
const label = (value2) => typeof value2 === "string" && value2.length > 0 && value2.length <= 200 ? value2 : null;
|
|
357
|
+
const failed = new Set(entry.errors.map((e) => e.source));
|
|
358
|
+
for (const source of ["financials", "dates"]) if (failed.has(source)) issues.push(source + "_read_failed");
|
|
359
|
+
let approvedValueCents = null;
|
|
360
|
+
const value = entry.financials?.approvedJobValue;
|
|
361
|
+
if (!failed.has("financials") && typeof value === "number" && Number.isFinite(value) && value >= 0 && Number.isSafeInteger(Math.round(value * 100)) && Math.abs(value * 100 - Math.round(value * 100)) <= 1e-6) approvedValueCents = Math.round(value * 100);
|
|
362
|
+
else issues.push("approved_value_unavailable");
|
|
363
|
+
let milestones = null;
|
|
364
|
+
if (!failed.has("dates") && Array.isArray(entry.dates)) {
|
|
365
|
+
milestones = entry.dates.map((d) => {
|
|
366
|
+
const name = label(d.name);
|
|
367
|
+
if (!name) issues.push("invalid_milestone_name");
|
|
368
|
+
return { name, sourceDate: date(d.date, "milestone_date") };
|
|
369
|
+
});
|
|
370
|
+
} else issues.push("milestone_history_unavailable");
|
|
371
|
+
return {
|
|
372
|
+
id,
|
|
373
|
+
createdDate: date(entry.job.createdDate, "created_date"),
|
|
374
|
+
modifiedDate: date(entry.job.modifiedDate, "modified_date"),
|
|
375
|
+
currentMilestone: label(entry.job.currentMilestone),
|
|
376
|
+
workType: label(record(entry.job.workType).name),
|
|
377
|
+
tradeTypes: Array.isArray(entry.job.tradeTypes) ? entry.job.tradeTypes.map((t) => label(record(t).name)).filter((v) => v !== null) : [],
|
|
378
|
+
milestones,
|
|
379
|
+
currentApprovedValueCents: approvedValueCents,
|
|
380
|
+
issues: [...new Set(issues)]
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
const queryComplete = report.complete && Number.isSafeInteger(report.serverCount) && report.serverCount >= 0 && report.scanned === report.serverCount && uniqueIds && jobs.every((j) => j.id !== null);
|
|
384
|
+
const enrichmentComplete = report.enrich.includes("dates") && report.enrich.includes("financials") && jobs.every((j) => j.issues.length === 0);
|
|
385
|
+
return {
|
|
386
|
+
schemaVersion: "acculynx-marketing-evidence/v1",
|
|
387
|
+
capturedAt,
|
|
388
|
+
filters: report.filters,
|
|
389
|
+
coverage: { scanned: report.scanned, serverCount: report.serverCount ?? null, emitted: jobs.length, queryComplete, enrichmentComplete, pageReadFailed: Boolean(report.pageError) },
|
|
390
|
+
// Query coverage is never a claim about historical approval or acquisition.
|
|
391
|
+
isApprovalCohort: false,
|
|
392
|
+
isFullLeadInventory: false,
|
|
393
|
+
jobs
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
260
397
|
// src/commands/jobs.ts
|
|
261
398
|
function collect(value, previous) {
|
|
262
399
|
return [...previous, value];
|
|
@@ -278,7 +415,7 @@ function registerJobsCommands(parentCmd, getClient2) {
|
|
|
278
415
|
});
|
|
279
416
|
console.log(JSON.stringify(result));
|
|
280
417
|
});
|
|
281
|
-
jobs.command("scan").description("Fetch ALL matching jobs and print a digest with a coverage receipt").option("--start-date <date>", "Start date (YYYY-MM-DD)").option("--end-date <date>", "End date (YYYY-MM-DD)").option("--date-filter-type <type>", "Date field to filter on").option("--milestones <milestones>", "Filter by milestones (comma-separated)").option("--assignment <type>", "Filter by assignment: assigned, unassigned").option("--trade-type <name>", "Client-side trade-type filter (repeatable)", collect, []).option("--enrich <list>", "Comma-separated: financials,reps,dates").option("--format <fmt>", "digest or
|
|
418
|
+
jobs.command("scan").description("Fetch ALL matching jobs and print a digest with a coverage receipt").option("--start-date <date>", "Start date (YYYY-MM-DD)").option("--end-date <date>", "End date (YYYY-MM-DD)").option("--date-filter-type <type>", "Date field to filter on").option("--milestones <milestones>", "Filter by milestones (comma-separated)").option("--assignment <type>", "Filter by assignment: assigned, unassigned").option("--trade-type <name>", "Client-side trade-type filter (repeatable)", collect, []).option("--enrich <list>", "Comma-separated: financials,reps,dates,messages").option("--format <fmt>", "digest, jsonl, or marketing-evidence (private operator data)", "digest").option("--out <path>", "Also write full jsonl to this file").action(async (opts) => {
|
|
282
419
|
const enrich = String(opts.enrich ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
283
420
|
const badEnricher = enrich.find((e) => !ENRICHERS.includes(e));
|
|
284
421
|
if (badEnricher) {
|
|
@@ -286,8 +423,13 @@ function registerJobsCommands(parentCmd, getClient2) {
|
|
|
286
423
|
process.exitCode = 2;
|
|
287
424
|
return;
|
|
288
425
|
}
|
|
289
|
-
if (
|
|
290
|
-
console.error("Unknown --format (valid: digest, jsonl)");
|
|
426
|
+
if (!["digest", "jsonl", "marketing-evidence"].includes(opts.format)) {
|
|
427
|
+
console.error("Unknown --format (valid: digest, jsonl, marketing-evidence)");
|
|
428
|
+
process.exitCode = 2;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (opts.format === "marketing-evidence" && (opts.out || !enrich.includes("dates") || !enrich.includes("financials") || enrich.some((e) => e !== "dates" && e !== "financials"))) {
|
|
432
|
+
console.error("marketing-evidence requires --enrich dates,financials and forbids --out or other enrichers");
|
|
291
433
|
process.exitCode = 2;
|
|
292
434
|
return;
|
|
293
435
|
}
|
|
@@ -299,11 +441,12 @@ function registerJobsCommands(parentCmd, getClient2) {
|
|
|
299
441
|
assignment: opts.assignment,
|
|
300
442
|
tradeType: opts.tradeType.length ? opts.tradeType : void 0
|
|
301
443
|
};
|
|
302
|
-
const result = await scanJobs(getClient2(), filters);
|
|
444
|
+
const result = await scanJobs(getClient2(), filters, { strict: opts.format === "marketing-evidence" });
|
|
303
445
|
const enrichedJobs = await enrichJobs(getClient2(), result.jobs, enrich);
|
|
304
446
|
const report = { filters, enrich, jobs: enrichedJobs, scanned: result.scanned, serverCount: result.serverCount, complete: result.complete, ...result.pageError ? { pageError: result.pageError } : {} };
|
|
305
|
-
|
|
306
|
-
|
|
447
|
+
const evidence = opts.format === "marketing-evidence" ? marketingEvidence(report) : null;
|
|
448
|
+
console.log(evidence ? JSON.stringify(evidence) : opts.format === "jsonl" ? formatScanJsonl(report) : formatScanDigest(report));
|
|
449
|
+
if (!result.complete || evidence && (!evidence.coverage.queryComplete || !evidence.coverage.enrichmentComplete)) process.exitCode = 3;
|
|
307
450
|
if (opts.out) {
|
|
308
451
|
try {
|
|
309
452
|
writeFileSync(opts.out, formatScanJsonl(report) + "\n");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opsrev/acculynx-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"description": "AccuLynx MCP server & CLI — use the AccuLynx roofing CRM in Claude Desktop, Cursor, and any MCP client. Also a JSON CLI for scripts and AI agents.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|