@opsrev/acculynx-cli 1.15.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.
Files changed (3) hide show
  1. package/README.md +33 -0
  2. package/dist/index.js +111 -8
  3. 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);
@@ -148,7 +185,8 @@ async function enrichJobs(client, jobs, enrich, concurrency = 5, deps = {}) {
148
185
  entry.reps = reps;
149
186
  } else if (source === "dates") {
150
187
  const m = asRecord(await jobMilestones(client, jobId));
151
- entry.dates = (m.items ?? []).map((i) => ({
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) => ({
152
190
  name: String(i.name ?? ""),
153
191
  date: String(i.date ?? "")
154
192
  }));
@@ -297,6 +335,65 @@ function formatScanJsonl(report) {
297
335
  return report.jobs.map((j) => JSON.stringify(j)).join("\n");
298
336
  }
299
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
+
300
397
  // src/commands/jobs.ts
301
398
  function collect(value, previous) {
302
399
  return [...previous, value];
@@ -318,7 +415,7 @@ function registerJobsCommands(parentCmd, getClient2) {
318
415
  });
319
416
  console.log(JSON.stringify(result));
320
417
  });
321
- 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 or jsonl", "digest").option("--out <path>", "Also write full jsonl to this file").action(async (opts) => {
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) => {
322
419
  const enrich = String(opts.enrich ?? "").split(",").map((s) => s.trim()).filter(Boolean);
323
420
  const badEnricher = enrich.find((e) => !ENRICHERS.includes(e));
324
421
  if (badEnricher) {
@@ -326,8 +423,13 @@ function registerJobsCommands(parentCmd, getClient2) {
326
423
  process.exitCode = 2;
327
424
  return;
328
425
  }
329
- if (opts.format !== "digest" && opts.format !== "jsonl") {
330
- 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");
331
433
  process.exitCode = 2;
332
434
  return;
333
435
  }
@@ -339,11 +441,12 @@ function registerJobsCommands(parentCmd, getClient2) {
339
441
  assignment: opts.assignment,
340
442
  tradeType: opts.tradeType.length ? opts.tradeType : void 0
341
443
  };
342
- const result = await scanJobs(getClient2(), filters);
444
+ const result = await scanJobs(getClient2(), filters, { strict: opts.format === "marketing-evidence" });
343
445
  const enrichedJobs = await enrichJobs(getClient2(), result.jobs, enrich);
344
446
  const report = { filters, enrich, jobs: enrichedJobs, scanned: result.scanned, serverCount: result.serverCount, complete: result.complete, ...result.pageError ? { pageError: result.pageError } : {} };
345
- console.log(opts.format === "jsonl" ? formatScanJsonl(report) : formatScanDigest(report));
346
- if (!result.complete) process.exitCode = 3;
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;
347
450
  if (opts.out) {
348
451
  try {
349
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.15.0",
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",