fullstackgtm 0.49.0 → 0.50.1
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 +77 -0
- package/DATA-FLOWS.md +1 -0
- package/README.md +48 -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/audit.js +6 -1
- package/dist/cli/auth.js +25 -0
- package/dist/cli/backfill.js +4 -0
- package/dist/cli/call.js +28 -6
- package/dist/cli/draft.js +11 -1
- package/dist/cli/enrich.js +286 -81
- package/dist/cli/fix.js +6 -3
- package/dist/cli/help.js +30 -26
- package/dist/cli/icp.js +15 -1
- package/dist/cli/market.js +14 -1
- package/dist/cli/planOutput.d.ts +5 -0
- package/dist/cli/planOutput.js +27 -0
- package/dist/cli/plans.js +269 -24
- package/dist/cli/schedule.js +22 -11
- package/dist/cli/shared.d.ts +3 -1
- package/dist/cli/shared.js +2 -2
- package/dist/cli/signals.js +22 -3
- package/dist/cli/tam.js +10 -1
- package/dist/cli/ui.d.ts +10 -5
- package/dist/cli/ui.js +28 -9
- package/dist/connectors/hubspot.d.ts +4 -0
- package/dist/connectors/hubspot.js +36 -18
- 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 +275 -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/audit.ts +5 -1
- package/src/cli/auth.ts +24 -0
- package/src/cli/backfill.ts +3 -0
- package/src/cli/call.ts +25 -6
- package/src/cli/draft.ts +9 -1
- package/src/cli/enrich.ts +325 -80
- package/src/cli/fix.ts +5 -3
- package/src/cli/help.ts +31 -26
- package/src/cli/icp.ts +13 -1
- package/src/cli/market.ts +12 -1
- package/src/cli/planOutput.ts +30 -0
- package/src/cli/plans.ts +259 -25
- package/src/cli/schedule.ts +21 -15
- package/src/cli/shared.ts +2 -1
- package/src/cli/signals.ts +22 -3
- package/src/cli/tam.ts +8 -1
- package/src/cli/ui.ts +31 -13
- package/src/connectors/hubspot.ts +41 -17
- 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 +291 -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/dist/cli/plans.js
CHANGED
|
@@ -14,9 +14,10 @@ import { createFilePlanStore } from "../planStore.js";
|
|
|
14
14
|
import { ENRICH_CONFIG_FILE_NAME, loadEnrichConfig } from "../enrich.js";
|
|
15
15
|
import { loadMeter, recordConsumption, remaining } from "../acquireMeter.js";
|
|
16
16
|
import { progressReporter, reportCounts } from "../runReport.js";
|
|
17
|
+
import { claimHostedPlanApply, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "../hostedPatchPlan.js";
|
|
17
18
|
import { APPLY_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
|
|
18
19
|
import { connectorFor, isOptionValue, numericOption, option, repeatedOption, selectedRules } from "./shared.js";
|
|
19
|
-
import { colorEnabled, createProgressRenderer, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
|
|
20
|
+
import { box, colorEnabled, createProgressRenderer, createStatusLine, formatDuration, paint, planStatusWord, stylizePlanMarkdown, table, truncateToWidth } from "./ui.js";
|
|
20
21
|
import { unknownSubcommandError } from "./suggest.js";
|
|
21
22
|
function parseValueOverrides(args) {
|
|
22
23
|
const valueOverrides = {};
|
|
@@ -28,6 +29,119 @@ function parseValueOverrides(args) {
|
|
|
28
29
|
}
|
|
29
30
|
return valueOverrides;
|
|
30
31
|
}
|
|
32
|
+
function recordHeadline(operation) {
|
|
33
|
+
const after = operation.afterValue && typeof operation.afterValue === "object" && !Array.isArray(operation.afterValue)
|
|
34
|
+
? operation.afterValue : {};
|
|
35
|
+
const properties = after.properties && typeof after.properties === "object" && !Array.isArray(after.properties)
|
|
36
|
+
? after.properties : {};
|
|
37
|
+
const first = typeof properties.firstname === "string" ? properties.firstname : "";
|
|
38
|
+
const last = typeof properties.lastname === "string" ? properties.lastname : "";
|
|
39
|
+
const company = typeof properties.company === "string" ? properties.company
|
|
40
|
+
: typeof after.associateCompanyName === "string" ? after.associateCompanyName : undefined;
|
|
41
|
+
const title = [first, last].filter(Boolean).join(" ") || company || operation.objectId || operation.id;
|
|
42
|
+
const role = typeof properties.jobtitle === "string" ? properties.jobtitle : undefined;
|
|
43
|
+
return { title, ...(role ? { subtitle: role } : {}), ...(company ? { company } : {}) };
|
|
44
|
+
}
|
|
45
|
+
function planEffect(stored) {
|
|
46
|
+
const total = stored.plan.operations.length;
|
|
47
|
+
const approved = stored.approvedOperationIds.length;
|
|
48
|
+
if (stored.status === "applied") {
|
|
49
|
+
const results = stored.runs.at(-1)?.results ?? [];
|
|
50
|
+
const applied = results.filter((result) => result.status === "applied").length;
|
|
51
|
+
const skipped = results.filter((result) => result.status === "skipped").length;
|
|
52
|
+
return `Completed ${applied} operation${applied === 1 ? "" : "s"}${skipped ? `; ${skipped} skipped` : ""}. No further writes will run.`;
|
|
53
|
+
}
|
|
54
|
+
if (stored.status === "approved")
|
|
55
|
+
return `Apply will execute ${approved} selected operation${approved === 1 ? "" : "s"}; ${total - approved} will not run.`;
|
|
56
|
+
if (stored.status === "rejected")
|
|
57
|
+
return "Rejected. No provider writes can run.";
|
|
58
|
+
return `No provider writes can run until operations are approved (0 of ${total} selected).`;
|
|
59
|
+
}
|
|
60
|
+
function printPlanCards(stored, hostedUrl) {
|
|
61
|
+
const p = paint(colorEnabled(process.stdout));
|
|
62
|
+
const total = stored.plan.operations.length;
|
|
63
|
+
const approved = new Set(stored.approvedOperationIds);
|
|
64
|
+
const width = Math.max(56, Math.min(100, (process.stdout.columns ?? 100) - 4));
|
|
65
|
+
const fit = (value) => truncateToWidth(value, width);
|
|
66
|
+
const summary = [
|
|
67
|
+
fit(stored.plan.title),
|
|
68
|
+
`Status ${stored.status.toUpperCase().replaceAll("_", " ")}`,
|
|
69
|
+
`Selection ${approved.size} of ${total} operation${total === 1 ? "" : "s"} approved`,
|
|
70
|
+
`Runs ${stored.runs.length}`,
|
|
71
|
+
fit(`Effect ${planEffect(stored)}`),
|
|
72
|
+
];
|
|
73
|
+
console.log(box(summary, p, "Plan").join("\n"));
|
|
74
|
+
if (hostedUrl)
|
|
75
|
+
console.log(`Hosted: ${hostedUrl}`);
|
|
76
|
+
console.log("\nOperations");
|
|
77
|
+
let operationIndex = 0;
|
|
78
|
+
const latestResults = new Map((stored.runs.at(-1)?.results ?? []).map((result) => [result.operationId, result.status]));
|
|
79
|
+
for (const operation of stored.plan.operations) {
|
|
80
|
+
const selected = approved.has(operation.id);
|
|
81
|
+
const resultStatus = latestResults.get(operation.id);
|
|
82
|
+
const headline = recordHeadline(operation);
|
|
83
|
+
const action = operation.operation === "create_record"
|
|
84
|
+
? `Create ${operation.objectType}${headline.company ? ` and resolve/link ${headline.company}` : ""}`
|
|
85
|
+
: `${operation.operation.replaceAll("_", " ")} ${operation.objectType}`;
|
|
86
|
+
const lines = [
|
|
87
|
+
fit(headline.title),
|
|
88
|
+
...(headline.subtitle || headline.company
|
|
89
|
+
? [fit([headline.subtitle, headline.company].filter(Boolean).join(" · "))]
|
|
90
|
+
: []),
|
|
91
|
+
fit(`Action ${action}`),
|
|
92
|
+
`Operation ${operation.id}`,
|
|
93
|
+
];
|
|
94
|
+
if (operationIndex++ > 0)
|
|
95
|
+
console.log("");
|
|
96
|
+
const state = resultStatus === "applied" ? "✓ APPLIED"
|
|
97
|
+
: resultStatus && selected ? `! ${resultStatus.toUpperCase()}`
|
|
98
|
+
: selected ? "✓ APPROVED" : stored.status === "applied" ? "○ EXCLUDED" : "○ NOT APPROVED";
|
|
99
|
+
console.log(box(lines, p, state).join("\n"));
|
|
100
|
+
}
|
|
101
|
+
if (stored.status === "approved") {
|
|
102
|
+
console.log(`\nNext: fullstackgtm apply --plan-id ${stored.plan.id} --provider <hubspot|salesforce>`);
|
|
103
|
+
}
|
|
104
|
+
console.log(`Details: fullstackgtm plans show ${stored.plan.id} --verbose`);
|
|
105
|
+
}
|
|
106
|
+
function printApplyCards(run, plan, approvedOperationIds, planIdStored) {
|
|
107
|
+
const p = paint(colorEnabled(process.stdout));
|
|
108
|
+
const approved = new Set(approvedOperationIds);
|
|
109
|
+
const operations = new Map(plan.operations.map((operation) => [operation.id, operation]));
|
|
110
|
+
const counts = { applied: 0, skipped: 0, failed: 0, conflict: 0 };
|
|
111
|
+
for (const result of run.results)
|
|
112
|
+
counts[result.status] += 1;
|
|
113
|
+
const elapsed = Math.max(0, Date.parse(run.finishedAt) - Date.parse(run.startedAt));
|
|
114
|
+
const summary = [
|
|
115
|
+
`Status ${run.status.toUpperCase()}`,
|
|
116
|
+
`Provider ${run.provider}`,
|
|
117
|
+
`Outcome ${counts.applied} applied · ${run.results.length - approved.size} excluded · ${counts.failed + counts.conflict} failed/conflicted`,
|
|
118
|
+
`Duration ${formatDuration(elapsed)}`,
|
|
119
|
+
...(planIdStored ? ["Receipt recorded locally; hosted reconciliation requested"] : []),
|
|
120
|
+
];
|
|
121
|
+
console.log(box(summary, p, "Apply result").join("\n"));
|
|
122
|
+
console.log("\nOperations");
|
|
123
|
+
let index = 0;
|
|
124
|
+
for (const result of run.results) {
|
|
125
|
+
const operation = operations.get(result.operationId);
|
|
126
|
+
if (!operation)
|
|
127
|
+
continue;
|
|
128
|
+
const selected = approved.has(result.operationId);
|
|
129
|
+
const headline = recordHeadline(operation);
|
|
130
|
+
const state = !selected ? "○ EXCLUDED" : result.status === "applied" ? "✓ APPLIED" : `! ${result.status.toUpperCase()}`;
|
|
131
|
+
const lines = [
|
|
132
|
+
headline.title,
|
|
133
|
+
...(headline.subtitle || headline.company ? [[headline.subtitle, headline.company].filter(Boolean).join(" · ")] : []),
|
|
134
|
+
`Operation ${result.operationId}`,
|
|
135
|
+
...(result.detail ? [truncateToWidth(`Result ${result.detail}`, 100)] : []),
|
|
136
|
+
];
|
|
137
|
+
if (index++ > 0)
|
|
138
|
+
console.log("");
|
|
139
|
+
console.log(box(lines, p, state).join("\n"));
|
|
140
|
+
}
|
|
141
|
+
if (planIdStored)
|
|
142
|
+
console.log(`\nInspect: fullstackgtm plans show ${run.planId}`);
|
|
143
|
+
console.log("Details: rerun with --verbose; machine output: --json");
|
|
144
|
+
}
|
|
31
145
|
function tryLoadAcquireConfig(args) {
|
|
32
146
|
try {
|
|
33
147
|
const path = resolve(process.cwd(), option(args, "--config") ?? ENRICH_CONFIG_FILE_NAME);
|
|
@@ -132,9 +246,20 @@ export async function apply(args) {
|
|
|
132
246
|
let valueOverrides;
|
|
133
247
|
const store = planId ? createFilePlanStore() : null;
|
|
134
248
|
if (planId && store) {
|
|
135
|
-
|
|
249
|
+
let stored = await store.get(planId);
|
|
136
250
|
if (!stored)
|
|
137
251
|
throw new Error(`No stored plan with id ${planId}.`);
|
|
252
|
+
const reconciliation = await reconcileHostedPatchPlan(store, stored);
|
|
253
|
+
if (reconciliation.status === "conflict")
|
|
254
|
+
throw new Error(`Refusing to apply plan ${planId}: ${reconciliation.reason}.`);
|
|
255
|
+
if (reconciliation.status === "updated") {
|
|
256
|
+
stored = reconciliation.stored;
|
|
257
|
+
console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
|
|
258
|
+
}
|
|
259
|
+
if (stored.status === "applied") {
|
|
260
|
+
console.log(`Plan ${planId} was already applied by another replica; imported its execution receipt. No provider writes were attempted.`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
138
263
|
if (stored.status !== "approved") {
|
|
139
264
|
throw new Error(`Plan ${planId} is ${stored.status}; approve operations first with \`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
|
|
140
265
|
}
|
|
@@ -211,11 +336,38 @@ export async function apply(args) {
|
|
|
211
336
|
// transmits nothing; a CRM provider writes records. Same governed apply path.
|
|
212
337
|
const connector = channel ? createChannelConnector(channel) : await connectorFor(provider, args);
|
|
213
338
|
let applyClaimId;
|
|
339
|
+
let hostedApplyClaimId;
|
|
214
340
|
if (planId && store) {
|
|
215
341
|
const claimed = await store.claimApply(planId, { provider: provider ?? `channel:${channel}`, source: "cli" });
|
|
216
342
|
applyClaimId = claimed.claimId;
|
|
343
|
+
const hostedClaim = await claimHostedPlanApply(claimed.stored);
|
|
344
|
+
if (hostedClaim.status === "applied") {
|
|
345
|
+
await store.abortApplyPreflight(planId, claimed.claimId, "Another replica completed apply before provider I/O.");
|
|
346
|
+
const refreshed = await store.get(planId);
|
|
347
|
+
if (refreshed)
|
|
348
|
+
await reconcileHostedPatchPlan(store, refreshed);
|
|
349
|
+
console.log(`Plan ${planId} was already applied by another replica. No provider writes were attempted.`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (hostedClaim.status === "conflict") {
|
|
353
|
+
await store.abortApplyPreflight(planId, claimed.claimId, `Hosted execution claim failed: ${hostedClaim.reason}`);
|
|
354
|
+
throw new Error(`Refusing to apply plan ${planId}: could not coordinate with its hosted replica (${hostedClaim.reason}).`);
|
|
355
|
+
}
|
|
356
|
+
if (hostedClaim.status === "unavailable") {
|
|
357
|
+
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.`);
|
|
358
|
+
}
|
|
359
|
+
if (hostedClaim.status === "claimed") {
|
|
360
|
+
hostedApplyClaimId = hostedClaim.claimId;
|
|
361
|
+
await store.recordHostedClaim(planId, claimed.claimId, hostedClaim.claimId);
|
|
362
|
+
}
|
|
217
363
|
const claimedVerification = verifyApprovalDigests(claimed.stored.plan.operations, claimed.stored.approvedOperationIds, claimed.stored.valueOverrides, claimed.stored.approvalDigests);
|
|
218
364
|
if (!claimedVerification.ok) {
|
|
365
|
+
if (hostedApplyClaimId) {
|
|
366
|
+
const released = await releaseHostedPlanApply(claimed.stored, hostedApplyClaimId, "Approval integrity verification failed after claim and before provider I/O.");
|
|
367
|
+
if (released.status === "conflict" || released.status === "unavailable") {
|
|
368
|
+
console.error(`Hosted apply claim may require recovery: ${released.reason}.`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
219
371
|
await store.abortApplyPreflight(planId, claimed.claimId, "Approval integrity verification failed after the claim and before provider I/O.");
|
|
220
372
|
throw new Error(`Refusing to apply plan ${planId}: approval changed while acquiring the apply claim.`);
|
|
221
373
|
}
|
|
@@ -248,7 +400,12 @@ export async function apply(args) {
|
|
|
248
400
|
renderer.done();
|
|
249
401
|
}
|
|
250
402
|
if (planId && store) {
|
|
251
|
-
await store.recordRun(planId, run, applyClaimId);
|
|
403
|
+
const recorded = await store.recordRun(planId, run, applyClaimId);
|
|
404
|
+
const durableHostedClaimId = recorded.applyAttempts?.find((attempt) => attempt.id === applyClaimId)?.hostedClaimId;
|
|
405
|
+
const mirrored = await reportHostedPlanLifecycle(recorded, { claimId: durableHostedClaimId ?? hostedApplyClaimId });
|
|
406
|
+
if (mirrored.status === "unavailable" || mirrored.status === "conflict") {
|
|
407
|
+
console.error(`Hosted plan status is stale: ${mirrored.reason}. Local apply history remains authoritative.`);
|
|
408
|
+
}
|
|
252
409
|
}
|
|
253
410
|
// Charge the acquire meter for the creates that actually landed.
|
|
254
411
|
if (createOps.length > 0) {
|
|
@@ -278,9 +435,12 @@ export async function apply(args) {
|
|
|
278
435
|
if (args.includes("--json")) {
|
|
279
436
|
console.log(JSON.stringify(run, null, 2));
|
|
280
437
|
}
|
|
281
|
-
else {
|
|
438
|
+
else if (args.includes("--verbose")) {
|
|
282
439
|
console.log(formatPatchPlanRun(run));
|
|
283
440
|
}
|
|
441
|
+
else {
|
|
442
|
+
printApplyCards(run, plan, approvedOperationIds, Boolean(planId && store));
|
|
443
|
+
}
|
|
284
444
|
if (run.status === "failed")
|
|
285
445
|
process.exitCode = 1;
|
|
286
446
|
}
|
|
@@ -353,7 +513,14 @@ export async function plansCommand(args) {
|
|
|
353
513
|
const [subcommand, ...rest] = args;
|
|
354
514
|
if (subcommand === "list" || subcommand === undefined) {
|
|
355
515
|
const status = option(rest, "--status");
|
|
356
|
-
|
|
516
|
+
let plans = await store.list(status ?? undefined);
|
|
517
|
+
const synced = await Promise.all(plans.map(async (stored) => {
|
|
518
|
+
const result = await reconcileHostedPatchPlan(store, stored);
|
|
519
|
+
if (result.status === "updated")
|
|
520
|
+
return result.stored;
|
|
521
|
+
return stored;
|
|
522
|
+
}));
|
|
523
|
+
plans = status ? synced.filter((stored) => stored.status === status) : synced;
|
|
357
524
|
if (rest.includes("--json") || args.includes("--json")) {
|
|
358
525
|
console.log(JSON.stringify(plans.map((stored) => ({
|
|
359
526
|
id: stored.plan.id,
|
|
@@ -371,31 +538,48 @@ export async function plansCommand(args) {
|
|
|
371
538
|
}
|
|
372
539
|
const p = paint(colorEnabled(process.stdout));
|
|
373
540
|
if (p.enabled) {
|
|
374
|
-
|
|
375
|
-
|
|
541
|
+
const statusCounts = new Map();
|
|
542
|
+
for (const stored of plans)
|
|
543
|
+
statusCounts.set(stored.status, (statusCounts.get(stored.status) ?? 0) + 1);
|
|
544
|
+
const awaiting = plans.filter((stored) => stored.status === "needs_approval").length;
|
|
545
|
+
const approved = plans.filter((stored) => stored.status === "approved").length;
|
|
546
|
+
console.log(box([
|
|
547
|
+
`${plans.length} plan${plans.length === 1 ? "" : "s"} · ${awaiting} awaiting approval · ${approved} ready to apply`,
|
|
548
|
+
[...statusCounts.entries()].map(([name, count]) => `${name.replaceAll("_", " ")}: ${count}`).join(" · "),
|
|
549
|
+
], p, "Change queue").join("\n"));
|
|
550
|
+
console.log("");
|
|
551
|
+
// Interactive terminals get an aligned decision table; the plain
|
|
552
|
+
// per-line format below remains stable for pipes and scripts.
|
|
376
553
|
const rows = plans.map((stored) => [
|
|
377
|
-
stored.plan.id,
|
|
378
554
|
stored.status,
|
|
379
|
-
|
|
555
|
+
stored.plan.title,
|
|
556
|
+
`${stored.approvedOperationIds.length}/${stored.plan.operations.length} selected`,
|
|
380
557
|
`${stored.runs.length} run${stored.runs.length === 1 ? "" : "s"}`,
|
|
381
|
-
stored.plan.
|
|
558
|
+
stored.plan.id,
|
|
382
559
|
]);
|
|
383
560
|
// Long summaries would wrap and break the table's alignment. The summary
|
|
384
561
|
// is the LAST column: cap it to what's left of the terminal width after
|
|
385
562
|
// the fixed columns and their two-space gutters (floor of 24 so narrow
|
|
386
563
|
// terminals still show something useful).
|
|
387
564
|
const columns = process.stdout.columns ?? 80;
|
|
388
|
-
const fixedWidth = [0,
|
|
389
|
-
const
|
|
565
|
+
const fixedWidth = [0, 2, 3, 4].reduce((sum, index) => sum + Math.max(...rows.map((row) => row[index].length)), 0) + 2 * 4;
|
|
566
|
+
const titleWidth = Math.max(24, columns - fixedWidth);
|
|
390
567
|
for (const row of rows)
|
|
391
|
-
row[
|
|
568
|
+
row[1] = truncateToWidth(row[1], titleWidth);
|
|
392
569
|
const statusPainter = (cell) => {
|
|
393
570
|
const painted = planStatusWord(cell.trimEnd(), p);
|
|
394
571
|
return painted + cell.slice(cell.trimEnd().length);
|
|
395
572
|
};
|
|
396
|
-
for (const line of table(rows, [
|
|
573
|
+
for (const line of table(rows, [statusPainter, null, p.dim, p.dim, p.dim])) {
|
|
397
574
|
console.log(line);
|
|
398
575
|
}
|
|
576
|
+
const next = plans.find((stored) => stored.status === "approved") ?? plans.find((stored) => stored.status === "needs_approval");
|
|
577
|
+
if (next) {
|
|
578
|
+
const command = next.status === "approved"
|
|
579
|
+
? `fullstackgtm apply --plan-id ${next.plan.id} --provider <name>`
|
|
580
|
+
: `fullstackgtm plans show ${next.plan.id}`;
|
|
581
|
+
console.log(`\nNext: ${command}`);
|
|
582
|
+
}
|
|
399
583
|
return;
|
|
400
584
|
}
|
|
401
585
|
for (const stored of plans) {
|
|
@@ -407,18 +591,26 @@ export async function plansCommand(args) {
|
|
|
407
591
|
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
408
592
|
if (!planId)
|
|
409
593
|
throw new Error("Usage: fullstackgtm plans show <planId>");
|
|
410
|
-
|
|
594
|
+
let stored = await store.get(planId);
|
|
411
595
|
if (!stored)
|
|
412
596
|
throw new Error(`No stored plan with id ${planId}.`);
|
|
597
|
+
const reconciliation = await reconcileHostedPatchPlan(store, stored);
|
|
598
|
+
if (reconciliation.status === "updated") {
|
|
599
|
+
stored = reconciliation.stored;
|
|
600
|
+
console.error(`Synced hosted ${reconciliation.remoteStatus} state for ${planId}.`);
|
|
601
|
+
}
|
|
602
|
+
else if (reconciliation.status === "conflict") {
|
|
603
|
+
console.error(`Hosted sync conflict: ${reconciliation.reason}.`);
|
|
604
|
+
}
|
|
413
605
|
if (rest.includes("--json")) {
|
|
414
606
|
console.log(JSON.stringify(stored, null, 2));
|
|
415
607
|
return;
|
|
416
608
|
}
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (stored.applyAttempts?.length) {
|
|
609
|
+
const mirror = await uploadHostedPatchPlan(stored);
|
|
610
|
+
printPlanCards(stored, mirror.status === "saved" ? mirror.state.url : undefined);
|
|
611
|
+
if (mirror.status === "unavailable" || mirror.status === "conflict")
|
|
612
|
+
console.log(`Hosted sync pending: ${mirror.reason}`);
|
|
613
|
+
if (rest.includes("--verbose") && stored.applyAttempts?.length) {
|
|
422
614
|
console.log("Apply attempts:");
|
|
423
615
|
for (const attempt of stored.applyAttempts) {
|
|
424
616
|
console.log(` ${attempt.id} ${attempt.status} ${attempt.provider} via ${attempt.source} ${attempt.claimedAt}`);
|
|
@@ -426,8 +618,11 @@ export async function plansCommand(args) {
|
|
|
426
618
|
console.log(` ${attempt.note}`);
|
|
427
619
|
}
|
|
428
620
|
}
|
|
429
|
-
|
|
430
|
-
|
|
621
|
+
if (rest.includes("--verbose")) {
|
|
622
|
+
const showPaint = paint(colorEnabled(process.stdout));
|
|
623
|
+
console.log("\nFull plan document\n");
|
|
624
|
+
console.log(stylizePlanMarkdown(patchPlanToMarkdown(stored.plan), showPaint));
|
|
625
|
+
}
|
|
431
626
|
return;
|
|
432
627
|
}
|
|
433
628
|
if (subcommand === "approve") {
|
|
@@ -470,6 +665,10 @@ export async function plansCommand(args) {
|
|
|
470
665
|
...fileOverrides,
|
|
471
666
|
...explicitOverrides,
|
|
472
667
|
});
|
|
668
|
+
const mirror = await reportHostedPlanLifecycle(updated);
|
|
669
|
+
if (mirror.status === "unavailable" || mirror.status === "conflict") {
|
|
670
|
+
console.error(`Hosted plan status is stale: ${mirror.reason}. Local approval remains authoritative.`);
|
|
671
|
+
}
|
|
473
672
|
console.log(`Approved ${updated.approvedOperationIds.length} operation(s) on ${planId}. Apply with \`fullstackgtm apply --plan-id ${planId} --provider <name>\`.`);
|
|
474
673
|
return;
|
|
475
674
|
}
|
|
@@ -477,7 +676,11 @@ export async function plansCommand(args) {
|
|
|
477
676
|
const planId = rest.find((arg) => !arg.startsWith("--"));
|
|
478
677
|
if (!planId)
|
|
479
678
|
throw new Error("Usage: fullstackgtm plans reject <planId>");
|
|
480
|
-
await store.reject(planId);
|
|
679
|
+
const rejected = await store.reject(planId);
|
|
680
|
+
const mirror = await reportHostedPlanLifecycle(rejected);
|
|
681
|
+
if (mirror.status === "unavailable" || mirror.status === "conflict") {
|
|
682
|
+
console.error(`Hosted plan status is stale: ${mirror.reason}. Local rejection remains authoritative.`);
|
|
683
|
+
}
|
|
481
684
|
console.log(`Rejected ${planId}.`);
|
|
482
685
|
return;
|
|
483
686
|
}
|
|
@@ -503,5 +706,47 @@ export async function plansCommand(args) {
|
|
|
503
706
|
`No writes were replayed. Re-audit provider state, review the plan, then approve operations again before any apply.`);
|
|
504
707
|
return;
|
|
505
708
|
}
|
|
506
|
-
|
|
709
|
+
if (subcommand === "sync") {
|
|
710
|
+
const plans = await store.list();
|
|
711
|
+
let updated = 0;
|
|
712
|
+
let completed = 0;
|
|
713
|
+
const warnings = [];
|
|
714
|
+
const status = createStatusLine();
|
|
715
|
+
status.set(`Synchronizing plan replicas 0/${plans.length}`);
|
|
716
|
+
// A workspace can accumulate hundreds of local plans. Reconcile with a
|
|
717
|
+
// small worker pool so one network round-trip per plan does not turn into
|
|
718
|
+
// a minutes-long silent serial wait, without stampeding the hosted API.
|
|
719
|
+
let next = 0;
|
|
720
|
+
const worker = async () => {
|
|
721
|
+
while (next < plans.length) {
|
|
722
|
+
const stored = plans[next++];
|
|
723
|
+
const result = await reconcileHostedPatchPlan(store, stored);
|
|
724
|
+
if (result.status === "updated")
|
|
725
|
+
updated += 1;
|
|
726
|
+
else if (result.status === "conflict" || result.status === "unavailable")
|
|
727
|
+
warnings.push(`${stored.plan.id}: ${result.reason}`);
|
|
728
|
+
// Historical local-only plans have no remote row to receive a receipt.
|
|
729
|
+
// `backfill runs` is the explicit import path for that old history.
|
|
730
|
+
if (stored.status === "applied" && result.status !== "missing" && (stored.applyAttempts?.length ?? 0) > 0) {
|
|
731
|
+
const hostedClaimId = [...(stored.applyAttempts ?? [])].reverse().find((attempt) => attempt.hostedClaimId)?.hostedClaimId;
|
|
732
|
+
const pushed = await reportHostedPlanLifecycle(stored, { claimId: hostedClaimId });
|
|
733
|
+
if (pushed.status === "conflict" || pushed.status === "unavailable")
|
|
734
|
+
warnings.push(`${stored.plan.id} receipt: ${pushed.reason}`);
|
|
735
|
+
}
|
|
736
|
+
completed += 1;
|
|
737
|
+
status.set(`Synchronizing plan replicas ${completed}/${plans.length}`);
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
try {
|
|
741
|
+
await Promise.all(Array.from({ length: Math.min(6, plans.length) }, () => worker()));
|
|
742
|
+
}
|
|
743
|
+
finally {
|
|
744
|
+
status.done();
|
|
745
|
+
}
|
|
746
|
+
console.log(`Plan replicas synchronized: ${updated} updated, ${plans.length - updated} unchanged.`);
|
|
747
|
+
for (const warning of warnings)
|
|
748
|
+
console.error(` ${warning}`);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
throw unknownSubcommandError("plans", subcommand, ["list", "show", "sync", "approve", "reject", "recover"]);
|
|
507
752
|
}
|
package/dist/cli/schedule.js
CHANGED
|
@@ -8,6 +8,7 @@ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleSto
|
|
|
8
8
|
import { runCli } from "../cli.js";
|
|
9
9
|
import { isOptionValue, numericOption, option } from "./shared.js";
|
|
10
10
|
import { unknownSubcommandError } from "./suggest.js";
|
|
11
|
+
import { colorEnabled, paint, table } from "./ui.js";
|
|
11
12
|
/**
|
|
12
13
|
* The schedule layer: declarative cadences for read/plan-side commands,
|
|
13
14
|
* materialized through a provider (MVP: the user crontab), with an
|
|
@@ -113,10 +114,19 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
113
114
|
console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
|
|
114
115
|
return;
|
|
115
116
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
const p = paint(colorEnabled(process.stdout));
|
|
118
|
+
const verbose = rest.includes("--verbose");
|
|
119
|
+
const rows = [
|
|
120
|
+
["STATE", "SCHEDULE", "NEXT", ...(verbose ? ["CRON", "COMMAND"] : [])],
|
|
121
|
+
...withNext.map((entry) => [
|
|
122
|
+
entry.enabled ? "on" : "off",
|
|
123
|
+
`${entry.label} · ${entry.id}`,
|
|
124
|
+
entry.nextFiring ?? "—",
|
|
125
|
+
...(verbose ? [entry.cron, entry.argv.join(" ")] : []),
|
|
126
|
+
]),
|
|
127
|
+
];
|
|
128
|
+
console.log(table(rows, [p.dim, null, null]).join("\n"));
|
|
129
|
+
console.log("\nDeclarative only · run `fullstackgtm schedule install` after changes.");
|
|
120
130
|
return;
|
|
121
131
|
}
|
|
122
132
|
if (subcommand === "remove") {
|
|
@@ -251,20 +261,21 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
251
261
|
const last = entry.lastRun;
|
|
252
262
|
const streak = entry.streak;
|
|
253
263
|
const missed = entry.missedFirings;
|
|
254
|
-
|
|
255
|
-
|
|
264
|
+
const verbose = rest.includes("--verbose");
|
|
265
|
+
const outcome = !last ? "never run" : last.exitCode === 0 ? (last.noopReason ? `no-op · ${last.noopReason}` : "healthy") : `failed · exit ${last.exitCode}`;
|
|
266
|
+
console.log(`${entry.enabled ? "●" : "○"} ${entry.label} · ${outcome}`);
|
|
267
|
+
console.log(` ${entry.id} · next ${entry.nextFiring ?? "— (disabled)"}`);
|
|
268
|
+
if (verbose)
|
|
269
|
+
console.log(` ${entry.argv.join(" ")} · cron ${entry.cron}`);
|
|
256
270
|
if (last) {
|
|
257
271
|
const artifacts = [
|
|
258
272
|
...last.artifacts.planIds.map((planId) => `plan ${planId}`),
|
|
259
273
|
...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
|
|
260
274
|
];
|
|
261
|
-
console.log(` last
|
|
262
|
-
(last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
|
|
263
|
-
(artifacts.length ? ` — ${artifacts.join(", ")}` : ""));
|
|
264
|
-
console.log(` streak: ${streak.length} ${streak.outcome}(s)`);
|
|
275
|
+
console.log(` last ${last.firedAt} · ${last.trigger} · ${streak.length} ${streak.outcome}${streak.length === 1 ? "" : "s"}${artifacts.length ? ` · ${artifacts.join(", ")}` : ""}`);
|
|
265
276
|
}
|
|
266
277
|
else {
|
|
267
|
-
console.log(" last
|
|
278
|
+
console.log(" last never fired");
|
|
268
279
|
}
|
|
269
280
|
if (missed.length > 0) {
|
|
270
281
|
console.log(` missed: ${missed.length}${entry.missedFiringsCapped ? "+" : ""} expected firing(s) with no run record ` +
|
package/dist/cli/shared.d.ts
CHANGED
|
@@ -23,7 +23,9 @@ export declare function saveRequested(args: string[]): boolean;
|
|
|
23
23
|
export declare function confirmRequested(args: string[], ...legacyAliases: string[]): boolean;
|
|
24
24
|
export declare function numericOption(args: string[], name: string): number | undefined;
|
|
25
25
|
export declare function connectorFor(provider: string, args: string[], progress?: ProgressEmitter): Promise<GtmConnector>;
|
|
26
|
-
export declare function readSnapshot(args: string[], progress?: ProgressEmitter
|
|
26
|
+
export declare function readSnapshot(args: string[], progress?: ProgressEmitter, options?: {
|
|
27
|
+
persistProgress?: boolean;
|
|
28
|
+
}): Promise<CanonicalGtmSnapshot>;
|
|
27
29
|
/**
|
|
28
30
|
* Validate that an --input file actually has the canonical snapshot shape
|
|
29
31
|
* (the JSON `snapshot --out` writes) instead of blindly casting — a plan or
|
package/dist/cli/shared.js
CHANGED
|
@@ -131,7 +131,7 @@ export async function connectorFor(provider, args, progress) {
|
|
|
131
131
|
throw new Error(`Unknown provider: ${provider}.${providerSuggestion ? ` Did you mean ${providerSuggestion}?` : ""} ` +
|
|
132
132
|
"Supported providers: hubspot, salesforce, stripe");
|
|
133
133
|
}
|
|
134
|
-
export async function readSnapshot(args, progress) {
|
|
134
|
+
export async function readSnapshot(args, progress, options = {}) {
|
|
135
135
|
const provider = option(args, "--provider");
|
|
136
136
|
if (provider) {
|
|
137
137
|
// A verb driving its own progress board (e.g. `backfill stripe`) passes
|
|
@@ -153,7 +153,7 @@ export async function readSnapshot(args, progress) {
|
|
|
153
153
|
return await connector.fetchSnapshot();
|
|
154
154
|
}
|
|
155
155
|
finally {
|
|
156
|
-
renderer.done();
|
|
156
|
+
renderer.done({ persist: options.persistProgress });
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
if (args.includes("--demo")) {
|
package/dist/cli/signals.js
CHANGED
|
@@ -22,6 +22,20 @@ function resolveSignalsConfig(args) {
|
|
|
22
22
|
return loadSignalsConfig(local);
|
|
23
23
|
return DEFAULT_SIGNALS_CONFIG;
|
|
24
24
|
}
|
|
25
|
+
function concise(value, max = 72) {
|
|
26
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
27
|
+
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
|
|
28
|
+
}
|
|
29
|
+
function renderSignals(signals) {
|
|
30
|
+
if (signals.length === 0)
|
|
31
|
+
return "No signals matched.";
|
|
32
|
+
const lines = [`Fresh signals (${signals.length})`, ""];
|
|
33
|
+
for (const signal of signals) {
|
|
34
|
+
lines.push(`${signal.weight.toFixed(2).padStart(5)} ${signal.accountDomain} · ${signal.bucket}`);
|
|
35
|
+
lines.push(` ${concise(signal.trigger)}`);
|
|
36
|
+
}
|
|
37
|
+
return lines.join("\n");
|
|
38
|
+
}
|
|
25
39
|
/**
|
|
26
40
|
* Resolve the watchlist of accounts to scan. Sources, in precedence order:
|
|
27
41
|
* - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
|
|
@@ -211,7 +225,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
211
225
|
const { fresh, deduped } = dedupeSignals(candidates, priorSignals, config.dedupWindowDays, now);
|
|
212
226
|
const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
213
227
|
// Ranked fresh signals to stdout; guidance to stderr.
|
|
214
|
-
console.log(JSON.stringify(ranked, null, 2));
|
|
228
|
+
console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
|
|
215
229
|
console.error(`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
|
|
216
230
|
`(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`);
|
|
217
231
|
const save = saveRequested(rest);
|
|
@@ -259,7 +273,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
259
273
|
return true;
|
|
260
274
|
});
|
|
261
275
|
const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
262
|
-
console.log(JSON.stringify(ranked, null, 2));
|
|
276
|
+
console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
|
|
263
277
|
console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
|
|
264
278
|
return;
|
|
265
279
|
}
|
|
@@ -286,7 +300,12 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
286
300
|
const outcomes = await store.listOutcomes();
|
|
287
301
|
const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
|
|
288
302
|
const weights = computeWeights(config, outcomes, signalsById);
|
|
289
|
-
|
|
303
|
+
if (rest.includes("--json") || rest.includes("--verbose")) {
|
|
304
|
+
console.log(JSON.stringify(weights, null, 2));
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
console.log(["Learned signal weights", "", ...SIGNAL_BUCKETS.map((bucket) => `${bucket.padEnd(10)} ${weights[bucket].toFixed(4)}`)].join("\n"));
|
|
308
|
+
}
|
|
290
309
|
if (rest.includes("--explain")) {
|
|
291
310
|
// Per-bucket config default vs learned + booked/total over credited signals.
|
|
292
311
|
const booked = new Map();
|
package/dist/cli/tam.js
CHANGED
|
@@ -253,9 +253,18 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
|
|
|
253
253
|
writeFileSync(resolve(process.cwd(), out), md);
|
|
254
254
|
console.log(`Wrote ${out}.`);
|
|
255
255
|
}
|
|
256
|
-
else {
|
|
256
|
+
else if (rest.includes("--verbose")) {
|
|
257
257
|
console.log(md);
|
|
258
258
|
}
|
|
259
|
+
else if (timeline.length > 0) {
|
|
260
|
+
console.log(coverageToText(model, timeline.at(-1), eta));
|
|
261
|
+
console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
|
|
265
|
+
console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
|
|
266
|
+
console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
|
|
267
|
+
}
|
|
259
268
|
return;
|
|
260
269
|
}
|
|
261
270
|
if (sub === "populate") {
|
package/dist/cli/ui.d.ts
CHANGED
|
@@ -88,14 +88,17 @@ export declare function startElapsedStatus(label: (elapsed: string) => string, s
|
|
|
88
88
|
};
|
|
89
89
|
export type Checklist = {
|
|
90
90
|
update(id: string, state: "pending" | "running" | "ok" | "warn" | "fail", note?: string): void;
|
|
91
|
-
/** Stop animating
|
|
92
|
-
done(
|
|
91
|
+
/** Stop animating; optionally leave the final board in the terminal history. */
|
|
92
|
+
done(options?: {
|
|
93
|
+
persist?: boolean;
|
|
94
|
+
}): void;
|
|
93
95
|
readonly active: boolean;
|
|
94
96
|
};
|
|
95
97
|
/**
|
|
96
98
|
* A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
|
|
97
99
|
* progresses (the audit rule registry renders through this). Repaints with
|
|
98
|
-
* cursor-up; erases
|
|
100
|
+
* cursor-up; done() normally erases it, while done({ persist: true }) leaves a
|
|
101
|
+
* final static history for multi-phase human workflows.
|
|
99
102
|
*/
|
|
100
103
|
export declare function createChecklist(items: Array<{
|
|
101
104
|
id: string;
|
|
@@ -107,8 +110,10 @@ export type ProgressRenderer = {
|
|
|
107
110
|
* listener via `composeListeners` when the run should also heartbeat).
|
|
108
111
|
*/
|
|
109
112
|
listener: ProgressListener;
|
|
110
|
-
/** Stop animating
|
|
111
|
-
done(
|
|
113
|
+
/** Stop animating; optionally leave the completed board in terminal history. */
|
|
114
|
+
done(options?: {
|
|
115
|
+
persist?: boolean;
|
|
116
|
+
}): void;
|
|
112
117
|
readonly active: boolean;
|
|
113
118
|
};
|
|
114
119
|
/**
|