fullstackgtm 0.49.0 → 0.50.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/CHANGELOG.md +53 -0
- package/DATA-FLOWS.md +1 -0
- package/README.md +41 -0
- package/dist/acquireCheckpoint.d.ts +33 -0
- package/dist/acquireCheckpoint.js +149 -0
- package/dist/acquireLinkedIn.d.ts +2 -0
- package/dist/acquireLinkedIn.js +1 -1
- package/dist/cli/enrich.js +283 -79
- package/dist/cli/help.js +13 -9
- package/dist/cli/plans.js +100 -6
- package/dist/cli/shared.d.ts +3 -1
- package/dist/cli/shared.js +2 -2
- package/dist/cli/ui.d.ts +10 -5
- package/dist/cli/ui.js +16 -5
- package/dist/connectors/linkedin.d.ts +2 -0
- package/dist/connectors/linkedin.js +5 -0
- package/dist/connectors/prospectSources.d.ts +23 -0
- package/dist/connectors/prospectSources.js +21 -3
- package/dist/enrich.d.ts +44 -1
- package/dist/hostedAcquireCheckpoint.d.ts +43 -0
- package/dist/hostedAcquireCheckpoint.js +129 -0
- package/dist/hostedPatchPlan.d.ts +87 -0
- package/dist/hostedPatchPlan.js +270 -0
- package/dist/icp.js +13 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/planStore.d.ts +14 -0
- package/dist/planStore.js +73 -0
- package/dist/progress.d.ts +2 -2
- package/dist/progress.js +2 -2
- package/docs/api.md +24 -0
- package/docs/architecture.md +9 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +1 -1
- package/src/acquireCheckpoint.ts +186 -0
- package/src/acquireLinkedIn.ts +3 -1
- package/src/cli/enrich.ts +322 -78
- package/src/cli/help.ts +14 -9
- package/src/cli/plans.ts +95 -6
- package/src/cli/shared.ts +2 -1
- package/src/cli/ui.ts +18 -9
- package/src/connectors/linkedin.ts +6 -0
- package/src/connectors/prospectSources.ts +46 -4
- package/src/enrich.ts +47 -1
- package/src/hostedAcquireCheckpoint.ts +156 -0
- package/src/hostedPatchPlan.ts +286 -0
- package/src/icp.ts +14 -2
- package/src/index.ts +20 -0
- package/src/planStore.ts +87 -0
- package/src/progress.ts +2 -2
package/src/cli/help.ts
CHANGED
|
@@ -180,7 +180,7 @@ Usage:
|
|
|
180
180
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
181
181
|
derive values for requires_human_* placeholders
|
|
182
182
|
from snapshot evidence, with confidence + reasons
|
|
183
|
-
fullstackgtm plans list [--status <s>] | show <id> | reject <id>
|
|
183
|
+
fullstackgtm plans list [--status <s>] | show <id> | sync | reject <id>
|
|
184
184
|
fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]
|
|
185
185
|
fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
|
|
186
186
|
fullstackgtm plans recover <id> --acknowledge-uncertain-writes
|
|
@@ -204,7 +204,11 @@ Profiles (multi-organization use):
|
|
|
204
204
|
Plan lifecycle:
|
|
205
205
|
audit --save persists the dry-run plan to ~/.fullstackgtm/plans. Approve
|
|
206
206
|
specific operations (optionally with --value <opId>=<v> for placeholders),
|
|
207
|
-
then apply by id — the store enforces approval and records every run.
|
|
207
|
+
then apply by id — the store enforces approval and records every run. When
|
|
208
|
+
paired, local and hosted are replicas of the same immutable plan: either
|
|
209
|
+
capable surface may approve or apply, and each CLI check-in imports missing
|
|
210
|
+
approvals and exact operation receipts. Use \`plans sync\` for an explicit
|
|
211
|
+
check-in; offline changes reconcile later without blocking local work.
|
|
208
212
|
|
|
209
213
|
Authentication (checked in order):
|
|
210
214
|
1. --token-env <name> explicit env var for this invocation (hubspot)
|
|
@@ -543,15 +547,16 @@ export const HELP: Record<string, HelpEntry> = {
|
|
|
543
547
|
seeAlso: ["audit", "plans", "apply"],
|
|
544
548
|
},
|
|
545
549
|
plans: {
|
|
546
|
-
summary: "plan lifecycle: list / show / approve / reject
|
|
550
|
+
summary: "replicated plan lifecycle: list / show / sync / approve / reject",
|
|
547
551
|
phase: "Govern",
|
|
548
552
|
synopsis: [
|
|
549
|
-
"fullstackgtm plans list [--status <s>] | show <id> | reject <id>",
|
|
553
|
+
"fullstackgtm plans list [--status <s>] | show <id> | sync | reject <id>",
|
|
550
554
|
"fullstackgtm plans approve <id> --operations <ids|all> [--value <opId>=<v>]",
|
|
551
555
|
"fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low]",
|
|
552
556
|
"fullstackgtm plans recover <id> --acknowledge-uncertain-writes",
|
|
553
557
|
],
|
|
554
|
-
detail:
|
|
558
|
+
detail:
|
|
559
|
+
"Approval is explicit and per-operation; placeholders need a concrete --value or a suggestion to be approvable. When paired, hosted and CLI are eventually consistent replicas of one immutable plan. `plans sync` explicitly exchanges approval revisions, execution state, and exact per-operation receipts; ordinary plan commands also check in best-effort. Either surface may apply when its CRM connector supports the selected operations.",
|
|
555
560
|
seeAlso: ["audit", "suggest", "apply"],
|
|
556
561
|
},
|
|
557
562
|
apply: {
|
|
@@ -563,7 +568,7 @@ export const HELP: Record<string, HelpEntry> = {
|
|
|
563
568
|
"fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [--value <opId>=<v>]",
|
|
564
569
|
],
|
|
565
570
|
detail:
|
|
566
|
-
"The
|
|
571
|
+
"The CLI CRM-write verb. Writes only operations approved locally or imported from the shared hosted replica, with an online execution claim when available, stable operation ids, compare-and-set, resolve-before-create, and readback. Hosted may execute the same synchronized plan when it has a compatible CRM connection; its exact operation receipts are imported on the next CLI check-in. Never writes requires_human_* placeholders without a --value override.",
|
|
567
572
|
seeAlso: ["plans", "suggest", "audit-log"],
|
|
568
573
|
},
|
|
569
574
|
"audit-log": {
|
|
@@ -681,7 +686,7 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
681
686
|
dedupe: [...SOURCE_FLAGS, "--key", "--keep", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
|
|
682
687
|
reassign: [...SOURCE_FLAGS, "--from", "--assign-unowned", "--to", "--objects", "--where", "--except-deal-stage", "--include-closed-deals", "--reason", "--max-operations", "--save", "--dry-run", "--json", "--out"],
|
|
683
688
|
backfill: [...SOURCE_FLAGS, "--since", "--pipeline", "--match-property", "--skip-unmatched", "--save", "--dry-run", "--json"],
|
|
684
|
-
enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--stale-days", "--assign-owner", "--objects", "--max", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--json", "--out"],
|
|
689
|
+
enrich: [...SOURCE_FLAGS, "--config", "--source", "--icp", "--input", "--provider", "--list", "--stale-days", "--assign-owner", "--objects", "--max", "--scan-limit", "--staged-run", "--run-label", "--label", "--runs", "--save", "--dry-run", "--json", "--out"],
|
|
685
690
|
call: [...SOURCE_FLAGS, "--transcript", "--title", "--source", "--model", "--deterministic", "--heuristics", "--llm", "--json", "--ndjson", "--out", "--call", "--call-type", "--rubric", "--list", "--list-rubrics", "--attendees", "--domain", "--deal", "--save", "--dry-run"],
|
|
686
691
|
suggest: [...SOURCE_FLAGS, "--plan-id", "--plan", "--json", "--out"],
|
|
687
692
|
plans: ["--status", "--operations", "--value", "--values-from", "--min-confidence", "--include-creates", "--acknowledge-uncertain-writes", "--json"],
|
|
@@ -693,11 +698,11 @@ export const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
693
698
|
icp: [...SOURCE_FLAGS, "--name", "--signals-from", "--with-history", "--prompt", "--min-score", "--from-judge", "--golden", "--against-outcomes", "--min-accuracy", "--model", "--label", "--save", "--json", "--out"],
|
|
694
699
|
signals: ["--bucket", "--source", "--connector", "--connector-opt", "--watchlist", "--keywords", "--from", "--label", "--since", "--account", "--contact", "--unjudged", "--touch", "--result", "--save", "--json", "--explain"],
|
|
695
700
|
draft: ["--from-judge", "--min-score", "--prompt", "--model", "--channel", "--save", "--dry-run", "--json", "--out"],
|
|
696
|
-
schedule: ["--cron", "--label", "--provider", "--trigger", "--runs", "--json"],
|
|
701
|
+
schedule: ["--cron", "--label", "--provider", "--trigger", "--timer", "--runs", "--json"],
|
|
697
702
|
};
|
|
698
703
|
|
|
699
704
|
export const FLAGS_WITH_VALUES = new Set([
|
|
700
|
-
"--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--login-url", "--match", "--match-property", "--max", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
|
|
705
|
+
"--account", "--account-id", "--accounts", "--acv", "--acv-basis", "--after", "--anchor", "--api-key", "--approve", "--archive", "--assign-owner", "--attendees", "--before", "--bucket", "--buyers-per-account", "--call", "--call-type", "--calls", "--capture-run", "--category", "--channel", "--client", "--client-id", "--client-secret", "--config", "--connector", "--connector-opt", "--contact", "--cron", "--cross-checks", "--deal", "--deal-period", "--diff", "--domain", "--email", "--except-deal-stage", "--format", "--from", "--from-judge", "--golden", "--guard", "--icp", "--in", "--input", "--instance-url", "--keep", "--key", "--keywords", "--label", "--list", "--login-url", "--match", "--match-property", "--max", "--max-claims", "--max-credits", "--max-examples", "--max-operations", "--min-accuracy", "--min-confidence", "--min-mentions", "--min-score", "--model", "--name", "--objects", "--operations", "--out", "--pipeline", "--plan", "--plan-id", "--policy", "--port", "--prepared-by", "--prior-run", "--profile", "--promote-lift", "--prompt", "--provider", "--reason", "--require", "--result", "--rubric", "--rule", "--rules", "--run", "--run-label", "--runs", "--scan-limit", "--scopes", "--seed", "--set", "--signals-from", "--since", "--snapshot", "--source", "--staged-run", "--stale-days", "--status", "--task-account", "--task-deal", "--timer", "--title", "--to", "--today", "--token", "--token-env", "--touch", "--transcript", "--trigger", "--usd-per-credit", "--value", "--values-from", "--vendor", "--via", "--watchlist", "--where",
|
|
701
706
|
]);
|
|
702
707
|
|
|
703
708
|
// Lifecycle-grouped front door. One line per verb, organized by the
|
package/src/cli/plans.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { createFilePlanStore } from "../planStore.ts";
|
|
|
15
15
|
import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig, type EnrichConfig } from "../enrich.ts";
|
|
16
16
|
import { loadMeter, recordConsumption, remaining } from "../acquireMeter.ts";
|
|
17
17
|
import { progressReporter, reportCounts } from "../runReport.ts";
|
|
18
|
+
import { claimHostedPlanApply, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
|
|
18
19
|
import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
|
|
19
20
|
import type { ValueSuggestion } from "../suggest.ts";
|
|
20
21
|
import type { CanonicalGtmSnapshot, CreateRecordPayload, PatchOperation, PatchPlan, PatchPlanRun } from "../types.ts";
|
|
@@ -141,8 +142,18 @@ export async function apply(args: string[]) {
|
|
|
141
142
|
const store = planId ? createFilePlanStore() : null;
|
|
142
143
|
|
|
143
144
|
if (planId && store) {
|
|
144
|
-
|
|
145
|
+
let stored = await store.get(planId);
|
|
145
146
|
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
147
|
+
const reconciliation = await reconcileHostedPatchPlan(store, stored);
|
|
148
|
+
if (reconciliation.status === "conflict") throw new Error(`Refusing to apply plan ${planId}: ${reconciliation.reason}.`);
|
|
149
|
+
if (reconciliation.status === "updated") {
|
|
150
|
+
stored = reconciliation.stored;
|
|
151
|
+
console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
|
|
152
|
+
}
|
|
153
|
+
if (stored.status === "applied") {
|
|
154
|
+
console.log(`Plan ${planId} was already applied by another replica; imported its execution receipt. No provider writes were attempted.`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
146
157
|
if (stored.status !== "approved") {
|
|
147
158
|
throw new Error(
|
|
148
159
|
`Plan ${planId} is ${stored.status}; approve operations first with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
|
|
@@ -235,9 +246,29 @@ export async function apply(args: string[]) {
|
|
|
235
246
|
// transmits nothing; a CRM provider writes records. Same governed apply path.
|
|
236
247
|
const connector = channel ? createChannelConnector(channel) : await connectorFor(provider!, args);
|
|
237
248
|
let applyClaimId: string | undefined;
|
|
249
|
+
let hostedApplyClaimId: string | undefined;
|
|
238
250
|
if (planId && store) {
|
|
239
251
|
const claimed = await store.claimApply(planId, { provider: provider ?? `channel:${channel}`, source: "cli" });
|
|
240
252
|
applyClaimId = claimed.claimId;
|
|
253
|
+
const hostedClaim = await claimHostedPlanApply(claimed.stored);
|
|
254
|
+
if (hostedClaim.status === "applied") {
|
|
255
|
+
await store.abortApplyPreflight(planId, claimed.claimId, "Another replica completed apply before provider I/O.");
|
|
256
|
+
const refreshed = await store.get(planId);
|
|
257
|
+
if (refreshed) await reconcileHostedPatchPlan(store, refreshed);
|
|
258
|
+
console.log(`Plan ${planId} was already applied by another replica. No provider writes were attempted.`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (hostedClaim.status === "conflict") {
|
|
262
|
+
await store.abortApplyPreflight(planId, claimed.claimId, `Hosted execution claim failed: ${hostedClaim.reason}`);
|
|
263
|
+
throw new Error(`Refusing to apply plan ${planId}: could not coordinate with its hosted replica (${hostedClaim.reason}).`);
|
|
264
|
+
}
|
|
265
|
+
if (hostedClaim.status === "unavailable") {
|
|
266
|
+
console.error(`Hosted replica is unavailable (${hostedClaim.reason}); continuing local-first. Resolve-before-create and provider conflict guards remain active, and the execution receipt will sync on a later check-in.`);
|
|
267
|
+
}
|
|
268
|
+
if (hostedClaim.status === "claimed") {
|
|
269
|
+
hostedApplyClaimId = hostedClaim.claimId;
|
|
270
|
+
await store.recordHostedClaim(planId, claimed.claimId, hostedClaim.claimId);
|
|
271
|
+
}
|
|
241
272
|
const claimedVerification = verifyApprovalDigests(
|
|
242
273
|
claimed.stored.plan.operations,
|
|
243
274
|
claimed.stored.approvedOperationIds,
|
|
@@ -245,6 +276,16 @@ export async function apply(args: string[]) {
|
|
|
245
276
|
claimed.stored.approvalDigests,
|
|
246
277
|
);
|
|
247
278
|
if (!claimedVerification.ok) {
|
|
279
|
+
if (hostedApplyClaimId) {
|
|
280
|
+
const released = await releaseHostedPlanApply(
|
|
281
|
+
claimed.stored,
|
|
282
|
+
hostedApplyClaimId,
|
|
283
|
+
"Approval integrity verification failed after claim and before provider I/O.",
|
|
284
|
+
);
|
|
285
|
+
if (released.status === "conflict" || released.status === "unavailable") {
|
|
286
|
+
console.error(`Hosted apply claim may require recovery: ${released.reason}.`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
248
289
|
await store.abortApplyPreflight(
|
|
249
290
|
planId,
|
|
250
291
|
claimed.claimId,
|
|
@@ -280,7 +321,12 @@ export async function apply(args: string[]) {
|
|
|
280
321
|
renderer.done();
|
|
281
322
|
}
|
|
282
323
|
if (planId && store) {
|
|
283
|
-
await store.recordRun(planId, run, applyClaimId!);
|
|
324
|
+
const recorded = await store.recordRun(planId, run, applyClaimId!);
|
|
325
|
+
const durableHostedClaimId = recorded.applyAttempts?.find((attempt) => attempt.id === applyClaimId)?.hostedClaimId;
|
|
326
|
+
const mirrored = await reportHostedPlanLifecycle(recorded, { claimId: durableHostedClaimId ?? hostedApplyClaimId });
|
|
327
|
+
if (mirrored.status === "unavailable" || mirrored.status === "conflict") {
|
|
328
|
+
console.error(`Hosted plan status is stale: ${mirrored.reason}. Local apply history remains authoritative.`);
|
|
329
|
+
}
|
|
284
330
|
}
|
|
285
331
|
|
|
286
332
|
// Charge the acquire meter for the creates that actually landed.
|
|
@@ -408,7 +454,13 @@ export async function plansCommand(args: string[]) {
|
|
|
408
454
|
const status = option(rest, "--status") as
|
|
409
455
|
| "draft" | "needs_approval" | "approved" | "rejected" | "applied"
|
|
410
456
|
| null;
|
|
411
|
-
|
|
457
|
+
let plans = await store.list(status ?? undefined);
|
|
458
|
+
const synced = await Promise.all(plans.map(async (stored) => {
|
|
459
|
+
const result = await reconcileHostedPatchPlan(store, stored);
|
|
460
|
+
if (result.status === "updated") return result.stored;
|
|
461
|
+
return stored;
|
|
462
|
+
}));
|
|
463
|
+
plans = status ? synced.filter((stored) => stored.status === status) : synced;
|
|
412
464
|
if (rest.includes("--json") || args.includes("--json")) {
|
|
413
465
|
console.log(
|
|
414
466
|
JSON.stringify(
|
|
@@ -473,8 +525,15 @@ export async function plansCommand(args: string[]) {
|
|
|
473
525
|
if (subcommand === "show") {
|
|
474
526
|
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
475
527
|
if (!planId) throw new Error("Usage: fullstackgtm plans show <planId>");
|
|
476
|
-
|
|
528
|
+
let stored = await store.get(planId);
|
|
477
529
|
if (!stored) throw new Error(`No stored plan with id ${planId}.`);
|
|
530
|
+
const reconciliation = await reconcileHostedPatchPlan(store, stored);
|
|
531
|
+
if (reconciliation.status === "updated") {
|
|
532
|
+
stored = reconciliation.stored;
|
|
533
|
+
console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
|
|
534
|
+
} else if (reconciliation.status === "conflict") {
|
|
535
|
+
console.error(`Hosted sync conflict: ${reconciliation.reason}.`);
|
|
536
|
+
}
|
|
478
537
|
if (rest.includes("--json")) {
|
|
479
538
|
console.log(JSON.stringify(stored, null, 2));
|
|
480
539
|
return;
|
|
@@ -483,6 +542,9 @@ export async function plansCommand(args: string[]) {
|
|
|
483
542
|
console.log(`Status: ${planStatusWord(stored.status, showPaint)}`);
|
|
484
543
|
console.log(`Approved operations: ${stored.approvedOperationIds.join(", ") || "none"}`);
|
|
485
544
|
console.log(`Runs: ${stored.runs.length}`);
|
|
545
|
+
const mirror = await uploadHostedPatchPlan(stored);
|
|
546
|
+
if (mirror.status === "saved") console.log(`Hosted review: ${mirror.state.url}`);
|
|
547
|
+
else if (mirror.status === "unavailable" || mirror.status === "conflict") console.log(`Hosted review: sync pending (${mirror.reason})`);
|
|
486
548
|
if (stored.applyAttempts?.length) {
|
|
487
549
|
console.log("Apply attempts:");
|
|
488
550
|
for (const attempt of stored.applyAttempts) {
|
|
@@ -541,6 +603,10 @@ export async function plansCommand(args: string[]) {
|
|
|
541
603
|
...fileOverrides,
|
|
542
604
|
...explicitOverrides,
|
|
543
605
|
});
|
|
606
|
+
const mirror = await reportHostedPlanLifecycle(updated);
|
|
607
|
+
if (mirror.status === "unavailable" || mirror.status === "conflict") {
|
|
608
|
+
console.error(`Hosted plan status is stale: ${mirror.reason}. Local approval remains authoritative.`);
|
|
609
|
+
}
|
|
544
610
|
console.log(
|
|
545
611
|
`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`,
|
|
546
612
|
);
|
|
@@ -550,7 +616,11 @@ export async function plansCommand(args: string[]) {
|
|
|
550
616
|
if (subcommand === "reject") {
|
|
551
617
|
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
552
618
|
if (!planId) throw new Error("Usage: fullstackgtm plans reject <planId>");
|
|
553
|
-
await store.reject(planId);
|
|
619
|
+
const rejected = await store.reject(planId);
|
|
620
|
+
const mirror = await reportHostedPlanLifecycle(rejected);
|
|
621
|
+
if (mirror.status === "unavailable" || mirror.status === "conflict") {
|
|
622
|
+
console.error(`Hosted plan status is stale: ${mirror.reason}. Local rejection remains authoritative.`);
|
|
623
|
+
}
|
|
554
624
|
console.log(`Rejected ${planId}.`);
|
|
555
625
|
return;
|
|
556
626
|
}
|
|
@@ -581,5 +651,24 @@ export async function plansCommand(args: string[]) {
|
|
|
581
651
|
return;
|
|
582
652
|
}
|
|
583
653
|
|
|
584
|
-
|
|
654
|
+
if (subcommand === "sync") {
|
|
655
|
+
const plans = await store.list();
|
|
656
|
+
let updated = 0;
|
|
657
|
+
const warnings: string[] = [];
|
|
658
|
+
for (const stored of plans) {
|
|
659
|
+
const result = await reconcileHostedPatchPlan(store, stored);
|
|
660
|
+
if (result.status === "updated") updated += 1;
|
|
661
|
+
else if (result.status === "conflict" || result.status === "unavailable") warnings.push(`${stored.plan.id}: ${result.reason}`);
|
|
662
|
+
if (stored.status === "applied") {
|
|
663
|
+
const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
|
|
664
|
+
const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
|
|
665
|
+
if (pushed.status === "conflict" || pushed.status === "unavailable") warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
|
|
669
|
+
for (const warning of warnings) console.error(` ${warning}`);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
throw unknownSubcommandError("plans", subcommand, ["list", "show", "sync", "approve", "reject", "recover"]);
|
|
585
674
|
}
|
package/src/cli/shared.ts
CHANGED
|
@@ -164,6 +164,7 @@ export async function connectorFor(
|
|
|
164
164
|
export async function readSnapshot(
|
|
165
165
|
args: string[],
|
|
166
166
|
progress?: ProgressEmitter,
|
|
167
|
+
options: { persistProgress?: boolean } = {},
|
|
167
168
|
): Promise<CanonicalGtmSnapshot> {
|
|
168
169
|
const provider = option(args, "--provider");
|
|
169
170
|
if (provider) {
|
|
@@ -189,7 +190,7 @@ export async function readSnapshot(
|
|
|
189
190
|
try {
|
|
190
191
|
return await connector.fetchSnapshot();
|
|
191
192
|
} finally {
|
|
192
|
-
renderer.done();
|
|
193
|
+
renderer.done({ persist: options.persistProgress });
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
if (args.includes("--demo")) {
|
package/src/cli/ui.ts
CHANGED
|
@@ -286,8 +286,8 @@ export function startElapsedStatus(
|
|
|
286
286
|
|
|
287
287
|
export type Checklist = {
|
|
288
288
|
update(id: string, state: "pending" | "running" | "ok" | "warn" | "fail", note?: string): void;
|
|
289
|
-
/** Stop animating
|
|
290
|
-
done(): void;
|
|
289
|
+
/** Stop animating; optionally leave the final board in the terminal history. */
|
|
290
|
+
done(options?: { persist?: boolean }): void;
|
|
291
291
|
readonly active: boolean;
|
|
292
292
|
};
|
|
293
293
|
|
|
@@ -296,7 +296,8 @@ const NOOP_CHECKLIST: Checklist = { update() {}, done() {}, active: false };
|
|
|
296
296
|
/**
|
|
297
297
|
* A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
|
|
298
298
|
* progresses (the audit rule registry renders through this). Repaints with
|
|
299
|
-
* cursor-up; erases
|
|
299
|
+
* cursor-up; done() normally erases it, while done({ persist: true }) leaves a
|
|
300
|
+
* final static history for multi-phase human workflows.
|
|
300
301
|
*/
|
|
301
302
|
export function createChecklist(
|
|
302
303
|
items: Array<{ id: string; label: string }>,
|
|
@@ -343,13 +344,20 @@ export function createChecklist(
|
|
|
343
344
|
const entry = states.get(id);
|
|
344
345
|
if (!entry) return;
|
|
345
346
|
entry.state = state;
|
|
346
|
-
entry.note = note;
|
|
347
|
+
if (note !== undefined) entry.note = note;
|
|
347
348
|
start();
|
|
348
349
|
render();
|
|
349
350
|
},
|
|
350
|
-
done() {
|
|
351
|
+
done(options = {}) {
|
|
351
352
|
if (timer) clearInterval(timer);
|
|
352
353
|
timer = null;
|
|
354
|
+
if (options.persist) {
|
|
355
|
+
// The caller's final state update already painted the completed board.
|
|
356
|
+
// Freeze that frame in scrollback; repainting here can leave both the
|
|
357
|
+
// last live frame and the final frame visible in some terminals.
|
|
358
|
+
painted = 0;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
353
361
|
if (painted > 0) {
|
|
354
362
|
// Erase the board: cursor up over every painted line, clear each.
|
|
355
363
|
stream.write(`\u001b[${painted}A${"\u001b[2K\n".repeat(painted)}\u001b[${painted}A`);
|
|
@@ -366,8 +374,8 @@ export type ProgressRenderer = {
|
|
|
366
374
|
* listener via `composeListeners` when the run should also heartbeat).
|
|
367
375
|
*/
|
|
368
376
|
listener: ProgressListener;
|
|
369
|
-
/** Stop animating
|
|
370
|
-
done(): void;
|
|
377
|
+
/** Stop animating; optionally leave the completed board in terminal history. */
|
|
378
|
+
done(options?: { persist?: boolean }): void;
|
|
371
379
|
readonly active: boolean;
|
|
372
380
|
};
|
|
373
381
|
|
|
@@ -423,8 +431,9 @@ export function createProgressRenderer(
|
|
|
423
431
|
// larger flow) are ignored by createChecklist's unknown-id no-op.
|
|
424
432
|
if (current) board.update(current, "running", noteFor(event, snapshot));
|
|
425
433
|
},
|
|
426
|
-
done() {
|
|
427
|
-
board.
|
|
434
|
+
done(options) {
|
|
435
|
+
if (current) board.update(current, "ok");
|
|
436
|
+
board.done(options);
|
|
428
437
|
},
|
|
429
438
|
active: true,
|
|
430
439
|
};
|
|
@@ -41,6 +41,8 @@ export type FetchProspectsOptions = {
|
|
|
41
41
|
sourceId?: string;
|
|
42
42
|
/** Hard cap on prospects returned; also bounds pagination. */
|
|
43
43
|
max?: number;
|
|
44
|
+
/** Opaque continuation cursor. HeyReach uses the decimal list offset. */
|
|
45
|
+
cursor?: string;
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
export type ConnectionStatus = { ok: boolean; detail: string };
|
|
@@ -152,6 +154,10 @@ export function createHeyReachProvider(options: HeyReachProviderOptions): Linked
|
|
|
152
154
|
const max = opts.max ?? Number.POSITIVE_INFINITY;
|
|
153
155
|
const out: LinkedInProspect[] = [];
|
|
154
156
|
let offset = 0;
|
|
157
|
+
if (opts.cursor !== undefined) {
|
|
158
|
+
if (!/^\d+$/.test(opts.cursor)) throw new Error(`Invalid HeyReach cursor: ${opts.cursor}`);
|
|
159
|
+
offset = Number(opts.cursor);
|
|
160
|
+
}
|
|
155
161
|
while (out.length < max) {
|
|
156
162
|
const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
|
|
157
163
|
const items = toArray(data);
|
|
@@ -68,6 +68,8 @@ export async function fetchExploriumProspects(opts: {
|
|
|
68
68
|
apiKey: string;
|
|
69
69
|
filters: ExploriumFilters;
|
|
70
70
|
size?: number;
|
|
71
|
+
/** One-based result page. */
|
|
72
|
+
page?: number;
|
|
71
73
|
apiBaseUrl?: string;
|
|
72
74
|
fetchImpl?: FetchImpl;
|
|
73
75
|
}): Promise<Prospect[]> {
|
|
@@ -77,7 +79,7 @@ export async function fetchExploriumProspects(opts: {
|
|
|
77
79
|
const response = await fetchImpl(`${base}/v1/prospects`, {
|
|
78
80
|
method: "POST",
|
|
79
81
|
headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
|
|
80
|
-
body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
|
|
82
|
+
body: JSON.stringify({ mode: "full", size, page_size: size, page: opts.page ?? 1, filters: opts.filters }),
|
|
81
83
|
});
|
|
82
84
|
if (!response.ok) {
|
|
83
85
|
throw new ProviderHttpError("Explorium", "prospect search", response.status);
|
|
@@ -119,17 +121,44 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
119
121
|
apiKey: string;
|
|
120
122
|
filters: Record<string, unknown>;
|
|
121
123
|
limit?: number;
|
|
124
|
+
/** Opaque cursor returned by a previous page. */
|
|
125
|
+
cursor?: string;
|
|
122
126
|
apiBaseUrl?: string;
|
|
123
127
|
fetchImpl?: FetchImpl;
|
|
124
128
|
}): Promise<Prospect[]> {
|
|
129
|
+
return (await fetchPipe0CrustdataProspectPage(opts)).prospects;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A page from pipe0/Crustdata's cursor-based people search. */
|
|
133
|
+
export type Pipe0CrustdataProspectPage = {
|
|
134
|
+
prospects: Prospect[];
|
|
135
|
+
/** Opaque cursor for the next page, or null when the search is exhausted. */
|
|
136
|
+
nextCursor: string | null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetch one page and expose its continuation cursor. The array-returning
|
|
141
|
+
* `fetchPipe0CrustdataProspects` remains available for existing consumers.
|
|
142
|
+
*/
|
|
143
|
+
export async function fetchPipe0CrustdataProspectPage(opts: {
|
|
144
|
+
apiKey: string;
|
|
145
|
+
filters: Record<string, unknown>;
|
|
146
|
+
limit?: number;
|
|
147
|
+
/** Opaque cursor returned by a previous page. */
|
|
148
|
+
cursor?: string;
|
|
149
|
+
apiBaseUrl?: string;
|
|
150
|
+
fetchImpl?: FetchImpl;
|
|
151
|
+
}): Promise<Pipe0CrustdataProspectPage> {
|
|
125
152
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
126
153
|
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
127
154
|
const limit = Math.min(opts.limit ?? 25, 100);
|
|
155
|
+
const config: Record<string, unknown> = { limit, filters: opts.filters };
|
|
156
|
+
if (opts.cursor !== undefined) config.cursor = opts.cursor;
|
|
128
157
|
const response = await fetchImpl(`${base}/v1/searches/run/sync`, {
|
|
129
158
|
method: "POST",
|
|
130
159
|
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
131
160
|
body: JSON.stringify({
|
|
132
|
-
searches: [{ search_id: "people:profiles:crustdata@1", config
|
|
161
|
+
searches: [{ search_id: "people:profiles:crustdata@1", config }],
|
|
133
162
|
}),
|
|
134
163
|
});
|
|
135
164
|
if (!response.ok) {
|
|
@@ -137,7 +166,12 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
137
166
|
}
|
|
138
167
|
const body = (await response.json()) as {
|
|
139
168
|
results?: Array<Record<string, { value?: unknown }>>;
|
|
140
|
-
|
|
169
|
+
next_cursor?: unknown;
|
|
170
|
+
search_statuses?: Array<{
|
|
171
|
+
errors?: Array<{ code?: string; message?: string }>;
|
|
172
|
+
next_cursor?: unknown;
|
|
173
|
+
cursor?: unknown;
|
|
174
|
+
}>;
|
|
141
175
|
};
|
|
142
176
|
// Surface upstream provider errors (e.g. CreditBalanceInsufficient) instead of
|
|
143
177
|
// silently returning [] — a throttle/credit failure must not look like "no ICP matches".
|
|
@@ -147,7 +181,7 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
147
181
|
`pipe0/Crustdata search error: ${upstreamErrors.map((e) => `${e.code ?? "error"}: ${e.message ?? ""}`).join("; ")}`,
|
|
148
182
|
);
|
|
149
183
|
}
|
|
150
|
-
|
|
184
|
+
const prospects = (body.results ?? []).map((row) => {
|
|
151
185
|
const fullName = strField(row.name);
|
|
152
186
|
const { firstName, lastName } = splitName(fullName);
|
|
153
187
|
return {
|
|
@@ -160,6 +194,14 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
160
194
|
sourceId: strField(row.profile_url) ?? fullName,
|
|
161
195
|
};
|
|
162
196
|
});
|
|
197
|
+
// Current pipe0 responses expose `next_cursor` at the top level. Accept the
|
|
198
|
+
// status-envelope variants too so older deployments remain usable.
|
|
199
|
+
const status = body.search_statuses?.[0];
|
|
200
|
+
const rawCursor = body.next_cursor ?? status?.next_cursor ?? status?.cursor;
|
|
201
|
+
return {
|
|
202
|
+
prospects,
|
|
203
|
+
nextCursor: typeof rawCursor === "string" && rawCursor.length > 0 ? rawCursor : null,
|
|
204
|
+
};
|
|
163
205
|
}
|
|
164
206
|
|
|
165
207
|
function strField(field: { value?: unknown } | undefined): string | undefined {
|
package/src/enrich.ts
CHANGED
|
@@ -111,6 +111,8 @@ export type AcquireDiscoveryConfig = {
|
|
|
111
111
|
provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
|
|
112
112
|
filters?: Record<string, unknown>;
|
|
113
113
|
size?: number;
|
|
114
|
+
/** Maximum raw provider candidates scanned per run while seeking fresh leads. */
|
|
115
|
+
scanLimit?: number;
|
|
114
116
|
resolveEmailsWith?: "pipe0";
|
|
115
117
|
/** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
|
|
116
118
|
listId?: string;
|
|
@@ -1326,7 +1328,49 @@ export function selectStaleWork(
|
|
|
1326
1328
|
// Checkpoint (cursor), staleness ledger (stamps), and observability surface
|
|
1327
1329
|
// (enrich status) in one structure — mirrors the market observations store.
|
|
1328
1330
|
|
|
1329
|
-
export type EnrichRunMode = EnrichMode | "ingest";
|
|
1331
|
+
export type EnrichRunMode = EnrichMode | "ingest" | "acquire";
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Provider continuation state for an acquisition query. The fingerprint binds
|
|
1335
|
+
* a cursor/offset to the ICP + provider filters that produced it, preventing a
|
|
1336
|
+
* changed query from accidentally resuming in the middle of the old audience.
|
|
1337
|
+
*/
|
|
1338
|
+
export type AcquireDiscoveryCheckpoint = {
|
|
1339
|
+
queryFingerprint: string;
|
|
1340
|
+
/** Opaque provider cursor (Pipe0 and other cursor-based sources). */
|
|
1341
|
+
cursor?: string | null;
|
|
1342
|
+
/** Zero-based provider offset (LinkedIn/HeyReach and other offset sources). */
|
|
1343
|
+
offset?: number | null;
|
|
1344
|
+
/** True only when the provider has positively reported no next page. */
|
|
1345
|
+
exhausted: boolean;
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Truthful acquisition funnel for one run. Each field counts candidates, not
|
|
1350
|
+
* provider requests, making duplicate saturation and resolution loss visible
|
|
1351
|
+
* instead of reporting every run as zero.
|
|
1352
|
+
*/
|
|
1353
|
+
export type AcquireRunFunnel = {
|
|
1354
|
+
/** Raw candidates returned across all discovery pages scanned this run. */
|
|
1355
|
+
discovered: number;
|
|
1356
|
+
/** Candidates that met the configured ICP threshold. */
|
|
1357
|
+
qualified: number;
|
|
1358
|
+
/** Qualified candidates removed by the CRM pre-email dedupe. */
|
|
1359
|
+
skippedCrm: number;
|
|
1360
|
+
/** Qualified candidates removed by the cross-run seen ledger. */
|
|
1361
|
+
skippedSeen: number;
|
|
1362
|
+
/** Fresh candidates carrying the configured resolve-first key. */
|
|
1363
|
+
resolved: number;
|
|
1364
|
+
/** Governed create operations emitted into the saved/dry-run plan. */
|
|
1365
|
+
proposed: number;
|
|
1366
|
+
/** Otherwise-proposable candidates held back by the acquire meter. */
|
|
1367
|
+
withheldByMeter: number;
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
export type AcquireRunTelemetry = {
|
|
1371
|
+
funnel: AcquireRunFunnel;
|
|
1372
|
+
discovery: AcquireDiscoveryCheckpoint;
|
|
1373
|
+
};
|
|
1330
1374
|
|
|
1331
1375
|
export type EnrichRun = {
|
|
1332
1376
|
id: string;
|
|
@@ -1339,6 +1383,8 @@ export type EnrichRun = {
|
|
|
1339
1383
|
/** Resume point for an interrupted pull (last processed pull key). */
|
|
1340
1384
|
cursor: string | null;
|
|
1341
1385
|
counts: EnrichCounts;
|
|
1386
|
+
/** Acquisition-only funnel + provider continuation state (absent on legacy runs). */
|
|
1387
|
+
acquireTelemetry?: AcquireRunTelemetry;
|
|
1342
1388
|
planIds: string[];
|
|
1343
1389
|
stamps: EnrichStamp[];
|
|
1344
1390
|
/** Staged source rows (ingest mode only), consumed by append/refresh. */
|